@musnows/scriverse 0.2.0 → 0.3.1

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.
Files changed (74) hide show
  1. package/README.en.md +13 -4
  2. package/README.md +13 -4
  3. package/dist/ai.js +3548 -0
  4. package/dist/ai.js.map +1 -0
  5. package/dist/app.js +1079 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli-core.js +147 -20
  8. package/dist/cli-core.js.map +1 -1
  9. package/dist/credential-vault.js +40 -0
  10. package/dist/credential-vault.js.map +1 -0
  11. package/dist/database.js +1220 -0
  12. package/dist/database.js.map +1 -0
  13. package/dist/docx-security.js +184 -0
  14. package/dist/docx-security.js.map +1 -0
  15. package/dist/domain.js +21 -0
  16. package/dist/domain.js.map +1 -0
  17. package/dist/errors.js +16 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/http-logging.js +76 -0
  20. package/dist/http-logging.js.map +1 -0
  21. package/dist/image-captcha.js +173 -0
  22. package/dist/image-captcha.js.map +1 -0
  23. package/dist/image-metadata.js +128 -0
  24. package/dist/image-metadata.js.map +1 -0
  25. package/dist/import-security.js +46 -0
  26. package/dist/import-security.js.map +1 -0
  27. package/dist/logger.js +110 -0
  28. package/dist/logger.js.map +1 -0
  29. package/dist/parser.js +223 -0
  30. package/dist/parser.js.map +1 -0
  31. package/dist/public/ai-context-meter.js +10 -0
  32. package/dist/public/ai-conversation.js +3 -0
  33. package/dist/public/ai-mentions.js +55 -0
  34. package/dist/public/ai-message-actions.js +27 -0
  35. package/dist/public/ai-message-meta.js +15 -0
  36. package/dist/public/ai-message-time.js +23 -0
  37. package/dist/public/ai-prompt-keyboard.js +3 -0
  38. package/dist/public/app.js +4435 -0
  39. package/dist/public/character-profile.d.ts +9 -0
  40. package/dist/public/character-profile.js +65 -0
  41. package/dist/public/character-version.d.ts +2 -0
  42. package/dist/public/character-version.js +31 -0
  43. package/dist/public/entity-version.js +34 -0
  44. package/dist/public/icon.svg +10 -0
  45. package/dist/public/index.html +547 -0
  46. package/dist/public/line-number-layout.js +15 -0
  47. package/dist/public/markdown.js +199 -0
  48. package/dist/public/model-config.d.ts +17 -0
  49. package/dist/public/model-config.js +57 -0
  50. package/dist/public/page-route.d.ts +11 -0
  51. package/dist/public/page-route.js +81 -0
  52. package/dist/public/relationship-graph.js +2017 -0
  53. package/dist/public/site.webmanifest +17 -0
  54. package/dist/public/styles.css +1441 -0
  55. package/dist/public/text-formatting.d.ts +2 -0
  56. package/dist/public/text-formatting.js +20 -0
  57. package/dist/public/theme-init.js +8 -0
  58. package/dist/public/theme.js +13 -0
  59. package/dist/public/whitespace-visualization.js +20 -0
  60. package/dist/request-context.js +16 -0
  61. package/dist/request-context.js.map +1 -0
  62. package/dist/security.js +247 -0
  63. package/dist/security.js.map +1 -0
  64. package/dist/server-runtime.js +90 -0
  65. package/dist/server-runtime.js.map +1 -0
  66. package/dist/server.js +26 -0
  67. package/dist/server.js.map +1 -0
  68. package/dist/store.js +2568 -0
  69. package/dist/store.js.map +1 -0
  70. package/dist/user-auth.js +515 -0
  71. package/dist/user-auth.js.map +1 -0
  72. package/dist/utils.js +67 -0
  73. package/dist/utils.js.map +1 -0
  74. package/package.json +4 -9
@@ -0,0 +1,4435 @@
1
+ import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260719-group-relation-list";
2
+ import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
+ import { renderMarkdown } from "/markdown.js?v=20260717-markdown-table-scrollbar";
4
+ import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
5
+ import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
+ import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
7
+ import { MODEL_PURPOSE_OPTIONS, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260718-thinking-toggle";
8
+ import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
9
+ import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260713-persisted-output-tokens";
10
+ import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
11
+ import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
12
+ import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
13
+ import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
14
+ import { buildCharacterDetails, buildCharacterSections, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
15
+ import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260713-character-history";
16
+ import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260714-all-knowledge-history";
17
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260714-refresh-restore";
18
+ import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
19
+
20
+ const state = {
21
+ user: null,
22
+ csrfToken: null,
23
+ works: [],
24
+ work: null,
25
+ chapter: null,
26
+ module: "editor",
27
+ models: [],
28
+ characters: [],
29
+ races: [],
30
+ organizations: [],
31
+ timelineTracks: [],
32
+ aiCitations: [],
33
+ aiReferences: [],
34
+ aiPromptSent: false,
35
+ aiConversationId: null,
36
+ aiConversations: [],
37
+ aiLastMessageAt: null,
38
+ settings: [],
39
+ dirty: false,
40
+ pendingImportMeta: null,
41
+ pendingCoverWorkId: null,
42
+ relationshipGraph: null,
43
+ galaxy: null,
44
+ relationshipMindMap: null,
45
+ relationshipExpandedMap: null,
46
+ collapsedVolumeIds: new Set(),
47
+ contextChapterId: null
48
+ };
49
+
50
+ const chapterTypes = ["正文", "设定", "作者的话", "其他"];
51
+
52
+ const taskTypeLabels = MODEL_PURPOSE_OPTIONS;
53
+ const analysisTaskTypeLabels = new Map([
54
+ ...MODEL_PURPOSE_OPTIONS,
55
+ ["character-extraction", "全书角色抽取"],
56
+ ["character-summary", "全书角色抽取"],
57
+ ["worldview-analysis", "世界观分析"],
58
+ ["setting-extraction", "设定抽取"],
59
+ ["structure", "结构分析"],
60
+ ["report-update", "报告更新"]
61
+ ]);
62
+
63
+ function analysisTaskTypeLabel(taskType) {
64
+ return analysisTaskTypeLabels.get(String(taskType)) ?? String(taskType);
65
+ }
66
+
67
+ function analysisTaskStatusLabel(status) {
68
+ return ({
69
+ pending: "待执行",
70
+ running: "运行中",
71
+ review: "已完成",
72
+ completed: "已完成",
73
+ partial: "部分失败",
74
+ expired: "已过期",
75
+ cancelled: "已取消"
76
+ })[String(status)] ?? String(status);
77
+ }
78
+
79
+ function canEditWork(work = state.work) {
80
+ return ["admin", "owner", "editor"].includes(String(work?.accessRole));
81
+ }
82
+
83
+ function applyWorkAccessMode() {
84
+ const viewOnly = Boolean(state.work) && !canEditWork();
85
+ $("#app").classList.toggle("view-only-mode", viewOnly);
86
+ document.body.classList.toggle("work-viewer-mode", viewOnly);
87
+ $("#chapter-title").readOnly = viewOnly;
88
+ $("#chapter-content").readOnly = viewOnly;
89
+ $("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
90
+ $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
91
+ if (viewOnly) {
92
+ cancelChapterAutoSave();
93
+ state.dirty = false;
94
+ }
95
+ }
96
+
97
+ const $ = (selector) => document.querySelector(selector);
98
+ const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character]);
99
+ const maximumAvatarFileSize = 5 * 1024 * 1024;
100
+
101
+ function userAvatarInitial(user) {
102
+ return Array.from(String(user?.displayName || user?.username || "作"))[0] ?? "作";
103
+ }
104
+
105
+ function userAvatarHtml(user, extraClass = "") {
106
+ const image = user?.avatarUrl
107
+ ? `<img src="${esc(user.avatarUrl)}" alt="" loading="lazy" decoding="async" data-user-avatar-image>`
108
+ : "";
109
+ return `<span class="user-avatar ${esc(extraClass)}" aria-hidden="true"><span class="user-avatar-fallback">${esc(userAvatarInitial(user))}</span>${image}</span>`;
110
+ }
111
+
112
+ function bindUserAvatarFallbacks(root) {
113
+ root.querySelectorAll("[data-user-avatar-image]").forEach((image) => {
114
+ image.addEventListener("error", () => image.remove(), { once: true });
115
+ });
116
+ }
117
+
118
+ function renderUserAvatar(element, user) {
119
+ element.replaceChildren();
120
+ const fallback = document.createElement("span");
121
+ fallback.className = "user-avatar-fallback";
122
+ fallback.textContent = userAvatarInitial(user);
123
+ element.append(fallback);
124
+ if (!user?.avatarUrl) return;
125
+ const image = document.createElement("img");
126
+ image.alt = "";
127
+ image.decoding = "async";
128
+ image.src = user.avatarUrl;
129
+ image.addEventListener("error", () => image.remove(), { once: true });
130
+ element.append(image);
131
+ }
132
+
133
+ function renderProfileAvatar() {
134
+ renderUserAvatar($("#profile-avatar-preview"), state.user);
135
+ $("#avatar-upload-button").textContent = state.user?.avatarUrl ? "更换头像" : "上传头像";
136
+ $("#avatar-remove-button").classList.toggle("hidden", !state.user?.avatarUrl);
137
+ }
138
+ const platformDocumentTitle = "叙界 · 小说 AI 创作工作台";
139
+ const onboardingStoragePrefix = "scriverse-onboarding-v2";
140
+ const panelLayoutStorageKey = "ai-novel-panel-layout-v1";
141
+ const panelLayoutDefaults = Object.freeze({ leftWidth: 280, aiWidth: 360, leftCollapsed: false, aiCollapsed: false });
142
+ let restoringPageRoute = true;
143
+ let memberDialogWork = null;
144
+ let onboardingStep = 0;
145
+ let onboardingAutoScheduled = false;
146
+ let onboardingPositionFrame = null;
147
+ let onboardingSteps = [];
148
+ let loadedAiModelsWorkId = null;
149
+ let loadedAiReferencesWorkId = null;
150
+ let loadedAiConversationsWorkId = null;
151
+ let aiModelsLoadPromise = null;
152
+ let aiModelsLoadWorkId = null;
153
+ let aiReferencesLoadPromise = null;
154
+ let aiReferencesLoadWorkId = null;
155
+ let aiConversationsLoadPromise = null;
156
+ let aiConversationsLoadWorkId = null;
157
+
158
+ const shelfOnboardingSteps = [
159
+ { selector: "#home-button", eyebrow: "作品入口", title: "这里是你的创作书架", description: "点击左上角的叙界标志,可以随时回到书架,在不同作品之间切换。", placement: "bottom" },
160
+ { selector: "#shelf-new-work", eyebrow: "开始创作", title: "创建一部新作品", description: "从空白作品开始搭建分卷、章节和世界设定。", placement: "bottom" },
161
+ { selector: "#book-add-card", eyebrow: "导入或新建", title: "从书架添加作品", description: "这个卡片同样可以创建作品,也支持在进入工作台后导入 TXT 或 DOCX 稿件。", placement: "right" },
162
+ { selector: "[data-open-work]", eyebrow: "继续写作", title: "打开已有作品", description: "每部作品都有独立的正文、知识库、AI 上下文与协作权限。", placement: "right" },
163
+ { selector: "#theme-toggle", eyebrow: "显示偏好", title: "切换白天或黑夜模式", description: "主题和字体设置只保存在当前设备,不会影响其他协作者。", placement: "bottom" },
164
+ { selector: "#settings-button", eyebrow: "工作台设置", title: "集中管理创作环境", description: "进入设置后可以管理 AI、显示偏好、作品协作与导出。", placement: "bottom" },
165
+ { selector: "#account-button", eyebrow: "账户", title: "管理个人账户", description: "这里可以修改账户资料、退出登录,并随时重新打开功能导览。", placement: "bottom" }
166
+ ];
167
+
168
+ const workspaceOnboardingSteps = [
169
+ { selector: "#home-button", eyebrow: "作品入口", title: "随时返回书架", description: "点击叙界标志返回书架,切换作品或创建新的故事世界。", placement: "bottom" },
170
+ { selector: ".file-button", eyebrow: "稿件导入", title: "导入 TXT 或 DOCX", description: "已有稿件可以直接导入,系统会解析分卷与章节结构。", placement: "right" },
171
+ { selector: "#new-chapter-button", eyebrow: "正文结构", title: "新建章节", description: "使用分卷和章节组织长篇正文,章节会自动保存并保留版本。", placement: "right" },
172
+ { selector: "#versions-button", eyebrow: "版本安全", title: "查看章节版本", description: "每次保存都会生成可恢复版本,误改内容时可以随时回溯。", placement: "bottom" },
173
+ { selector: "[data-module=\"characters\"]", eyebrow: "作品知识", title: "维护角色与世界资料", description: "角色、种族、组织、设定和时间线共同构成 AI 可引用的作品知识。", placement: "right" },
174
+ { selector: "[data-module=\"outlines\"]", eyebrow: "创作规划", title: "跟踪大纲与伏笔", description: "记录剧情目标、冲突、转折和伏笔回收,避免长线遗漏。", placement: "right" },
175
+ { selector: "[data-module=\"tasks\"]", eyebrow: "AI 分析中心", title: "从这里理解整部小说", description: "运行人物、关系、世界观、设定、事件和一致性分析,并查看每次分析的结果与进度。", placement: "right" },
176
+ { selector: "#top-search-button", eyebrow: "全文检索", title: "搜索整部作品", description: "一次检索正文、角色、设定、种族与组织,快速定位创作依据。", placement: "bottom" },
177
+ { selector: ".quick-actions button[data-task=\"continue\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
178
+ { selector: "#ai-send", eyebrow: "AI 对话", title: "发送你的创作要求", description: "选择上下文范围与模型后发送任务。AI 结果默认只是建议,不会直接覆盖正文。", placement: "left" },
179
+ { selector: "#settings-button", eyebrow: "工作台设置", title: "管理 AI、协作与导出", description: "供应商、显示偏好、作品成员和 Markdown 导出都集中在这里。", placement: "bottom" },
180
+ { selector: "#account-button", eyebrow: "账户", title: "管理账户并重看导览", description: "账户菜单保存个人设置入口,也可以随时重新打开这套功能导览。", placement: "bottom" }
181
+ ];
182
+
183
+ function onboardingStorageKey() {
184
+ return `${onboardingStoragePrefix}:${state.user?.userId ?? "anonymous"}`;
185
+ }
186
+
187
+ function hasCompletedOnboarding() {
188
+ try { return localStorage.getItem(onboardingStorageKey()) === "completed"; }
189
+ catch { return false; }
190
+ }
191
+
192
+ function persistOnboardingCompletion() {
193
+ try { localStorage.setItem(onboardingStorageKey(), "completed"); }
194
+ catch { /* 浏览器禁用存储时仅在当前页面关闭导览 */ }
195
+ }
196
+
197
+ function isOnboardingTargetVisible(target) {
198
+ if (!target) return false;
199
+ const style = window.getComputedStyle(target);
200
+ const rect = target.getBoundingClientRect();
201
+ return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0
202
+ && rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0
203
+ && rect.left < window.innerWidth && rect.top < window.innerHeight;
204
+ }
205
+
206
+ function currentOnboardingSteps() {
207
+ const candidates = $("#app").classList.contains("shelf-mode") ? shelfOnboardingSteps : workspaceOnboardingSteps;
208
+ return candidates.filter((step) => isOnboardingTargetVisible(document.querySelector(step.selector)));
209
+ }
210
+
211
+ function onboardingPlacementCoordinates(placement, targetRect, popoverRect, gap) {
212
+ if (placement === "right") return { left: targetRect.right + gap, top: targetRect.top + (targetRect.height - popoverRect.height) / 2 };
213
+ if (placement === "left") return { left: targetRect.left - popoverRect.width - gap, top: targetRect.top + (targetRect.height - popoverRect.height) / 2 };
214
+ if (placement === "top") return { left: targetRect.left + (targetRect.width - popoverRect.width) / 2, top: targetRect.top - popoverRect.height - gap };
215
+ return { left: targetRect.left + (targetRect.width - popoverRect.width) / 2, top: targetRect.bottom + gap };
216
+ }
217
+
218
+ function onboardingPlacementFits(placement, targetRect, popoverRect, gap, margin) {
219
+ if (placement === "right") return targetRect.right + gap + popoverRect.width <= window.innerWidth - margin;
220
+ if (placement === "left") return targetRect.left - gap - popoverRect.width >= margin;
221
+ if (placement === "top") return targetRect.top - gap - popoverRect.height >= margin;
222
+ return targetRect.bottom + gap + popoverRect.height <= window.innerHeight - margin;
223
+ }
224
+
225
+ function positionOnboardingElements() {
226
+ onboardingPositionFrame = null;
227
+ const dialog = $("#onboarding-dialog");
228
+ const step = onboardingSteps[onboardingStep];
229
+ if (!dialog.open || !step) return;
230
+ const target = document.querySelector(step.selector);
231
+ if (!isOnboardingTargetVisible(target)) return;
232
+ const padding = 7;
233
+ const targetBox = target.getBoundingClientRect();
234
+ const targetRect = {
235
+ left: Math.max(0, targetBox.left - padding),
236
+ top: Math.max(0, targetBox.top - padding),
237
+ right: Math.min(window.innerWidth, targetBox.right + padding),
238
+ bottom: Math.min(window.innerHeight, targetBox.bottom + padding)
239
+ };
240
+ targetRect.width = targetRect.right - targetRect.left;
241
+ targetRect.height = targetRect.bottom - targetRect.top;
242
+ const spotlight = $("#onboarding-spotlight");
243
+ spotlight.style.left = `${targetRect.left}px`;
244
+ spotlight.style.top = `${targetRect.top}px`;
245
+ spotlight.style.width = `${targetRect.width}px`;
246
+ spotlight.style.height = `${targetRect.height}px`;
247
+
248
+ const popover = $("#onboarding-popover");
249
+ popover.style.visibility = "hidden";
250
+ popover.style.left = "0px";
251
+ popover.style.top = "0px";
252
+ const popoverRect = popover.getBoundingClientRect();
253
+ const gap = 18;
254
+ const margin = 14;
255
+ const placements = [step.placement, "right", "left", "bottom", "top"].filter((placement, index, values) => values.indexOf(placement) === index);
256
+ const placement = placements.find((candidate) => onboardingPlacementFits(candidate, targetRect, popoverRect, gap, margin)) ?? step.placement;
257
+ const coordinates = onboardingPlacementCoordinates(placement, targetRect, popoverRect, gap);
258
+ const left = Math.max(margin, Math.min(window.innerWidth - popoverRect.width - margin, coordinates.left));
259
+ const top = Math.max(margin, Math.min(window.innerHeight - popoverRect.height - margin, coordinates.top));
260
+ const arrowOffset = placement === "left" || placement === "right"
261
+ ? targetRect.top + targetRect.height / 2 - top
262
+ : targetRect.left + targetRect.width / 2 - left;
263
+ const maximumArrowOffset = (placement === "left" || placement === "right" ? popoverRect.height : popoverRect.width) - 26;
264
+ popover.dataset.placement = placement;
265
+ popover.style.setProperty("--onboarding-arrow-offset", `${Math.max(26, Math.min(maximumArrowOffset, arrowOffset))}px`);
266
+ popover.style.left = `${left}px`;
267
+ popover.style.top = `${top}px`;
268
+ popover.style.visibility = "visible";
269
+ }
270
+
271
+ function scheduleOnboardingPosition() {
272
+ if (!$("#onboarding-dialog").open) return;
273
+ if (onboardingPositionFrame !== null) return;
274
+ onboardingPositionFrame = window.requestAnimationFrame(positionOnboardingElements);
275
+ }
276
+
277
+ function refreshOnboardingForViewport() {
278
+ const dialog = $("#onboarding-dialog");
279
+ if (!dialog.open) return;
280
+ if (onboardingPositionFrame !== null) window.cancelAnimationFrame(onboardingPositionFrame);
281
+ onboardingPositionFrame = window.requestAnimationFrame(() => {
282
+ onboardingPositionFrame = null;
283
+ const currentSelector = onboardingSteps[onboardingStep]?.selector;
284
+ const refreshedSteps = currentOnboardingSteps();
285
+ if (!refreshedSteps.length) return;
286
+ onboardingSteps = refreshedSteps;
287
+ const preservedStep = onboardingSteps.findIndex((step) => step.selector === currentSelector);
288
+ renderOnboardingStep(preservedStep >= 0 ? preservedStep : 0);
289
+ });
290
+ }
291
+
292
+ function renderOnboardingStep(step, focusTitle = false) {
293
+ const lastStep = Math.max(0, onboardingSteps.length - 1);
294
+ onboardingStep = Math.max(0, Math.min(lastStep, Number(step) || 0));
295
+ const current = onboardingSteps[onboardingStep];
296
+ if (!current) return;
297
+ $("#onboarding-progress").textContent = `第 ${onboardingStep + 1} 步,共 ${onboardingSteps.length} 步`;
298
+ $("#onboarding-eyebrow").textContent = current.eyebrow;
299
+ $("#onboarding-dialog-title").textContent = current.title;
300
+ $("#onboarding-dialog-description").textContent = current.description;
301
+ $("#onboarding-previous").disabled = onboardingStep === 0;
302
+ $("#onboarding-next").textContent = onboardingStep === lastStep ? "完成导览" : "下一步";
303
+ $("#onboarding-dialog").dataset.step = String(onboardingStep + 1);
304
+ $("#onboarding-dialog").dataset.target = current.selector;
305
+ document.querySelector(current.selector)?.scrollIntoView({ block: "nearest", inline: "nearest" });
306
+ scheduleOnboardingPosition();
307
+ if (focusTitle) $("#onboarding-dialog-title").focus();
308
+ }
309
+
310
+ function openOnboarding(force = false) {
311
+ const dialog = $("#onboarding-dialog");
312
+ if (!force && hasCompletedOnboarding()) return;
313
+ onboardingSteps = currentOnboardingSteps();
314
+ if (!onboardingSteps.length) return;
315
+ onboardingStep = 0;
316
+ if (!dialog.open) dialog.showModal();
317
+ renderOnboardingStep(0);
318
+ window.requestAnimationFrame(() => $("#onboarding-dialog-title").focus());
319
+ }
320
+
321
+ function completeOnboarding() {
322
+ persistOnboardingCompletion();
323
+ if (onboardingPositionFrame !== null) window.cancelAnimationFrame(onboardingPositionFrame);
324
+ onboardingPositionFrame = null;
325
+ if ($("#onboarding-dialog").open) $("#onboarding-dialog").close();
326
+ }
327
+
328
+ function scheduleFirstUseOnboarding() {
329
+ if (onboardingAutoScheduled || hasCompletedOnboarding()) return;
330
+ onboardingAutoScheduled = true;
331
+ window.requestAnimationFrame(() => {
332
+ onboardingAutoScheduled = false;
333
+ if (state.user && !document.body.classList.contains("auth-pending")) openOnboarding();
334
+ });
335
+ }
336
+
337
+ function settingsRouteContext() {
338
+ const context = settingsReturnContext ?? {};
339
+ return {
340
+ returnView: context.view,
341
+ returnModule: context.module,
342
+ returnChapterId: context.chapterId
343
+ };
344
+ }
345
+
346
+ function replacePageRoute(route) {
347
+ if (restoringPageRoute) return;
348
+ const hash = serializePageRoute(route);
349
+ if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
350
+ }
351
+
352
+ function currentPageRoute() {
353
+ const workId = state.work?.id ?? null;
354
+ if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
355
+ if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
356
+ if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
357
+ if (!workId) return { view: "shelf" };
358
+ if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", workId, chapterId: state.chapter?.id ?? null };
359
+ if (!$("#module-view").classList.contains("hidden")) return { view: "module", workId, module: state.module };
360
+ return { view: "welcome", workId };
361
+ }
362
+
363
+ function loadPanelLayout() {
364
+ try {
365
+ const stored = JSON.parse(localStorage.getItem(panelLayoutStorageKey) ?? "{}");
366
+ return {
367
+ leftWidth: Math.round(Math.max(180, Math.min(520, Number(stored.leftWidth) || panelLayoutDefaults.leftWidth))),
368
+ aiWidth: Math.round(Math.max(260, Math.min(600, Number(stored.aiWidth) || panelLayoutDefaults.aiWidth))),
369
+ leftCollapsed: Boolean(stored.leftCollapsed),
370
+ aiCollapsed: Boolean(stored.aiCollapsed)
371
+ };
372
+ } catch {
373
+ return { ...panelLayoutDefaults };
374
+ }
375
+ }
376
+
377
+ let panelLayout = loadPanelLayout();
378
+
379
+ function constrainPanelLayout() {
380
+ const minimumMainWidth = 480;
381
+ if (!panelLayout.leftCollapsed) {
382
+ const available = window.innerWidth - (panelLayout.aiCollapsed ? 42 : panelLayout.aiWidth) - minimumMainWidth;
383
+ panelLayout.leftWidth = Math.max(180, Math.min(520, available, panelLayout.leftWidth));
384
+ }
385
+ if (!panelLayout.aiCollapsed) {
386
+ const available = window.innerWidth - (panelLayout.leftCollapsed ? 42 : panelLayout.leftWidth) - minimumMainWidth;
387
+ panelLayout.aiWidth = Math.max(260, Math.min(600, available, panelLayout.aiWidth));
388
+ }
389
+ }
390
+
391
+ function applyPanelLayout(persist = false) {
392
+ constrainPanelLayout();
393
+ const app = $("#app");
394
+ app.style.setProperty("--left-panel-width", `${panelLayout.leftWidth}px`);
395
+ app.style.setProperty("--ai-panel-width", `${panelLayout.aiWidth}px`);
396
+ app.classList.toggle("left-panel-collapsed", panelLayout.leftCollapsed);
397
+ app.classList.toggle("ai-panel-collapsed", panelLayout.aiCollapsed);
398
+ $("#left-panel-toggle").textContent = panelLayout.leftCollapsed ? "›" : "‹";
399
+ $("#left-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.leftCollapsed));
400
+ $("#left-panel-toggle").setAttribute("aria-label", panelLayout.leftCollapsed ? "展开作品侧栏" : "收起作品侧栏");
401
+ $("#ai-panel-toggle").textContent = panelLayout.aiCollapsed ? "‹" : "›";
402
+ $("#ai-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.aiCollapsed));
403
+ $("#ai-panel-toggle").setAttribute("aria-label", panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手");
404
+ scheduleChapterLineNumbers();
405
+ if (persist) {
406
+ try { localStorage.setItem(panelLayoutStorageKey, JSON.stringify(panelLayout)); } catch { /* 浏览器禁用存储时仅保留当前布局 */ }
407
+ }
408
+ }
409
+
410
+ function ensureAiPanelExpanded() {
411
+ if (!panelLayout.aiCollapsed) return;
412
+ panelLayout.aiCollapsed = false;
413
+ applyPanelLayout(true);
414
+ }
415
+
416
+ function setupPanelResize(handle, side) {
417
+ let resize = null;
418
+ const updateWidth = (width) => {
419
+ if (side === "left") panelLayout.leftWidth = width;
420
+ else panelLayout.aiWidth = width;
421
+ applyPanelLayout();
422
+ };
423
+ handle.addEventListener("pointerdown", (event) => {
424
+ if (event.button !== 0 || (side === "left" ? panelLayout.leftCollapsed : panelLayout.aiCollapsed)) return;
425
+ resize = { pointerId: event.pointerId, startX: event.clientX, startWidth: side === "left" ? panelLayout.leftWidth : panelLayout.aiWidth };
426
+ handle.setPointerCapture(event.pointerId);
427
+ document.body.classList.add("is-panel-resizing");
428
+ });
429
+ handle.addEventListener("pointermove", (event) => {
430
+ if (!resize || event.pointerId !== resize.pointerId) return;
431
+ updateWidth(resize.startWidth + (event.clientX - resize.startX) * (side === "left" ? 1 : -1));
432
+ });
433
+ const finish = (event) => {
434
+ if (!resize || event.pointerId !== resize.pointerId) return;
435
+ resize = null;
436
+ document.body.classList.remove("is-panel-resizing");
437
+ applyPanelLayout(true);
438
+ };
439
+ handle.addEventListener("pointerup", finish);
440
+ handle.addEventListener("pointercancel", finish);
441
+ handle.addEventListener("keydown", (event) => {
442
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
443
+ event.preventDefault();
444
+ const direction = event.key === "ArrowRight" ? 1 : -1;
445
+ const current = side === "left" ? panelLayout.leftWidth : panelLayout.aiWidth;
446
+ updateWidth(current + direction * 12 * (side === "left" ? 1 : -1));
447
+ applyPanelLayout(true);
448
+ });
449
+ }
450
+
451
+ let chapterLineNumberFrame = null;
452
+ let chapterLineSelection = null;
453
+ let chapterLineDrag = null;
454
+ let chapterWhitespaceVisible = true;
455
+ let chapterAutoSaveTimer = null;
456
+ let chapterSaveInFlight = null;
457
+ let lastSavedChapterSnapshot = null;
458
+ let moduleNavExpanded = false;
459
+ const chapterAutoSaveDelay = 800;
460
+ let aiMentionMatch = null;
461
+ let aiMentionRange = null;
462
+ let settingsReturnContext = null;
463
+ let characterEditorItem = null;
464
+ let characterEditorVersions = [];
465
+ let entityHistoryContext = null;
466
+
467
+ function setModuleNavExpanded(expanded) {
468
+ moduleNavExpanded = expanded;
469
+ $("#module-more-button").textContent = expanded ? "收起" : "更多";
470
+ $("#module-more-button").setAttribute("aria-expanded", String(expanded));
471
+ $("#module-nav").querySelectorAll(".module-nav-secondary").forEach((button) => button.classList.toggle("hidden", !expanded));
472
+ }
473
+
474
+ function syncChapterLineNumberScroll() {
475
+ const input = $("#chapter-content");
476
+ const inner = $("#chapter-line-numbers-inner");
477
+ const whitespace = $("#chapter-whitespace-inner");
478
+ if (!input || !inner) return;
479
+ inner.style.transform = `translateY(${-input.scrollTop}px)`;
480
+ inner.dataset.scrollTop = String(input.scrollTop);
481
+ if (whitespace) {
482
+ whitespace.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`;
483
+ whitespace.dataset.scrollTop = String(input.scrollTop);
484
+ }
485
+ }
486
+
487
+ function renderChapterWhitespaceMarkers(input, style) {
488
+ const overlay = $("#chapter-whitespace-overlay");
489
+ const inner = $("#chapter-whitespace-inner");
490
+ const button = $("#toggle-whitespace-button");
491
+ if (!overlay || !inner || !button) return;
492
+ overlay.classList.toggle("is-visible", chapterWhitespaceVisible);
493
+ button.setAttribute("aria-pressed", String(chapterWhitespaceVisible));
494
+ button.textContent = chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符";
495
+ if (!chapterWhitespaceVisible) {
496
+ inner.replaceChildren();
497
+ return;
498
+ }
499
+ Object.assign(inner.style, {
500
+ width: `${input.clientWidth}px`,
501
+ fontFamily: style.fontFamily,
502
+ fontSize: style.fontSize,
503
+ fontWeight: style.fontWeight,
504
+ fontStyle: style.fontStyle,
505
+ lineHeight: style.lineHeight,
506
+ letterSpacing: style.letterSpacing,
507
+ tabSize: style.tabSize,
508
+ padding: style.padding,
509
+ overflowWrap: style.overflowWrap,
510
+ wordBreak: style.wordBreak
511
+ });
512
+ const fragment = document.createDocumentFragment();
513
+ for (const token of tokenizeVisibleSpaces(input.value.replace(/\r\n?/gu, "\n"))) {
514
+ if (token.type === "text") {
515
+ fragment.append(document.createTextNode(token.text));
516
+ continue;
517
+ }
518
+ const marker = document.createElement("span");
519
+ marker.className = `chapter-space-marker ${token.type}`;
520
+ marker.textContent = token.text;
521
+ marker.dataset.spaceType = token.type;
522
+ fragment.append(marker);
523
+ }
524
+ inner.replaceChildren(fragment);
525
+ }
526
+
527
+ function renderChapterLineNumbers() {
528
+ const input = $("#chapter-content");
529
+ const inner = $("#chapter-line-numbers-inner");
530
+ const measure = $("#chapter-line-measure");
531
+ if (!input || !inner || !measure || input.clientWidth === 0) return;
532
+ const style = getComputedStyle(input);
533
+ const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
534
+ const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
535
+ const numberStyle = getComputedStyle($("#chapter-line-numbers"));
536
+ const numberLineHeight = parseFloat(numberStyle.lineHeight) || parseFloat(numberStyle.fontSize) * 1.2;
537
+ inner.style.top = `${calculateLineNumberTop(parseFloat(style.paddingTop), lineHeight, numberLineHeight)}px`;
538
+ const numberTextOffset = calculateLineNumberTextOffset(lineHeight, numberLineHeight);
539
+ Object.assign(measure.style, {
540
+ width: `${contentWidth}px`,
541
+ fontFamily: style.fontFamily,
542
+ fontSize: style.fontSize,
543
+ fontWeight: style.fontWeight,
544
+ fontStyle: style.fontStyle,
545
+ lineHeight: style.lineHeight,
546
+ letterSpacing: style.letterSpacing,
547
+ tabSize: style.tabSize
548
+ });
549
+ const lines = input.value.replace(/\r\n?/gu, "\n").split("\n");
550
+ const measureRows = lines.map((line) => {
551
+ const row = document.createElement("div");
552
+ row.textContent = line || "\u200b";
553
+ return row;
554
+ });
555
+ measure.replaceChildren(...measureRows);
556
+ const measureRect = measure.getBoundingClientRect();
557
+ const numbers = document.createDocumentFragment();
558
+ measureRows.forEach((row, index) => {
559
+ const number = document.createElement("button");
560
+ number.type = "button";
561
+ number.className = "chapter-line-number";
562
+ number.textContent = String(index + 1);
563
+ number.dataset.lineIndex = String(index);
564
+ number.setAttribute("aria-label", `选择第 ${index + 1} 行`);
565
+ number.tabIndex = -1;
566
+ const selected = chapterLineSelection && index >= chapterLineSelection.start && index <= chapterLineSelection.end;
567
+ if (selected) {
568
+ number.classList.add("is-line-selected");
569
+ number.setAttribute("aria-pressed", "true");
570
+ }
571
+ const rowRect = row.getBoundingClientRect();
572
+ const rowHeight = calculateLineNumberRowHeight(lineHeight, rowRect.height);
573
+ number.style.top = `${calculateLineNumberRowTop(measureRect.top, rowRect.top)}px`;
574
+ number.style.height = `${rowHeight}px`;
575
+ number.style.paddingTop = `${numberTextOffset}px`;
576
+ numbers.append(number);
577
+ });
578
+ inner.replaceChildren(numbers);
579
+ inner.style.height = `${measureRect.height}px`;
580
+ inner.dataset.lineCount = String(lines.length);
581
+ measure.replaceChildren();
582
+ renderChapterWhitespaceMarkers(input, style);
583
+ syncChapterLineNumberScroll();
584
+ }
585
+
586
+ function scheduleChapterLineNumbers() {
587
+ if (chapterLineNumberFrame !== null) return;
588
+ chapterLineNumberFrame = requestAnimationFrame(() => {
589
+ chapterLineNumberFrame = null;
590
+ renderChapterLineNumbers();
591
+ });
592
+ }
593
+
594
+ function collapseChapterInputBlankLines(input) {
595
+ const value = input.value;
596
+ const normalized = collapseExcessBlankLines(value);
597
+ if (normalized === value) return false;
598
+ const selectionStart = input.selectionStart ?? value.length;
599
+ const selectionEnd = input.selectionEnd ?? selectionStart;
600
+ const nextStart = collapseExcessBlankLines(value.slice(0, selectionStart)).length;
601
+ const nextEnd = collapseExcessBlankLines(value.slice(0, selectionEnd)).length;
602
+ input.value = normalized;
603
+ input.setSelectionRange(nextStart, nextEnd);
604
+ return true;
605
+ }
606
+
607
+ function lineIndexAtPointer(clientY) {
608
+ const rows = [...$("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number")];
609
+ if (!rows.length) return 0;
610
+ for (let index = 0; index < rows.length; index += 1) {
611
+ if (clientY < rows[index].getBoundingClientRect().bottom) return index;
612
+ }
613
+ return rows.length - 1;
614
+ }
615
+
616
+ function paintChapterLineSelection(anchor, focus) {
617
+ const start = Math.min(anchor, focus);
618
+ const end = Math.max(anchor, focus);
619
+ chapterLineSelection = { start, end };
620
+ $("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number").forEach((row) => {
621
+ const selected = Number(row.dataset.lineIndex) >= start && Number(row.dataset.lineIndex) <= end;
622
+ row.classList.toggle("is-line-selected", selected);
623
+ row.setAttribute("aria-pressed", String(selected));
624
+ });
625
+ }
626
+
627
+ function selectedChapterLinePayload(start, end) {
628
+ const input = $("#chapter-content");
629
+ const lines = input.value.replace(/\r\n?/gu, "\n").split("\n");
630
+ const safeStart = Math.max(0, Math.min(start, lines.length - 1));
631
+ const safeEnd = Math.max(safeStart, Math.min(end, lines.length - 1));
632
+ const text = lines.slice(safeStart, safeEnd + 1).join("\n");
633
+ const startOffset = lines.slice(0, safeStart).reduce((length, line) => length + line.length + 1, 0);
634
+ return { safeStart, safeEnd, text, startOffset };
635
+ }
636
+
637
+ function selectChapterLines(start, end) {
638
+ const input = $("#chapter-content");
639
+ const selection = selectedChapterLinePayload(start, end);
640
+ input.focus({ preventScroll: true });
641
+ input.setSelectionRange(selection.startOffset, selection.startOffset);
642
+ return selection;
643
+ }
644
+
645
+ function renderAiCitations() {
646
+ const host = $("#ai-citations");
647
+ host.replaceChildren();
648
+ host.classList.toggle("hidden", state.aiCitations.length === 0);
649
+ for (const citation of state.aiCitations) {
650
+ const card = document.createElement("article");
651
+ card.className = "ai-citation-card";
652
+ const main = document.createElement("button");
653
+ main.type = "button";
654
+ main.className = "ai-citation-main";
655
+ const source = document.createElement("strong");
656
+ source.textContent = citation.chapterTitle;
657
+ const range = document.createElement("small");
658
+ range.textContent = citation.startLine === citation.endLine ? `第 ${citation.startLine} 行` : `第 ${citation.startLine}-${citation.endLine} 行`;
659
+ const excerpt = document.createElement("span");
660
+ excerpt.textContent = citation.text.replace(/\s+/gu, " ").trim() || "空白行";
661
+ main.append(source, range, excerpt);
662
+ main.addEventListener("click", () => card.classList.toggle("is-expanded"));
663
+ const remove = document.createElement("button");
664
+ remove.type = "button";
665
+ remove.className = "ai-citation-remove";
666
+ remove.setAttribute("aria-label", `移除 ${citation.chapterTitle} 第 ${citation.startLine}-${citation.endLine} 行引用`);
667
+ remove.textContent = "×";
668
+ remove.addEventListener("click", () => {
669
+ state.aiCitations = state.aiCitations.filter((item) => item.id !== citation.id);
670
+ renderAiCitations();
671
+ });
672
+ const fullText = document.createElement("pre");
673
+ fullText.textContent = citation.text || "(空白行)";
674
+ card.append(main, remove, fullText);
675
+ host.append(card);
676
+ }
677
+ scheduleAiContextUsage();
678
+ }
679
+
680
+ function aiReferenceKey(reference) {
681
+ return `${reference.kind}:${reference.id}`;
682
+ }
683
+
684
+ function aiReferenceKindLabel(reference) {
685
+ return ({ character: "角色", setting: "设定", chapter: "章节" })[reference.kind] ?? "引用";
686
+ }
687
+
688
+ function createAiReferenceChip(reference) {
689
+ const chip = document.createElement("span");
690
+ chip.className = "ai-prompt-reference";
691
+ chip.contentEditable = "false";
692
+ chip.dataset.aiReferenceKey = aiReferenceKey(reference);
693
+ chip.setAttribute("role", "group");
694
+ chip.setAttribute("aria-label", `已引用${aiReferenceKindLabel(reference)} ${reference.name}`);
695
+ const label = document.createElement("span");
696
+ label.textContent = `${aiReferenceKindLabel(reference)} · ${reference.name}`;
697
+ const remove = document.createElement("button");
698
+ remove.type = "button";
699
+ remove.setAttribute("aria-label", `移除引用 ${reference.name}`);
700
+ remove.textContent = "×";
701
+ remove.addEventListener("click", () => {
702
+ state.aiReferences = state.aiReferences.filter((item) => aiReferenceKey(item) !== aiReferenceKey(reference));
703
+ renderAiReferences();
704
+ $("#ai-prompt").focus();
705
+ });
706
+ chip.append(label, remove);
707
+ return chip;
708
+ }
709
+
710
+ function renderAiReferences() {
711
+ const prompt = $("#ai-prompt");
712
+ const references = new Map(state.aiReferences.map((reference) => [aiReferenceKey(reference), reference]));
713
+ prompt.querySelectorAll("[data-ai-reference-key]").forEach((chip) => {
714
+ if (!references.has(chip.dataset.aiReferenceKey)) chip.remove();
715
+ });
716
+ const rendered = new Set([...prompt.querySelectorAll("[data-ai-reference-key]")].map((chip) => chip.dataset.aiReferenceKey));
717
+ for (const reference of state.aiReferences) {
718
+ if (rendered.has(aiReferenceKey(reference))) continue;
719
+ prompt.append(createAiReferenceChip(reference), document.createTextNode(" "));
720
+ }
721
+ scheduleAiContextUsage();
722
+ }
723
+
724
+ function renderAiQuickActions() {
725
+ const quickActions = $(".quick-actions");
726
+ const visible = shouldShowAiQuickActions(state.aiPromptSent);
727
+ quickActions.classList.toggle("hidden", !visible);
728
+ quickActions.setAttribute("aria-hidden", String(!visible));
729
+ }
730
+
731
+ function attachMessageHeading(message, label, createdAt = new Date().toISOString()) {
732
+ const timestamp = createdAt || new Date().toISOString();
733
+ const previousCreatedAt = state.aiLastMessageAt;
734
+ const heading = document.createElement("span");
735
+ heading.className = "message-heading";
736
+ const role = document.createElement("span");
737
+ role.textContent = label;
738
+ const time = document.createElement("time");
739
+ time.dateTime = timestamp;
740
+ time.textContent = formatAiMessageTime(timestamp, previousCreatedAt);
741
+ heading.append(role, time);
742
+ message.prepend(heading);
743
+ message.dataset.createdAt = timestamp;
744
+ message.dataset.previousCreatedAt = previousCreatedAt ?? "";
745
+ state.aiLastMessageAt = timestamp;
746
+ return heading;
747
+ }
748
+
749
+ function updateMessageCreatedAt(message, createdAt) {
750
+ if (!message || !createdAt) return;
751
+ const time = message.querySelector(".message-heading time");
752
+ if (!time) return;
753
+ time.dateTime = createdAt;
754
+ time.textContent = formatAiMessageTime(createdAt, message.dataset.previousCreatedAt || null);
755
+ message.dataset.createdAt = createdAt;
756
+ if (message === $("#ai-feed").lastElementChild) state.aiLastMessageAt = createdAt;
757
+ }
758
+
759
+ function resetAiFeed() {
760
+ state.aiLastMessageAt = null;
761
+ $("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
762
+ }
763
+
764
+ function renderMessageCardActions(message) {
765
+ let actions = message.querySelector(".message-card-actions");
766
+ if (!actions) {
767
+ actions = document.createElement("div");
768
+ actions.className = "message-card-actions";
769
+ message.append(actions);
770
+ }
771
+ actions.replaceChildren();
772
+ if (Object.hasOwn(message.dataset, "rawMarkdown")) {
773
+ const copy = document.createElement("button");
774
+ copy.type = "button";
775
+ copy.className = "message-copy-button";
776
+ copy.setAttribute("aria-label", "复制 AI 原始 Markdown");
777
+ copy.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span>复制</span>';
778
+ const copyLabel = copy.querySelector("span");
779
+ copy.addEventListener("click", async () => {
780
+ try {
781
+ await copyAiRawMarkdown(message.dataset.rawMarkdown);
782
+ copyLabel.textContent = "已复制";
783
+ window.setTimeout(() => { copyLabel.textContent = "复制"; }, 1200);
784
+ } catch (error) {
785
+ toast(error.message, "error");
786
+ }
787
+ });
788
+ actions.append(copy);
789
+ }
790
+ if (message.dataset.messageId && message.classList.contains("assistant-message")) {
791
+ const fork = document.createElement("button");
792
+ fork.type = "button";
793
+ fork.className = "message-fork-button";
794
+ fork.setAttribute("aria-label", "从此消息 Fork 新对话");
795
+ fork.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v2a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4V7M12 13v4"/></svg><span>分支</span>';
796
+ fork.addEventListener("click", async () => {
797
+ fork.disabled = true;
798
+ try {
799
+ const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, { method: "POST", body: { messageId: message.dataset.messageId } });
800
+ await loadAiConversations(false);
801
+ await openAiConversation(conversation.id);
802
+ toast("已从所选消息创建分支对话");
803
+ } catch (error) {
804
+ fork.disabled = false;
805
+ toast(error.message, "error");
806
+ }
807
+ });
808
+ actions.append(fork);
809
+ }
810
+ const copyAction = actions.querySelector(".message-copy-button");
811
+ if (copyAction) actions.append(copyAction);
812
+ message.classList.toggle("has-message-actions", actions.childElementCount > 0);
813
+ }
814
+
815
+ function attachAssistantCopyAction(message, rawMarkdown) {
816
+ message.dataset.rawMarkdown = String(rawMarkdown ?? "");
817
+ renderMessageCardActions(message);
818
+ }
819
+
820
+ function attachMessageIdentity(message, messageId) {
821
+ if (!messageId) return;
822
+ message.dataset.messageId = messageId;
823
+ renderMessageCardActions(message);
824
+ }
825
+
826
+ const AI_TOOL_DISPLAY_NAMES = {
827
+ story_index: "作品目录与章节概要",
828
+ read_chapters: "读取章节",
829
+ grep: "查询正文关键字",
830
+ query_story_knowledge: "查询作品知识"
831
+ };
832
+
833
+ const AI_TOOL_DESCRIPTIONS = {
834
+ story_index: "分页读取当前作品的卷章目录和章节概要。",
835
+ read_chapters: "读取指定章节的概要、正文或两者。",
836
+ grep: "查询正文关键字所在的完整段落及章节信息。",
837
+ query_story_knowledge: "按关键词查询设定、人物、组织、时间线等作品知识。"
838
+ };
839
+
840
+ let aiFeedScrollFrame = null;
841
+
842
+ function scrollAiFeedToBottom() {
843
+ const feed = $("#ai-feed");
844
+ feed.scrollTop = feed.scrollHeight;
845
+ if (aiFeedScrollFrame !== null) window.cancelAnimationFrame(aiFeedScrollFrame);
846
+ aiFeedScrollFrame = window.requestAnimationFrame(() => {
847
+ feed.scrollTop = feed.scrollHeight;
848
+ aiFeedScrollFrame = null;
849
+ });
850
+ }
851
+
852
+ function formatAiToolCallTime(value) {
853
+ if (!value) return "历史记录未保存";
854
+ const date = new Date(value);
855
+ if (Number.isNaN(date.getTime())) return "时间记录无效";
856
+ return new Intl.DateTimeFormat("zh-CN", {
857
+ year: "numeric",
858
+ month: "2-digit",
859
+ day: "2-digit",
860
+ hour: "2-digit",
861
+ minute: "2-digit",
862
+ second: "2-digit",
863
+ hour12: false
864
+ }).format(date);
865
+ }
866
+
867
+ function openAiToolCallDetail(toolCall) {
868
+ const name = String(toolCall?.name ?? "unknown");
869
+ const status = toolCall?.status === "failed" ? "调用失败" : "调用成功";
870
+ const calledAt = String(toolCall?.calledAt ?? "");
871
+ $("#ai-tool-call-status").textContent = status;
872
+ $("#ai-tool-call-title").textContent = `${AI_TOOL_DISPLAY_NAMES[name] ?? name} · ${name}`;
873
+ $("#ai-tool-call-name").textContent = name;
874
+ $("#ai-tool-call-description").textContent = AI_TOOL_DESCRIPTIONS[name] ?? "未登记此工具函数的用途说明。";
875
+ const time = $("#ai-tool-call-time");
876
+ time.textContent = formatAiToolCallTime(calledAt);
877
+ if (calledAt && !Number.isNaN(new Date(calledAt).getTime())) time.dateTime = new Date(calledAt).toISOString();
878
+ else time.removeAttribute("datetime");
879
+ $("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
880
+ $("#ai-tool-call-result").textContent = JSON.stringify(toolCall?.result ?? {}, null, 2);
881
+ $("#ai-tool-call-dialog").showModal();
882
+ }
883
+
884
+ function createAiToolCallButton(toolCall) {
885
+ const name = String(toolCall?.name ?? "unknown");
886
+ const button = document.createElement("button");
887
+ button.type = "button";
888
+ button.className = `ai-tool-call-summary${toolCall?.status === "failed" ? " is-failed" : ""}`;
889
+ button.setAttribute("aria-haspopup", "dialog");
890
+ button.textContent = toolCall?.status === "failed" ? `调用 ${name} 工具失败` : `调用了 ${name} 工具`;
891
+ button.title = AI_TOOL_DISPLAY_NAMES[name] ?? name;
892
+ button.addEventListener("click", () => openAiToolCallDetail(toolCall));
893
+ return button;
894
+ }
895
+
896
+ function aiToolProcessStep(toolCall, round = 1) {
897
+ const normalizedToolCall = { ...toolCall };
898
+ delete normalizedToolCall.round;
899
+ return {
900
+ id: `tool-${String(toolCall?.id ?? "unknown")}`,
901
+ type: "tool",
902
+ round: Number(round) || 1,
903
+ toolCall: normalizedToolCall,
904
+ createdAt: toolCall?.calledAt ?? new Date().toISOString()
905
+ };
906
+ }
907
+
908
+ function formatAiProcessDuration(value) {
909
+ const durationMs = Number(value);
910
+ if (!Number.isFinite(durationMs) || durationMs < 0) return "";
911
+ const totalSeconds = durationMs / 1000;
912
+ if (totalSeconds < 60) return `${Math.max(0.1, totalSeconds).toFixed(1)} 秒`;
913
+ const roundedSeconds = Math.round(totalSeconds);
914
+ const hours = Math.floor(roundedSeconds / 3600);
915
+ const minutes = Math.floor((roundedSeconds % 3600) / 60);
916
+ const seconds = roundedSeconds % 60;
917
+ if (hours > 0) return `${hours} 小时 ${minutes} 分 ${seconds} 秒`;
918
+ return `${minutes} 分 ${String(seconds).padStart(2, "0")} 秒`;
919
+ }
920
+
921
+ function resolveAiProcessDuration(metadata, steps, completedAt) {
922
+ const storedDuration = Number(metadata?.processDurationMs);
923
+ if (Number.isFinite(storedDuration) && storedDuration >= 0) return storedDuration;
924
+ const completedTime = new Date(completedAt ?? "").getTime();
925
+ if (!Number.isFinite(completedTime)) return null;
926
+ const startedTimes = steps
927
+ .map((step) => new Date(step?.createdAt ?? "").getTime())
928
+ .filter((value) => Number.isFinite(value));
929
+ if (!startedTimes.length) return null;
930
+ return Math.max(0, completedTime - Math.min(...startedTimes));
931
+ }
932
+
933
+ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
934
+ message.querySelector(".ai-process-details")?.remove();
935
+ if (!Array.isArray(steps) || !steps.length) return;
936
+ const details = document.createElement("details");
937
+ details.className = "ai-process-details";
938
+ details.open = !completed;
939
+ const summary = document.createElement("summary");
940
+ const title = document.createElement("span");
941
+ title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
942
+ const status = document.createElement("small");
943
+ const duration = durationMs === null || durationMs === undefined ? "" : formatAiProcessDuration(durationMs);
944
+ status.textContent = `${steps.length} 个步骤${duration ? ` · 耗时 ${duration}` : ""}`;
945
+ summary.append(title, status);
946
+ const list = document.createElement("div");
947
+ list.className = "ai-process-list";
948
+ for (const step of steps) {
949
+ if (step?.type === "tool" && step.toolCall) {
950
+ const tool = document.createElement("section");
951
+ tool.className = "ai-process-step ai-process-tool-step";
952
+ const label = document.createElement("small");
953
+ label.textContent = `第 ${Number(step.round) || 1} 轮 · 工具调用`;
954
+ tool.append(label, createAiToolCallButton(step.toolCall));
955
+ list.append(tool);
956
+ continue;
957
+ }
958
+ if (!step?.content || !["thinking", "intermediate"].includes(step.type)) continue;
959
+ const section = document.createElement("section");
960
+ section.className = `ai-process-step ai-process-${step.type}-step`;
961
+ const label = document.createElement("small");
962
+ label.textContent = `第 ${Number(step.round) || 1} 轮 · ${step.type === "thinking" ? "Thinking" : "中间输出"}`;
963
+ const body = document.createElement("div");
964
+ body.className = "message-body ai-process-step-body";
965
+ body.innerHTML = renderMarkdown(step.content);
966
+ section.append(label, body);
967
+ list.append(section);
968
+ }
969
+ details.append(summary, list);
970
+ const body = message.querySelector(".message-body");
971
+ if (body) body.before(details);
972
+ else message.append(details);
973
+ }
974
+
975
+ function renderAiToolCalls(message, toolCalls, completed = false) {
976
+ renderAiProcessSteps(message, (Array.isArray(toolCalls) ? toolCalls : []).map((toolCall) => aiToolProcessStep(toolCall)), completed);
977
+ }
978
+
979
+ function setAiHistoryVisible(visible) {
980
+ const dialog = $("#ai-history-dialog");
981
+ if (visible && !dialog.open) dialog.showModal();
982
+ else if (!visible && dialog.open) dialog.close();
983
+ $("#ai-history-toggle").setAttribute("aria-expanded", String(dialog.open));
984
+ }
985
+
986
+ function renderAiConversationHistory() {
987
+ const host = $("#ai-history-list");
988
+ host.replaceChildren();
989
+ if (!state.aiConversations.length) {
990
+ host.innerHTML = '<p class="ai-history-empty">还没有历史对话</p>';
991
+ return;
992
+ }
993
+ for (const conversation of state.aiConversations) {
994
+ const button = document.createElement("button");
995
+ button.type = "button";
996
+ button.className = `ai-history-item${conversation.id === state.aiConversationId ? " is-active" : ""}`;
997
+ button.dataset.aiConversationId = conversation.id;
998
+ const title = document.createElement("strong");
999
+ title.textContent = conversation.title;
1000
+ const meta = document.createElement("small");
1001
+ meta.textContent = `${conversation.messageCount} 条 · ${formatDateTime(conversation.updatedAt)}`;
1002
+ button.append(title, meta);
1003
+ button.addEventListener("click", () => openAiConversation(conversation.id));
1004
+ host.append(button);
1005
+ }
1006
+ }
1007
+
1008
+ async function loadAiConversations(openLatest = true) {
1009
+ const workId = state.work?.id;
1010
+ if (!workId) return;
1011
+ const conversations = await api(`/api/works/${workId}/ai-conversations`);
1012
+ if (state.work?.id !== workId) return;
1013
+ state.aiConversations = conversations;
1014
+ loadedAiConversationsWorkId = workId;
1015
+ renderAiConversationHistory();
1016
+ if (openLatest && state.aiConversations.length) await openAiConversation(state.aiConversations[0].id, false);
1017
+ else {
1018
+ const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1019
+ if (current) $("#ai-conversation-title").textContent = current.title;
1020
+ }
1021
+ }
1022
+
1023
+ async function ensureAiConversationsLoaded() {
1024
+ const workId = state.work?.id;
1025
+ if (!workId || loadedAiConversationsWorkId === workId) return;
1026
+ if (aiConversationsLoadPromise && aiConversationsLoadWorkId === workId) return aiConversationsLoadPromise;
1027
+ aiConversationsLoadWorkId = workId;
1028
+ aiConversationsLoadPromise = loadAiConversations(false);
1029
+ try {
1030
+ await aiConversationsLoadPromise;
1031
+ } finally {
1032
+ if (aiConversationsLoadWorkId === workId) {
1033
+ aiConversationsLoadPromise = null;
1034
+ aiConversationsLoadWorkId = null;
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ async function openAiConversation(conversationId, hideHistory = true) {
1040
+ const conversation = await api(`/api/ai-conversations/${conversationId}`);
1041
+ state.aiConversationId = conversation.id;
1042
+ state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1043
+ $("#ai-conversation-title").textContent = conversation.title;
1044
+ resetAiFeed();
1045
+ for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
1046
+ state.aiCitations = [];
1047
+ state.aiReferences = [];
1048
+ setAiPromptText("");
1049
+ renderAiCitations();
1050
+ renderAiReferences();
1051
+ renderAiQuickActions();
1052
+ renderAiConversationHistory();
1053
+ if (conversation.contextWarningPending) showAiContextWarning();
1054
+ else hideAiContextWarning();
1055
+ if (hideHistory) setAiHistoryVisible(false);
1056
+ }
1057
+
1058
+ async function createNewAiConversation() {
1059
+ if (!state.work) return;
1060
+ const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: {} });
1061
+ state.aiConversationId = conversation.id;
1062
+ state.aiPromptSent = false;
1063
+ $("#ai-conversation-title").textContent = conversation.title;
1064
+ resetAiFeed();
1065
+ hideAiContextWarning();
1066
+ renderAiQuickActions();
1067
+ setAiHistoryVisible(false);
1068
+ await loadAiConversations(false);
1069
+ }
1070
+
1071
+ async function ensureAiConversation() {
1072
+ if (state.aiConversationId) return state.aiConversationId;
1073
+ await createNewAiConversation();
1074
+ return state.aiConversationId;
1075
+ }
1076
+
1077
+ async function persistAiConversationMessage(role, content, citations = [], metadata = {}) {
1078
+ const conversationId = await ensureAiConversation();
1079
+ if (!conversationId) throw new Error("无法创建 AI 对话");
1080
+ const message = await api(`/api/ai-conversations/${conversationId}/messages`, { method: "POST", body: { role, content, citations, metadata } });
1081
+ await loadAiConversations(false);
1082
+ return message;
1083
+ }
1084
+
1085
+ function promptTextFromNode(node, root = node) {
1086
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
1087
+ if (!(node instanceof Element)) return "";
1088
+ if (node.matches("[data-ai-reference-key]")) return "";
1089
+ if (node.tagName === "BR") return "\n";
1090
+ const text = [...node.childNodes].map((child) => promptTextFromNode(child, root)).join("");
1091
+ return node !== root && ["DIV", "P"].includes(node.tagName) ? `${text}\n` : text;
1092
+ }
1093
+
1094
+ function aiPromptText() {
1095
+ return promptTextFromNode($("#ai-prompt")).replace(/\n$/u, "");
1096
+ }
1097
+
1098
+ function setAiPromptText(value) {
1099
+ const prompt = $("#ai-prompt");
1100
+ prompt.replaceChildren();
1101
+ if (value) prompt.append(document.createTextNode(value));
1102
+ renderAiReferences();
1103
+ }
1104
+
1105
+ function clearAiPromptComposer() {
1106
+ state.aiCitations = [];
1107
+ state.aiReferences = [];
1108
+ setAiPromptText("");
1109
+ renderAiCitations();
1110
+ hideAiMentionMenu();
1111
+ }
1112
+
1113
+ function aiPromptTextBeforeCursor() {
1114
+ const prompt = $("#ai-prompt");
1115
+ const selection = window.getSelection();
1116
+ if (!selection?.rangeCount || !prompt.contains(selection.anchorNode)) return aiPromptText();
1117
+ const range = selection.getRangeAt(0).cloneRange();
1118
+ range.selectNodeContents(prompt);
1119
+ range.setEnd(selection.anchorNode, selection.anchorOffset);
1120
+ const fragment = document.createElement("div");
1121
+ fragment.append(range.cloneContents());
1122
+ return promptTextFromNode(fragment).replace(/\n$/u, "");
1123
+ }
1124
+
1125
+ function hideAiMentionMenu() {
1126
+ aiMentionMatch = null;
1127
+ aiMentionRange = null;
1128
+ $("#ai-mention-menu").classList.add("hidden");
1129
+ }
1130
+
1131
+ function syncAiReferencesWithPrompt() {
1132
+ const prompt = $("#ai-prompt");
1133
+ const activeKeys = new Set([...prompt.querySelectorAll("[data-ai-reference-key]")].map((chip) => chip.dataset.aiReferenceKey));
1134
+ const activeReferences = state.aiReferences.filter((reference) => activeKeys.has(aiReferenceKey(reference)));
1135
+ if (activeReferences.length !== state.aiReferences.length) {
1136
+ state.aiReferences = activeReferences;
1137
+ renderAiReferences();
1138
+ }
1139
+ }
1140
+
1141
+ function updateAiMentionMenu() {
1142
+ syncAiReferencesWithPrompt();
1143
+ const prompt = $("#ai-prompt");
1144
+ const match = findAiMention(aiPromptTextBeforeCursor());
1145
+ if (!match) return hideAiMentionMenu();
1146
+ const selection = window.getSelection();
1147
+ if (!selection?.rangeCount || !prompt.contains(selection.anchorNode)) return hideAiMentionMenu();
1148
+ aiMentionMatch = match;
1149
+ aiMentionRange = selection.getRangeAt(0).cloneRange();
1150
+ const menu = $("#ai-mention-menu");
1151
+ const chapters = state.work?.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
1152
+ ...chapter,
1153
+ volumeTitle: volume.title
1154
+ }))) ?? [];
1155
+ const options = listAiMentionOptions(state.characters, state.settings, chapters, match.query);
1156
+ menu.innerHTML = options.length
1157
+ ? options.map((item) => `<button class="ai-mention-option" type="button" role="option" data-ai-reference-kind="${esc(item.kind)}" data-ai-reference-id="${esc(item.id)}" data-ai-reference-name="${esc(item.name)}"><small>${esc(item.kindLabel)}</small><strong>${esc(item.name)}</strong></button>`).join("")
1158
+ : '<p class="ai-mention-empty">没有匹配的角色、设定或章节</p>';
1159
+ menu.classList.remove("hidden");
1160
+ }
1161
+
1162
+ function selectAiMention(button) {
1163
+ if (!aiMentionMatch || !aiMentionRange) return;
1164
+ const prompt = $("#ai-prompt");
1165
+ const reference = {
1166
+ kind: button.dataset.aiReferenceKind,
1167
+ id: button.dataset.aiReferenceId,
1168
+ name: button.dataset.aiReferenceName
1169
+ };
1170
+ const range = aiMentionRange.cloneRange();
1171
+ const textNode = range.startContainer;
1172
+ const localText = textNode.nodeType === Node.TEXT_NODE ? textNode.textContent?.slice(0, range.startOffset) ?? "" : "";
1173
+ const localMention = findAiMention(localText);
1174
+ if (!localMention) return hideAiMentionMenu();
1175
+ range.setStart(textNode, localMention.start);
1176
+ range.deleteContents();
1177
+ const spacer = document.createTextNode(" ");
1178
+ range.insertNode(spacer);
1179
+ if (!state.aiReferences.some((item) => aiReferenceKey(item) === aiReferenceKey(reference))) {
1180
+ state.aiReferences.push(reference);
1181
+ range.insertNode(createAiReferenceChip(reference));
1182
+ }
1183
+ const selection = window.getSelection();
1184
+ selection?.removeAllRanges();
1185
+ range.setStartAfter(spacer);
1186
+ range.collapse(true);
1187
+ selection?.addRange(range);
1188
+ prompt.focus();
1189
+ renderAiReferences();
1190
+ hideAiMentionMenu();
1191
+ }
1192
+
1193
+ function addSelectedLinesAsCitation() {
1194
+ if (!state.chapter || !chapterLineSelection) return;
1195
+ const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
1196
+ const citation = {
1197
+ id: `${state.chapter.id}:${selection.safeStart}:${selection.safeEnd}`,
1198
+ chapterId: state.chapter.id,
1199
+ chapterTitle: state.chapter.title,
1200
+ startLine: selection.safeStart + 1,
1201
+ endLine: selection.safeEnd + 1,
1202
+ text: selection.text
1203
+ };
1204
+ const existing = state.aiCitations.findIndex((item) => item.id === citation.id);
1205
+ if (existing < 0 && state.aiCitations.length >= 20) return toast("一次最多添加 20 条正文引用", "error");
1206
+ if (existing >= 0) state.aiCitations.splice(existing, 1, citation);
1207
+ else state.aiCitations.push(citation);
1208
+ ensureAiPanelExpanded();
1209
+ renderAiCitations();
1210
+ closeLineCitationMenu();
1211
+ toast(`已引用《${citation.chapterTitle}》第 ${citation.startLine}${citation.startLine === citation.endLine ? "" : `-${citation.endLine}`} 行`);
1212
+ }
1213
+
1214
+ function closeLineCitationMenu() {
1215
+ $("#line-citation-menu").classList.add("hidden");
1216
+ }
1217
+
1218
+ function showLineCitationMenu(event, lineIndex) {
1219
+ event.preventDefault();
1220
+ if (!chapterLineSelection || lineIndex < chapterLineSelection.start || lineIndex > chapterLineSelection.end) {
1221
+ paintChapterLineSelection(lineIndex, lineIndex);
1222
+ selectChapterLines(lineIndex, lineIndex);
1223
+ }
1224
+ const menu = $("#line-citation-menu");
1225
+ const { start, end } = chapterLineSelection;
1226
+ $("#line-citation-label").textContent = start === end ? `第 ${start + 1} 行` : `第 ${start + 1}-${end + 1} 行`;
1227
+ menu.classList.remove("hidden");
1228
+ const rect = menu.getBoundingClientRect();
1229
+ menu.style.left = `${Math.max(8, Math.min(event.clientX, window.innerWidth - rect.width - 8))}px`;
1230
+ menu.style.top = `${Math.max(8, Math.min(event.clientY, window.innerHeight - rect.height - 8))}px`;
1231
+ }
1232
+
1233
+ function clearChapterLineSelection() {
1234
+ chapterLineSelection = null;
1235
+ $("#chapter-line-numbers-inner")?.querySelectorAll(".is-line-selected").forEach((row) => {
1236
+ row.classList.remove("is-line-selected");
1237
+ row.setAttribute("aria-pressed", "false");
1238
+ });
1239
+ }
1240
+
1241
+ const typographyStorageKey = "ai-novel-typography-v1";
1242
+ const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, density: "balanced" });
1243
+ const cjkFontStacks = {
1244
+ system: '"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC"',
1245
+ pingfang: '"PingFang SC", "Heiti SC", "Microsoft YaHei", "Noto Sans CJK SC"',
1246
+ heiti: '"Heiti SC", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"',
1247
+ yahei: '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Heiti SC"',
1248
+ "noto-sans": '"Noto Sans CJK SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei"'
1249
+ };
1250
+ const latinFontStacks = {
1251
+ system: '"SFMono-Regular", "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono"',
1252
+ "sf-mono": '"SFMono-Regular", "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono"',
1253
+ menlo: 'Menlo, Monaco, Consolas, "Liberation Mono", "SFMono-Regular"',
1254
+ monaco: 'Monaco, Menlo, Consolas, "Liberation Mono", "SFMono-Regular"',
1255
+ consolas: 'Consolas, "Liberation Mono", Menlo, Monaco, "SFMono-Regular"'
1256
+ };
1257
+ const typographyFontSizes = [15, 16, 17, 18, 20];
1258
+ const densityLineHeights = { compact: 1.4, balanced: 1.55, relaxed: 1.75 };
1259
+
1260
+ function normalizeTypographySettings(input) {
1261
+ const value = input && typeof input === "object" ? input : {};
1262
+ const fontSize = Number(value.fontSize);
1263
+ return {
1264
+ cjkFont: Object.hasOwn(cjkFontStacks, value.cjkFont) ? value.cjkFont : typographyDefaults.cjkFont,
1265
+ latinFont: Object.hasOwn(latinFontStacks, value.latinFont) ? value.latinFont : typographyDefaults.latinFont,
1266
+ fontSize: typographyFontSizes.includes(fontSize) ? fontSize : typographyDefaults.fontSize,
1267
+ density: Object.hasOwn(densityLineHeights, value.density) ? value.density : typographyDefaults.density
1268
+ };
1269
+ }
1270
+
1271
+ function loadTypographySettings() {
1272
+ try {
1273
+ return normalizeTypographySettings(JSON.parse(localStorage.getItem(typographyStorageKey) ?? "{}"));
1274
+ } catch {
1275
+ return { ...typographyDefaults };
1276
+ }
1277
+ }
1278
+
1279
+ let typographySettings = loadTypographySettings();
1280
+
1281
+ function currentColorTheme() {
1282
+ return normalizeTheme(document.documentElement.dataset.theme, window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
1283
+ }
1284
+
1285
+ function applyColorTheme(theme) {
1286
+ const normalized = normalizeTheme(theme);
1287
+ const root = document.documentElement;
1288
+ root.dataset.theme = normalized;
1289
+ root.style.colorScheme = normalized;
1290
+ const label = themeToggleLabel(normalized);
1291
+ const button = $("#theme-toggle");
1292
+ button.setAttribute("aria-label", label);
1293
+ button.setAttribute("title", label);
1294
+ button.setAttribute("aria-pressed", String(normalized === "dark"));
1295
+ const themeColor = document.querySelector('meta[name="theme-color"]');
1296
+ if (themeColor) themeColor.content = normalized === "dark" ? "#171714" : "#8b3d2c";
1297
+ }
1298
+
1299
+ function saveColorTheme(theme) {
1300
+ const normalized = normalizeTheme(theme);
1301
+ applyColorTheme(normalized);
1302
+ try {
1303
+ localStorage.setItem(THEME_STORAGE_KEY, normalized);
1304
+ return true;
1305
+ } catch {
1306
+ return false;
1307
+ }
1308
+ }
1309
+
1310
+ function applyTypographySettings(settings) {
1311
+ const normalized = normalizeTypographySettings(settings);
1312
+ const root = document.documentElement;
1313
+ root.style.setProperty("--font-cjk", cjkFontStacks[normalized.cjkFont]);
1314
+ root.style.setProperty("--font-latin", latinFontStacks[normalized.latinFont]);
1315
+ root.style.setProperty("--editor-font-size", `${normalized.fontSize}px`);
1316
+ root.style.setProperty("--editor-line-height", String(densityLineHeights[normalized.density]));
1317
+ root.dataset.cjkFont = normalized.cjkFont;
1318
+ root.dataset.latinFont = normalized.latinFont;
1319
+ root.dataset.fontSize = String(normalized.fontSize);
1320
+ root.dataset.density = normalized.density;
1321
+ scheduleChapterLineNumbers();
1322
+ }
1323
+
1324
+ function saveTypographySettings(settings) {
1325
+ typographySettings = normalizeTypographySettings(settings);
1326
+ applyTypographySettings(typographySettings);
1327
+ try {
1328
+ localStorage.setItem(typographyStorageKey, JSON.stringify(typographySettings));
1329
+ return true;
1330
+ } catch {
1331
+ return false;
1332
+ }
1333
+ }
1334
+
1335
+ function fillAppearanceForm(settings) {
1336
+ const normalized = normalizeTypographySettings(settings);
1337
+ $("#appearance-cjk-font").value = normalized.cjkFont;
1338
+ $("#appearance-latin-font").value = normalized.latinFont;
1339
+ $("#appearance-font-size").value = String(normalized.fontSize);
1340
+ $("#appearance-density").value = normalized.density;
1341
+ }
1342
+
1343
+ function readAppearanceForm() {
1344
+ const form = new FormData($("#appearance-form"));
1345
+ return normalizeTypographySettings({
1346
+ cjkFont: form.get("cjkFont"),
1347
+ latinFont: form.get("latinFont"),
1348
+ fontSize: form.get("fontSize"),
1349
+ density: form.get("density")
1350
+ });
1351
+ }
1352
+
1353
+ function renderTypographyPreview() {
1354
+ const settings = readAppearanceForm();
1355
+ const preview = $("#font-preview");
1356
+ preview.style.fontFamily = `${latinFontStacks[settings.latinFont]}, ${cjkFontStacks[settings.cjkFont]}, monospace, sans-serif`;
1357
+ preview.style.fontSize = `${settings.fontSize}px`;
1358
+ preview.style.lineHeight = String(densityLineHeights[settings.density]);
1359
+ }
1360
+
1361
+ function openAppearanceDialog() {
1362
+ fillAppearanceForm(typographySettings);
1363
+ renderTypographyPreview();
1364
+ $("#appearance-dialog").showModal();
1365
+ }
1366
+
1367
+ applyTypographySettings(typographySettings);
1368
+ applyColorTheme(currentColorTheme());
1369
+ applyPanelLayout();
1370
+
1371
+ async function api(path, options = {}) {
1372
+ const method = String(options.method ?? "GET").toUpperCase();
1373
+ const headers = { ...(options.headers ?? {}) };
1374
+ if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
1375
+ if (!(options.body instanceof FormData)) headers["Content-Type"] = "application/json";
1376
+ const response = await fetch(path, options.body instanceof FormData ? { ...options, headers } : {
1377
+ ...options,
1378
+ headers,
1379
+ body: options.body && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body
1380
+ });
1381
+ if (!response.ok) {
1382
+ const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
1383
+ if (response.status === 401 && !path.startsWith("/api/auth/")) showAuth(false);
1384
+ throw new Error(payload.error?.message ?? `请求失败:${response.status}`);
1385
+ }
1386
+ if (response.status === 204) return null;
1387
+ const payload = await response.json();
1388
+ return payload.data;
1389
+ }
1390
+
1391
+ function selectAuthMode(mode) {
1392
+ const login = mode === "login";
1393
+ $("#auth-login-tab").setAttribute("aria-selected", String(login));
1394
+ $("#auth-register-tab").setAttribute("aria-selected", String(!login));
1395
+ $("#login-form").classList.toggle("hidden", !login);
1396
+ $("#register-form").classList.toggle("hidden", login);
1397
+ $("#auth-error").textContent = "";
1398
+ refreshAuthCaptcha(login ? "login" : "register").catch(() => {});
1399
+ }
1400
+
1401
+ async function refreshAuthCaptcha(target = "login") {
1402
+ const response = await fetch("/api/auth/captcha", { headers: { Accept: "application/json" } });
1403
+ if (!response.ok) throw new Error("无法加载验证码");
1404
+ const challenge = (await response.json()).data;
1405
+ const prefix = target === "register" ? "register" : "login";
1406
+ $(`#${prefix}-captcha-id`).value = challenge.captchaId;
1407
+ $(`#${prefix}-captcha-image`).src = challenge.imageDataUrl;
1408
+ const answerInput = $(`#${prefix}-form`).querySelector('input[name="captchaAnswer"]');
1409
+ if (answerInput) answerInput.value = "";
1410
+ }
1411
+
1412
+ function showAuth(setupRequired, registrationOpen = true) {
1413
+ document.body.classList.add("auth-pending");
1414
+ $("#auth-view").classList.remove("hidden");
1415
+ $("#auth-title").textContent = setupRequired ? "创建首个管理员账户" : "登录后继续创作";
1416
+ $("#auth-description").textContent = setupRequired
1417
+ ? "这是首次启动。首个注册用户会成为系统管理员,并接管现有作品。"
1418
+ : "你的作品、协作权限和每一次修改都会绑定到账户。";
1419
+ const canRegister = setupRequired || registrationOpen;
1420
+ $("#auth-register-tab").classList.toggle("hidden", !canRegister);
1421
+ selectAuthMode(setupRequired ? "register" : "login");
1422
+ }
1423
+
1424
+ function applyAuthenticatedUser(session) {
1425
+ state.user = session.user;
1426
+ state.csrfToken = session.csrfToken;
1427
+ $("#account-name").textContent = session.user.displayName;
1428
+ renderUserAvatar($("#account-avatar"), session.user);
1429
+ $("#account-menu-name").textContent = `${session.user.displayName} · @${session.user.username}`;
1430
+ $("#account-menu-role").textContent = session.user.role === "admin" ? "系统管理员" : "普通用户";
1431
+ $("#auth-view").classList.add("hidden");
1432
+ document.body.classList.remove("auth-pending");
1433
+ }
1434
+
1435
+ function applyPlatformUiSettings(settings) {
1436
+ const position = settings?.toastPosition === "top-right" ? "top-right" : "bottom-right";
1437
+ $("#toast-region").dataset.position = position;
1438
+ }
1439
+
1440
+ async function loadPlatformUiSettings() {
1441
+ try {
1442
+ applyPlatformUiSettings(await api("/api/ui-settings"));
1443
+ } catch {
1444
+ applyPlatformUiSettings({ toastPosition: "bottom-right" });
1445
+ }
1446
+ }
1447
+
1448
+ async function initializeAuthentication() {
1449
+ const response = await fetch("/api/auth/session", { headers: { Accept: "application/json" } });
1450
+ if (!response.ok) throw new Error("无法读取登录状态");
1451
+ const session = (await response.json()).data;
1452
+ if (!session.authenticated) {
1453
+ showAuth(session.setupRequired, session.registrationOpen !== false);
1454
+ return false;
1455
+ }
1456
+ applyAuthenticatedUser(session);
1457
+ await loadPlatformUiSettings();
1458
+ return true;
1459
+ }
1460
+
1461
+ function raiseToastRegion() {
1462
+ const region = $("#toast-region");
1463
+ if (typeof region.showPopover !== "function") return;
1464
+ if (region.matches(":popover-open")) region.hidePopover();
1465
+ region.showPopover();
1466
+ }
1467
+
1468
+ function toast(message, type = "info") {
1469
+ const region = $("#toast-region");
1470
+ const element = document.createElement("div");
1471
+ element.className = `toast ${type}`;
1472
+ element.textContent = message;
1473
+ region.append(element);
1474
+ raiseToastRegion();
1475
+ setTimeout(() => {
1476
+ element.remove();
1477
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
1478
+ region.hidePopover();
1479
+ }
1480
+ }, 3600);
1481
+ }
1482
+
1483
+ document.addEventListener("toggle", (event) => {
1484
+ const target = event.target;
1485
+ if (target instanceof HTMLDialogElement && target.open && $("#toast-region").childElementCount) {
1486
+ raiseToastRegion();
1487
+ }
1488
+ }, true);
1489
+
1490
+ function setSaveState(text, dirty = false) {
1491
+ state.dirty = dirty;
1492
+ $("#save-state").textContent = text;
1493
+ $("#save-state").style.color = dirty ? "var(--accent)" : "var(--green)";
1494
+ }
1495
+
1496
+ function chapterDraftSnapshot() {
1497
+ if (!state.chapter) return null;
1498
+ return {
1499
+ chapterId: state.chapter.id,
1500
+ title: $("#chapter-title").value.trim(),
1501
+ content: normalizeParagraphSpacing($("#chapter-content").value)
1502
+ };
1503
+ }
1504
+
1505
+ function sameChapterSnapshot(left, right) {
1506
+ return Boolean(left && right && left.chapterId === right.chapterId && left.title === right.title && left.content === right.content);
1507
+ }
1508
+
1509
+ function cancelChapterAutoSave() {
1510
+ if (chapterAutoSaveTimer !== null) clearTimeout(chapterAutoSaveTimer);
1511
+ chapterAutoSaveTimer = null;
1512
+ }
1513
+
1514
+ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1515
+ if (!state.chapter || !canEditWork()) return;
1516
+ cancelChapterAutoSave();
1517
+ setSaveState("等待自动保存", true);
1518
+ chapterAutoSaveTimer = setTimeout(() => {
1519
+ chapterAutoSaveTimer = null;
1520
+ persistChapter({ automatic: true });
1521
+ }, delay);
1522
+ }
1523
+
1524
+ async function persistChapter({ automatic = false } = {}) {
1525
+ if (!canEditWork()) return null;
1526
+ if (!state.chapter) {
1527
+ if (!automatic) toast("请先选择章节", "error");
1528
+ return null;
1529
+ }
1530
+ cancelChapterAutoSave();
1531
+ if (chapterSaveInFlight) {
1532
+ await chapterSaveInFlight;
1533
+ const pendingDraft = chapterDraftSnapshot();
1534
+ if (!sameChapterSnapshot(pendingDraft, lastSavedChapterSnapshot)) return persistChapter({ automatic });
1535
+ return state.chapter;
1536
+ }
1537
+ const draft = chapterDraftSnapshot();
1538
+ if (!draft?.title) {
1539
+ setSaveState("标题不能为空", true);
1540
+ if (!automatic) toast("章节标题不能为空", "error");
1541
+ return null;
1542
+ }
1543
+ const input = $("#chapter-content");
1544
+ if (input.value !== draft.content) {
1545
+ input.value = draft.content;
1546
+ scheduleChapterLineNumbers();
1547
+ }
1548
+ if (sameChapterSnapshot(draft, lastSavedChapterSnapshot)) {
1549
+ setSaveState(automatic ? "已自动保存" : "已保存");
1550
+ return state.chapter;
1551
+ }
1552
+ setSaveState(automatic ? "自动保存中" : "保存中", true);
1553
+ const workId = state.work.id;
1554
+ const request = (async () => {
1555
+ const chapter = await api(`/api/chapters/${draft.chapterId}`, {
1556
+ method: "PATCH",
1557
+ body: { title: draft.title, content: draft.content, source: automatic ? "auto" : "manual" }
1558
+ });
1559
+ const work = await api(`/api/works/${workId}`);
1560
+ return { chapter, work };
1561
+ })();
1562
+ chapterSaveInFlight = request;
1563
+ try {
1564
+ const saved = await request;
1565
+ if (state.work?.id !== workId || state.chapter?.id !== draft.chapterId) return saved.chapter;
1566
+ state.chapter = saved.chapter;
1567
+ state.work = saved.work;
1568
+ lastSavedChapterSnapshot = draft;
1569
+ renderTree();
1570
+ updateChapterStats();
1571
+ const currentDraft = chapterDraftSnapshot();
1572
+ if (sameChapterSnapshot(currentDraft, draft)) {
1573
+ setSaveState(automatic ? "已自动保存" : "已保存");
1574
+ if (!automatic) toast(`正文已保存为 v${state.chapter.versionNo}`);
1575
+ } else {
1576
+ scheduleChapterAutoSave(250);
1577
+ }
1578
+ return state.chapter;
1579
+ } catch (error) {
1580
+ if (state.chapter?.id === draft.chapterId) setSaveState("自动保存失败", true);
1581
+ toast(error.message, "error");
1582
+ return null;
1583
+ } finally {
1584
+ if (chapterSaveInFlight === request) chapterSaveInFlight = null;
1585
+ }
1586
+ }
1587
+
1588
+ function confirmDiscardChanges(message = "当前章节有未保存修改,继续将丢弃这些修改。是否继续?") {
1589
+ if (!state.dirty) return true;
1590
+ return window.confirm(message);
1591
+ }
1592
+
1593
+ function updateDocumentTitle(work = null) {
1594
+ const workTitle = String(work?.title ?? "").trim();
1595
+ document.title = workTitle ? `${workTitle} · 叙界` : platformDocumentTitle;
1596
+ }
1597
+
1598
+ async function loadWorks(preferredId) {
1599
+ state.works = await api("/api/works");
1600
+ if (preferredId) {
1601
+ await selectWork(preferredId);
1602
+ return;
1603
+ }
1604
+ showShelf();
1605
+ }
1606
+
1607
+ function restoredSettingsReturnContext(route) {
1608
+ if (route.returnView === "module" && route.returnModule) return { view: "module", module: route.returnModule };
1609
+ if (route.returnView === "editor" && route.returnChapterId) return { view: "editor", chapterId: route.returnChapterId };
1610
+ if (route.returnView === "welcome") return { view: "welcome" };
1611
+ if (route.returnView === "shelf") return { view: "shelf" };
1612
+ if (state.work && state.chapter) return { view: "editor", chapterId: state.chapter.id };
1613
+ if (state.work) return { view: "welcome" };
1614
+ return { view: "shelf" };
1615
+ }
1616
+
1617
+ async function initializePage() {
1618
+ if (!(await initializeAuthentication())) {
1619
+ restoringPageRoute = false;
1620
+ return;
1621
+ }
1622
+ const route = parsePageRoute(window.location.hash);
1623
+ state.works = await api("/api/works");
1624
+ try {
1625
+ if (route.view === "shelf") {
1626
+ showShelf();
1627
+ return;
1628
+ }
1629
+
1630
+ const requestedWork = route.workId ? state.works.find((work) => work.id === route.workId) : null;
1631
+ if (route.workId && !requestedWork) {
1632
+ showShelf();
1633
+ return;
1634
+ }
1635
+
1636
+ if (requestedWork) {
1637
+ state.module = route.view === "module" ? route.module : "editor";
1638
+ await selectWork(requestedWork.id, route.view === "editor" ? route.chapterId : null);
1639
+ }
1640
+
1641
+ if (route.view === "editor") {
1642
+ const chapterExists = state.work?.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === route.chapterId));
1643
+ if (route.chapterId && chapterExists && state.chapter?.id !== route.chapterId) await selectChapter(route.chapterId);
1644
+ return;
1645
+ }
1646
+ if (route.view === "module") return;
1647
+ if (route.view === "welcome") {
1648
+ showWelcome(true);
1649
+ return;
1650
+ }
1651
+ if (route.view === "settings") {
1652
+ showSettingsHub();
1653
+ settingsReturnContext = restoredSettingsReturnContext(route);
1654
+ renderSettingsHub();
1655
+ return;
1656
+ }
1657
+ if (route.view === "platform-ai") {
1658
+ await showPlatformAi();
1659
+ settingsReturnContext = restoredSettingsReturnContext(route);
1660
+ }
1661
+ } finally {
1662
+ restoringPageRoute = false;
1663
+ replacePageRoute(currentPageRoute());
1664
+ scheduleFirstUseOnboarding();
1665
+ }
1666
+ }
1667
+
1668
+ function showShelf() {
1669
+ state.dirty = false;
1670
+ settingsReturnContext = null;
1671
+ updateDocumentTitle();
1672
+ $("#app").classList.add("shelf-mode");
1673
+ $("#shelf-view").classList.remove("hidden");
1674
+ $("#platform-ai-view").classList.add("hidden");
1675
+ $("#settings-hub-view").classList.add("hidden");
1676
+ $("#welcome-view").classList.add("hidden");
1677
+ $("#editor-view").classList.add("hidden");
1678
+ $("#module-view").classList.add("hidden");
1679
+ $("#work-meta").textContent = `${state.works.length} 部作品`;
1680
+ $("#settings-button").removeAttribute("aria-current");
1681
+ $("#top-search-button").disabled = true;
1682
+ setSaveState("书架");
1683
+ renderShelf();
1684
+ replacePageRoute({ view: "shelf" });
1685
+ }
1686
+
1687
+ function captureSettingsReturnContext() {
1688
+ if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
1689
+ if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", chapterId: state.chapter?.id ?? null };
1690
+ if (!$("#module-view").classList.contains("hidden")) return { view: "module", module: state.module };
1691
+ if (!$("#welcome-view").classList.contains("hidden")) return { view: "welcome" };
1692
+ return { view: state.work ? "editor" : "shelf", chapterId: state.chapter?.id ?? null };
1693
+ }
1694
+
1695
+ function renderSettingsHub() {
1696
+ const hasWork = Boolean(state.work);
1697
+ const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
1698
+ const isAdmin = state.user?.role === "admin";
1699
+ $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
1700
+ $("#user-management-button").classList.toggle("hidden", !isAdmin);
1701
+ $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
1702
+ $("#collaboration-button").disabled = !canManageWork;
1703
+ $("#top-search-button").disabled = !hasWork;
1704
+ $("#export-button").disabled = !hasWork;
1705
+ $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
1706
+ $("#settings-work-note").textContent = hasWork
1707
+ ? `当前作品:《${state.work.title}》。导出将作用于这部作品。`
1708
+ : "当前未选择作品;打开作品后可使用导出。";
1709
+ }
1710
+
1711
+ function renderUsers(users) {
1712
+ const currentUserId = state.user?.userId;
1713
+ $("#users-list").innerHTML = users.map((user) => `<article class="access-row" data-user-row="${esc(user.userId)}">
1714
+ <div class="access-person">${userAvatarHtml(user, "access-avatar")}<div class="access-person-copy"><strong>${esc(user.displayName)} · @${esc(user.username)}</strong><small>${user.userId === currentUserId ? "当前账户 · " : ""}${user.status === "active" ? "账户可用" : "账户已停用"}</small></div></div>
1715
+ <select data-user-role="${esc(user.userId)}" aria-label="${esc(user.displayName)}的角色" ${user.userId === currentUserId ? "disabled" : ""}><option value="user" ${user.role === "user" ? "selected" : ""}>普通用户</option><option value="admin" ${user.role === "admin" ? "selected" : ""}>系统管理员</option></select>
1716
+ <button type="button" data-user-status="${esc(user.userId)}" ${user.userId === currentUserId ? "disabled" : ""}>${user.status === "active" ? "停用" : "启用"}</button>
1717
+ </article>`).join("");
1718
+ bindUserAvatarFallbacks($("#users-list"));
1719
+ $("#users-list").querySelectorAll("[data-user-role]").forEach((select) => select.addEventListener("change", async () => {
1720
+ try {
1721
+ const updated = await api(`/api/users/${encodeURIComponent(select.dataset.userRole)}`, { method: "PATCH", body: { role: select.value } });
1722
+ const usersAfterUpdate = users.map((item) => item.userId === updated.userId ? updated : item);
1723
+ renderUsers(usersAfterUpdate);
1724
+ toast("用户角色已更新");
1725
+ } catch (error) {
1726
+ select.value = users.find((item) => item.userId === select.dataset.userRole)?.role ?? "user";
1727
+ toast(error.message, "error");
1728
+ }
1729
+ }));
1730
+ $("#users-list").querySelectorAll("[data-user-status]").forEach((button) => button.addEventListener("click", async () => {
1731
+ const existing = users.find((item) => item.userId === button.dataset.userStatus);
1732
+ if (!existing) return;
1733
+ try {
1734
+ const updated = await api(`/api/users/${encodeURIComponent(existing.userId)}`, { method: "PATCH", body: { status: existing.status === "active" ? "disabled" : "active" } });
1735
+ renderUsers(users.map((item) => item.userId === updated.userId ? updated : item));
1736
+ toast("账户状态已更新");
1737
+ } catch (error) { toast(error.message, "error"); }
1738
+ }));
1739
+ }
1740
+
1741
+ async function openUsersDialog() {
1742
+ if (state.user?.role !== "admin") {
1743
+ toast("需要系统管理员权限", "error");
1744
+ return;
1745
+ }
1746
+ $("#users-list").innerHTML = '<p class="empty-state">正在读取用户……</p>';
1747
+ $("#users-dialog").showModal();
1748
+ try { renderUsers(await api("/api/users")); }
1749
+ catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
1750
+ }
1751
+
1752
+ async function openPlatformUiSettingsDialog() {
1753
+ if (state.user?.role !== "admin") {
1754
+ toast("需要系统管理员权限", "error");
1755
+ return;
1756
+ }
1757
+ try {
1758
+ const settings = await api("/api/platform/ui-settings");
1759
+ $("#toast-position").value = settings.toastPosition === "top-right" ? "top-right" : "bottom-right";
1760
+ $("#platform-ui-settings-dialog").showModal();
1761
+ } catch (error) {
1762
+ toast(error.message, "error");
1763
+ }
1764
+ }
1765
+
1766
+ function renderMembers(members) {
1767
+ const work = memberDialogWork ?? state.work;
1768
+ const canManage = ["admin", "owner"].includes(String(work?.accessRole));
1769
+ $("#members-list").innerHTML = members.map((member) => `<article class="access-row">
1770
+ <div class="access-person">${userAvatarHtml(member, "access-avatar")}<div class="access-person-copy"><strong>${esc(member.displayName)} · @${esc(member.username)}</strong><small>${member.role === "owner" ? "作品创建者" : member.role === "viewer" ? "查看者" : "协作者"}${member.status === "disabled" ? " · 已停用" : ""}</small></div></div>
1771
+ ${member.role === "owner" ? "<span>所有者</span>" : `<select data-member-role="${esc(member.userId)}" aria-label="${esc(member.displayName)}的作品权限" ${canManage ? "" : "disabled"}><option value="viewer" ${member.role === "viewer" ? "selected" : ""}>仅查看</option><option value="editor" ${member.role === "editor" ? "selected" : ""}>可编辑</option></select>`}
1772
+ ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
1773
+ </article>`).join("");
1774
+ bindUserAvatarFallbacks($("#members-list"));
1775
+ $("#members-list").querySelectorAll("[data-member-role]").forEach((select) => select.addEventListener("change", async () => {
1776
+ if (!work) return;
1777
+ const previousRole = members.find((member) => member.userId === select.dataset.memberRole)?.role ?? "viewer";
1778
+ try {
1779
+ const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(select.dataset.memberRole)}`, { method: "PATCH", body: { role: select.value } });
1780
+ renderMembers(updated);
1781
+ toast("成员权限已更新");
1782
+ } catch (error) {
1783
+ select.value = previousRole;
1784
+ toast(error.message, "error");
1785
+ }
1786
+ }));
1787
+ $("#members-list").querySelectorAll("[data-remove-member]").forEach((button) => button.addEventListener("click", async () => {
1788
+ if (!work) return;
1789
+ try {
1790
+ const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(button.dataset.removeMember)}`, { method: "DELETE" });
1791
+ renderMembers(updated);
1792
+ await fillMemberCandidates(updated);
1793
+ toast("协作者已移除");
1794
+ } catch (error) { toast(error.message, "error"); }
1795
+ }));
1796
+ }
1797
+
1798
+ async function fillMemberCandidates(members) {
1799
+ const directory = await api("/api/users/directory");
1800
+ const memberIds = new Set(members.map((member) => member.userId));
1801
+ const candidates = directory.filter((user) => !memberIds.has(user.userId));
1802
+ $("#member-user-select").innerHTML = candidates.length
1803
+ ? `<option value="">选择用户</option>${candidates.map((user) => `<option value="${esc(user.userId)}">${esc(user.displayName)} · @${esc(user.username)}</option>`).join("")}`
1804
+ : '<option value="">没有可邀请的用户</option>';
1805
+ $("#member-user-select").disabled = !candidates.length;
1806
+ }
1807
+
1808
+ async function openMembersDialog(targetWork = state.work) {
1809
+ if (!targetWork) return;
1810
+ memberDialogWork = targetWork;
1811
+ const canManage = ["admin", "owner"].includes(String(targetWork.accessRole));
1812
+ $("#members-dialog-eyebrow").textContent = `作品权限 · 《${targetWork.title}》`;
1813
+ $("#members-dialog-title").textContent = "可访问人";
1814
+ $("#members-list").innerHTML = '<p class="empty-state">正在读取成员……</p>';
1815
+ $("#member-invite-form").classList.toggle("hidden", !canManage);
1816
+ $("#members-dialog").showModal();
1817
+ try {
1818
+ const members = await api(`/api/works/${encodeURIComponent(targetWork.id)}/members`);
1819
+ renderMembers(members);
1820
+ if (canManage) await fillMemberCandidates(members);
1821
+ } catch (error) { $("#members-dialog").close(); toast(error.message, "error"); }
1822
+ }
1823
+
1824
+ const searchResultTypeLabels = {
1825
+ chapter: "章节",
1826
+ setting: "设定",
1827
+ character: "角色",
1828
+ race: "种族",
1829
+ organization: "组织"
1830
+ };
1831
+
1832
+ async function openSearchDialog() {
1833
+ if (!state.work) {
1834
+ toast("请先打开一部作品", "error");
1835
+ return;
1836
+ }
1837
+ $("#search-dialog .eyebrow").textContent = `当前作品 · 《${state.work.title}》`;
1838
+ $("#search-query").value = "";
1839
+ $("#search-results").innerHTML = '<p class="search-results-empty">输入关键词后开始检索。</p>';
1840
+ $("#search-dialog").showModal();
1841
+ queueMicrotask(() => $("#search-query").focus());
1842
+ }
1843
+
1844
+ function renderSearchResults(results) {
1845
+ if (!results.length) {
1846
+ $("#search-results").innerHTML = '<p class="search-results-status">未找到相关内容。</p>';
1847
+ return;
1848
+ }
1849
+ $("#search-results").innerHTML = results.map((item) => `
1850
+ <button type="button" class="search-result" data-search-type="${esc(item.type)}" data-search-id="${esc(item.id)}">
1851
+ <div class="search-result-meta"><span>${esc(searchResultTypeLabels[item.type] ?? item.type)}</span><strong>${esc(item.title)}</strong></div>
1852
+ <p>${esc(item.snippet || "无摘要")}</p>
1853
+ </button>`).join("");
1854
+ $("#search-results").querySelectorAll(".search-result").forEach((button) => {
1855
+ button.addEventListener("click", () => {
1856
+ openSearchResult({ type: button.dataset.searchType, id: button.dataset.searchId })
1857
+ .catch((error) => toast(error.message, "error"));
1858
+ });
1859
+ });
1860
+ }
1861
+
1862
+ async function runWorkSearch() {
1863
+ if (!state.work) throw new Error("请先打开一部作品");
1864
+ const query = $("#search-query").value.trim();
1865
+ if (!query) {
1866
+ $("#search-results").innerHTML = '<p class="search-results-empty">请输入关键词。</p>';
1867
+ return;
1868
+ }
1869
+ $("#search-results").innerHTML = '<p class="search-results-status">正在检索……</p>';
1870
+ const results = await api(`/api/works/${encodeURIComponent(state.work.id)}/search?q=${encodeURIComponent(query)}`);
1871
+ renderSearchResults(results);
1872
+ }
1873
+
1874
+ async function openSearchResult(result) {
1875
+ $("#search-dialog").close();
1876
+ const inSettings = !$("#settings-hub-view").classList.contains("hidden") || !$("#platform-ai-view").classList.contains("hidden");
1877
+ if (inSettings) await returnFromSettings();
1878
+ if (result.type === "chapter") {
1879
+ await selectChapter(result.id);
1880
+ return;
1881
+ }
1882
+ if (result.type === "character") {
1883
+ await showModule("characters");
1884
+ const character = state.characters.find((item) => item.id === result.id);
1885
+ if (character) openCharacterDialog(character);
1886
+ return;
1887
+ }
1888
+ if (result.type === "setting") {
1889
+ await showModule("settings");
1890
+ const setting = await api(`/api/settings/${encodeURIComponent(result.id)}`);
1891
+ openSettingDialog(setting);
1892
+ return;
1893
+ }
1894
+ if (result.type === "race") {
1895
+ await showModule("races");
1896
+ const race = state.races.find((item) => item.id === result.id);
1897
+ if (race) openRaceDialog(race);
1898
+ return;
1899
+ }
1900
+ if (result.type === "organization") {
1901
+ await showModule("organizations");
1902
+ const organization = state.organizations.find((item) => item.id === result.id);
1903
+ if (organization) openOrganizationDialog(organization);
1904
+ }
1905
+ }
1906
+
1907
+ function showSettingsHub() {
1908
+ const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden") || !$("#platform-ai-view").classList.contains("hidden");
1909
+ if (!alreadyInSettings) {
1910
+ if (state.dirty && !confirmDiscardChanges("当前章节有未保存修改,进入设置将放弃本地修改。是否继续?")) return false;
1911
+ settingsReturnContext = captureSettingsReturnContext();
1912
+ state.dirty = false;
1913
+ }
1914
+ updateDocumentTitle(state.work);
1915
+ $("#app").classList.add("shelf-mode");
1916
+ $("#shelf-view").classList.add("hidden");
1917
+ $("#platform-ai-view").classList.add("hidden");
1918
+ $("#settings-hub-view").classList.remove("hidden");
1919
+ $("#welcome-view").classList.add("hidden");
1920
+ $("#editor-view").classList.add("hidden");
1921
+ $("#module-view").classList.add("hidden");
1922
+ $("#work-meta").textContent = "设置中心";
1923
+ $("#settings-button").setAttribute("aria-current", "page");
1924
+ setSaveState("设置");
1925
+ renderSettingsHub();
1926
+ replacePageRoute({ view: "settings", workId: state.work?.id ?? null, ...settingsRouteContext() });
1927
+ return true;
1928
+ }
1929
+
1930
+ async function returnFromSettings() {
1931
+ const context = settingsReturnContext ?? { view: "shelf" };
1932
+ settingsReturnContext = null;
1933
+ $("#settings-button").removeAttribute("aria-current");
1934
+ $("#settings-hub-view").classList.add("hidden");
1935
+ $("#platform-ai-view").classList.add("hidden");
1936
+ if (context.view === "shelf" || !state.work) return showShelf();
1937
+ $("#app").classList.remove("shelf-mode");
1938
+ $("#shelf-view").classList.add("hidden");
1939
+ updateDocumentTitle(state.work);
1940
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
1941
+ if (context.view === "module") return showModule(context.module);
1942
+ if (context.view === "editor" && context.chapterId) return selectChapter(context.chapterId);
1943
+ return showWelcome(true);
1944
+ }
1945
+
1946
+ async function showPlatformAi() {
1947
+ if (state.dirty && !confirmDiscardChanges("当前章节有未保存修改,进入平台 AI 管理将放弃本地修改。是否继续?")) return false;
1948
+ state.dirty = false;
1949
+ updateDocumentTitle();
1950
+ $("#app").classList.add("shelf-mode");
1951
+ $("#shelf-view").classList.add("hidden");
1952
+ $("#platform-ai-view").classList.remove("hidden");
1953
+ $("#settings-hub-view").classList.add("hidden");
1954
+ $("#welcome-view").classList.add("hidden");
1955
+ $("#editor-view").classList.add("hidden");
1956
+ $("#module-view").classList.add("hidden");
1957
+ $("#work-meta").textContent = "平台 AI 管理";
1958
+ $("#settings-button").setAttribute("aria-current", "page");
1959
+ setSaveState("平台 AI");
1960
+ await renderPlatformAiConfig();
1961
+ replacePageRoute({ view: "platform-ai", workId: state.work?.id ?? null, ...settingsRouteContext() });
1962
+ return true;
1963
+ }
1964
+
1965
+ function renderShelf() {
1966
+ const shelf = $("#book-shelf");
1967
+ shelf.innerHTML = `${state.works.map((work) => `
1968
+ <article class="book-card" data-work-card="${esc(work.id)}">
1969
+ <button class="book-open" type="button" data-open-work="${esc(work.id)}" aria-label="打开 ${esc(work.title)}">
1970
+ <span class="book-cover ${work.coverUrl ? "has-cover" : ""}">
1971
+ <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
1972
+ ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
1973
+ </span>
1974
+ <span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "仅查看" : work.accessRole === "editor" ? "协作作品" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
1975
+ </button>
1976
+ ${canEditWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
1977
+ </article>`).join("")}
1978
+ <button class="book-card book-add-card" id="book-add-card" type="button" aria-label="新建作品" data-testid="book-add-card"><span>+</span><strong>新建作品</strong><small>从零开始或导入 TXT / DOCX</small></button>`;
1979
+ shelf.querySelectorAll("[data-open-work]").forEach((button) => button.addEventListener("click", () => selectWork(button.dataset.openWork)));
1980
+ shelf.querySelectorAll("[data-edit-work]").forEach((button) => button.addEventListener("click", (event) => {
1981
+ event.stopPropagation();
1982
+ openWorkSettingsDialog(state.works.find((work) => work.id === button.dataset.editWork));
1983
+ }));
1984
+ $("#book-add-card").addEventListener("click", openWorkDialog);
1985
+ }
1986
+
1987
+ async function selectWork(workId, preferredChapterId = null) {
1988
+ const discarding = state.work?.id !== workId && state.dirty;
1989
+ if (discarding && !confirmDiscardChanges()) return false;
1990
+ const nextWork = await api(`/api/works/${workId}`);
1991
+ if (state.work?.id !== nextWork.id) {
1992
+ loadedAiModelsWorkId = null;
1993
+ loadedAiReferencesWorkId = null;
1994
+ loadedAiConversationsWorkId = null;
1995
+ state.models = [];
1996
+ state.characters = [];
1997
+ state.settings = [];
1998
+ if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
1999
+ aiContextUsageTimer = null;
2000
+ aiContextUsageRequest += 1;
2001
+ state.aiCitations = [];
2002
+ state.aiReferences = [];
2003
+ state.aiPromptSent = false;
2004
+ state.aiConversationId = null;
2005
+ state.aiConversations = [];
2006
+ renderAiCitations();
2007
+ renderAiReferences();
2008
+ renderAiQuickActions();
2009
+ resetAiFeed();
2010
+ $("#ai-conversation-title").textContent = "新对话";
2011
+ $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
2012
+ setAiContextMeter(null);
2013
+ renderAiConversationHistory();
2014
+ }
2015
+ if (discarding) setSaveState("就绪");
2016
+ $("#app").classList.remove("shelf-mode");
2017
+ $("#shelf-view").classList.add("hidden");
2018
+ $("#platform-ai-view").classList.add("hidden");
2019
+ $("#settings-hub-view").classList.add("hidden");
2020
+ $("#settings-button").removeAttribute("aria-current");
2021
+ settingsReturnContext = null;
2022
+ state.work = nextWork;
2023
+ state.chapter = null;
2024
+ applyWorkAccessMode();
2025
+ updateDocumentTitle(state.work);
2026
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
2027
+ $("#top-search-button").disabled = false;
2028
+ renderTree();
2029
+ const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
2030
+ const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
2031
+ if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
2032
+ else if (state.module === "editor") showWelcome(true);
2033
+ else await showModule(state.module);
2034
+ return true;
2035
+ }
2036
+
2037
+ function renderTree() {
2038
+ if (!state.work) return;
2039
+ const count = state.work.volumes.reduce((total, volume) => total + volume.chapters.length, 0);
2040
+ $("#chapter-count").textContent = `${count} 章`;
2041
+ $("#novel-tree").classList.remove("empty-copy");
2042
+ $("#novel-tree").innerHTML = state.work.volumes.map((volume) => `
2043
+ <div class="volume-node ${state.collapsedVolumeIds.has(volume.id) ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
2044
+ <button class="volume-title" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${state.collapsedVolumeIds.has(volume.id) ? "false" : "true"}" title="左键折叠,右键设置分卷"><span>${esc(volume.title)}</span><span>${volume.chapters.length} 章</span></button>
2045
+ <div class="volume-chapters">
2046
+ ${volume.chapters.map((chapter) => `
2047
+ <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}">
2048
+ <span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
2049
+ </button>`).join("")}
2050
+ </div>
2051
+ </div>`).join("");
2052
+ $("#novel-tree").querySelectorAll("[data-volume-toggle]").forEach((button) => {
2053
+ button.addEventListener("click", () => {
2054
+ const volumeId = button.dataset.volumeToggle;
2055
+ if (state.collapsedVolumeIds.has(volumeId)) state.collapsedVolumeIds.delete(volumeId);
2056
+ else state.collapsedVolumeIds.add(volumeId);
2057
+ renderTree();
2058
+ });
2059
+ button.addEventListener("contextmenu", (event) => {
2060
+ if (!canEditWork()) return;
2061
+ event.preventDefault();
2062
+ openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
2063
+ });
2064
+ });
2065
+ $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
2066
+ button.addEventListener("click", () => selectChapter(button.dataset.chapterId));
2067
+ button.addEventListener("contextmenu", (event) => {
2068
+ if (!canEditWork()) return;
2069
+ event.preventDefault();
2070
+ openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
2071
+ });
2072
+ });
2073
+ }
2074
+
2075
+ function closeChapterTypeMenu() {
2076
+ state.contextChapterId = null;
2077
+ $("#chapter-type-menu").classList.add("hidden");
2078
+ }
2079
+
2080
+ function openChapterTypeMenu(chapterId, clientX, clientY) {
2081
+ const chapter = state.work?.volumes.flatMap((volume) => volume.chapters).find((item) => item.id === chapterId);
2082
+ if (!chapter) return;
2083
+ state.contextChapterId = chapterId;
2084
+ const menu = $("#chapter-type-menu");
2085
+ menu.querySelector("strong").textContent = `标记“${chapter.title}”`;
2086
+ menu.querySelectorAll("[data-chapter-type]").forEach((button) => {
2087
+ button.classList.toggle("active", button.dataset.chapterType === (chapter.chapterType || "正文"));
2088
+ button.setAttribute("aria-checked", String(button.classList.contains("active")));
2089
+ });
2090
+ menu.classList.remove("hidden");
2091
+ const rect = menu.getBoundingClientRect();
2092
+ menu.style.left = `${Math.max(8, Math.min(clientX, window.innerWidth - rect.width - 8))}px`;
2093
+ menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`;
2094
+ }
2095
+
2096
+ async function selectChapter(chapterId) {
2097
+ if (state.chapter?.id !== chapterId && !confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?")) return;
2098
+ cancelChapterAutoSave();
2099
+ state.chapter = await api(`/api/chapters/${chapterId}`);
2100
+ lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
2101
+ state.module = "editor";
2102
+ markActiveModule("editor");
2103
+ $("#welcome-view").classList.add("hidden");
2104
+ $("#module-view").classList.add("hidden");
2105
+ $("#editor-view").classList.remove("hidden");
2106
+ const volume = state.work.volumes.find((item) => item.id === state.chapter.volumeId);
2107
+ const chapterPath = `${volume?.title ?? "正文"} / 保存于 ${formatDateTime(state.chapter.updatedAt)}`;
2108
+ $("#chapter-path").textContent = chapterPath;
2109
+ $("#chapter-path").title = chapterPath;
2110
+ $("#chapter-title").value = state.chapter.title;
2111
+ const normalizedContent = normalizeParagraphSpacing(state.chapter.content);
2112
+ const spacingChanged = normalizedContent !== state.chapter.content;
2113
+ $("#chapter-content").value = normalizedContent;
2114
+ clearChapterLineSelection();
2115
+ scheduleChapterLineNumbers();
2116
+ $("#chapter-insight").classList.add("hidden");
2117
+ updateChapterStats();
2118
+ if (!canEditWork()) setSaveState("仅查看");
2119
+ else if (spacingChanged) scheduleChapterAutoSave(120);
2120
+ else setSaveState("已保存");
2121
+ renderTree();
2122
+ replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
2123
+ }
2124
+
2125
+ function updateChapterStats() {
2126
+ if (!state.chapter) return;
2127
+ const text = $("#chapter-content").value;
2128
+ const count = Array.from(text.replace(/\s/g, "")).length;
2129
+ $("#chapter-stats").textContent = `${count} 字 · v${state.chapter.versionNo}`;
2130
+ }
2131
+
2132
+ async function saveChapter() {
2133
+ return persistChapter({ automatic: false });
2134
+ }
2135
+
2136
+ function tidyChapterBlankLines() {
2137
+ if (!state.chapter) return toast("请先选择章节", "error");
2138
+ const input = $("#chapter-content");
2139
+ const normalized = normalizeParagraphSpacing(input.value);
2140
+ if (normalized === input.value) return toast("正文空行已经符合要求");
2141
+ input.value = normalized;
2142
+ scheduleChapterLineNumbers();
2143
+ updateChapterStats();
2144
+ scheduleChapterAutoSave(120);
2145
+ toast("已整理空行:段与段之间保留 1 个空行");
2146
+ }
2147
+
2148
+ function showWelcome(hasWork = false) {
2149
+ $("#editor-view").classList.add("hidden");
2150
+ $("#module-view").classList.add("hidden");
2151
+ $("#welcome-view").classList.remove("hidden");
2152
+ $("#welcome-view h1").innerHTML = hasWork ? "故事已经就位,<br>从新章节继续。" : "把长篇故事的每条线索,<br>留在作者掌控之中。";
2153
+ $("#welcome-new-work").textContent = hasWork ? "新建章节" : "创建第一部作品";
2154
+ replacePageRoute(hasWork && state.work ? { view: "welcome", workId: state.work.id } : { view: "shelf" });
2155
+ }
2156
+
2157
+ function markActiveModule(module) {
2158
+ $("#module-nav").querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.module === module));
2159
+ }
2160
+
2161
+ const moduleMeta = {
2162
+ settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
2163
+ characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
2164
+ races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
2165
+ organizations: ["世界阵营", "组织与成员", "维护组织简介、设定清单,并将角色绑定到所属组织。", "新建组织"],
2166
+ timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
2167
+ outlines: ["创作规划", "大纲与伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
2168
+ relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
2169
+ reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
2170
+ tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
2171
+ "ai-settings": ["书籍提示词", "本书 AI 设置", "本书系统提示词会追加在内置提示词和平台全局提示词之后;任务默认模型只作用于当前作品。", "保存设置"]
2172
+ };
2173
+
2174
+ async function showModule(module) {
2175
+ if (!state.work) return showWelcome();
2176
+ if (!canEditWork() && ["tasks", "ai-settings"].includes(module)) module = "editor";
2177
+ if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
2178
+ if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2179
+ state.module = module;
2180
+ markActiveModule(module);
2181
+ if (module === "editor") {
2182
+ if (state.chapter) await selectChapter(state.chapter.id);
2183
+ else {
2184
+ const first = state.work.volumes.flatMap((volume) => volume.chapters)[0];
2185
+ if (first) await selectChapter(first.id);
2186
+ else showWelcome(true);
2187
+ }
2188
+ return;
2189
+ }
2190
+ $("#welcome-view").classList.add("hidden");
2191
+ $("#editor-view").classList.add("hidden");
2192
+ $("#module-view").classList.remove("hidden");
2193
+ const meta = moduleMeta[module];
2194
+ $("#module-eyebrow").textContent = meta[0];
2195
+ $("#module-title").textContent = meta[1];
2196
+ $("#module-description").textContent = meta[2];
2197
+ $("#module-create-button").textContent = meta[3];
2198
+ $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditWork());
2199
+ $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
2200
+ try {
2201
+ if (module === "settings") await renderSettings();
2202
+ if (module === "characters") await renderCharacters();
2203
+ if (module === "races") await renderRaces();
2204
+ if (module === "organizations") await renderOrganizations();
2205
+ if (module === "timeline") await renderTimeline();
2206
+ if (module === "outlines") await renderOutlines();
2207
+ if (module === "relationships") await renderRelationships();
2208
+ if (module === "reviews") await renderReviews();
2209
+ if (module === "tasks") await renderTasks();
2210
+ if (module === "ai-settings") await renderBookAiSettings();
2211
+ } catch (error) {
2212
+ $("#module-content").innerHTML = `<div class="empty-state"><b>载入失败</b>${esc(error.message)}</div>`;
2213
+ }
2214
+ replacePageRoute({ view: "module", workId: state.work.id, module });
2215
+ }
2216
+
2217
+ function emptyModule(title, description) {
2218
+ return `<div class="empty-state"><b>${esc(title)}</b>${esc(description)}</div>`;
2219
+ }
2220
+
2221
+ function renderEntityHistory(versions) {
2222
+ const host = $("#entity-history-list");
2223
+ if (!versions.length) {
2224
+ host.innerHTML = '<p class="entity-history-empty">还没有可用的历史版本。</p>';
2225
+ return;
2226
+ }
2227
+ host.innerHTML = versions.map((version, index) => `<article class="entity-version-card${index === 0 ? " is-current" : ""}" data-entity-version="${version.versionNo}">
2228
+ <header><strong>v${version.versionNo}</strong><span>${esc(entityVersionSourceLabel(version.source))}</span></header>
2229
+ <time>${esc(formatDateTime(version.createdAt))} · ${esc(version.actor || "历史数据")}</time>
2230
+ <p>${esc(version.changeNote || entityVersionSnapshotSummary(entityHistoryContext.type, version.snapshot))}</p>
2231
+ <small>${esc(entityVersionSnapshotSummary(entityHistoryContext.type, version.snapshot))}</small>
2232
+ ${index === 0 ? '<button type="button" disabled>当前版本</button>' : `<button type="button" data-entity-version-restore="${version.versionNo}">回滚到此版本</button>`}
2233
+ </article>`).join("");
2234
+ host.querySelectorAll("[data-entity-version-restore]").forEach((button) => button.addEventListener("click", async () => {
2235
+ const versionNo = Number(button.dataset.entityVersionRestore);
2236
+ if (button.dataset.confirmed !== "true") {
2237
+ host.querySelectorAll("[data-entity-version-restore]").forEach((other) => {
2238
+ other.dataset.confirmed = "false";
2239
+ other.classList.remove("is-confirming");
2240
+ other.textContent = "回滚到此版本";
2241
+ });
2242
+ button.dataset.confirmed = "true";
2243
+ button.classList.add("is-confirming");
2244
+ button.textContent = `确认回滚至 v${versionNo}`;
2245
+ window.setTimeout(() => {
2246
+ if (!button.isConnected || button.dataset.confirmed !== "true") return;
2247
+ button.dataset.confirmed = "false";
2248
+ button.classList.remove("is-confirming");
2249
+ button.textContent = "回滚到此版本";
2250
+ }, 5000);
2251
+ return;
2252
+ }
2253
+ button.disabled = true;
2254
+ try {
2255
+ const restored = await api(`/api/entity-versions/${encodeURIComponent(entityHistoryContext.type)}/${encodeURIComponent(entityHistoryContext.entityId)}/restore`, { method: "POST", body: { versionNo } });
2256
+ await entityHistoryContext.refresh();
2257
+ const versionsAfterRestore = await api(`/api/entity-versions/${encodeURIComponent(entityHistoryContext.type)}/${encodeURIComponent(entityHistoryContext.entityId)}`);
2258
+ renderEntityHistory(versionsAfterRestore);
2259
+ toast(`已回滚至 v${versionNo},并生成 v${restored.versionNo}`);
2260
+ } catch (error) {
2261
+ button.disabled = false;
2262
+ toast(error.message, "error");
2263
+ }
2264
+ }));
2265
+ }
2266
+
2267
+ async function openEntityHistory(type, entityId, title, refresh) {
2268
+ entityHistoryContext = { type, entityId, refresh };
2269
+ $("#entity-history-eyebrow").textContent = VERSIONED_ENTITY_LABELS[type] ?? "创作资料";
2270
+ $("#entity-history-title").textContent = `${title} · 版本历史`;
2271
+ $("#entity-history-list").innerHTML = '<p class="entity-history-empty">正在读取版本历史…</p>';
2272
+ $("#entity-history-dialog").showModal();
2273
+ try {
2274
+ renderEntityHistory(await api(`/api/entity-versions/${encodeURIComponent(type)}/${encodeURIComponent(entityId)}`));
2275
+ } catch (error) {
2276
+ $("#entity-history-dialog").close();
2277
+ toast(error.message, "error");
2278
+ }
2279
+ }
2280
+
2281
+ function bindEntityHistoryButtons(refresh) {
2282
+ $("#module-content").querySelectorAll("[data-entity-history]").forEach((button) => button.addEventListener("click", () => {
2283
+ openEntityHistory(button.dataset.entityHistory, button.dataset.entityId, button.dataset.entityTitle, refresh);
2284
+ }));
2285
+ }
2286
+
2287
+ async function renderSettings() {
2288
+ const records = await api(`/api/works/${state.work.id}/settings`);
2289
+ state.settings = records;
2290
+ $("#module-content").innerHTML = records.length ? `<div class="card-grid">${records.map((item) => `
2291
+ <article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
2292
+ <h3>${esc(item.title)}</h3><p>${esc(item.content)}</p>
2293
+ <div class="card-actions">${item.status === "pending" ? `<button data-setting-status="confirmed" data-setting-id="${esc(item.id)}">确认候选</button><button data-setting-status="deprecated" data-setting-id="${esc(item.id)}">弃用</button>` : ""}<button data-edit-setting="${esc(item.id)}">编辑</button><button data-entity-history="setting" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.title)}">版本历史</button></div></article>`).join("")}</div>`
2294
+ : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
2295
+ $("#module-content").querySelectorAll("[data-setting-status]").forEach((button) => button.addEventListener("click", async () => {
2296
+ await api(`/api/settings/${button.dataset.settingId}`, { method: "PATCH", body: { status: button.dataset.settingStatus, changeNote: button.dataset.settingStatus === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
2297
+ await renderSettings();
2298
+ await loadAiReferences();
2299
+ }));
2300
+ $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingDialog(records.find((item) => item.id === button.dataset.editSetting))));
2301
+ bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
2302
+ }
2303
+
2304
+ async function renderCharacters() {
2305
+ [state.characters, state.races, state.organizations] = await Promise.all([
2306
+ api(`/api/works/${state.work.id}/characters`),
2307
+ api(`/api/works/${state.work.id}/races`),
2308
+ api(`/api/works/${state.work.id}/organizations`)
2309
+ ]);
2310
+ $("#module-content").innerHTML = state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
2311
+ const details = normalizeCharacterDetails(item.attributes?.details);
2312
+ const sections = normalizeCharacterSections(item.profile?.sections);
2313
+ return `
2314
+ <article class="record-card character-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}"><small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(item.visibility)}</small>
2315
+ <h3>${esc(item.name)}</h3><div>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>
2316
+ ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(item.species)}</span></div>` : ""}
2317
+ ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
2318
+ ${details.length ? `<dl class="character-detail-list">${details.slice(0, 4).map((detail) => `<div><dt>${esc(detail.label)}</dt><dd>${esc(detail.value)}</dd></div>`).join("")}</dl>` : ""}
2319
+ <div class="organization-links"><b>所属组织</b>${(item.organizations ?? []).length ? item.organizations.map((organization) => `<span class="pill organization-pill">${esc(organization.name)}</span>`).join("") : '<span class="organization-empty">未加入组织</span>'}</div>
2320
+ ${item.profile?.summary ? `<p class="character-summary">${esc(item.profile.summary)}</p>` : `<p>${esc(Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join("\n") || "尚未记录当前状态")}</p>`}
2321
+ ${sections.length ? `<small class="character-section-count">${sections.length} 个设定章节</small>` : ""}
2322
+ <div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button></div></article>`;
2323
+ }).join("")}</div>`
2324
+ : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。");
2325
+ $("#module-content").querySelectorAll("[data-open-character]").forEach((card) => {
2326
+ const open = () => openCharacterDialog(state.characters.find((item) => item.id === card.dataset.openCharacter));
2327
+ card.addEventListener("click", (event) => { if (!event.target.closest("button")) open(); });
2328
+ card.addEventListener("keydown", (event) => {
2329
+ if (event.key !== "Enter" && event.key !== " ") return;
2330
+ event.preventDefault();
2331
+ open();
2332
+ });
2333
+ });
2334
+ $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterDialog(state.characters.find((item) => item.id === button.dataset.editCharacter))));
2335
+ }
2336
+
2337
+ async function renderRaces() {
2338
+ [state.races, state.characters] = await Promise.all([
2339
+ api(`/api/works/${state.work.id}/races`),
2340
+ api(`/api/works/${state.work.id}/characters`)
2341
+ ]);
2342
+ $("#module-content").innerHTML = state.races.length ? `<div class="card-grid race-grid">${state.races.map((item) => `
2343
+ <article class="record-card race-card"><small>${item.memberIds.length} 位角色 · ${item.settings.length} 条共同设定</small>
2344
+ <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写种族简介")}</p>
2345
+ <div class="race-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无共同设定</span>'}</div>
2346
+ <p class="race-members">角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2347
+ <div class="card-actions"><button data-edit-race="${esc(item.id)}">编辑</button><button data-entity-history="race" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div>
2348
+ </article>`).join("")}</div>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2349
+ $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
2350
+ bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
2351
+ }
2352
+
2353
+ async function renderOrganizations() {
2354
+ [state.organizations, state.characters] = await Promise.all([
2355
+ api(`/api/works/${state.work.id}/organizations`),
2356
+ api(`/api/works/${state.work.id}/characters`)
2357
+ ]);
2358
+ $("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
2359
+ <article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
2360
+ <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
2361
+ <div class="organization-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无组织设定</span>'}</div>
2362
+ <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2363
+ <div class="card-actions"><button data-edit-organization="${esc(item.id)}">编辑</button><button data-entity-history="organization" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div>
2364
+ </article>`).join("")}</div>` : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
2365
+ $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
2366
+ bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
2367
+ }
2368
+
2369
+ async function renderTimeline() {
2370
+ const [events, tracks] = await Promise.all([
2371
+ api(`/api/works/${state.work.id}/timeline`),
2372
+ api(`/api/works/${state.work.id}/timeline-tracks`)
2373
+ ]);
2374
+ state.timelineTracks = tracks;
2375
+ const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
2376
+ const eventCard = (item) => `<article class="timeline-kanban-card"><div class="timeline-card-meta"><input type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}"><small>${esc(item.timeLabel)} · ${esc(item.status)}</small></div><h4>${esc(item.name)}</h4><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span>地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}">编辑与排序</button><button data-split-event="${esc(item.id)}">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div></article>`;
2377
+ $("#module-content").innerHTML = `<div class="timeline-tools"><button id="create-timeline-track" class="primary-button" type="button">新建独立时间轴</button>${events.length > 1 ? '<button id="merge-events" class="ghost-button" type="button">合并所选事件</button>' : ""}</div><div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
2378
+ const laneEvents = events.filter((item) => (item.trackId ?? "") === track.id);
2379
+ return `<section class="timeline-lane" data-track-id="${esc(track.id)}"><header><div><small>${laneEvents.length} 个节点</small><h3>${esc(track.name)}</h3></div>${track.id ? `<div class="timeline-track-actions"><button class="timeline-track-menu" data-edit-timeline-track="${esc(track.id)}" type="button">编辑</button><button class="timeline-track-menu" data-entity-history="timeline-track" data-entity-id="${esc(track.id)}" data-entity-title="${esc(track.name)}" type="button">历史</button></div>` : ""}</header><p class="timeline-track-description">${esc(track.description || "暂无说明")}</p><div class="timeline-lane-events">${laneEvents.map(eventCard).join("") || '<div class="timeline-lane-empty">还没有时间节点</div>'}</div><button class="timeline-add-event" data-add-event-track="${esc(track.id)}" type="button">添加事件</button></section>`;
2380
+ }).join("")}</div>`;
2381
+ $("#create-timeline-track").addEventListener("click", () => openTimelineTrackDialog());
2382
+ $("#module-content").querySelectorAll("[data-edit-timeline-track]").forEach((button) => button.addEventListener("click", () => openTimelineTrackDialog(tracks.find((track) => track.id === button.dataset.editTimelineTrack))));
2383
+ $("#module-content").querySelectorAll("[data-add-event-track]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(null, button.dataset.addEventTrack || null)));
2384
+ $("#module-content").querySelectorAll("[data-edit-event]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(events.find((item) => item.id === button.dataset.editEvent))));
2385
+ $("#module-content").querySelectorAll("[data-split-event]").forEach((button) => button.addEventListener("click", () => openTimelineSplitDialog(events.find((item) => item.id === button.dataset.splitEvent))));
2386
+ bindEntityHistoryButtons(renderTimeline);
2387
+ $("#merge-events")?.addEventListener("click", () => {
2388
+ const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
2389
+ if (eventIds.length < 2) return toast("请至少选择两个时间事件", "error");
2390
+ openDialog("合并时间事件", field("name", "合并后的事件名称") + field("description", "合并说明(留空则拼接原说明)", "textarea"), async (form) => {
2391
+ await api(`/api/works/${state.work.id}/timeline/merge`, { method: "POST", body: { eventIds, name: form.get("name"), description: form.get("description") || undefined } });
2392
+ await renderTimeline();
2393
+ }, "保留参与者与证据");
2394
+ });
2395
+ }
2396
+
2397
+ async function renderOutlines() {
2398
+ const currentChapterId = state.chapter?.id;
2399
+ const [outlines, foreshadows] = await Promise.all([
2400
+ api(`/api/works/${state.work.id}/outlines`),
2401
+ api(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
2402
+ ]);
2403
+ const unresolved = foreshadows.filter((item) => item.unresolved);
2404
+ const overdue = unresolved.filter((item) => item.overdue);
2405
+ const navButton = $("#module-nav [data-module=outlines]");
2406
+ navButton.textContent = unresolved.length ? `大纲与伏笔 · ${unresolved.length}` : "大纲与伏笔";
2407
+ const foreshadowHtml = foreshadows.length ? `<div class="card-grid foreshadow-grid">${foreshadows.map((item) => `
2408
+ <article class="record-card foreshadow-card ${item.overdue ? "is-overdue" : ""}">
2409
+ <small>${esc(item.importance)} · ${esc(item.status)}${item.overdue ? " · 已逾期" : ""}</small>
2410
+ <h3>${esc(item.title)}</h3><p>${esc(item.description || "暂无说明")}</p>
2411
+ <div class="foreshadow-links">${item.occurrences.length ? item.occurrences.map((link) => `<span class="pill">${esc({ setup: "埋设", reminder: "提醒", payoff: "回收" }[link.role] ?? link.role)} · ${esc(link.volumeTitle)} / ${esc(link.chapterTitle)}</span>`).join("") : '<span class="pill">尚未关联章节</span>'}</div>
2412
+ <div class="card-actions"><button data-edit-foreshadow="${esc(item.id)}">编辑伏笔</button><button data-entity-history="foreshadow" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.title)}">版本历史</button></div>
2413
+ </article>`).join("")}</div>` : emptyModule("还没有伏笔", "创建伏笔并关联埋设、提醒与回收章节,未回收项会持续显示。\n");
2414
+ const outlineHtml = outlines.length ? `<div class="outline-list">${outlines.map((item) => `
2415
+ <article class="outline-row ${item.status === "completed" ? "is-complete" : ""}">
2416
+ <div><small>${esc(item.volumeTitle)} · ${esc(item.status)}</small><h3>${esc(item.chapterTitle)}</h3></div>
2417
+ <div><b>目标</b><p>${esc(item.goal || "未填写")}</p></div>
2418
+ <div><b>冲突</b><p>${esc(item.conflict || "未填写")}</p></div>
2419
+ <div><b>转折</b><p>${esc(item.turningPoint || "未填写")}</p></div>
2420
+ <div class="outline-actions">${item.unresolvedForeshadowCount ? `<span>${item.unresolvedForeshadowCount} 个未回收伏笔</span>` : ""}<button data-edit-outline="${esc(item.chapterId)}">编辑</button><button data-entity-history="chapter-outline" data-entity-id="${esc(item.chapterId)}" data-entity-title="${esc(item.chapterTitle)}">版本历史</button></div>
2421
+ </article>`).join("")}</div>` : emptyModule("还没有章节", "先创建章节,再为每章维护目标、冲突和转折。\n");
2422
+ $("#module-content").innerHTML = `<div class="outline-summary"><article><strong>${outlines.length}</strong><span>章节规划</span></article><article><strong>${unresolved.length}</strong><span>未回收伏笔</span></article><article class="${overdue.length ? "danger-text" : ""}"><strong>${overdue.length}</strong><span>已逾期</span></article></div><section class="planning-section"><div class="section-title"><div><span class="eyebrow">伏笔追踪</span><h2>尚未回收与历史伏笔</h2></div></div>${foreshadowHtml}</section><section class="planning-section"><div class="section-title"><div><span class="eyebrow">逐章规划</span><h2>章节目标、冲突与转折</h2></div></div>${outlineHtml}</section>`;
2423
+ $("#module-content").querySelectorAll("[data-edit-outline]").forEach((button) => button.addEventListener("click", () => openOutlineDialog(outlines.find((item) => item.chapterId === button.dataset.editOutline))));
2424
+ $("#module-content").querySelectorAll("[data-edit-foreshadow]").forEach((button) => button.addEventListener("click", () => openForeshadowDialog(foreshadows.find((item) => item.id === button.dataset.editForeshadow))));
2425
+ bindEntityHistoryButtons(renderOutlines);
2426
+ }
2427
+
2428
+ async function renderRelationships() {
2429
+ state.characters = await api(`/api/works/${state.work.id}/characters`);
2430
+ const relationships = await api(`/api/works/${state.work.id}/relationships`);
2431
+ const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
2432
+ state.galaxy?.destroy();
2433
+ state.relationshipExpandedMap?.destroy?.();
2434
+ if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
2435
+ const graph = buildRelationshipGraph(state.characters, relationships);
2436
+ state.relationshipGraph = graph;
2437
+ $("#module-content").innerHTML = `<div id="relationship-map-host"></div>${relationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${relationships.map((item) => `
2438
+ <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
2439
+ <td>${esc(item.category)} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length} 条</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(item.confirmationStatus)}</td><td class="relationship-actions"><button data-edit-relationship="${esc(item.id)}">编辑</button><button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>'}`;
2440
+ const openGalaxy = () => {
2441
+ state.galaxy?.destroy();
2442
+ state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
2443
+ state.galaxy.open();
2444
+ };
2445
+ const openExpanded = () => {
2446
+ state.relationshipExpandedMap?.destroy?.();
2447
+ state.relationshipExpandedMap = renderRelationshipMindMap($("#relationship-map-expanded-host"), graph, {
2448
+ expanded: true,
2449
+ onOpenGalaxy: openGalaxy
2450
+ });
2451
+ $("#relationship-map-dialog").showModal();
2452
+ };
2453
+ state.relationshipMindMap?.destroy?.();
2454
+ state.relationshipMindMap = renderRelationshipMindMap($("#relationship-map-host"), graph, { onOpenGalaxy: openGalaxy, onOpenExpanded: openExpanded });
2455
+ $("#module-content").querySelectorAll("[data-edit-relationship]").forEach((button) => button.addEventListener("click", () => openRelationshipDialog(relationships.find((item) => item.id === button.dataset.editRelationship))));
2456
+ bindEntityHistoryButtons(async () => { await renderRelationships(); await loadAiReferences(); });
2457
+ }
2458
+
2459
+ async function renderReviews() {
2460
+ const reviews = await api(`/api/works/${state.work.id}/reviews`);
2461
+ $("#module-content").innerHTML = reviews.length ? `<div class="card-grid">${reviews.map((item) => `
2462
+ <article class="record-card"><small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3>
2463
+ <p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>
2464
+ ${item.status === "pending" ? `<div class="card-actions"><button data-review-status="fixed" data-review-id="${esc(item.id)}">标为已修复</button><button data-review-status="ignored" data-review-id="${esc(item.id)}">忽略</button></div>` : ""}</article>`).join("")}</div>`
2465
+ : emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
2466
+ $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
2467
+ await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
2468
+ await renderReviews();
2469
+ }));
2470
+ }
2471
+
2472
+ async function renderTasks() {
2473
+ const [tasks, settings] = await Promise.all([
2474
+ api(`/api/works/${state.work.id}/tasks`),
2475
+ api(`/api/works/${state.work.id}/ai-settings`)
2476
+ ]);
2477
+ const pendingCount = tasks.filter((item) => item.status === "pending").length;
2478
+ const runningCount = tasks.filter((item) => item.status === "running").length;
2479
+ $("#module-content").innerHTML = `
2480
+ <section class="task-auto-run-panel" aria-labelledby="task-auto-run-title">
2481
+ <div class="task-auto-run-copy">
2482
+ <strong id="task-auto-run-title">自动执行待分析任务</strong>
2483
+ <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
2484
+ <small>每轮最多启动「每轮任务上限」个,同时运行数量不超过「同时运行上限」;剩余任务需点击“开始下一轮”。</small>
2485
+ </div>
2486
+ <div class="task-auto-run-controls">
2487
+ <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""}><span>自动执行待分析任务</span></label>
2488
+ <label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}"></label>
2489
+ <label>每轮任务上限<input id="task-auto-run-batch-limit" type="number" min="1" max="200" value="${esc(String(settings.autoRunBatchLimit ?? 20))}"></label>
2490
+ <button id="task-auto-run-save" class="primary-button" type="button">保存并生效</button>
2491
+ <button id="task-auto-run-continue" class="ghost-button" type="button" ${settings.autoRunEnabled ? "" : "disabled"}>开始下一轮</button>
2492
+ </div>
2493
+ <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个</p>
2494
+ </section>
2495
+ ${tasks.length ? `<table class="table-list task-table"><thead><tr><th>分析类型</th><th>范围</th><th>状态</th><th>进度</th><th>操作</th></tr></thead><tbody>${tasks.map((item) => `
2496
+ <tr>
2497
+ <td>${esc(analysisTaskTypeLabel(item.taskType))}<br><small>${esc(item.taskType)}</small></td>
2498
+ <td>${esc(item.scopeSummary || item.scope?.type || "book")}</td>
2499
+ <td>${esc(analysisTaskStatusLabel(item.status))}</td>
2500
+ <td>${Number(item.progress ?? 0)}%</td>
2501
+ <td class="task-row-actions">
2502
+ <button class="ghost-button" type="button" data-task-detail="${esc(item.id)}">详情</button>
2503
+ ${item.status === "pending" ? `<button class="ghost-button" type="button" data-run-task="${esc(item.id)}">运行</button>` : ""}
2504
+ ${item.status === "pending" || item.status === "running" ? `<button class="ghost-button" type="button" data-cancel-task="${esc(item.id)}">取消</button>` : ""}
2505
+ </td>
2506
+ </tr>`).join("")}</tbody></table>` : emptyModule("还没有 AI 分析记录", "点击“开始 AI 分析”,可分析指定章节或整部作品。")}`;
2507
+
2508
+ $("#task-auto-run-save")?.addEventListener("click", async () => {
2509
+ const button = $("#task-auto-run-save");
2510
+ button.disabled = true;
2511
+ try {
2512
+ const updated = await api(`/api/works/${state.work.id}/ai-settings`, {
2513
+ method: "PATCH",
2514
+ body: {
2515
+ autoRunEnabled: $("#task-auto-run-enabled").checked,
2516
+ autoRunConcurrency: Number($("#task-auto-run-concurrency").value),
2517
+ autoRunBatchLimit: Number($("#task-auto-run-batch-limit").value)
2518
+ }
2519
+ });
2520
+ toast(updated.autoRunEnabled
2521
+ ? `自动执行已开启:同时最多 ${updated.autoRunConcurrency} 个,每轮最多 ${updated.autoRunBatchLimit} 个`
2522
+ : "自动执行已关闭");
2523
+ await renderTasks();
2524
+ } catch (error) {
2525
+ toast(error.message, "error");
2526
+ button.disabled = false;
2527
+ }
2528
+ });
2529
+ $("#task-auto-run-continue")?.addEventListener("click", async () => {
2530
+ const button = $("#task-auto-run-continue");
2531
+ button.disabled = true;
2532
+ try {
2533
+ const result = await api(`/api/works/${state.work.id}/tasks/auto-run`, { method: "POST", body: {} });
2534
+ toast(`已开始下一轮,队列中还有 ${result.pendingCount} 个待执行任务`);
2535
+ await renderTasks();
2536
+ } catch (error) {
2537
+ toast(error.message, "error");
2538
+ button.disabled = false;
2539
+ }
2540
+ });
2541
+
2542
+ const taskById = new Map(tasks.map((item) => [item.id, item]));
2543
+ $("#module-content").querySelectorAll("[data-task-detail]").forEach((button) => button.addEventListener("click", () => {
2544
+ openTaskDetailDialog(taskById.get(button.dataset.taskDetail));
2545
+ }));
2546
+ $("#module-content").querySelectorAll("[data-run-task]").forEach((button) => button.addEventListener("click", async () => {
2547
+ const workId = state.work.id;
2548
+ try {
2549
+ button.disabled = true;
2550
+ button.textContent = "运行中";
2551
+ const cancel = button.parentElement.querySelector("[data-cancel-task]");
2552
+ if (cancel) cancel.textContent = "取消运行";
2553
+ const completed = await api(`/api/tasks/${button.dataset.runTask}/run`, { method: "POST", body: { modelId: $("#ai-model").value || undefined } });
2554
+ toast(completed.status === "cancelled" ? "分析任务已取消" : completed.status === "expired" ? "正文已变化,本次分析已过期" : "分析已完成");
2555
+ if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
2556
+ } catch (error) {
2557
+ toast(error.message, "error");
2558
+ if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
2559
+ }
2560
+ }));
2561
+ $("#module-content").querySelectorAll("[data-cancel-task]").forEach((button) => button.addEventListener("click", async () => {
2562
+ button.disabled = true;
2563
+ try {
2564
+ await api(`/api/tasks/${button.dataset.cancelTask}/cancel`, { method: "POST", body: {} });
2565
+ toast("分析任务已取消");
2566
+ if (state.module === "tasks") await renderTasks();
2567
+ } catch (error) {
2568
+ toast(error.message, "error");
2569
+ button.disabled = false;
2570
+ }
2571
+ }));
2572
+ }
2573
+
2574
+ function openTaskDetailDialog(task) {
2575
+ if (!task) return;
2576
+ const details = Array.isArray(task.scopeDetails) ? task.scopeDetails : [];
2577
+ const detailHtml = details.map((item) => {
2578
+ if (item.type === "chapter") {
2579
+ if (item.missing) return `<li>章节已删除(${esc(item.chapterId)})</li>`;
2580
+ return `<li>${esc(item.volumeTitle)} · ${esc(item.title)}<br><small>ID ${esc(item.chapterId)} · v${esc(String(item.versionNo))}</small></li>`;
2581
+ }
2582
+ if (item.type === "volume") {
2583
+ if (item.missing) return `<li>分卷已删除(${esc(item.volumeId)})</li>`;
2584
+ const chapters = Array.isArray(item.chapters) ? item.chapters : [];
2585
+ return `<li>分卷 · ${esc(item.title)}(${chapters.length} 章)
2586
+ <ul>${chapters.slice(0, 30).map((chapter) => `<li>${esc(chapter.title)} · v${esc(String(chapter.versionNo))}</li>`).join("")}${chapters.length > 30 ? "<li>……</li>" : ""}</ul>
2587
+ </li>`;
2588
+ }
2589
+ if (item.type === "book") return "<li>全书</li>";
2590
+ return `<li>${esc(JSON.stringify(item))}</li>`;
2591
+ }).join("") || "<li>无范围详情</li>";
2592
+ const failures = Array.isArray(task.failures) ? task.failures : [];
2593
+ const failureHtml = failures.length
2594
+ ? `<ul>${failures.map((item) => `<li>${esc(item.message || JSON.stringify(item))}</li>`).join("")}</ul>`
2595
+ : "<p>无</p>";
2596
+ const resultPreview = task.result && Object.keys(task.result).length
2597
+ ? `<pre class="task-detail-result">${esc(JSON.stringify(task.result, null, 2).slice(0, 2000))}</pre>`
2598
+ : "<p>尚无结果</p>";
2599
+ openDialog("任务详情",
2600
+ `<div class="task-detail">
2601
+ <p><strong>任务 ID</strong><br><code>${esc(task.id)}</code></p>
2602
+ <p><strong>类型</strong> ${esc(analysisTaskTypeLabel(task.taskType))}(${esc(task.taskType)})</p>
2603
+ <p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
2604
+ <p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
2605
+ <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
2606
+ <div><strong>失败信息</strong>${failureHtml}</div>
2607
+ <div><strong>结果摘要</strong>${resultPreview}</div>
2608
+ <p><small>创建于 ${esc(formatDateTime(task.createdAt))} · 更新于 ${esc(formatDateTime(task.updatedAt))}</small></p>
2609
+ </div>`,
2610
+ async () => undefined,
2611
+ "AI 分析详情",
2612
+ { submitLabel: "关闭", wide: true });
2613
+ }
2614
+
2615
+ function renderProviderCards(providers, models) {
2616
+ return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => `
2617
+ <article class="record-card provider-card"><small>平台级 · ${esc(provider.status)} · ${esc(provider.connectionStatus)}</small><h3>${esc(provider.name)}</h3>
2618
+ <p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · RPM:${provider.rpmLimit} · max_tokens:${provider.maxTokens ?? 32000}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
2619
+ <div class="provider-models">${models.filter((model) => model.providerId === provider.id).map((model) => `<button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"} · Thinking ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} Token · max_tokens ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>`).join("")}</div>
2620
+ <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><button data-test-provider="${esc(provider.id)}">测试连接</button><button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`).join("")}</div>`
2621
+ : emptyModule("尚未配置 AI 供应商", "添加 OpenAI Chat Completions 兼容地址和密钥,测试成功后再添加模型。");
2622
+ }
2623
+
2624
+ function bindPlatformProviderActions(host, providers, models) {
2625
+ host.querySelectorAll("[data-test-provider]").forEach((button) => button.addEventListener("click", async () => {
2626
+ button.disabled = true;
2627
+ button.textContent = "测试中";
2628
+ const result = await api(`/api/providers/${button.dataset.testProvider}/test`, { method: "POST", body: {} });
2629
+ toast(result.ok ? "连接测试成功" : `连接失败:${result.error}`, result.ok ? "info" : "error");
2630
+ await renderPlatformAiConfig();
2631
+ await loadModels();
2632
+ }));
2633
+ host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel)));
2634
+ host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(undefined, models.find((model) => model.id === button.dataset.editModel))));
2635
+ host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
2636
+ }
2637
+
2638
+ function renderTaskDefaults(models, providers, taskDefaults) {
2639
+ const providerById = new Map(providers.map((provider) => [provider.id, provider]));
2640
+ const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
2641
+ return models.length ? `<section class="config-section">
2642
+ <div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带 max_tokens,默认值为 32000。</p></div></div>
2643
+ <table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody>${taskTypeLabels.map(([taskType, label]) => {
2644
+ const currentModelId = defaultModelByTask.get(taskType) ?? "";
2645
+ return `<tr><td>${esc(label)}<br><small>${esc(taskType)}</small></td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
2646
+ <option value="" disabled ${currentModelId ? "" : "selected"}>请选择模型</option>
2647
+ ${models.map((model) => {
2648
+ const provider = providerById.get(model.providerId);
2649
+ const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
2650
+ return `<option value="${esc(model.id)}" ${model.id === currentModelId ? "selected" : ""} ${available || model.id === currentModelId ? "" : "disabled"}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
2651
+ }).join("")}
2652
+ </select></td></tr>`;
2653
+ }).join("")}</tbody></table>
2654
+ </section>` : emptyModule("尚未配置平台模型", "请先在平台 AI 管理中添加并测试供应商模型。");
2655
+ }
2656
+
2657
+ async function renderPlatformAiConfig() {
2658
+ const [providers, models, settings] = await Promise.all([
2659
+ api("/api/platform/ai/providers"),
2660
+ api("/api/platform/ai/models"),
2661
+ api("/api/platform/ai/settings")
2662
+ ]);
2663
+ const host = $("#platform-ai-content");
2664
+ host.innerHTML = `<section class="config-section platform-system-prompt-section"><div class="config-section-header"><div><h2>平台全局系统提示词</h2><p>会追加在内置系统提示词之后,并在所有作品的专属提示词之前发送给模型。</p></div></div><div class="field-label"><textarea id="platform-system-prompt" rows="7" aria-label="全局系统提示词" placeholder="例如:默认使用简体中文,避免代替作者做最终决定。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-platform-system-prompt" class="primary-button">保存全局提示词</button></div></section>${renderProviderCards(providers, models)}`;
2665
+ $("#save-platform-system-prompt").addEventListener("click", async () => {
2666
+ const button = $("#save-platform-system-prompt");
2667
+ button.disabled = true;
2668
+ try {
2669
+ await api("/api/platform/ai/settings", { method: "PATCH", body: { systemPrompt: $("#platform-system-prompt").value } });
2670
+ toast("平台全局系统提示词已保存");
2671
+ } catch (error) {
2672
+ toast(error.message, "error");
2673
+ } finally {
2674
+ button.disabled = false;
2675
+ }
2676
+ });
2677
+ bindPlatformProviderActions(host, providers, models);
2678
+ }
2679
+
2680
+ async function renderBookAiSettings() {
2681
+ const [settings, providers, models, taskDefaults] = await Promise.all([
2682
+ api(`/api/works/${state.work.id}/ai-settings`),
2683
+ api("/api/platform/ai/providers"),
2684
+ api(`/api/works/${state.work.id}/models`),
2685
+ api(`/api/works/${state.work.id}/task-defaults`)
2686
+ ]);
2687
+ const host = $("#module-content");
2688
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "query_story_knowledge"]);
2689
+ host.innerHTML = `<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="primary-button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="field-label"><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></div><div class="card-actions"><button id="save-book-summary-context-percent" class="primary-button">保存概要配额</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话长期记忆</h2><p>对话历史使用独立预算;达到阈值时先提醒,继续发送会把较早消息整理成带来源的结构化长期记忆,并尽量保留最近八条原文。</p></div></div><div class="field-label"><label class="context-compact-threshold-field">整理提醒阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话长期记忆整理提醒阈值"></label></div><div class="card-actions"><button id="save-context-compact-threshold" class="primary-button">保存整理阈值</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="query_story_knowledge" ${agentTools.has("query_story_knowledge") ? "checked" : ""}><span><strong>查询作品知识</strong><small>按关键词查询设定、人物、组织、时间线、关系、大纲和伏笔。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="primary-button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
2690
+ host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
2691
+ "beforebegin",
2692
+ `<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
2693
+ );
2694
+ $("#save-work-system-prompt").addEventListener("click", async () => {
2695
+ const button = $("#save-work-system-prompt");
2696
+ button.disabled = true;
2697
+ try {
2698
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { systemPrompt: $("#work-system-prompt").value } });
2699
+ toast("本书系统提示词已保存");
2700
+ scheduleAiContextUsage();
2701
+ } catch (error) {
2702
+ toast(error.message, "error");
2703
+ } finally {
2704
+ button.disabled = false;
2705
+ }
2706
+ });
2707
+ $("#save-book-summary-context-percent").addEventListener("click", async () => {
2708
+ const button = $("#save-book-summary-context-percent");
2709
+ button.disabled = true;
2710
+ try {
2711
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { bookSummaryContextPercent: Number($("#book-summary-context-percent").value) } });
2712
+ toast("全书概要引用配额已保存");
2713
+ scheduleAiContextUsage();
2714
+ } catch (error) {
2715
+ toast(error.message, "error");
2716
+ } finally {
2717
+ button.disabled = false;
2718
+ }
2719
+ });
2720
+ $("#save-context-compact-threshold").addEventListener("click", async () => {
2721
+ const button = $("#save-context-compact-threshold");
2722
+ button.disabled = true;
2723
+ try {
2724
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { contextCompactThreshold: Number($("#context-compact-threshold").value) } });
2725
+ toast("对话长期记忆整理阈值已保存");
2726
+ scheduleAiContextUsage();
2727
+ } catch (error) {
2728
+ toast(error.message, "error");
2729
+ } finally {
2730
+ button.disabled = false;
2731
+ }
2732
+ });
2733
+ $("#save-agent-tools").addEventListener("click", async () => {
2734
+ const button = $("#save-agent-tools");
2735
+ button.disabled = true;
2736
+ try {
2737
+ const agentTools = [...host.querySelectorAll('input[name="agent-tool"]:checked')].map((input) => input.value);
2738
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { agentTools } });
2739
+ toast("AI 查询工具设置已保存");
2740
+ } catch (error) {
2741
+ toast(error.message, "error");
2742
+ } finally {
2743
+ button.disabled = false;
2744
+ }
2745
+ });
2746
+ host.querySelectorAll("[data-task-default]").forEach((select) => select.addEventListener("change", async () => {
2747
+ select.disabled = true;
2748
+ try {
2749
+ await api(`/api/works/${state.work.id}/task-defaults/${select.dataset.taskDefault}`, { method: "PUT", body: { modelId: select.value } });
2750
+ toast("默认模型已更新");
2751
+ } catch (error) {
2752
+ toast(error.message, "error");
2753
+ }
2754
+ await renderBookAiSettings();
2755
+ await loadModels();
2756
+ }));
2757
+ }
2758
+
2759
+ async function loadModels() {
2760
+ const workId = state.work?.id;
2761
+ if (!workId) return;
2762
+ const models = await api(`/api/works/${workId}/models`);
2763
+ if (state.work?.id !== workId) return;
2764
+ state.models = models;
2765
+ loadedAiModelsWorkId = workId;
2766
+ const select = $("#ai-model");
2767
+ select.innerHTML = state.models.length
2768
+ ? state.models.map((model) => `<option value="${esc(model.id)}" ${model.enabled ? "" : "disabled"}>${esc(modelOptionLabel(model))}</option>`).join("")
2769
+ : '<option value="">请先配置模型</option>';
2770
+ scheduleAiContextUsage();
2771
+ }
2772
+
2773
+ async function ensureAiModelsLoaded() {
2774
+ const workId = state.work?.id;
2775
+ if (!workId || loadedAiModelsWorkId === workId) return;
2776
+ if (aiModelsLoadPromise && aiModelsLoadWorkId === workId) return aiModelsLoadPromise;
2777
+ const select = $("#ai-model");
2778
+ select.innerHTML = '<option value="">正在加载模型……</option>';
2779
+ aiModelsLoadWorkId = workId;
2780
+ aiModelsLoadPromise = loadModels();
2781
+ try {
2782
+ await aiModelsLoadPromise;
2783
+ } catch (error) {
2784
+ if (state.work?.id === workId) select.innerHTML = '<option value="">模型加载失败,点击重试</option>';
2785
+ throw error;
2786
+ } finally {
2787
+ if (aiModelsLoadWorkId === workId) {
2788
+ aiModelsLoadPromise = null;
2789
+ aiModelsLoadWorkId = null;
2790
+ }
2791
+ }
2792
+ }
2793
+
2794
+ let aiContextUsageTimer = null;
2795
+ let aiContextUsageRequest = 0;
2796
+
2797
+ function currentAiRequestScope() {
2798
+ if (!state.work || !state.chapter) return null;
2799
+ const taskType = $("#ai-task").value;
2800
+ const scopeType = $("#ai-scope").value;
2801
+ const selection = $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd);
2802
+ const volume = state.work.volumes.find((item) => item.id === state.chapter.volumeId);
2803
+ const includeBookSummary = scopeType === "chapter-summary";
2804
+ const scope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter.id, selection }
2805
+ : scopeType === "none" ? { type: "none", ...(taskType === "continue" ? { chapterId: state.chapter.id } : {}) }
2806
+ : scopeType === "book" ? { type: "book" }
2807
+ : scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
2808
+ : { type: "chapter", chapterId: state.chapter.id };
2809
+ Object.assign(scope, buildAiReferenceScope(state.aiReferences));
2810
+ if (includeBookSummary) scope.includeBookSummary = true;
2811
+ return { taskType, scope, selection };
2812
+ }
2813
+
2814
+ function setAiContextMeter(usage) {
2815
+ const meter = $("#ai-context-meter");
2816
+ const value = meter.querySelector("b");
2817
+ if (!usage) {
2818
+ meter.classList.add("is-empty");
2819
+ meter.classList.remove("is-warning", "is-danger");
2820
+ meter.style.setProperty("--context-usage", "0");
2821
+ value.textContent = "—";
2822
+ const tooltip = formatAiContextUsageTooltip(null);
2823
+ meter.dataset.tooltip = tooltip;
2824
+ meter.setAttribute("aria-label", tooltip);
2825
+ return;
2826
+ }
2827
+ const percent = Math.max(0, Math.min(100, Number(usage.usagePercent) || 0));
2828
+ meter.classList.remove("is-empty");
2829
+ meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
2830
+ meter.classList.toggle("is-danger", percent >= 90);
2831
+ meter.style.setProperty("--context-usage", String(percent));
2832
+ value.textContent = `${percent}%`;
2833
+ const tooltip = formatAiContextUsageTooltip(usage);
2834
+ meter.dataset.tooltip = tooltip;
2835
+ meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
2836
+ }
2837
+
2838
+ function showAiContextWarning(usage = null) {
2839
+ const percent = Math.max(0, Math.round(Number(usage?.conversationUsagePercent) || 0));
2840
+ const threshold = Math.max(50, Math.min(90, Number(usage?.compactThreshold) || 85));
2841
+ $("#ai-context-warning-title").textContent = percent ? `对话历史已使用 ${percent}% 的独立预算` : "对话历史接近整理阈值";
2842
+ $("#ai-context-warning-message").textContent = `已达到 ${threshold}% 的长期记忆整理阈值。现在可整理较早对话或新开对话;若继续发送,系统会先生成带来源的结构化长期记忆。作品正文超限不会触发此操作。`;
2843
+ $("#ai-context-warning").classList.remove("hidden");
2844
+ }
2845
+
2846
+ function hideAiContextWarning() {
2847
+ $("#ai-context-warning").classList.add("hidden");
2848
+ }
2849
+
2850
+ async function prepareAiConversationContext({ instruction, scope, modelId, citations }) {
2851
+ const conversationId = await ensureAiConversation();
2852
+ const prepared = await api(`/api/ai-conversations/${conversationId}/context/prepare`, {
2853
+ method: "POST",
2854
+ body: { instruction, scope, modelId, citations }
2855
+ });
2856
+ setAiContextMeter(prepared.usage);
2857
+ if (prepared.action === "warn") {
2858
+ showAiContextWarning(prepared.usage);
2859
+ return false;
2860
+ }
2861
+ hideAiContextWarning();
2862
+ if (prepared.action === "compacted") toast("已自动整理较早对话为长期记忆并继续发送");
2863
+ return true;
2864
+ }
2865
+
2866
+ function scheduleAiContextUsage() {
2867
+ if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
2868
+ aiContextUsageTimer = setTimeout(() => {
2869
+ aiContextUsageTimer = null;
2870
+ void refreshAiContextUsage();
2871
+ }, 260);
2872
+ }
2873
+
2874
+ async function refreshAiContextUsage() {
2875
+ const requestScope = currentAiRequestScope();
2876
+ const modelId = $("#ai-model").value;
2877
+ if (!requestScope || !modelId || (requestScope.taskType === "polish" && !requestScope.selection)) {
2878
+ setAiContextMeter(null);
2879
+ return;
2880
+ }
2881
+ const requestId = ++aiContextUsageRequest;
2882
+ try {
2883
+ const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
2884
+ const usage = await api(`/api/works/${state.work.id}/ai-context-usage`, {
2885
+ method: "POST",
2886
+ body: {
2887
+ modelId,
2888
+ taskType: requestScope.taskType,
2889
+ scope: requestScope.scope,
2890
+ instruction: aiPromptText(),
2891
+ citations,
2892
+ conversationId: state.aiConversationId || undefined
2893
+ }
2894
+ });
2895
+ if (requestId === aiContextUsageRequest) setAiContextMeter(usage);
2896
+ } catch {
2897
+ if (requestId === aiContextUsageRequest) setAiContextMeter(null);
2898
+ }
2899
+ }
2900
+
2901
+ async function loadAiReferences() {
2902
+ const workId = state.work?.id;
2903
+ if (!workId) return;
2904
+ const [characters, settings] = await Promise.all([
2905
+ api(`/api/works/${workId}/characters`),
2906
+ api(`/api/works/${workId}/settings`)
2907
+ ]);
2908
+ if (state.work?.id !== workId) return;
2909
+ state.characters = characters;
2910
+ state.settings = settings;
2911
+ loadedAiReferencesWorkId = workId;
2912
+ }
2913
+
2914
+ async function ensureAiReferencesLoaded() {
2915
+ const workId = state.work?.id;
2916
+ if (!workId || loadedAiReferencesWorkId === workId) return;
2917
+ if (aiReferencesLoadPromise && aiReferencesLoadWorkId === workId) return aiReferencesLoadPromise;
2918
+ aiReferencesLoadWorkId = workId;
2919
+ aiReferencesLoadPromise = loadAiReferences();
2920
+ try {
2921
+ await aiReferencesLoadPromise;
2922
+ } finally {
2923
+ if (aiReferencesLoadWorkId === workId) {
2924
+ aiReferencesLoadPromise = null;
2925
+ aiReferencesLoadWorkId = null;
2926
+ }
2927
+ }
2928
+ }
2929
+
2930
+ function field(name, label, type = "text", value = "", options = []) {
2931
+ if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
2932
+ if (type === "item-list") {
2933
+ const values = Array.isArray(value) && value.length ? value : [""];
2934
+ return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
2935
+ }
2936
+ if (type === "key-value-list") {
2937
+ const config = Array.isArray(options) ? {} : options;
2938
+ const keyName = config.keyName ?? "detailLabel";
2939
+ const valueName = config.valueName ?? "detailValue";
2940
+ const keyPlaceholder = config.keyPlaceholder ?? "字段名,如身高";
2941
+ const valuePlaceholder = config.valuePlaceholder ?? "字段值,如119.786米";
2942
+ const keyAriaLabel = config.keyAriaLabel ?? "扩展属性名称";
2943
+ const valueAriaLabel = config.valueAriaLabel ?? "扩展属性内容";
2944
+ const removeLabel = config.removeLabel ?? "删除此扩展属性";
2945
+ const addLabel = config.addLabel ?? "添加属性";
2946
+ const values = normalizeCharacterDetails(value);
2947
+ const rows = values.length ? values : [{ label: "", value: "" }];
2948
+ return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}"><input name="${esc(valueName)}" value="${esc(item.value)}" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}"><button type="button" data-structured-list-remove aria-label="${esc(removeLabel)}">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-structured-list-add>${esc(addLabel)}</button></div>`;
2949
+ }
2950
+ if (type === "section-list") {
2951
+ const values = normalizeCharacterSections(value);
2952
+ const rows = values.length ? values : [{ title: "", content: "" }];
2953
+ return `<div class="form-field structured-list-field character-profile-section-list"><span>${esc(label)}</span><small>适合记录能力、形态、生态、历史事件、传说和研究版本等长篇内容。</small><div class="structured-list-rows" data-structured-list-rows data-kind="section">${rows.map((item) => `<div class="structured-list-row section-list-row"><input name="sectionTitle" value="${esc(item.title)}" placeholder="章节标题,如能力与特征" aria-label="设定章节标题"><textarea name="sectionContent" placeholder="章节内容,支持 Markdown" aria-label="设定章节内容">${esc(item.content)}</textarea><button type="button" data-structured-list-remove aria-label="删除此设定章节">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-structured-list-add>添加设定章节</button></div>`;
2954
+ }
2955
+ if (type === "select") return `<label>${esc(label)}<select name="${esc(name)}">${options.map(([key, text]) => `<option value="${esc(key)}" ${key === value ? "selected" : ""}>${esc(text)}</option>`).join("")}</select></label>`;
2956
+ if (type === "multiselect") {
2957
+ const selected = new Set((Array.isArray(value) ? value : []).map(String));
2958
+ return `<label>${esc(label)}<select name="${esc(name)}" multiple size="${Math.min(8, Math.max(3, options.length))}">${options.map(([key, text]) => `<option value="${esc(key)}" ${selected.has(String(key)) ? "selected" : ""}>${esc(text)}</option>`).join("")}</select></label>`;
2959
+ }
2960
+ if (type === "chips") {
2961
+ const selected = new Set((Array.isArray(value) ? value : []).map(String));
2962
+ return `<div class="form-field chip-field"><span>${esc(label)}</span><div class="chip-picker" role="group" aria-label="${esc(label)}">${options.map(([key, text]) => `<label class="member-chip"><input type="checkbox" name="${esc(name)}" value="${esc(key)}" ${selected.has(String(key)) ? "checked" : ""}><span>${esc(text)}</span></label>`).join("")}</div></div>`;
2963
+ }
2964
+ if (type === "checkbox") return `<label class="checkbox-field"><input name="${esc(name)}" type="checkbox" ${value ? "checked" : ""}><span>${esc(label)}</span></label>`;
2965
+ return `<label>${esc(label)}<input name="${esc(name)}" type="${esc(type)}" value="${esc(value)}" ${type === "password" ? 'autocomplete="new-password"' : ""} ${type === "number" ? 'step="any"' : ""}></label>`;
2966
+ }
2967
+
2968
+ function bindDynamicListControls(container) {
2969
+ container.querySelectorAll("[data-item-list-add]").forEach((button) => button.addEventListener("click", () => {
2970
+ const rows = button.previousElementSibling;
2971
+ const row = document.createElement("div");
2972
+ row.className = "item-list-row";
2973
+ const input = document.createElement("input");
2974
+ input.name = rows.dataset.name;
2975
+ input.setAttribute("aria-label", rows.dataset.label || "列表项目");
2976
+ const remove = document.createElement("button");
2977
+ remove.type = "button";
2978
+ remove.dataset.itemListRemove = "";
2979
+ remove.setAttribute("aria-label", "删除此条");
2980
+ remove.textContent = "删除";
2981
+ row.append(input, remove);
2982
+ rows.append(row);
2983
+ input.focus();
2984
+ }));
2985
+ container.querySelectorAll("[data-structured-list-add]").forEach((button) => button.addEventListener("click", () => {
2986
+ const rows = button.previousElementSibling;
2987
+ const row = rows.lastElementChild.cloneNode(true);
2988
+ row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
2989
+ rows.append(row);
2990
+ row.querySelector("input").focus();
2991
+ }));
2992
+ container.onclick = (event) => {
2993
+ const remove = event.target.closest("[data-item-list-remove], [data-structured-list-remove]");
2994
+ if (!remove) return;
2995
+ const row = remove.closest(".item-list-row, .structured-list-row");
2996
+ const rows = row.parentElement;
2997
+ if (rows.children.length === 1) row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
2998
+ else row.remove();
2999
+ };
3000
+ }
3001
+
3002
+ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
3003
+ $("#dialog-title").textContent = title;
3004
+ $("#dialog-eyebrow").textContent = eyebrow;
3005
+ $("#dialog-fields").innerHTML = fields;
3006
+ $("#dialog-submit").textContent = options.submitLabel ?? "保存";
3007
+ $("#form-dialog").classList.toggle("wide-dialog", Boolean(options.wide));
3008
+ bindDynamicListControls($("#dialog-fields"));
3009
+ const form = $("#dynamic-form");
3010
+ form.onsubmit = async (event) => {
3011
+ if (event.submitter?.value === "cancel") return;
3012
+ event.preventDefault();
3013
+ const submit = $("#dialog-submit");
3014
+ submit.disabled = true;
3015
+ try {
3016
+ await onSubmit(new FormData(form));
3017
+ $("#form-dialog").close();
3018
+ } catch (error) {
3019
+ toast(error.message, "error");
3020
+ } finally {
3021
+ submit.disabled = false;
3022
+ }
3023
+ };
3024
+ $("#form-dialog").showModal();
3025
+ }
3026
+
3027
+ function openWorkDialog() {
3028
+ openDialog("创建作品",
3029
+ field("method", "创建方式", "select", "blank", [["blank", "从零新建"], ["import", "导入 TXT / DOCX 新建"]]) +
3030
+ field("title", "作品名称(导入时可留空)") + field("author", "作者") + field("description", "简介", "textarea"),
3031
+ async (form) => {
3032
+ const metadata = { title: String(form.get("title") ?? "").trim(), author: form.get("author"), description: form.get("description") };
3033
+ if (form.get("method") === "import") {
3034
+ state.pendingImportMeta = metadata;
3035
+ $("#new-import-file").click();
3036
+ return;
3037
+ }
3038
+ if (!metadata.title) throw new Error("从零新建时必须填写作品名称");
3039
+ const work = await api("/api/works", { method: "POST", body: metadata });
3040
+ toast("作品已创建");
3041
+ await loadWorks(work.id);
3042
+ }, "新的世界");
3043
+ }
3044
+
3045
+ function workCoverFieldHtml(work) {
3046
+ return `<section class="work-cover-field" aria-labelledby="work-cover-title">
3047
+ <div class="work-cover-copy">
3048
+ <strong id="work-cover-title">封面</strong>
3049
+ <small>用于书架展示。支持 PNG、JPEG、WebP。</small>
3050
+ </div>
3051
+ <div class="work-cover-preview ${work.coverUrl ? "has-cover" : ""}" aria-hidden="${work.coverUrl ? "false" : "true"}">
3052
+ ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面预览">` : "<span>暂无封面</span>"}
3053
+ </div>
3054
+ <div class="work-cover-actions">
3055
+ <button id="work-cover-upload" class="ghost-button" type="button">${work.coverUrl ? "更换封面" : "设置封面"}</button>
3056
+ ${work.coverUrl ? '<button id="work-cover-remove" class="ghost-button" type="button">移除封面</button>' : ""}
3057
+ </div>
3058
+ </section>`;
3059
+ }
3060
+
3061
+ function bindWorkCoverControls(work) {
3062
+ $("#work-cover-upload")?.addEventListener("click", () => {
3063
+ state.pendingCoverWorkId = work.id;
3064
+ $("#cover-file").click();
3065
+ });
3066
+ $("#work-cover-remove")?.addEventListener("click", async () => {
3067
+ try {
3068
+ await api(`/api/works/${work.id}/cover`, { method: "DELETE" });
3069
+ state.works = await api("/api/works");
3070
+ const updated = state.works.find((item) => item.id === work.id) ?? { ...work, coverUrl: null };
3071
+ Object.assign(work, updated);
3072
+ const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
3073
+ if (coverField) {
3074
+ coverField.outerHTML = workCoverFieldHtml(work);
3075
+ bindWorkCoverControls(work);
3076
+ }
3077
+ renderShelf();
3078
+ toast("封面已移除");
3079
+ } catch (error) {
3080
+ toast(error.message, "error");
3081
+ }
3082
+ });
3083
+ }
3084
+
3085
+ function openWorkSettingsDialog(work) {
3086
+ if (!work) return;
3087
+ const canManageAccess = ["admin", "owner"].includes(String(work.accessRole));
3088
+ const accessField = `<section class="work-access-field" aria-labelledby="work-access-title">
3089
+ <div><strong id="work-access-title">可访问人</strong><small>可以分别授予成员仅查看或共同编辑权限。</small></div>
3090
+ ${canManageAccess ? '<button id="work-access-manage" class="ghost-button" type="button">添加或管理可访问人</button>' : '<small>仅作品创建者或系统管理员可以调整访问权限。</small>'}
3091
+ </section>`;
3092
+ openDialog("作品信息",
3093
+ workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
3094
+ async (form) => {
3095
+ await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
3096
+ state.works = await api("/api/works");
3097
+ const updated = state.works.find((item) => item.id === work.id);
3098
+ if (updated) Object.assign(work, updated);
3099
+ if (state.work?.id === work.id) {
3100
+ state.work.title = String(form.get("title") ?? state.work.title);
3101
+ state.work.author = String(form.get("author") ?? state.work.author);
3102
+ state.work.description = String(form.get("description") ?? state.work.description);
3103
+ if (updated?.coverUrl !== undefined) state.work.coverUrl = updated.coverUrl;
3104
+ updateDocumentTitle(state.work);
3105
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
3106
+ }
3107
+ renderShelf();
3108
+ toast("作品信息已保存");
3109
+ }, "作品设置");
3110
+ bindWorkCoverControls(work);
3111
+ $("#work-access-manage")?.addEventListener("click", () => {
3112
+ $("#form-dialog").close();
3113
+ openMembersDialog(work);
3114
+ });
3115
+ }
3116
+
3117
+ async function openChapterDialog() {
3118
+ if (!state.work) return openWorkDialog();
3119
+ if (!state.work.volumes.length) {
3120
+ await api(`/api/works/${state.work.id}/volumes`, { method: "POST", body: { title: "正文", kind: "main" } });
3121
+ state.work = await api(`/api/works/${state.work.id}`);
3122
+ renderTree();
3123
+ }
3124
+ openDialog("新建章节", field("title", "章节标题") + field("volumeId", "所属卷", "select", state.work.volumes[0].id, state.work.volumes.map((volume) => [volume.id, volume.title])) + field("chapterType", "章节类型", "select", "正文", chapterTypes.map((value) => [value, value])), async (form) => {
3125
+ const chapter = await api(`/api/works/${state.work.id}/chapters`, { method: "POST", body: { title: form.get("title"), volumeId: form.get("volumeId"), chapterType: form.get("chapterType"), content: "" } });
3126
+ state.work = await api(`/api/works/${state.work.id}`);
3127
+ await selectChapter(chapter.id);
3128
+ });
3129
+ }
3130
+
3131
+ function openVolumeDialog(item) {
3132
+ if (!state.work) return openWorkDialog();
3133
+ const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
3134
+ openDialog(item ? "编辑分卷" : "新建分卷",
3135
+ field("title", "分卷名称", "text", item?.title) +
3136
+ field("kind", "分卷类型", "select", item?.kind ?? "main", kindOptions) +
3137
+ field("description", "分卷简介", "textarea", item?.description) +
3138
+ field("keywords", "分卷关键词(逐条填写)", "item-list", item?.keywords ?? []),
3139
+ async (form) => {
3140
+ const body = {
3141
+ title: form.get("title"),
3142
+ kind: form.get("kind"),
3143
+ description: form.get("description"),
3144
+ keywords: form.getAll("keywords").map((value) => String(value).trim()).filter(Boolean)
3145
+ };
3146
+ await api(item ? `/api/volumes/${item.id}` : `/api/works/${state.work.id}/volumes`, { method: item ? "PATCH" : "POST", body });
3147
+ state.work = await api(`/api/works/${state.work.id}`);
3148
+ renderTree();
3149
+ toast(item ? "分卷设置已保存" : "分卷已创建");
3150
+ }, "分卷设置");
3151
+ }
3152
+
3153
+ function openSettingDialog(item) {
3154
+ openDialog(item ? "编辑设定" : "新建设定",
3155
+ field("title", "标题", "text", item?.title) +
3156
+ field("category", "分类", "select", item?.category ?? "世界规则", [["世界规则", "世界规则"], ["历史与年代", "历史与年代"], ["地点与地图", "地点与地图"], ["组织与阵营", "组织与阵营"], ["物种与族群", "物种与族群"], ["科技与物品", "科技与物品"], ["术语与称谓", "术语与称谓"], ["创作约束", "创作约束"]]) +
3157
+ field("content", "设定说明", "textarea", item?.content) + field("locked", "锁定为 AI 硬约束", "checkbox", item?.locked),
3158
+ async (form) => {
3159
+ const body = { title: form.get("title"), category: form.get("category"), content: form.get("content"), locked: form.get("locked") === "on", status: form.get("locked") === "on" ? "confirmed" : (item?.status ?? "draft") };
3160
+ await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
3161
+ await renderSettings();
3162
+ await loadAiReferences();
3163
+ }, item ? "人工修正" : "作者事实");
3164
+ }
3165
+
3166
+ function characterEditorSection(key, title, description, content) {
3167
+ return `<section class="character-editor-section${key === "basic" ? "" : " hidden"}" data-character-editor-panel="${esc(key)}" role="tabpanel">
3168
+ <header><div><span class="eyebrow">${esc(title)}</span><h3>${esc(title)}</h3></div><p>${esc(description)}</p></header>
3169
+ <div class="character-editor-section-fields">${content}</div>
3170
+ </section>`;
3171
+ }
3172
+
3173
+ function activateCharacterEditorTab(key) {
3174
+ document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
3175
+ const active = button.dataset.characterEditorTab === key;
3176
+ button.setAttribute("aria-selected", String(active));
3177
+ button.tabIndex = active ? 0 : -1;
3178
+ });
3179
+ document.querySelectorAll("[data-character-editor-panel]").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.characterEditorPanel !== key));
3180
+ }
3181
+
3182
+ function setCharacterHistoryVisible(visible) {
3183
+ const panel = $("#character-history-panel");
3184
+ const workspace = panel.closest(".character-editor-workspace");
3185
+ panel.classList.toggle("hidden", !visible);
3186
+ workspace.classList.toggle("history-open", visible);
3187
+ $("#character-history-button").setAttribute("aria-expanded", String(visible));
3188
+ }
3189
+
3190
+ function renderCharacterEditorFields(item) {
3191
+ const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, race.name])];
3192
+ const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
3193
+ const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
3194
+ const stateEntries = characterStateEntries(item?.currentState ?? {});
3195
+ $("#character-editor-fields").innerHTML = [
3196
+ characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
3197
+ field("name", "标准名", "text", item?.name) +
3198
+ field("aliases", "别名", "item-list", item?.aliases ?? []) +
3199
+ (state.races.length
3200
+ ? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
3201
+ : '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
3202
+ (organizationOptions.length
3203
+ ? field("organizationIds", "所属组织(可多选)", "chips", item?.organizationIds ?? [], organizationOptions)
3204
+ : '<div class="character-editor-empty-field"><b>所属组织</b><span>尚未创建组织,可稍后在“组织”模块中补充。</span></div>') +
3205
+ field("visibility", "可见范围", "select", item?.visibility ?? "author", [["author", "仅作者"], ["collaborators", "协作者"], ["public", "公开"]]) +
3206
+ field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)),
3207
+ characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
3208
+ field("identity", "身份与定位", "text", item?.attributes?.identity) +
3209
+ field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
3210
+ field("summary", "人物简介", "textarea", item?.profile?.summary)),
3211
+ characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
3212
+ field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
3213
+ field("sections", "设定章节", "section-list", item?.profile?.sections)),
3214
+ characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
3215
+ field("currentState", "当前状态", "key-value-list", stateEntries, {
3216
+ keyName: "stateKey",
3217
+ valueName: "stateValue",
3218
+ keyPlaceholder: "状态字段,如 location",
3219
+ valuePlaceholder: "当前值,如 地球",
3220
+ keyAriaLabel: "状态字段名称",
3221
+ valueAriaLabel: "状态字段内容",
3222
+ removeLabel: "删除此状态字段",
3223
+ addLabel: "添加状态"
3224
+ }) +
3225
+ '<p class="character-editor-field-help">未修改的数字、布尔值、数组和对象会保留原有数据类型;被修改的值会按文本保存。</p>' +
3226
+ field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? []))
3227
+ ].join("");
3228
+ const name = $("#character-editor-fields [name='name']");
3229
+ if (name) name.required = true;
3230
+ bindDynamicListControls($("#character-editor-fields"));
3231
+ activateCharacterEditorTab("basic");
3232
+ }
3233
+
3234
+ function collectCharacterBody(form) {
3235
+ const item = characterEditorItem;
3236
+ return {
3237
+ name: String(form.get("name") ?? "").trim(),
3238
+ aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
3239
+ raceId: form.get("raceId") || null,
3240
+ organizationIds: form.getAll("organizationIds").map(String),
3241
+ attributes: {
3242
+ ...(item?.attributes ?? {}),
3243
+ identity: String(form.get("identity") ?? "").trim(),
3244
+ details: buildCharacterDetails(form.getAll("detailLabel"), form.getAll("detailValue"))
3245
+ },
3246
+ profile: {
3247
+ ...(item?.profile ?? {}),
3248
+ motivation: String(form.get("motivation") ?? "").trim(),
3249
+ summary: String(form.get("summary") ?? "").trim(),
3250
+ sections: buildCharacterSections(form.getAll("sectionTitle"), form.getAll("sectionContent"))
3251
+ },
3252
+ currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
3253
+ lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
3254
+ visibility: String(form.get("visibility") ?? "author"),
3255
+ firstChapterId: form.get("firstChapterId") || null,
3256
+ changeNote: String(form.get("changeNote") ?? "").trim()
3257
+ };
3258
+ }
3259
+
3260
+ function renderCharacterHistory() {
3261
+ const host = $("#character-history-list");
3262
+ if (!characterEditorVersions.length) {
3263
+ host.innerHTML = '<p class="character-history-empty">还没有可用的历史版本。</p>';
3264
+ return;
3265
+ }
3266
+ host.innerHTML = characterEditorVersions.map((version, index) => {
3267
+ const previous = characterEditorVersions[index + 1];
3268
+ const changes = describeCharacterVersionChanges(version.snapshot, previous?.snapshot);
3269
+ const isCurrent = version.versionNo === characterEditorItem?.versionNo;
3270
+ return `<article class="character-version-card${isCurrent ? " is-current" : ""}" data-character-version="${version.versionNo}">
3271
+ <div class="character-version-card-heading"><div><strong>v${version.versionNo}</strong><span>${esc(characterVersionSourceLabel(version.source))}</span></div><time>${esc(formatDateTime(version.createdAt))} · ${esc(version.actor || "历史数据")}</time></div>
3272
+ <p>${esc(version.changeNote || "未填写版本说明")}</p>
3273
+ <div class="character-version-changes">${changes.map((change) => `<span>${esc(change)}</span>`).join("")}</div>
3274
+ ${isCurrent ? '<button type="button" disabled>当前版本</button>' : `<button type="button" data-character-restore="${version.versionNo}">回滚到此版本</button>`}
3275
+ </article>`;
3276
+ }).join("");
3277
+ host.querySelectorAll("[data-character-restore]").forEach((button) => button.addEventListener("click", async () => {
3278
+ const versionNo = Number(button.dataset.characterRestore);
3279
+ if (button.dataset.confirmed !== "true") {
3280
+ host.querySelectorAll("[data-character-restore]").forEach((other) => {
3281
+ other.dataset.confirmed = "false";
3282
+ other.classList.remove("is-confirming");
3283
+ other.textContent = "回滚到此版本";
3284
+ });
3285
+ button.dataset.confirmed = "true";
3286
+ button.classList.add("is-confirming");
3287
+ button.textContent = `确认回滚至 v${versionNo}`;
3288
+ window.setTimeout(() => {
3289
+ if (!button.isConnected || button.dataset.confirmed !== "true") return;
3290
+ button.dataset.confirmed = "false";
3291
+ button.classList.remove("is-confirming");
3292
+ button.textContent = "回滚到此版本";
3293
+ }, 5000);
3294
+ return;
3295
+ }
3296
+ button.disabled = true;
3297
+ try {
3298
+ const restored = await api(`/api/characters/${characterEditorItem.id}/restore`, { method: "POST", body: { versionNo } });
3299
+ characterEditorItem = restored;
3300
+ renderCharacterEditorFields(restored);
3301
+ $("#character-editor-title").textContent = restored.name;
3302
+ $("#character-editor-version").textContent = `v${restored.versionNo}`;
3303
+ $("#character-change-note").value = "";
3304
+ await Promise.all([renderCharacters(), loadAiReferences()]);
3305
+ await showCharacterHistory();
3306
+ toast(`已回滚至 v${versionNo},并生成 v${restored.versionNo}`);
3307
+ } catch (error) {
3308
+ button.disabled = false;
3309
+ toast(error.message, "error");
3310
+ }
3311
+ }));
3312
+ }
3313
+
3314
+ async function showCharacterHistory() {
3315
+ if (!characterEditorItem?.id) return;
3316
+ setCharacterHistoryVisible(true);
3317
+ $("#character-history-list").innerHTML = '<p class="character-history-empty">正在读取版本历史…</p>';
3318
+ try {
3319
+ characterEditorVersions = await api(`/api/characters/${characterEditorItem.id}/versions`);
3320
+ renderCharacterHistory();
3321
+ } catch (error) {
3322
+ setCharacterHistoryVisible(false);
3323
+ toast(error.message, "error");
3324
+ }
3325
+ }
3326
+
3327
+ async function openCharacterDialog(item) {
3328
+ [state.races, state.organizations] = await Promise.all([
3329
+ api(`/api/works/${state.work.id}/races`),
3330
+ api(`/api/works/${state.work.id}/organizations`)
3331
+ ]);
3332
+ characterEditorItem = item ?? null;
3333
+ characterEditorVersions = [];
3334
+ $("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
3335
+ $("#character-editor-title").textContent = item?.name || "新建角色";
3336
+ $("#character-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
3337
+ $("#character-change-note").value = "";
3338
+ $("#character-editor-submit").textContent = item ? "保存新版本" : "创建人物档案";
3339
+ $("#character-history-button").disabled = !item;
3340
+ $("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
3341
+ setCharacterHistoryVisible(false);
3342
+ renderCharacterEditorFields(item);
3343
+ const viewOnly = !canEditWork();
3344
+ if (viewOnly) {
3345
+ $("#character-editor-eyebrow").textContent = "人物档案";
3346
+ $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
3347
+ $("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
3348
+ }
3349
+ document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
3350
+ button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
3351
+ });
3352
+ const dialog = $("#character-editor-dialog");
3353
+ const form = $("#character-editor-form");
3354
+ form.onsubmit = async (event) => {
3355
+ event.preventDefault();
3356
+ if (!canEditWork()) return;
3357
+ const submit = $("#character-editor-submit");
3358
+ submit.disabled = true;
3359
+ try {
3360
+ const body = collectCharacterBody(new FormData(form));
3361
+ if (!body.name) throw new Error("请填写角色标准名");
3362
+ const wasEditing = Boolean(characterEditorItem);
3363
+ const previousVersion = characterEditorItem?.versionNo;
3364
+ if (!wasEditing) delete body.changeNote;
3365
+ const saved = await api(wasEditing ? `/api/characters/${characterEditorItem.id}` : `/api/works/${state.work.id}/characters`, { method: wasEditing ? "PATCH" : "POST", body });
3366
+ dialog.close();
3367
+ await Promise.all([renderCharacters(), loadAiReferences()]);
3368
+ toast(!wasEditing ? "人物档案已创建" : saved.versionNo === previousVersion ? "没有检测到人物档案变更" : `人物档案已保存为 v${saved.versionNo}`);
3369
+ } catch (error) {
3370
+ toast(error.message, "error");
3371
+ } finally {
3372
+ submit.disabled = false;
3373
+ }
3374
+ };
3375
+ dialog.showModal();
3376
+ }
3377
+
3378
+ async function openRaceDialog(item) {
3379
+ state.characters = await api(`/api/works/${state.work.id}/characters`);
3380
+ const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3381
+ openDialog(item ? "编辑种族" : "新建种族",
3382
+ field("name", "种族名称", "text", item?.name) +
3383
+ field("description", "种族简介", "textarea", item?.description) +
3384
+ field("settings", "种族共同设定(逐条填写)", "item-list", item?.settings ?? []) +
3385
+ (memberOptions.length ? field("memberIds", "属于该种族的角色(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
3386
+ async (form) => {
3387
+ const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
3388
+ const body = { name: form.get("name"), description: form.get("description"), settings, memberIds: form.getAll("memberIds").map(String) };
3389
+ await api(item ? `/api/races/${item.id}` : `/api/works/${state.work.id}/races`, { method: item ? "PATCH" : "POST", body });
3390
+ await renderRaces();
3391
+ await loadAiReferences();
3392
+ }, item ? "种族档案" : "作品内种族");
3393
+ }
3394
+
3395
+ async function openOrganizationDialog(item) {
3396
+ state.characters = await api(`/api/works/${state.work.id}/characters`);
3397
+ const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3398
+ openDialog(item ? "编辑组织" : "新建组织",
3399
+ field("name", "组织名称", "text", item?.name) +
3400
+ field("description", "组织简介", "textarea", item?.description) +
3401
+ field("settings", "组织设定(逐条填写)", "item-list", item?.settings ?? []) +
3402
+ (memberOptions.length ? field("memberIds", "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
3403
+ async (form) => {
3404
+ const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
3405
+ const body = { name: form.get("name"), description: form.get("description"), settings, memberIds: form.getAll("memberIds").map(String) };
3406
+ await api(item ? `/api/organizations/${item.id}` : `/api/works/${state.work.id}/organizations`, { method: item ? "PATCH" : "POST", body });
3407
+ await renderOrganizations();
3408
+ await loadAiReferences();
3409
+ }, item ? "组织档案" : "世界内组织");
3410
+ }
3411
+
3412
+ function openTimelineTrackDialog(item) {
3413
+ openDialog(item ? "编辑独立时间轴" : "新建独立时间轴", field("name", "时间轴名称", "text", item?.name) + field("description", "时间轴简介", "textarea", item?.description) + field("sortOrder", "看板排序", "number", item?.sortOrder ?? state.timelineTracks.length), async (form) => {
3414
+ await api(item ? `/api/timeline-tracks/${item.id}` : `/api/works/${state.work.id}/timeline-tracks`, { method: item ? "PATCH" : "POST", body: { name: form.get("name"), description: form.get("description"), sortOrder: Number(form.get("sortOrder")) } });
3415
+ await renderTimeline();
3416
+ }, item ? "大事件分组" : "多线叙事");
3417
+ }
3418
+
3419
+ function openTimelineDialog(item, preferredTrackId = null) {
3420
+ const trackOptions = [["", "未分组"], ...state.timelineTracks.map((track) => [track.id, track.name])];
3421
+ openDialog(item ? "编辑大事件" : "新建大事件", field("trackId", "所属独立时间轴", "select", item?.trackId ?? preferredTrackId ?? "", trackOptions) + field("name", "事件名称", "text", item?.name) + field("timeLabel", "时间描述", "text", item?.timeLabel ?? "时间待定") + field("timeSort", "排序值(留空表示时间待定)", "number", item?.timeSort ?? "") + field("eventType", "事件类型", "text", item?.eventType ?? "other") + field("location", "地点", "text", item?.location) + field("description", "事件简述", "textarea", item?.description), async (form) => {
3422
+ const rawSort = String(form.get("timeSort") ?? "").trim();
3423
+ const body = { trackId: form.get("trackId") || null, name: form.get("name"), timeLabel: form.get("timeLabel"), timeSort: rawSort ? Number(rawSort) : null, eventType: form.get("eventType"), location: form.get("location"), description: form.get("description"), status: item?.status ?? "confirmed" };
3424
+ await api(item ? `/api/timeline/${item.id}` : `/api/works/${state.work.id}/timeline`, { method: item ? "PATCH" : "POST", body });
3425
+ await renderTimeline();
3426
+ }, item ? "人工调整" : "作者确认事件");
3427
+ }
3428
+
3429
+ function openOutlineDialog(item) {
3430
+ if (!item) return;
3431
+ openDialog(`规划:${item.chapterTitle}`,
3432
+ field("goal", "本章目标", "textarea", item.goal) +
3433
+ field("conflict", "核心冲突", "textarea", item.conflict) +
3434
+ field("turningPoint", "关键转折", "textarea", item.turningPoint) +
3435
+ field("notes", "补充说明", "textarea", item.notes) +
3436
+ field("status", "规划状态", "select", item.status ?? "draft", [["draft", "草稿"], ["ready", "可执行"], ["completed", "已完成"]]),
3437
+ async (form) => {
3438
+ await api(`/api/chapters/${item.chapterId}/outline`, { method: "PUT", body: {
3439
+ goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status")
3440
+ } });
3441
+ await renderOutlines();
3442
+ toast("章节规划已保存");
3443
+ }, "章节大纲");
3444
+ }
3445
+
3446
+ function openForeshadowDialog(item) {
3447
+ const chapters = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
3448
+ if (!chapters.length) return toast("请先创建章节", "error");
3449
+ const options = [["", "暂不关联"], ...chapters];
3450
+ const occurrence = (role) => item?.occurrences?.find((record) => record.role === role);
3451
+ const editableOccurrenceIds = new Set(["setup", "reminder", "payoff"].map((role) => occurrence(role)?.id).filter(Boolean));
3452
+ const preservedOccurrences = (item?.occurrences ?? [])
3453
+ .filter((record) => !editableOccurrenceIds.has(record.id))
3454
+ .map((record) => ({ chapterId: record.chapterId, role: record.role, note: record.note, evidence: record.evidence }));
3455
+ openDialog(item ? "编辑伏笔" : "新建伏笔",
3456
+ field("title", "伏笔名称", "text", item?.title) +
3457
+ field("description", "内容与预期作用", "textarea", item?.description) +
3458
+ field("importance", "重要程度", "select", item?.importance ?? "medium", [["low", "低"], ["medium", "中"], ["high", "高"]]) +
3459
+ field("status", "状态", "select", item?.status ?? "planned", [["planned", "计划中"], ["planted", "已埋设"], ["resolved", "已回收"], ["abandoned", "已放弃"]]) +
3460
+ field("setupChapterId", "埋设章节", "select", occurrence("setup")?.chapterId ?? "", options) +
3461
+ field("setupNote", "埋设说明", "textarea", occurrence("setup")?.note ?? "") +
3462
+ field("reminderChapterId", "提醒章节", "select", occurrence("reminder")?.chapterId ?? "", options) +
3463
+ field("reminderNote", "提醒说明", "textarea", occurrence("reminder")?.note ?? "") +
3464
+ field("payoffChapterId", "回收章节", "select", occurrence("payoff")?.chapterId ?? item?.plannedPayoffChapterId ?? "", options) +
3465
+ field("payoffNote", "回收说明", "textarea", occurrence("payoff")?.note ?? "") +
3466
+ field("resolutionNote", "回收结论", "textarea", item?.resolutionNote),
3467
+ async (form) => {
3468
+ const editedOccurrences = ["setup", "reminder", "payoff"].flatMap((role) => {
3469
+ const chapterId = form.get(`${role}ChapterId`);
3470
+ if (!chapterId) return [];
3471
+ const previous = occurrence(role);
3472
+ return [{
3473
+ chapterId,
3474
+ role,
3475
+ note: form.get(`${role}Note`),
3476
+ evidence: previous?.chapterId === chapterId ? previous.evidence : []
3477
+ }];
3478
+ });
3479
+ const occurrences = [...preservedOccurrences, ...editedOccurrences];
3480
+ const body = {
3481
+ title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
3482
+ plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences
3483
+ };
3484
+ await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
3485
+ await renderOutlines();
3486
+ toast(item ? "伏笔已更新" : "伏笔已创建");
3487
+ }, item ? "伏笔管理" : "创作线索");
3488
+ }
3489
+
3490
+ function openTimelineSplitDialog(item) {
3491
+ openDialog("拆分时间事件", field("firstName", "第一阶段名称", "text", `${item.name}(一)`) + field("firstDescription", "第一阶段说明", "textarea", item.description) + field("secondName", "第二阶段名称", "text", `${item.name}(二)`) + field("secondDescription", "第二阶段说明", "textarea", item.description), async (form) => {
3492
+ await api(`/api/timeline/${item.id}/split`, { method: "POST", body: { parts: [
3493
+ { name: form.get("firstName"), description: form.get("firstDescription") },
3494
+ { name: form.get("secondName"), description: form.get("secondDescription") }
3495
+ ] } });
3496
+ await renderTimeline();
3497
+ }, "原证据同步保留");
3498
+ }
3499
+
3500
+ async function openRelationshipDialog(item) {
3501
+ state.characters = await api(`/api/works/${state.work.id}/characters`);
3502
+ if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
3503
+ const options = state.characters.map((item) => [item.id, item.name]);
3504
+ openDialog(item ? "编辑人物关系" : "新建人物关系", field("from", "起点人物", "select", item?.fromCharacterId ?? options[0][0], options) + field("to", "终点人物", "select", item?.toCharacterId ?? options[1][0], options) + field("category", "关系大类", "select", item?.category ?? "social", [["family", "亲属"], ["social", "社交"], ["emotional", "情感"], ["conflict", "冲突"], ["uncertain", "未确定"]]) + field("subtype", "关系子类", "text", item?.subtype) + field("keywords", "关系关键词(用逗号分隔)", "text", item?.keywords?.join("、") ?? "") + field("confidence", "置信度(0-1)", "number", item?.confidence ?? "1") + field("directed", "有方向性", "checkbox", item?.directed ?? false), async (form) => {
3505
+ const keywords = String(form.get("keywords") ?? "").split(/[,,、;;]/u).map((value) => value.trim()).filter(Boolean);
3506
+ await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed" } });
3507
+ await renderRelationships();
3508
+ }, item ? "关系档案" : "人工确认关系");
3509
+ }
3510
+
3511
+ function openReviewDialog() {
3512
+ openDialog("新增审核项", field("title", "问题标题") + field("itemType", "问题类型", "text", "consistency") + field("severity", "严重程度", "select", "medium", [["low", "低"], ["medium", "中"], ["high", "高"]]) + field("description", "问题说明", "textarea") + field("suggestion", "可选建议", "textarea"), async (form) => {
3513
+ await api(`/api/works/${state.work.id}/reviews`, { method: "POST", body: { title: form.get("title"), itemType: form.get("itemType"), severity: form.get("severity"), description: form.get("description"), suggestion: form.get("suggestion") } });
3514
+ await renderReviews();
3515
+ });
3516
+ }
3517
+
3518
+ function openTaskDialog() {
3519
+ const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
3520
+ openDialog("开始 AI 分析", field("taskType", "分析类型", "select", "chapter-analysis", [["chapter-analysis", "章节理解"], ["character-extraction", "全书角色抽取"], ["timeline-analysis", "时间轴与事件抽取"], ["relationship-analysis", "全书人物关系分析"], ["worldview-analysis", "世界观分析"], ["setting-extraction", "设定抽取"], ["consistency-check", "一致性校对"], ["book-analysis", "全书综合分析"]]) + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + field("chapterId", "章节", "select", chapterOptions[0]?.[0] ?? "", chapterOptions), async (form) => {
3521
+ const scope = form.get("scopeType") === "book" ? { type: "book" } : { type: "chapter", chapterId: form.get("chapterId") };
3522
+ await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: form.get("taskType"), scope } });
3523
+ await renderTasks();
3524
+ });
3525
+ }
3526
+
3527
+ function openProviderDialog(item) {
3528
+ openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("baseUrl", "Chat Completions 基础地址", "url", item?.baseUrl ?? "https://api.openai.com/v1") + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限(RPM)", "number", item?.rpmLimit ?? 10) + field("maxTokens", "最大输出 Token 数", "number", item?.maxTokens ?? 32000) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
3529
+ const body = { name: form.get("name"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), maxTokens: Number(form.get("maxTokens")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
3530
+ if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
3531
+ await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
3532
+ await renderPlatformAiConfig();
3533
+ await loadModels();
3534
+ }, item ? "限流与凭据" : "OpenAI 兼容协议");
3535
+ }
3536
+
3537
+ function openModelDialog(providerId, item = null) {
3538
+ const values = modelFormValues(item);
3539
+ openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + field("contextWindow", "模型上下文总量(Token)", "number", values.contextWindow) + field("temperature", "默认温度", "number", values.temperature) + field("maxTokens", "默认 max_tokens", "number", values.maxTokens) + field("thinkingEnabled", "开启 Thinking(供应商需支持 thinking 参数)", "checkbox", values.thinkingEnabled) + field("enabled", "启用模型", "checkbox", values.enabled), async (form) => {
3540
+ const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
3541
+ await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
3542
+ await renderPlatformAiConfig();
3543
+ await loadModels();
3544
+ }, item ? "模型配置" : "供应商模型");
3545
+ }
3546
+
3547
+ async function sendAi() {
3548
+ if (!state.work || !state.chapter) return toast("请先选择章节", "error");
3549
+ try {
3550
+ await Promise.all([ensureAiModelsLoaded(), ensureAiConversationsLoaded()]);
3551
+ } catch (error) {
3552
+ return toast(`创作助手加载失败:${error.message}`, "error");
3553
+ }
3554
+ const modelId = $("#ai-model").value;
3555
+ if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
3556
+ const instruction = aiPromptText().trim();
3557
+ if (!instruction) return toast("请输入指令", "error");
3558
+ const requestScope = currentAiRequestScope();
3559
+ if (!requestScope) return toast("请先选择章节", "error");
3560
+ const { taskType, scope, selection } = requestScope;
3561
+ if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
3562
+ const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
3563
+ if (taskType === "chat") {
3564
+ $("#ai-send").disabled = true;
3565
+ $("#ai-send").textContent = "检查上下文";
3566
+ try {
3567
+ const mayContinue = await prepareAiConversationContext({ instruction, scope, modelId, citations });
3568
+ if (!mayContinue) return;
3569
+ } catch (error) {
3570
+ toast(`上下文检查失败:${error.message}`, "error");
3571
+ return;
3572
+ } finally {
3573
+ $("#ai-send").disabled = false;
3574
+ $("#ai-send").textContent = "发送";
3575
+ }
3576
+ }
3577
+ let persistedUserMessage;
3578
+ try {
3579
+ persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
3580
+ } catch (error) {
3581
+ return toast(`对话记录创建失败:${error.message}`, "error");
3582
+ }
3583
+ state.aiPromptSent = true;
3584
+ renderAiQuickActions();
3585
+ appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
3586
+ clearAiPromptComposer();
3587
+ $("#ai-send").disabled = true;
3588
+ $("#ai-send").textContent = "发送中";
3589
+ try {
3590
+ let assistantContent = "";
3591
+ let assistantMessage;
3592
+ let assistantMetadata = {};
3593
+ let suggestion = null;
3594
+ if (taskType === "chat") {
3595
+ const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId, currentMessageId: persistedUserMessage.id });
3596
+ assistantContent = streamed.content;
3597
+ assistantMessage = streamed.message;
3598
+ assistantMetadata = streamed.metadata;
3599
+ } else {
3600
+ suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
3601
+ assistantContent = suggestion.content;
3602
+ assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens };
3603
+ }
3604
+ try {
3605
+ const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
3606
+ if (assistantMessage) {
3607
+ updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
3608
+ attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
3609
+ } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
3610
+ } catch (error) {
3611
+ if (suggestion) appendSuggestion(suggestion);
3612
+ toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
3613
+ }
3614
+ } catch (error) {
3615
+ const failureMessage = `调用失败:${error.message}`;
3616
+ let persistedFailureMessage = null;
3617
+ try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
3618
+ appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
3619
+ } finally {
3620
+ $("#ai-send").disabled = false;
3621
+ $("#ai-send").textContent = "发送";
3622
+ }
3623
+ }
3624
+
3625
+ async function streamChat(body) {
3626
+ const message = document.createElement("div");
3627
+ message.className = "assistant-message is-streaming";
3628
+ message.dataset.testid = "ai-stream-message";
3629
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite"></div><div class="message-meta">正在连接模型流……</div>';
3630
+ attachMessageHeading(message, "助手 · 正在生成");
3631
+ $("#ai-feed").append(message);
3632
+ scrollAiFeedToBottom();
3633
+ const content = message.querySelector(".message-body");
3634
+ const meta = message.querySelector(".message-meta");
3635
+ let streamedText = "";
3636
+ let generatedMetadata = {};
3637
+ let toolCalls = [];
3638
+ let processSteps = [];
3639
+ let finalAnswerStarted = false;
3640
+ const processStartedAt = Date.now();
3641
+ const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
3642
+ try {
3643
+ const response = await fetch(`/api/works/${state.work.id}/chat/stream`, {
3644
+ method: "POST",
3645
+ headers: { "Content-Type": "application/json", Accept: "text/event-stream", "X-CSRF-Token": state.csrfToken },
3646
+ body: JSON.stringify(body)
3647
+ });
3648
+ if (!response.ok || !response.body) {
3649
+ const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
3650
+ throw new Error(payload.error?.message ?? `请求失败:${response.status}`);
3651
+ }
3652
+ const reader = response.body.getReader();
3653
+ const decoder = new TextDecoder();
3654
+ let buffer = "";
3655
+ let streamError = null;
3656
+ const consume = (eventText) => {
3657
+ let eventName = "message";
3658
+ const dataLines = [];
3659
+ for (const line of eventText.split(/\r?\n/)) {
3660
+ if (line.startsWith("event:")) eventName = line.slice(6).trim();
3661
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
3662
+ }
3663
+ if (!dataLines.length) return;
3664
+ const payload = JSON.parse(dataLines.join("\n"));
3665
+ if (eventName === "delta") {
3666
+ const firstFinalDelta = streamedText.length === 0;
3667
+ streamedText += payload.delta ?? "";
3668
+ if (streamedText.length > 0) finalAnswerStarted = true;
3669
+ content.innerHTML = renderMarkdown(streamedText);
3670
+ if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
3671
+ meta.textContent = `已接收 ${Array.from(streamedText).length} 字`;
3672
+ scrollAiFeedToBottom();
3673
+ } else if (eventName === "process_step") {
3674
+ const step = { ...payload };
3675
+ const append = step.append === true;
3676
+ delete step.append;
3677
+ const existing = append ? processSteps.find((item) => item.id === step.id && item.type === step.type) : null;
3678
+ if (existing && typeof step.content === "string") existing.content += step.content;
3679
+ else processSteps.push(step);
3680
+ renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
3681
+ meta.textContent = step.type === "thinking" ? `正在思考 · 第 ${Number(step.round) || 1} 轮` : `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
3682
+ scrollAiFeedToBottom();
3683
+ } else if (eventName === "tool_call") {
3684
+ const toolCall = { ...payload };
3685
+ const round = toolCall.round;
3686
+ delete toolCall.round;
3687
+ toolCalls.push(toolCall);
3688
+ processSteps.push(aiToolProcessStep(toolCall, round));
3689
+ renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
3690
+ meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
3691
+ scrollAiFeedToBottom();
3692
+ } else if (eventName === "complete") {
3693
+ message.classList.remove("is-streaming");
3694
+ message.querySelector(".message-heading > span").textContent = "助手";
3695
+ toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
3696
+ processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
3697
+ const processDurationMs = elapsedProcessTime();
3698
+ generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, toolCalls, processSteps, processDurationMs };
3699
+ renderAiProcessSteps(message, processSteps, true, processDurationMs);
3700
+ meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens);
3701
+ attachAssistantCopyAction(message, streamedText);
3702
+ scrollAiFeedToBottom();
3703
+ } else if (eventName === "error") {
3704
+ streamError = new Error(payload.message ?? "AI 流式调用失败");
3705
+ }
3706
+ };
3707
+ while (true) {
3708
+ const chunk = await reader.read();
3709
+ buffer += decoder.decode(chunk.value, { stream: !chunk.done });
3710
+ const events = buffer.split(/\r?\n\r?\n/);
3711
+ buffer = events.pop() ?? "";
3712
+ events.forEach(consume);
3713
+ if (chunk.done) break;
3714
+ }
3715
+ if (buffer.trim()) consume(buffer);
3716
+ if (streamError) throw streamError;
3717
+ return { content: streamedText, message, metadata: generatedMetadata };
3718
+ } catch (error) {
3719
+ message.classList.remove("is-streaming");
3720
+ message.querySelector(".message-heading > span").textContent = "助手 · 生成中断";
3721
+ renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
3722
+ meta.textContent = "生成中断";
3723
+ scrollAiFeedToBottom();
3724
+ throw error;
3725
+ }
3726
+ }
3727
+
3728
+ function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
3729
+ const message = document.createElement("div");
3730
+ message.className = role === "user" ? "user-message" : "assistant-message";
3731
+ message.innerHTML = `<div class="message-body">${renderMarkdown(text)}</div>`;
3732
+ attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
3733
+ if (citations.length) {
3734
+ const references = document.createElement("div");
3735
+ references.className = "message-citations";
3736
+ for (const citation of citations) {
3737
+ const reference = document.createElement("span");
3738
+ reference.textContent = `${citation.chapterTitle} · L${citation.startLine}${citation.endLine === citation.startLine ? "" : `-L${citation.endLine}`}`;
3739
+ references.append(reference);
3740
+ }
3741
+ message.append(references);
3742
+ }
3743
+ if (role === "assistant") {
3744
+ const processSteps = Array.isArray(metadata?.processSteps) && metadata.processSteps.length
3745
+ ? metadata.processSteps
3746
+ : (Array.isArray(metadata?.toolCalls) ? metadata.toolCalls : []).map((toolCall) => aiToolProcessStep(toolCall));
3747
+ renderAiProcessSteps(message, processSteps, true, resolveAiProcessDuration(metadata, processSteps, createdAt));
3748
+ }
3749
+ if (role === "assistant" && !text.startsWith("调用失败:")) {
3750
+ const selectedModel = state.models.find((model) => model.id === $("#ai-model").value) ?? state.models[0];
3751
+ const modelDisplayName = metadata?.modelDisplayName || selectedModel?.displayName || "模型";
3752
+ const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
3753
+ const meta = document.createElement("div");
3754
+ meta.className = "message-meta";
3755
+ meta.textContent = formatAiMessageMeta(modelDisplayName, outputTokens);
3756
+ message.append(meta);
3757
+ attachAssistantCopyAction(message, text);
3758
+ }
3759
+ attachMessageIdentity(message, messageId);
3760
+ $("#ai-feed").append(message);
3761
+ scrollAiFeedToBottom();
3762
+ }
3763
+
3764
+ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
3765
+ const message = document.createElement("div");
3766
+ message.className = "assistant-message";
3767
+ const applicable = suggestion.action !== "note";
3768
+ const guard = suggestion.guard;
3769
+ const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(issue.severity)} · ${esc(issue.type)}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
3770
+ message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
3771
+ attachMessageHeading(message, "助手建议", createdAt ?? undefined);
3772
+ attachAssistantCopyAction(message, suggestion.content);
3773
+ attachMessageIdentity(message, messageId);
3774
+ if (applicable) {
3775
+ message.querySelector('[data-action="accept"]').addEventListener("click", async () => {
3776
+ try {
3777
+ const result = await api(`/api/suggestions/${suggestion.id}/accept`, { method: "POST", body: {} });
3778
+ state.chapter = result.chapter;
3779
+ lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
3780
+ $("#chapter-content").value = state.chapter.content;
3781
+ scheduleChapterLineNumbers();
3782
+ updateChapterStats();
3783
+ state.work = await api(`/api/works/${state.work.id}`);
3784
+ renderTree();
3785
+ message.querySelector(".message-actions").innerHTML = "<span>已采纳并生成新版本</span>";
3786
+ toast("AI 建议已采纳,正文已生成新版本");
3787
+ } catch (error) { toast(error.message, "error"); }
3788
+ });
3789
+ message.querySelector('[data-action="reject"]').addEventListener("click", async () => {
3790
+ await api(`/api/suggestions/${suggestion.id}/reject`, { method: "POST", body: {} });
3791
+ message.querySelector(".message-actions").innerHTML = "<span>已拒绝</span>";
3792
+ });
3793
+ }
3794
+ $("#ai-feed").append(message);
3795
+ scrollAiFeedToBottom();
3796
+ return message;
3797
+ }
3798
+
3799
+ async function showVersions() {
3800
+ if (!state.chapter) return;
3801
+ const versions = await api(`/api/chapters/${state.chapter.id}/versions`);
3802
+ $("#versions-list").innerHTML = versions.map((version) => `<div class="version-row"><div><b>v${version.versionNo}</b><small>${esc(version.source)} · ${esc(version.actor || "历史数据")}</small></div><p>${esc(version.content.slice(0, 300) || "空白章节")}</p><button class="ghost-button" data-restore-version="${version.versionNo}">恢复</button></div>`).join("");
3803
+ $("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
3804
+ if (!window.confirm(`将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`)) return;
3805
+ state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
3806
+ lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
3807
+ $("#chapter-title").value = state.chapter.title;
3808
+ $("#chapter-content").value = state.chapter.content;
3809
+ scheduleChapterLineNumbers();
3810
+ updateChapterStats();
3811
+ setSaveState("已保存");
3812
+ $("#versions-dialog").close();
3813
+ toast("历史内容已恢复为新版本");
3814
+ }));
3815
+ $("#versions-dialog").showModal();
3816
+ }
3817
+
3818
+ async function showChapterInsight() {
3819
+ if (!state.chapter) return;
3820
+ const panel = $("#chapter-insight");
3821
+ const insights = await api(`/api/chapters/${state.chapter.id}/insights`);
3822
+ const insight = insights.find((item) => item.chapterVersion === state.chapter.versionNo) ?? insights[0];
3823
+ panel.classList.remove("hidden");
3824
+ if (!insight) {
3825
+ panel.innerHTML = "<strong>尚无章节概览</strong>请在“AI 分析”中运行章节理解,完成后可在此查看结果。";
3826
+ return;
3827
+ }
3828
+ const eventNames = insight.events.map((event) => typeof event === "string" ? event : (event.name ?? event.description ?? "未命名事件"));
3829
+ const stale = insight.chapterVersion !== state.chapter.versionNo ? `;基于旧版本 v${insight.chapterVersion}` : "";
3830
+ panel.innerHTML = `<strong>章节概览${esc(stale)}</strong>${esc(insight.summary || "暂无梗概")}${eventNames.length ? `<br><strong>事件</strong>${esc(eventNames.join(";"))}` : ""}${insight.uncertainties.length ? `<br><strong>待确认</strong>${esc(insight.uncertainties.map((item) => typeof item === "string" ? item : JSON.stringify(item)).join(";"))}` : ""}`;
3831
+ }
3832
+
3833
+ $("#home-button").addEventListener("click", () => {
3834
+ if (!confirmDiscardChanges()) return;
3835
+ loadWorks().catch((error) => toast(error.message, "error"));
3836
+ });
3837
+ $("#settings-button").addEventListener("click", showSettingsHub);
3838
+ $("#account-button").addEventListener("click", () => {
3839
+ const expanded = $("#account-menu").classList.toggle("hidden") === false;
3840
+ $("#account-button").setAttribute("aria-expanded", String(expanded));
3841
+ });
3842
+ $("#onboarding-menu-button").addEventListener("click", () => {
3843
+ $("#account-menu").classList.add("hidden");
3844
+ $("#account-button").setAttribute("aria-expanded", "false");
3845
+ openOnboarding(true);
3846
+ });
3847
+ $("#onboarding-skip").addEventListener("click", completeOnboarding);
3848
+ $("#onboarding-previous").addEventListener("click", () => renderOnboardingStep(onboardingStep - 1, true));
3849
+ $("#onboarding-next").addEventListener("click", () => {
3850
+ const lastStep = onboardingSteps.length - 1;
3851
+ if (onboardingStep >= lastStep) completeOnboarding();
3852
+ else renderOnboardingStep(onboardingStep + 1, true);
3853
+ });
3854
+ $("#onboarding-dialog").addEventListener("cancel", (event) => {
3855
+ event.preventDefault();
3856
+ completeOnboarding();
3857
+ });
3858
+ $("#onboarding-dialog").addEventListener("keydown", (event) => {
3859
+ if (event.key === "Escape") {
3860
+ event.preventDefault();
3861
+ completeOnboarding();
3862
+ return;
3863
+ }
3864
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
3865
+ event.preventDefault();
3866
+ const direction = event.key === "ArrowRight" ? 1 : -1;
3867
+ const lastStep = onboardingSteps.length - 1;
3868
+ renderOnboardingStep(Math.max(0, Math.min(lastStep, onboardingStep + direction)), true);
3869
+ });
3870
+ window.addEventListener("resize", refreshOnboardingForViewport);
3871
+ document.addEventListener("scroll", scheduleOnboardingPosition, true);
3872
+ $("#account-settings-button").addEventListener("click", () => {
3873
+ $("#account-menu").classList.add("hidden");
3874
+ $("#account-button").setAttribute("aria-expanded", "false");
3875
+ $("#profile-display-name").value = state.user?.displayName ?? "";
3876
+ renderProfileAvatar();
3877
+ $("#password-form").reset();
3878
+ $("#api-key-result").classList.add("hidden");
3879
+ $("#api-key-value").value = "";
3880
+ $("#account-dialog").showModal();
3881
+ api("/api/auth/api-key").then((status) => {
3882
+ $("#api-key-status").textContent = status.configured
3883
+ ? `已配置 ${status.prefix}…${status.lastUsedAt ? `,最近使用:${formatDateTime(status.lastUsedAt)}` : ",尚未使用"}`
3884
+ : "尚未生成 API Key。";
3885
+ $("#api-key-reset-button").textContent = status.configured ? "重置 API Key" : "生成 API Key";
3886
+ }).catch((error) => {
3887
+ $("#api-key-status").textContent = error.message;
3888
+ });
3889
+ });
3890
+ $("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
3891
+ $("#avatar-upload-button").addEventListener("click", () => $("#avatar-file").click());
3892
+ $("#avatar-file").addEventListener("change", async (event) => {
3893
+ const file = event.target.files[0];
3894
+ if (!file) return;
3895
+ if (file.size > maximumAvatarFileSize) {
3896
+ toast("头像文件不能超过 5 MB", "error");
3897
+ event.target.value = "";
3898
+ return;
3899
+ }
3900
+ const body = new FormData();
3901
+ body.append("file", file);
3902
+ $("#avatar-upload-button").disabled = true;
3903
+ $("#avatar-remove-button").disabled = true;
3904
+ try {
3905
+ const updated = await api("/api/auth/avatar", { method: "PUT", body });
3906
+ applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
3907
+ renderProfileAvatar();
3908
+ toast("头像已更新");
3909
+ } catch (error) {
3910
+ toast(error.message, "error");
3911
+ } finally {
3912
+ $("#avatar-upload-button").disabled = false;
3913
+ $("#avatar-remove-button").disabled = false;
3914
+ event.target.value = "";
3915
+ }
3916
+ });
3917
+ $("#avatar-remove-button").addEventListener("click", async () => {
3918
+ if (!state.user?.avatarUrl || !window.confirm("确定移除当前头像吗?")) return;
3919
+ $("#avatar-upload-button").disabled = true;
3920
+ $("#avatar-remove-button").disabled = true;
3921
+ try {
3922
+ const updated = await api("/api/auth/avatar", { method: "DELETE" });
3923
+ applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
3924
+ renderProfileAvatar();
3925
+ toast("头像已移除");
3926
+ } catch (error) {
3927
+ toast(error.message, "error");
3928
+ } finally {
3929
+ $("#avatar-upload-button").disabled = false;
3930
+ $("#avatar-remove-button").disabled = false;
3931
+ }
3932
+ });
3933
+ $("#profile-form").addEventListener("submit", async (event) => {
3934
+ event.preventDefault();
3935
+ const form = new FormData(event.currentTarget);
3936
+ try {
3937
+ const updated = await api("/api/auth/profile", { method: "PATCH", body: { displayName: form.get("displayName") } });
3938
+ state.user = updated;
3939
+ applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
3940
+ toast("显示名称已更新");
3941
+ } catch (error) { toast(error.message, "error"); }
3942
+ });
3943
+ $("#password-form").addEventListener("submit", async (event) => {
3944
+ event.preventDefault();
3945
+ const form = new FormData(event.currentTarget);
3946
+ try {
3947
+ await api("/api/auth/password", { method: "PATCH", body: { currentPassword: form.get("currentPassword"), newPassword: form.get("newPassword") } });
3948
+ event.currentTarget.reset();
3949
+ toast("密码已更新,其他设备的会话已退出");
3950
+ } catch (error) { toast(error.message, "error"); }
3951
+ });
3952
+ $("#api-key-reset-button").addEventListener("click", async () => {
3953
+ if ($("#api-key-reset-button").textContent.includes("重置") && !window.confirm("重置后,所有使用旧 API Key 的 CLI 会立即退出登录。确定继续吗?")) return;
3954
+ try {
3955
+ const result = await api("/api/auth/api-key/reset", { method: "POST", body: {} });
3956
+ $("#api-key-status").textContent = `已配置 ${result.prefix}…,尚未使用`;
3957
+ $("#api-key-reset-button").textContent = "重置 API Key";
3958
+ $("#api-key-value").value = result.apiKey;
3959
+ $("#api-key-result").classList.remove("hidden");
3960
+ $("#api-key-value").focus();
3961
+ $("#api-key-value").select();
3962
+ toast("新的 API Key 已生成,请立即保存");
3963
+ } catch (error) { toast(error.message, "error"); }
3964
+ });
3965
+ $("#api-key-copy-button").addEventListener("click", async () => {
3966
+ const value = $("#api-key-value").value;
3967
+ if (!value) return;
3968
+ try {
3969
+ await navigator.clipboard.writeText(value);
3970
+ toast("API Key 已复制");
3971
+ } catch {
3972
+ $("#api-key-value").focus();
3973
+ $("#api-key-value").select();
3974
+ toast("无法自动复制,请手动复制", "error");
3975
+ }
3976
+ });
3977
+ $("#logout-button").addEventListener("click", async () => {
3978
+ try {
3979
+ await api("/api/auth/session", { method: "DELETE" });
3980
+ state.user = null;
3981
+ state.csrfToken = null;
3982
+ window.location.reload();
3983
+ } catch (error) { toast(error.message, "error"); }
3984
+ });
3985
+ $("#auth-login-tab").addEventListener("click", () => selectAuthMode("login"));
3986
+ $("#auth-register-tab").addEventListener("click", () => selectAuthMode("register"));
3987
+ $("#login-captcha-refresh").addEventListener("click", () => {
3988
+ refreshAuthCaptcha("login").catch((error) => { $("#auth-error").textContent = error.message; });
3989
+ });
3990
+ $("#register-captcha-refresh").addEventListener("click", () => {
3991
+ refreshAuthCaptcha("register").catch((error) => { $("#auth-error").textContent = error.message; });
3992
+ });
3993
+ function validatePasswordConfirmation() {
3994
+ const passwordInput = $("#register-form input[name='password']");
3995
+ const confirmationInput = $("#register-form input[name='passwordConfirmation']");
3996
+ const matches = !confirmationInput.value || passwordInput.value === confirmationInput.value;
3997
+ confirmationInput.setCustomValidity(matches ? "" : "两次输入的密码不一致");
3998
+ return matches;
3999
+ }
4000
+ $("#register-form input[name='password']").addEventListener("input", validatePasswordConfirmation);
4001
+ $("#register-form input[name='passwordConfirmation']").addEventListener("input", validatePasswordConfirmation);
4002
+ function passwordVisibilityIcon(visible) {
4003
+ const eye = '<path d="M2.5 12s3.3-5.5 9.5-5.5S21.5 12 21.5 12 18.2 17.5 12 17.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/>';
4004
+ return `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">${eye}${visible ? '<path d="M3.5 3.5 20.5 20.5"/>' : ""}</svg>`;
4005
+ }
4006
+ document.querySelectorAll("[data-password-toggle]").forEach((button) => {
4007
+ const input = document.getElementById(button.dataset.passwordToggle ?? "");
4008
+ if (!(input instanceof HTMLInputElement)) return;
4009
+ button.addEventListener("click", () => {
4010
+ const visible = input.type === "password";
4011
+ input.type = visible ? "text" : "password";
4012
+ button.innerHTML = passwordVisibilityIcon(visible);
4013
+ button.setAttribute("aria-pressed", String(visible));
4014
+ const label = `${visible ? "隐藏" : "显示"}${input.name === "passwordConfirmation" ? "确认密码" : "密码"}`;
4015
+ button.setAttribute("aria-label", label);
4016
+ button.setAttribute("title", label);
4017
+ });
4018
+ });
4019
+ $("#login-form").addEventListener("submit", async (event) => {
4020
+ event.preventDefault();
4021
+ const form = new FormData(event.currentTarget);
4022
+ $("#auth-error").textContent = "";
4023
+ try {
4024
+ await api("/api/auth/login", {
4025
+ method: "POST",
4026
+ body: {
4027
+ username: form.get("username"),
4028
+ password: form.get("password"),
4029
+ captchaId: form.get("captchaId"),
4030
+ captchaAnswer: form.get("captchaAnswer")
4031
+ }
4032
+ });
4033
+ window.location.reload();
4034
+ } catch (error) {
4035
+ $("#auth-error").textContent = error.message;
4036
+ refreshAuthCaptcha("login").catch(() => {});
4037
+ }
4038
+ });
4039
+ $("#register-form").addEventListener("submit", async (event) => {
4040
+ event.preventDefault();
4041
+ const form = new FormData(event.currentTarget);
4042
+ $("#auth-error").textContent = "";
4043
+ if (!validatePasswordConfirmation()) {
4044
+ $("#auth-error").textContent = "两次输入的密码不一致,请重新确认。";
4045
+ $("#register-form input[name='passwordConfirmation']").focus();
4046
+ return;
4047
+ }
4048
+ try {
4049
+ await api("/api/auth/register", {
4050
+ method: "POST",
4051
+ body: {
4052
+ username: form.get("username"),
4053
+ password: form.get("password"),
4054
+ passwordConfirmation: form.get("passwordConfirmation"),
4055
+ captchaId: form.get("captchaId"),
4056
+ captchaAnswer: form.get("captchaAnswer")
4057
+ }
4058
+ });
4059
+ window.location.reload();
4060
+ } catch (error) {
4061
+ $("#auth-error").textContent = error.message;
4062
+ refreshAuthCaptcha("register").catch(() => {});
4063
+ }
4064
+ });
4065
+ $("#settings-return").addEventListener("click", () => returnFromSettings().catch((error) => toast(error.message, "error")));
4066
+ $("#platform-ai-button").addEventListener("click", () => showPlatformAi().catch((error) => toast(error.message, "error")));
4067
+ $("#user-management-button").addEventListener("click", openUsersDialog);
4068
+ $("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
4069
+ $("#collaboration-button").addEventListener("click", () => openMembersDialog());
4070
+ $("#users-dialog-close").addEventListener("click", () => $("#users-dialog").close());
4071
+ $("#platform-ui-settings-close").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
4072
+ $("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
4073
+ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
4074
+ event.preventDefault();
4075
+ const button = $("#platform-ui-settings-save");
4076
+ button.disabled = true;
4077
+ try {
4078
+ const settings = await api("/api/platform/ui-settings", {
4079
+ method: "PATCH",
4080
+ body: { toastPosition: $("#toast-position").value }
4081
+ });
4082
+ applyPlatformUiSettings(settings);
4083
+ $("#platform-ui-settings-dialog").close();
4084
+ toast("界面通知设置已保存");
4085
+ } catch (error) {
4086
+ toast(error.message, "error");
4087
+ } finally {
4088
+ button.disabled = false;
4089
+ }
4090
+ });
4091
+ $("#members-dialog-close").addEventListener("click", () => $("#members-dialog").close());
4092
+ $("#members-dialog").addEventListener("close", () => { memberDialogWork = null; });
4093
+ $("#member-invite-form").addEventListener("submit", async (event) => {
4094
+ event.preventDefault();
4095
+ const userId = $("#member-user-select").value;
4096
+ const work = memberDialogWork ?? state.work;
4097
+ if (!work || !userId) return;
4098
+ try {
4099
+ const role = $("#member-role-select").value;
4100
+ const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId, role } });
4101
+ renderMembers(members);
4102
+ await fillMemberCandidates(members);
4103
+ toast(role === "viewer" ? "仅查看成员已邀请" : "协作者已邀请");
4104
+ } catch (error) { toast(error.message, "error"); }
4105
+ });
4106
+ $("#platform-new-provider").addEventListener("click", () => openProviderDialog());
4107
+ $("#shelf-new-work").addEventListener("click", openWorkDialog);
4108
+ $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
4109
+ $("#new-chapter-button").addEventListener("click", openChapterDialog);
4110
+ $("#save-button").addEventListener("click", saveChapter);
4111
+ $("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
4112
+ $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
4113
+ $("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));
4114
+ $("#versions-button").addEventListener("click", showVersions);
4115
+ $("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
4116
+ $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
4117
+ $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
4118
+ $("#character-editor-close").addEventListener("click", () => $("#character-editor-dialog").close());
4119
+ $("#character-editor-cancel").addEventListener("click", () => $("#character-editor-dialog").close());
4120
+ $("#character-history-button").addEventListener("click", () => {
4121
+ if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
4122
+ else setCharacterHistoryVisible(false);
4123
+ });
4124
+ $("#character-history-close").addEventListener("click", () => setCharacterHistoryVisible(false));
4125
+ function cleanupExpandedRelationshipMap() {
4126
+ state.relationshipExpandedMap?.destroy?.();
4127
+ state.relationshipExpandedMap = null;
4128
+ }
4129
+ $("#relationship-map-close").addEventListener("click", () => $("#relationship-map-dialog").close());
4130
+ $("#relationship-map-dialog").addEventListener("close", cleanupExpandedRelationshipMap);
4131
+ $("#appearance-button").addEventListener("click", openAppearanceDialog);
4132
+ $("#theme-toggle").addEventListener("click", () => {
4133
+ const theme = nextTheme(currentColorTheme());
4134
+ const persisted = saveColorTheme(theme);
4135
+ if (!persisted) toast("主题已切换,但当前浏览器无法保存偏好", "error");
4136
+ });
4137
+ window.addEventListener("storage", (event) => {
4138
+ if (event.key === THEME_STORAGE_KEY) applyColorTheme(normalizeTheme(event.newValue));
4139
+ });
4140
+ $("#appearance-reset").addEventListener("click", () => {
4141
+ fillAppearanceForm(typographyDefaults);
4142
+ renderTypographyPreview();
4143
+ });
4144
+ $("#appearance-form").addEventListener("input", renderTypographyPreview);
4145
+ $("#appearance-form").addEventListener("submit", (event) => {
4146
+ if (event.submitter?.value !== "save") return;
4147
+ event.preventDefault();
4148
+ const persisted = saveTypographySettings(readAppearanceForm());
4149
+ $("#appearance-dialog").close();
4150
+ toast(persisted ? "显示设置已保存" : "显示设置已应用,但当前浏览器无法保存偏好", persisted ? "info" : "error");
4151
+ });
4152
+ $("#chapter-title").addEventListener("input", () => scheduleChapterAutoSave());
4153
+ $("#chapter-content").addEventListener("input", (event) => {
4154
+ if (!event.isComposing) collapseChapterInputBlankLines(event.currentTarget);
4155
+ updateChapterStats();
4156
+ scheduleChapterAutoSave();
4157
+ clearChapterLineSelection();
4158
+ scheduleChapterLineNumbers();
4159
+ scheduleAiContextUsage();
4160
+ });
4161
+ $("#chapter-content").addEventListener("select", scheduleAiContextUsage);
4162
+ $("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
4163
+ $("#toggle-whitespace-button").addEventListener("click", () => {
4164
+ chapterWhitespaceVisible = !chapterWhitespaceVisible;
4165
+ scheduleChapterLineNumbers();
4166
+ });
4167
+ $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
4168
+ const row = event.target.closest(".chapter-line-number");
4169
+ if (!row || event.button !== 0) return;
4170
+ event.preventDefault();
4171
+ const anchor = Number(row.dataset.lineIndex);
4172
+ chapterLineDrag = { pointerId: event.pointerId, anchor, focus: anchor };
4173
+ paintChapterLineSelection(anchor, anchor);
4174
+ $("#chapter-line-numbers-inner").setPointerCapture(event.pointerId);
4175
+ });
4176
+ $("#chapter-line-numbers-inner").addEventListener("pointermove", (event) => {
4177
+ if (!chapterLineDrag || event.pointerId !== chapterLineDrag.pointerId) return;
4178
+ chapterLineDrag.focus = lineIndexAtPointer(event.clientY);
4179
+ paintChapterLineSelection(chapterLineDrag.anchor, chapterLineDrag.focus);
4180
+ });
4181
+ const finishChapterLineDrag = (event) => {
4182
+ if (!chapterLineDrag || event.pointerId !== chapterLineDrag.pointerId) return;
4183
+ const { anchor, focus, pointerId } = chapterLineDrag;
4184
+ chapterLineDrag = null;
4185
+ if ($("#chapter-line-numbers-inner").hasPointerCapture(pointerId)) $("#chapter-line-numbers-inner").releasePointerCapture(pointerId);
4186
+ selectChapterLines(Math.min(anchor, focus), Math.max(anchor, focus));
4187
+ };
4188
+ $("#chapter-line-numbers-inner").addEventListener("pointerup", finishChapterLineDrag);
4189
+ $("#chapter-line-numbers-inner").addEventListener("pointercancel", finishChapterLineDrag);
4190
+ $("#chapter-line-numbers-inner").addEventListener("contextmenu", (event) => {
4191
+ const row = event.target.closest(".chapter-line-number");
4192
+ if (row) showLineCitationMenu(event, Number(row.dataset.lineIndex));
4193
+ });
4194
+ $("#add-line-citation").addEventListener("click", addSelectedLinesAsCitation);
4195
+ $("#left-panel-toggle").addEventListener("click", () => {
4196
+ panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
4197
+ applyPanelLayout(true);
4198
+ });
4199
+ $("#ai-panel-toggle").addEventListener("click", () => {
4200
+ panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
4201
+ applyPanelLayout(true);
4202
+ });
4203
+ setupPanelResize($("#left-panel-resize"), "left");
4204
+ setupPanelResize($("#ai-panel-resize"), "ai");
4205
+ if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
4206
+ window.addEventListener("resize", () => { applyPanelLayout(); scheduleChapterLineNumbers(); });
4207
+ $("#module-nav").addEventListener("click", (event) => {
4208
+ const button = event.target.closest("button");
4209
+ if (!button || button.id === "module-more-button") return;
4210
+ if (button.hasAttribute("data-work-settings")) {
4211
+ const work = state.works.find((item) => item.id === state.work?.id) ?? state.work;
4212
+ openWorkSettingsDialog(work);
4213
+ return;
4214
+ }
4215
+ if (button.dataset.module) showModule(button.dataset.module);
4216
+ });
4217
+ $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
4218
+ $("#module-create-button").addEventListener("click", () => ({ settings: openSettingDialog, characters: openCharacterDialog, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
4219
+ $("#ai-prompt").addEventListener("input", async () => {
4220
+ updateAiMentionMenu();
4221
+ scheduleAiContextUsage();
4222
+ if (!findAiMention(aiPromptTextBeforeCursor())) return;
4223
+ try {
4224
+ await ensureAiReferencesLoaded();
4225
+ updateAiMentionMenu();
4226
+ } catch (error) {
4227
+ toast(`引用数据加载失败:${error.message}`, "error");
4228
+ }
4229
+ });
4230
+ $("#ai-prompt").addEventListener("focus", () => {
4231
+ ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
4232
+ });
4233
+ $("#ai-model").addEventListener("focus", () => {
4234
+ ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
4235
+ });
4236
+ $("#ai-model").addEventListener("change", scheduleAiContextUsage);
4237
+ $("#ai-task").addEventListener("change", scheduleAiContextUsage);
4238
+ $("#ai-scope").addEventListener("change", scheduleAiContextUsage);
4239
+ $("#ai-mention-menu").addEventListener("click", (event) => {
4240
+ const button = event.target.closest("[data-ai-reference-id]");
4241
+ if (button) selectAiMention(button);
4242
+ });
4243
+ $("#import-file").addEventListener("change", async (event) => {
4244
+ if (!state.work || !event.target.files[0]) return;
4245
+ if (!confirmDiscardChanges("导入会替换当前作品目录,未保存修改将丢失。是否继续?")) {
4246
+ event.target.value = "";
4247
+ return;
4248
+ }
4249
+ const body = new FormData();
4250
+ body.append("file", event.target.files[0]);
4251
+ try {
4252
+ const result = await api(`/api/works/${state.work.id}/import`, { method: "POST", body });
4253
+ setSaveState("已导入");
4254
+ state.work = result.tree;
4255
+ renderTree();
4256
+ toast(result.warnings.length ? `导入完成:${result.warnings.join(";")}` : "导入完成");
4257
+ const first = state.work.volumes.flatMap((volume) => volume.chapters)[0];
4258
+ if (first) await selectChapter(first.id);
4259
+ } catch (error) { toast(error.message, "error"); }
4260
+ event.target.value = "";
4261
+ });
4262
+ $("#new-import-file").addEventListener("change", async (event) => {
4263
+ const file = event.target.files[0];
4264
+ const metadata = state.pendingImportMeta;
4265
+ if (!file || !metadata) return;
4266
+ const body = new FormData();
4267
+ body.append("file", file);
4268
+ body.append("title", metadata.title ?? "");
4269
+ body.append("author", metadata.author ?? "");
4270
+ body.append("description", metadata.description ?? "");
4271
+ try {
4272
+ const result = await api("/api/works/import", { method: "POST", body });
4273
+ toast(result.warnings.length ? `作品已导入:${result.warnings.join(";")}` : "作品已导入");
4274
+ await loadWorks(result.work.id);
4275
+ } catch (error) {
4276
+ toast(error.message, "error");
4277
+ } finally {
4278
+ state.pendingImportMeta = null;
4279
+ event.target.value = "";
4280
+ }
4281
+ });
4282
+ $("#cover-file").addEventListener("change", async (event) => {
4283
+ const file = event.target.files[0];
4284
+ const workId = state.pendingCoverWorkId;
4285
+ if (!file || !workId) return;
4286
+ const body = new FormData();
4287
+ body.append("file", file);
4288
+ try {
4289
+ await api(`/api/works/${workId}/cover`, { method: "PUT", body });
4290
+ state.works = await api("/api/works");
4291
+ const updated = state.works.find((item) => item.id === workId);
4292
+ const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
4293
+ if (updated && coverField && $("#form-dialog")?.open) {
4294
+ coverField.outerHTML = workCoverFieldHtml(updated);
4295
+ bindWorkCoverControls(updated);
4296
+ }
4297
+ renderShelf();
4298
+ toast("封面已更新");
4299
+ } catch (error) {
4300
+ toast(error.message, "error");
4301
+ } finally {
4302
+ state.pendingCoverWorkId = null;
4303
+ event.target.value = "";
4304
+ }
4305
+ });
4306
+ $("#chapter-type-menu").addEventListener("click", async (event) => {
4307
+ const button = event.target.closest("[data-chapter-type]");
4308
+ const chapterId = state.contextChapterId;
4309
+ if (!button || !chapterId) return;
4310
+ const chapterType = button.dataset.chapterType;
4311
+ closeChapterTypeMenu();
4312
+ try {
4313
+ const updated = await api(`/api/chapters/${chapterId}`, { method: "PATCH", body: { chapterType } });
4314
+ for (const volume of state.work.volumes) {
4315
+ const chapter = volume.chapters.find((item) => item.id === chapterId);
4316
+ if (chapter) chapter.chapterType = updated.chapterType;
4317
+ }
4318
+ if (state.chapter?.id === chapterId) state.chapter.chapterType = updated.chapterType;
4319
+ renderTree();
4320
+ toast(`章节类型已标记为“${chapterType}”`);
4321
+ } catch (error) {
4322
+ toast(error.message, "error");
4323
+ }
4324
+ });
4325
+ document.addEventListener("pointerdown", (event) => {
4326
+ if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
4327
+ if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
4328
+ if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
4329
+ if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
4330
+ $("#account-menu").classList.add("hidden");
4331
+ $("#account-button").setAttribute("aria-expanded", "false");
4332
+ }
4333
+ });
4334
+ document.addEventListener("keydown", (event) => {
4335
+ if (event.key === "Escape") {
4336
+ closeChapterTypeMenu();
4337
+ closeLineCitationMenu();
4338
+ hideAiMentionMenu();
4339
+ }
4340
+ });
4341
+ $("#ai-send").addEventListener("click", sendAi);
4342
+ $("#ai-new-conversation").addEventListener("click", async () => {
4343
+ const button = $("#ai-new-conversation");
4344
+ button.disabled = true;
4345
+ try {
4346
+ await createNewAiConversation();
4347
+ } catch (error) {
4348
+ toast(error.message, "error");
4349
+ } finally {
4350
+ button.disabled = false;
4351
+ }
4352
+ });
4353
+ $("#ai-context-compact").addEventListener("click", async () => {
4354
+ const requestScope = currentAiRequestScope();
4355
+ const modelId = $("#ai-model").value;
4356
+ if (!requestScope || !modelId) return toast("请先选择章节和模型", "error");
4357
+ const button = $("#ai-context-compact");
4358
+ button.disabled = true;
4359
+ button.textContent = "压缩中";
4360
+ try {
4361
+ const conversationId = await ensureAiConversation();
4362
+ const result = await api(`/api/ai-conversations/${conversationId}/compact`, {
4363
+ method: "POST",
4364
+ body: { modelId, scope: requestScope.scope }
4365
+ });
4366
+ hideAiContextWarning();
4367
+ toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
4368
+ await refreshAiContextUsage();
4369
+ } catch (error) {
4370
+ toast(`上下文压缩失败:${error.message}`, "error");
4371
+ } finally {
4372
+ button.disabled = false;
4373
+ button.textContent = "压缩上下文";
4374
+ }
4375
+ });
4376
+ $("#ai-context-new-conversation").addEventListener("click", async () => {
4377
+ try {
4378
+ await createNewAiConversation();
4379
+ hideAiContextWarning();
4380
+ } catch (error) {
4381
+ toast(error.message, "error");
4382
+ }
4383
+ });
4384
+ $("#ai-context-dismiss").addEventListener("click", hideAiContextWarning);
4385
+ $("#ai-history-toggle").addEventListener("click", async () => {
4386
+ if ($("#ai-history-dialog").open) return setAiHistoryVisible(false);
4387
+ try {
4388
+ await ensureAiConversationsLoaded();
4389
+ setAiHistoryVisible(true);
4390
+ } catch (error) {
4391
+ toast(`对话历史加载失败:${error.message}`, "error");
4392
+ }
4393
+ });
4394
+ $("#ai-history-close").addEventListener("click", () => setAiHistoryVisible(false));
4395
+ $("#ai-history-dialog").addEventListener("close", () => {
4396
+ $("#ai-history-toggle").setAttribute("aria-expanded", "false");
4397
+ });
4398
+ $("#ai-prompt").addEventListener("keydown", (event) => {
4399
+ if (event.key === "Escape" && !$("#ai-mention-menu").classList.contains("hidden")) {
4400
+ event.preventDefault();
4401
+ hideAiMentionMenu();
4402
+ return;
4403
+ }
4404
+ if (shouldSendAiPrompt(event)) {
4405
+ event.preventDefault();
4406
+ if (!$("#ai-send").disabled) void sendAi();
4407
+ }
4408
+ });
4409
+ $(".quick-actions").addEventListener("click", (event) => {
4410
+ const button = event.target.closest("[data-task]");
4411
+ if (!button) return;
4412
+ $("#ai-task").value = button.dataset.task;
4413
+ setAiPromptText(button.dataset.prompt);
4414
+ $("#ai-prompt").focus();
4415
+ });
4416
+ $("#top-search-button").addEventListener("click", () => {
4417
+ openSearchDialog().catch((error) => toast(error.message, "error"));
4418
+ });
4419
+ $("#search-dialog-close").addEventListener("click", () => $("#search-dialog").close());
4420
+ $("#search-form").addEventListener("submit", async (event) => {
4421
+ event.preventDefault();
4422
+ await runWorkSearch().catch((error) => {
4423
+ $("#search-results").innerHTML = `<p class="search-results-status">${esc(error.message)}</p>`;
4424
+ });
4425
+ });
4426
+ $("#export-button").addEventListener("click", () => {
4427
+ if (state.work) window.location.href = `/api/works/${state.work.id}/export?format=markdown`;
4428
+ });
4429
+ window.addEventListener("beforeunload", (event) => { if (state.dirty) event.preventDefault(); });
4430
+
4431
+ initializePage().catch((error) => {
4432
+ restoringPageRoute = false;
4433
+ showShelf();
4434
+ toast(`系统初始化失败:${error.message}`, "error");
4435
+ });