@musnows/scriverse 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.en.md +3 -0
  2. package/README.md +3 -0
  3. package/dist/ai-connectivity-test.js +109 -0
  4. package/dist/ai-connectivity-test.js.map +1 -0
  5. package/dist/ai-conversation-export.js +70 -0
  6. package/dist/ai-conversation-export.js.map +1 -0
  7. package/dist/ai-stream-timeout.js +18 -0
  8. package/dist/ai-stream-timeout.js.map +1 -0
  9. package/dist/ai.js +681 -107
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +326 -53
  12. package/dist/app.js.map +1 -1
  13. package/dist/character-extraction.js +133 -0
  14. package/dist/character-extraction.js.map +1 -0
  15. package/dist/cli-core.js +7 -6
  16. package/dist/cli-core.js.map +1 -1
  17. package/dist/database.js +175 -2
  18. package/dist/database.js.map +1 -1
  19. package/dist/epub-export.js +319 -0
  20. package/dist/epub-export.js.map +1 -0
  21. package/dist/hybrid-search.js +8 -0
  22. package/dist/hybrid-search.js.map +1 -1
  23. package/dist/public/ai-connectivity-test.d.ts +7 -0
  24. package/dist/public/ai-connectivity-test.js +82 -0
  25. package/dist/public/ai-request-manager.js +99 -0
  26. package/dist/public/ai-stream-protocol.js +51 -0
  27. package/dist/public/app.js +2567 -265
  28. package/dist/public/chapter-version-diff.d.ts +20 -0
  29. package/dist/public/chapter-version-diff.js +116 -0
  30. package/dist/public/foreshadow-reminder.d.ts +32 -0
  31. package/dist/public/foreshadow-reminder.js +73 -0
  32. package/dist/public/global-replace-refresh.js +60 -0
  33. package/dist/public/index.html +120 -12
  34. package/dist/public/outline-board.d.ts +61 -0
  35. package/dist/public/outline-board.js +137 -0
  36. package/dist/public/page-route.d.ts +1 -0
  37. package/dist/public/page-route.js +8 -0
  38. package/dist/public/reading-preview.d.ts +32 -0
  39. package/dist/public/reading-preview.js +136 -0
  40. package/dist/public/styles.css +485 -4
  41. package/dist/public/upload-progress.d.ts +2 -0
  42. package/dist/public/upload-progress.js +10 -0
  43. package/dist/security.js +5 -2
  44. package/dist/security.js.map +1 -1
  45. package/dist/server-runtime.js +2 -0
  46. package/dist/server-runtime.js.map +1 -1
  47. package/dist/store.js +825 -88
  48. package/dist/store.js.map +1 -1
  49. package/dist/user-auth.js +45 -9
  50. package/dist/user-auth.js.map +1 -1
  51. package/dist/utils.js +3 -0
  52. package/dist/utils.js.map +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +2 -1
@@ -0,0 +1,20 @@
1
+ export type ChapterDiffRow =
2
+ | { type: "equal"; before: string; after: string; beforeLine: number; afterLine: number }
3
+ | { type: "added"; after: string; afterLine: number }
4
+ | { type: "deleted"; before: string; beforeLine: number }
5
+ | { type: "modified"; before: string; after: string; beforeLine: number; afterLine: number };
6
+
7
+ export type ChapterDiffSummary = {
8
+ added: number;
9
+ deleted: number;
10
+ modified: number;
11
+ unchanged: number;
12
+ };
13
+
14
+ export function diffChapterLines(
15
+ beforeContent: unknown,
16
+ afterContent: unknown,
17
+ matrixCellLimit?: number
18
+ ): ChapterDiffRow[];
19
+
20
+ export function chapterDiffSummary(rows: ChapterDiffRow[]): ChapterDiffSummary;
@@ -0,0 +1,116 @@
1
+ const DEFAULT_MATRIX_CELL_LIMIT = 2_000_000;
2
+
3
+ function chapterLines(content) {
4
+ return String(content ?? "").replace(/\r\n?/gu, "\n").split("\n");
5
+ }
6
+
7
+ function positionalDiff(beforeLines, afterLines) {
8
+ const rows = [];
9
+ const length = Math.max(beforeLines.length, afterLines.length);
10
+ for (let index = 0; index < length; index += 1) {
11
+ const before = beforeLines[index];
12
+ const after = afterLines[index];
13
+ if (before === undefined) rows.push({ type: "added", after, afterLine: index + 1 });
14
+ else if (after === undefined) rows.push({ type: "deleted", before, beforeLine: index + 1 });
15
+ else if (before === after) rows.push({ type: "equal", before, after, beforeLine: index + 1, afterLine: index + 1 });
16
+ else rows.push({ type: "modified", before, after, beforeLine: index + 1, afterLine: index + 1 });
17
+ }
18
+ return rows;
19
+ }
20
+
21
+ function rawLineDiff(beforeLines, afterLines, matrixCellLimit) {
22
+ const rows = beforeLines.length + 1;
23
+ const columns = afterLines.length + 1;
24
+ if (rows * columns > matrixCellLimit) return null;
25
+ const matrix = Array.from({ length: rows }, () => new Uint32Array(columns));
26
+ for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex -= 1) {
27
+ for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex -= 1) {
28
+ matrix[beforeIndex][afterIndex] = beforeLines[beforeIndex] === afterLines[afterIndex]
29
+ ? matrix[beforeIndex + 1][afterIndex + 1] + 1
30
+ : Math.max(matrix[beforeIndex + 1][afterIndex], matrix[beforeIndex][afterIndex + 1]);
31
+ }
32
+ }
33
+
34
+ const diff = [];
35
+ let beforeIndex = 0;
36
+ let afterIndex = 0;
37
+ while (beforeIndex < beforeLines.length || afterIndex < afterLines.length) {
38
+ if (beforeIndex < beforeLines.length && afterIndex < afterLines.length
39
+ && beforeLines[beforeIndex] === afterLines[afterIndex]) {
40
+ diff.push({ type: "equal", text: beforeLines[beforeIndex], beforeLine: beforeIndex + 1, afterLine: afterIndex + 1 });
41
+ beforeIndex += 1;
42
+ afterIndex += 1;
43
+ } else if (afterIndex < afterLines.length
44
+ && (beforeIndex >= beforeLines.length || matrix[beforeIndex][afterIndex + 1] >= matrix[beforeIndex + 1][afterIndex])) {
45
+ diff.push({ type: "added", text: afterLines[afterIndex], afterLine: afterIndex + 1 });
46
+ afterIndex += 1;
47
+ } else {
48
+ diff.push({ type: "deleted", text: beforeLines[beforeIndex], beforeLine: beforeIndex + 1 });
49
+ beforeIndex += 1;
50
+ }
51
+ }
52
+ return diff;
53
+ }
54
+
55
+ function pairChangedLines(changes) {
56
+ const deleted = changes.filter((row) => row.type === "deleted");
57
+ const added = changes.filter((row) => row.type === "added");
58
+ const paired = [];
59
+ const pairCount = Math.min(deleted.length, added.length);
60
+ for (let index = 0; index < pairCount; index += 1) {
61
+ paired.push({
62
+ type: "modified",
63
+ before: deleted[index].text,
64
+ after: added[index].text,
65
+ beforeLine: deleted[index].beforeLine,
66
+ afterLine: added[index].afterLine
67
+ });
68
+ }
69
+ for (let index = pairCount; index < deleted.length; index += 1) {
70
+ paired.push({ type: "deleted", before: deleted[index].text, beforeLine: deleted[index].beforeLine });
71
+ }
72
+ for (let index = pairCount; index < added.length; index += 1) {
73
+ paired.push({ type: "added", after: added[index].text, afterLine: added[index].afterLine });
74
+ }
75
+ return paired;
76
+ }
77
+
78
+ export function diffChapterLines(beforeContent, afterContent, matrixCellLimit = DEFAULT_MATRIX_CELL_LIMIT) {
79
+ const beforeLines = chapterLines(beforeContent);
80
+ const afterLines = chapterLines(afterContent);
81
+ const raw = rawLineDiff(beforeLines, afterLines, matrixCellLimit);
82
+ if (!raw) return positionalDiff(beforeLines, afterLines);
83
+
84
+ const result = [];
85
+ let index = 0;
86
+ while (index < raw.length) {
87
+ if (raw[index].type === "equal") {
88
+ result.push({
89
+ type: "equal",
90
+ before: raw[index].text,
91
+ after: raw[index].text,
92
+ beforeLine: raw[index].beforeLine,
93
+ afterLine: raw[index].afterLine
94
+ });
95
+ index += 1;
96
+ continue;
97
+ }
98
+ const changes = [];
99
+ while (index < raw.length && raw[index].type !== "equal") {
100
+ changes.push(raw[index]);
101
+ index += 1;
102
+ }
103
+ result.push(...pairChangedLines(changes));
104
+ }
105
+ return result;
106
+ }
107
+
108
+ export function chapterDiffSummary(rows) {
109
+ return rows.reduce((summary, row) => {
110
+ if (row.type === "added") summary.added += 1;
111
+ if (row.type === "deleted") summary.deleted += 1;
112
+ if (row.type === "modified") summary.modified += 1;
113
+ if (row.type === "equal") summary.unchanged += 1;
114
+ return summary;
115
+ }, { added: 0, deleted: 0, modified: 0, unchanged: 0 });
116
+ }
@@ -0,0 +1,32 @@
1
+ export type ForeshadowReminder = {
2
+ foreshadowId: string;
3
+ occurrenceId: string;
4
+ title: string;
5
+ description: string;
6
+ status: "planned" | "planted";
7
+ importance: "low" | "medium" | "high";
8
+ role: "reminder" | "payoff";
9
+ note: string;
10
+ versionNo: number;
11
+ updatedAt: string;
12
+ };
13
+
14
+ export const FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY: string;
15
+ export function normalizeForeshadowReminders(value: unknown): ForeshadowReminder[];
16
+ export function foreshadowReminderSnoozeKey(
17
+ workId: unknown,
18
+ chapterId: unknown,
19
+ reminder: Partial<ForeshadowReminder> | null | undefined
20
+ ): string | null;
21
+ export function parseForeshadowReminderSnoozes(serialized: unknown): Set<string>;
22
+ export function serializeForeshadowReminderSnoozes(snoozes: ReadonlySet<string>, limit?: number): string;
23
+ export function visibleForeshadowReminders(
24
+ reminders: unknown,
25
+ workId: unknown,
26
+ chapterId: unknown,
27
+ snoozes: ReadonlySet<string>
28
+ ): ForeshadowReminder[];
29
+ export function foreshadowReminderRequestTargetsState(
30
+ request: { workId: unknown; chapterId: unknown } | null | undefined,
31
+ current: { workId?: unknown; chapterId?: unknown } | null | undefined
32
+ ): boolean;
@@ -0,0 +1,73 @@
1
+ export const FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY = "scriverse.foreshadow-reminder-snoozes.v1";
2
+
3
+ function normalizedId(value) {
4
+ const normalized = String(value ?? "").trim();
5
+ return normalized || null;
6
+ }
7
+
8
+ export function normalizeForeshadowReminders(value) {
9
+ if (!Array.isArray(value)) return [];
10
+ return value.flatMap((item) => {
11
+ if (!item || typeof item !== "object" || Array.isArray(item)) return [];
12
+ const foreshadowId = normalizedId(item.foreshadowId);
13
+ const occurrenceId = normalizedId(item.occurrenceId);
14
+ const title = String(item.title ?? "").trim();
15
+ const role = item.role === "payoff" ? "payoff" : item.role === "reminder" ? "reminder" : null;
16
+ const versionNo = Number(item.versionNo);
17
+ if (!foreshadowId || !occurrenceId || !title || !role || !Number.isInteger(versionNo) || versionNo < 1) return [];
18
+ return [{
19
+ foreshadowId,
20
+ occurrenceId,
21
+ title,
22
+ description: String(item.description ?? ""),
23
+ status: item.status === "planned" ? "planned" : "planted",
24
+ importance: ["low", "medium", "high"].includes(item.importance) ? item.importance : "medium",
25
+ role,
26
+ note: String(item.note ?? ""),
27
+ versionNo,
28
+ updatedAt: String(item.updatedAt ?? "")
29
+ }];
30
+ });
31
+ }
32
+
33
+ export function foreshadowReminderSnoozeKey(workId, chapterId, reminder) {
34
+ const parts = [
35
+ normalizedId(workId),
36
+ normalizedId(chapterId),
37
+ normalizedId(reminder?.foreshadowId),
38
+ normalizedId(reminder?.occurrenceId)
39
+ ];
40
+ const versionNo = Number(reminder?.versionNo);
41
+ if (parts.some((part) => !part) || !Number.isInteger(versionNo) || versionNo < 1) return null;
42
+ return [...parts, `v${versionNo}`].map((part) => encodeURIComponent(part)).join("|");
43
+ }
44
+
45
+ export function parseForeshadowReminderSnoozes(serialized) {
46
+ try {
47
+ const value = JSON.parse(String(serialized ?? "[]"));
48
+ return new Set(Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length <= 1000) : []);
49
+ } catch {
50
+ return new Set();
51
+ }
52
+ }
53
+
54
+ export function serializeForeshadowReminderSnoozes(snoozes, limit = 500) {
55
+ const safeLimit = Number.isInteger(limit) && limit > 0 ? limit : 500;
56
+ const values = [...snoozes].filter((item) => typeof item === "string" && item.length <= 1000);
57
+ return JSON.stringify(values.slice(-safeLimit));
58
+ }
59
+
60
+ export function visibleForeshadowReminders(reminders, workId, chapterId, snoozes) {
61
+ return normalizeForeshadowReminders(reminders).filter((reminder) => {
62
+ const key = foreshadowReminderSnoozeKey(workId, chapterId, reminder);
63
+ return key && !snoozes.has(key);
64
+ });
65
+ }
66
+
67
+ export function foreshadowReminderRequestTargetsState(request, current) {
68
+ return Boolean(
69
+ request
70
+ && normalizedId(request.workId) === normalizedId(current?.workId)
71
+ && normalizedId(request.chapterId) === normalizedId(current?.chapterId)
72
+ );
73
+ }
@@ -0,0 +1,60 @@
1
+ function normalizeId(value) {
2
+ const id = String(value ?? "").trim();
3
+ return id || null;
4
+ }
5
+
6
+ function collapsedIds(value) {
7
+ if (value instanceof Set) return value;
8
+ return new Set(Array.isArray(value) ? value.map(normalizeId).filter(Boolean) : []);
9
+ }
10
+
11
+ function normalizedChapterCount(value) {
12
+ if (value === null || value === undefined || value === "") return null;
13
+ const count = Number(value);
14
+ return Number.isSafeInteger(count) && count >= 0 ? count : null;
15
+ }
16
+
17
+ export function resolveGlobalReplaceChapterCount(volume, previousVolume) {
18
+ const responseCount = normalizedChapterCount(volume?.chapterCount);
19
+ if (responseCount !== null) return responseCount;
20
+ if (Array.isArray(volume?.chapters)) return volume.chapters.length;
21
+ const previousCount = normalizedChapterCount(previousVolume?.chapterCount);
22
+ if (previousCount !== null) return previousCount;
23
+ return Array.isArray(previousVolume?.chapters) ? previousVolume.chapters.length : 0;
24
+ }
25
+
26
+ export function buildGlobalReplaceRefreshPlan({
27
+ volumes = [],
28
+ collapsedVolumeIds = [],
29
+ selectedChapterId = null,
30
+ selectedChapterVolumeId = null,
31
+ routeChapterId = null,
32
+ scope = "",
33
+ chapterCount = 0,
34
+ settingCount = 0
35
+ } = {}) {
36
+ const volumeList = Array.isArray(volumes) ? volumes : [];
37
+ const collapsed = collapsedIds(collapsedVolumeIds);
38
+ const chapterId = normalizeId(selectedChapterId) ?? normalizeId(routeChapterId);
39
+ const chapterVolumeId = normalizeId(selectedChapterVolumeId)
40
+ ?? normalizeId(volumeList.find((volume) => Array.isArray(volume?.chapters)
41
+ && volume.chapters.some((chapter) => normalizeId(chapter?.id) === chapterId))?.id);
42
+ const proseChanged = (scope === "prose" || scope === "prose-and-settings") && Number(chapterCount) > 0;
43
+ const settingsChanged = (scope === "settings" || scope === "prose-and-settings") && Number(settingCount) > 0;
44
+ const expandedVolumeIds = volumeList
45
+ .filter((volume) => !collapsed.has(volume?.id))
46
+ .map((volume) => normalizeId(volume?.id))
47
+ .filter(Boolean);
48
+ const reloadVolumeIds = proseChanged
49
+ ? [...new Set([...expandedVolumeIds, chapterVolumeId].filter(Boolean))]
50
+ : [];
51
+
52
+ return {
53
+ proseChanged,
54
+ settingsChanged,
55
+ selectedChapterId: chapterId,
56
+ selectedChapterVolumeId: chapterVolumeId,
57
+ expandedVolumeIds,
58
+ reloadVolumeIds
59
+ };
60
+ }
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260812-agent-chat-font-size-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260812-ai-conversation-export-mobile-foreshadow-epub-character-extraction-outline-reader-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -125,6 +125,7 @@
125
125
  </div>
126
126
  <nav id="module-nav" class="module-nav" aria-label="作品模块">
127
127
  <button type="button" data-module="editor" class="active"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>正文</button>
128
+ <button id="reader-open-button" type="button" aria-label="打开沉浸式阅读预览" title="阅读预览"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5.5A2.5 2.5 0 0 1 5.5 3H11v16H5.5A2.5 2.5 0 0 0 3 21.5Z"/><path d="M21 5.5A2.5 2.5 0 0 0 18.5 3H13v16h5.5a2.5 2.5 0 0 1 2.5 2.5Z"/></svg>阅读预览</button>
128
129
  <button type="button" data-module="drafts"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4l11-11a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/></svg>想法</button>
129
130
  <button type="button" data-module="settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>设定</button>
130
131
  <button type="button" data-module="characters"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>角色</button>
@@ -156,7 +157,10 @@
156
157
  <section id="shelf-view" class="shelf-view hidden" aria-labelledby="shelf-title">
157
158
  <div class="shelf-header">
158
159
  <div><span class="eyebrow">我的创作书架</span><h1 id="shelf-title">选择一本书,继续写下去</h1><p>封面、正文、人物关系和创作约束都保存在同一部作品中。</p></div>
159
- <button id="shelf-new-work" class="primary-button" type="button">新建作品</button>
160
+ <div class="shelf-header-actions">
161
+ <button id="shelf-recycle-bin" class="ghost-button" type="button" aria-controls="work-recycle-bin-dialog" aria-haspopup="dialog">作品回收站</button>
162
+ <button id="shelf-new-work" class="primary-button" type="button">新建作品</button>
163
+ </div>
160
164
  </div>
161
165
  <div id="book-shelf" class="book-shelf" data-testid="book-shelf"></div>
162
166
  <footer class="product-footer" data-product-footer aria-label="叙界项目信息">
@@ -216,7 +220,7 @@
216
220
  <button id="work-audit-button" class="settings-hub-card" type="button"><span class="settings-card-mark">录</span><strong>操作记录</strong><small>查看作品修改、操作者、对象与时间</small></button>
217
221
  <button id="global-replace-button" class="settings-hub-card" type="button"><span class="settings-card-mark">替</span><strong>全局替换</strong><small>替换已保存正文、设定库或两者内容</small></button>
218
222
  <button id="appearance-button" class="settings-hub-card" type="button" data-settings-action="appearance"><span class="settings-card-mark">Aa</span><strong>显示设置</strong><small>中文字体、等宽英文字体、字号与行距</small></button>
219
- <button id="export-button" class="settings-hub-card" type="button" data-settings-action="export" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false"><span class="settings-card-mark">出</span><strong>导出正文</strong><small>选择导出 Markdown ZIP 或 DOCX;不包含角色和设定资料</small></button>
223
+ <button id="export-button" class="settings-hub-card" type="button" data-settings-action="export" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false"><span class="settings-card-mark">出</span><strong>导出正文</strong><small>选择 Markdown ZIP、DOCXEPUB;不包含角色和设定资料</small></button>
220
224
  </div>
221
225
  <p id="settings-work-note" class="settings-work-note"></p>
222
226
  <footer class="product-footer settings-product-footer" data-product-footer aria-label="叙界项目信息">
@@ -244,6 +248,7 @@
244
248
  <input id="chapter-title" class="chapter-title" aria-label="章节标题" placeholder="章节标题">
245
249
  <div class="editor-actions">
246
250
  <span id="chapter-stats" class="chapter-stats">0 字 · v0</span>
251
+ <button id="chapter-reader-button" class="ghost-button" type="button">阅读预览</button>
247
252
  <button id="insight-button" class="ghost-button" type="button" aria-controls="chapter-insight-toast" aria-expanded="false">章节概览</button>
248
253
  <button id="versions-button" class="ghost-button" type="button">版本</button>
249
254
  <button id="chapter-annotations-button" class="ghost-button" type="button">评论</button>
@@ -253,6 +258,28 @@
253
258
  <button id="save-button" class="primary-button" type="button">保存正文</button>
254
259
  </div>
255
260
  </div>
261
+ <section id="chapter-foreshadow-reminder" class="chapter-foreshadow-reminder hidden" aria-live="polite" aria-labelledby="chapter-foreshadow-reminder-title">
262
+ <div class="chapter-foreshadow-reminder-summary">
263
+ <span id="chapter-foreshadow-reminder-role" class="chapter-foreshadow-reminder-role">提醒章</span>
264
+ <div>
265
+ <strong id="chapter-foreshadow-reminder-title">本章有伏笔需要留意</strong>
266
+ <span id="chapter-foreshadow-reminder-context"></span>
267
+ </div>
268
+ </div>
269
+ <div class="chapter-foreshadow-reminder-actions">
270
+ <span id="chapter-foreshadow-reminder-counter" class="chapter-foreshadow-reminder-counter"></span>
271
+ <button id="chapter-foreshadow-reminder-previous" class="ghost-button" type="button" aria-label="查看上一条伏笔提醒">上一条</button>
272
+ <button id="chapter-foreshadow-reminder-next" class="ghost-button" type="button" aria-label="查看下一条伏笔提醒">下一条</button>
273
+ <button id="chapter-foreshadow-reminder-details-button" class="ghost-button" type="button" aria-controls="chapter-foreshadow-reminder-details" aria-expanded="false">查看详情</button>
274
+ <button id="chapter-foreshadow-reminder-snooze" class="ghost-button" type="button">暂不处理</button>
275
+ <button id="chapter-foreshadow-reminder-resolve" class="primary-button" type="button">标记已回收</button>
276
+ </div>
277
+ <dl id="chapter-foreshadow-reminder-details" class="chapter-foreshadow-reminder-details hidden">
278
+ <div id="chapter-foreshadow-reminder-description-row"><dt>伏笔说明</dt><dd id="chapter-foreshadow-reminder-description"></dd></div>
279
+ <div id="chapter-foreshadow-reminder-note-row"><dt>本章节点</dt><dd id="chapter-foreshadow-reminder-note"></dd></div>
280
+ <div><dt>重要程度</dt><dd id="chapter-foreshadow-reminder-importance"></dd></div>
281
+ </dl>
282
+ </section>
256
283
  <div class="editor-body">
257
284
  <div class="chapter-editor-frame">
258
285
  <aside id="chapter-line-numbers" class="chapter-line-numbers" aria-label="正文行号,可拖拽引用多行"><div id="chapter-line-numbers-inner"></div></aside>
@@ -345,6 +372,55 @@
345
372
  </aside>
346
373
  </div>
347
374
 
375
+ <section id="reader-view" class="reader-view hidden" data-reader-theme="paper" role="dialog" aria-labelledby="reader-title">
376
+ <header class="reader-header">
377
+ <button id="reader-close" class="entity-editor-back" type="button">返回工作台</button>
378
+ <div class="reader-heading">
379
+ <span class="eyebrow">沉浸式阅读预览</span>
380
+ <h1 id="reader-title">阅读预览</h1>
381
+ </div>
382
+ <details id="reader-settings" class="reader-settings">
383
+ <summary>显示设置</summary>
384
+ <div class="reader-settings-panel">
385
+ <label>字号<select id="reader-font-size" aria-label="阅读字号">
386
+ <option value="16">16 像素</option><option value="18">18 像素</option><option value="20">20 像素</option><option value="22">22 像素</option><option value="24">24 像素</option>
387
+ </select></label>
388
+ <label>行距<select id="reader-line-height" aria-label="阅读行距">
389
+ <option value="1.6">紧凑 1.6</option><option value="1.8">适中 1.8</option><option value="1.9">舒展 1.9</option><option value="2">宽松 2.0</option><option value="2.2">宽松 2.2</option>
390
+ </select></label>
391
+ <label>主题<select id="reader-theme" aria-label="阅读主题">
392
+ <option value="paper">纸张</option><option value="light">明亮</option><option value="dark">夜间</option>
393
+ </select></label>
394
+ </div>
395
+ </details>
396
+ <div class="reader-jump-controls" aria-label="阅读导航与模式">
397
+ <label>分卷<select id="reader-volume" aria-label="从分卷开始阅读"></select></label>
398
+ <label>章节<select id="reader-chapter" aria-label="快速跳章"></select></label>
399
+ <label>方式<select id="reader-mode" aria-label="阅读方式"><option value="scroll">连续滚动</option><option value="paged">翻页阅读</option></select></label>
400
+ </div>
401
+ </header>
402
+ <main id="reader-viewport" class="reader-viewport" tabindex="0" aria-describedby="reader-keyboard-hint">
403
+ <article id="reader-document" class="reader-document" aria-live="polite">
404
+ <header class="reader-chapter-header">
405
+ <span id="reader-volume-title" class="eyebrow">正文</span>
406
+ <h2 id="reader-chapter-title">请选择章节</h2>
407
+ <p id="reader-chapter-meta">0 字</p>
408
+ </header>
409
+ <div id="reader-page-shell" class="reader-page-shell">
410
+ <div id="reader-content" class="reader-content"></div>
411
+ </div>
412
+ <div id="reader-continuation" class="reader-continuation">
413
+ <button id="reader-continue" class="ghost-button" type="button">继续下一章</button>
414
+ </div>
415
+ </article>
416
+ </main>
417
+ <footer class="reader-footer">
418
+ <button id="reader-previous" class="ghost-button" type="button">上一章</button>
419
+ <div class="reader-footer-status"><div class="reader-page-actions"><button id="reader-page-previous" class="hidden" type="button" aria-label="上一页">‹</button><strong id="reader-progress" role="status" aria-live="polite">第 0 / 0 章</strong><button id="reader-page-next" class="hidden" type="button" aria-label="下一页">›</button></div><small id="reader-keyboard-hint">Esc 关闭;PageUp/PageDown 或方向键阅读</small></div>
420
+ <button id="reader-next" class="primary-button" type="button">下一章</button>
421
+ </footer>
422
+ </section>
423
+
348
424
  <div id="toast-region" class="toast-region" data-position="bottom-right" aria-live="polite" popover="manual"></div>
349
425
  <div id="chapter-type-menu" class="chapter-type-menu hidden" role="menu" aria-label="章节操作" data-testid="chapter-type-menu">
350
426
  <strong>标记章节</strong>
@@ -378,6 +454,10 @@
378
454
  <span>导出 DOCX</span>
379
455
  <small>Word 正文;有封面时嵌入为首页</small>
380
456
  </button>
457
+ <button type="button" role="menuitem" data-export-format="epub">
458
+ <span>导出 EPUB</span>
459
+ <small>电子书正文;包含元信息、封面和可跳转目录</small>
460
+ </button>
381
461
  </div>
382
462
  <input id="new-import-file" class="hidden" type="file" accept=".txt,.docx">
383
463
  <input id="cover-file" class="hidden" type="file" accept="image/png,image/jpeg,image/webp">
@@ -616,7 +696,7 @@
616
696
  </div>
617
697
  <div class="access-dialog-body">
618
698
  <form id="search-form" class="search-form">
619
- <label>关键词<input id="search-query" name="query" type="search" maxlength="500" placeholder="搜索正文、设定、人物、时间线、关系、大纲、伏笔或 Agent 历史" required></label>
699
+ <label>关键词<input id="search-query" name="query" type="search" maxlength="100" placeholder="搜索正文、设定、人物、时间线、关系、大纲、伏笔或 Agent 历史" required></label>
620
700
  <label>资料类型<select id="search-type" name="type">
621
701
  <option value="">全部资料</option>
622
702
  <option value="chapter">章节</option>
@@ -746,11 +826,18 @@
746
826
  </form>
747
827
  </dialog>
748
828
 
749
- <dialog id="versions-dialog" class="dialog wide-dialog">
829
+ <dialog id="versions-dialog" class="dialog wide-dialog" aria-labelledby="chapter-versions-title" aria-describedby="chapter-version-diff-summary">
750
830
  <div class="dialog-header">
751
- <div><span class="eyebrow">版本历史</span><h2>章节保存记录</h2></div>
752
- <button id="versions-close" class="dialog-close" aria-label="关闭" type="button">×</button>
831
+ <div><span class="eyebrow">版本历史</span><h2 id="chapter-versions-title">章节保存记录</h2></div>
832
+ <button id="versions-close" class="dialog-close" aria-label="关闭章节版本历史" type="button">×</button>
753
833
  </div>
834
+ <section class="chapter-version-compare" aria-label="选择要比较的章节版本">
835
+ <label>较早内容<select id="chapter-version-before" aria-label="选择比较前的版本"></select></label>
836
+ <button id="chapter-version-compare" class="ghost-button" type="button">比较差异</button>
837
+ <label>较新内容<select id="chapter-version-after" aria-label="选择比较后的版本"></select></label>
838
+ </section>
839
+ <p id="chapter-version-diff-summary" class="chapter-version-diff-summary" aria-live="polite"></p>
840
+ <div id="chapter-version-diff" class="chapter-version-diff" aria-label="逐行差异结果"></div>
754
841
  <div id="versions-list" class="versions-list"></div>
755
842
  </dialog>
756
843
 
@@ -765,14 +852,25 @@
765
852
  </div>
766
853
  </dialog>
767
854
 
855
+ <dialog id="work-recycle-bin-dialog" class="dialog wide-dialog" aria-labelledby="work-recycle-bin-title" aria-describedby="work-recycle-bin-description">
856
+ <div class="dialog-header">
857
+ <div><span class="eyebrow">作品安全</span><h2 id="work-recycle-bin-title">作品回收站</h2></div>
858
+ <button id="work-recycle-bin-close" class="dialog-close" aria-label="关闭作品回收站" type="button">×</button>
859
+ </div>
860
+ <div class="import-history-body">
861
+ <p id="work-recycle-bin-description" class="import-history-note">删除的作品默认保留 30 天,期间会完整保留正文、层级、设定、关联资料与版本历史。恢复后可继续编辑;彻底删除后无法恢复。</p>
862
+ <div id="work-recycle-bin-list" class="entity-history-list recycle-bin-list" aria-live="polite"></div>
863
+ </div>
864
+ </dialog>
865
+
768
866
  <dialog id="chapter-recycle-bin-dialog" class="dialog wide-dialog" aria-labelledby="chapter-recycle-bin-title" aria-describedby="chapter-recycle-bin-description">
769
867
  <div class="dialog-header">
770
- <div><span class="eyebrow">正文安全</span><h2 id="chapter-recycle-bin-title">章节回收站</h2></div>
771
- <button id="chapter-recycle-bin-close" class="dialog-close" aria-label="关闭章节回收站" type="button">×</button>
868
+ <div><span class="eyebrow">正文安全</span><h2 id="chapter-recycle-bin-title">正文回收站</h2></div>
869
+ <button id="chapter-recycle-bin-close" class="dialog-close" aria-label="关闭正文回收站" type="button">×</button>
772
870
  </div>
773
871
  <div class="import-history-body">
774
- <p id="chapter-recycle-bin-description" class="import-history-note">软删除的章节会保留正文、版本和关联资料。可以恢复到原分卷,也可以彻底删除;彻底删除后无法恢复。</p>
775
- <div id="chapter-recycle-bin-list" class="entity-history-list" aria-live="polite"></div>
872
+ <p id="chapter-recycle-bin-description" class="import-history-note">删除的分卷与章节默认保留 30 天。分卷会连同其中章节一起恢复;独立章节会恢复到原分卷。彻底删除会清理正文、版本和关联资料,且无法恢复。</p>
873
+ <div id="chapter-recycle-bin-list" class="entity-history-list recycle-bin-list" aria-live="polite"></div>
776
874
  </div>
777
875
  </dialog>
778
876
 
@@ -950,6 +1048,10 @@
950
1048
  <p>预览</p>
951
1049
  </aside>
952
1050
  </div>
1051
+ <div id="avatar-upload-progress" class="image-upload-progress-panel hidden" role="status" aria-live="polite" aria-busy="true">
1052
+ <div class="image-upload-placeholder-box" aria-hidden="true"><span class="image-upload-placeholder-icon">IMG</span><span class="image-upload-placeholder-shine"></span></div>
1053
+ <div class="image-upload-progress-copy"><strong>正在上传头像</strong><small id="avatar-upload-progress-name"></small><div class="image-upload-progress" data-upload-progress data-upload-progress-label="头像上传进度" style="--upload-progress: 0%"><div class="image-upload-progress-heading"><span data-upload-progress-text>上传中 0%</span><output data-upload-progress-value>0%</output></div><div class="image-upload-progress-track" role="progressbar" aria-label="头像上传进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span data-upload-progress-fill></span></div></div></div>
1054
+ </div>
953
1055
  <div class="dialog-actions">
954
1056
  <button id="avatar-crop-cancel" class="ghost-button" type="button">取消</button>
955
1057
  <button id="avatar-crop-confirm" class="primary-button" type="button">确认裁剪</button>
@@ -1015,6 +1117,12 @@
1015
1117
  <button id="ai-history-next" type="button" disabled>下一页</button>
1016
1118
  </nav>
1017
1119
  </div>
1120
+ <div id="ai-history-action-menu" class="ai-history-action-menu hidden" role="menu" aria-label="对话操作">
1121
+ <button type="button" role="menuitem" data-ai-history-action="export">
1122
+ <span>导出 Markdown</span>
1123
+ <small>下载当前对话的角色、时间和消息正文</small>
1124
+ </button>
1125
+ </div>
1018
1126
  </dialog>
1019
1127
 
1020
1128
  <dialog id="relationship-map-dialog" class="relationship-map-dialog" aria-label="放大人物关系图" data-testid="relationship-map-expanded">
@@ -1048,6 +1156,6 @@
1048
1156
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1049
1157
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1050
1158
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1051
- <script type="module" src="/app.js?v=20260812-image-upload-limit-v3"></script>
1159
+ <script type="module" src="/app.js?v=20260812-ai-stream-connectivity-global-replace-foreshadow-epub-character-extraction-outline-reader-v1"></script>
1052
1160
  </body>
1053
1161
  </html>
@@ -0,0 +1,61 @@
1
+ export type OutlineBoardForeshadow = {
2
+ id?: string | null;
3
+ title?: string | null;
4
+ status?: string | null;
5
+ importance?: string | null;
6
+ roles?: string[];
7
+ plannedPayoff?: boolean;
8
+ };
9
+
10
+ export type OutlineBoardOutline = {
11
+ goal?: string | null;
12
+ conflict?: string | null;
13
+ turningPoint?: string | null;
14
+ notes?: string | null;
15
+ status?: string | null;
16
+ truncated?: boolean;
17
+ updatedAt?: string | null;
18
+ };
19
+
20
+ export type OutlineBoardChapter = {
21
+ id?: string | null;
22
+ title?: string | null;
23
+ chapterType?: string | null;
24
+ sortOrder?: number | null;
25
+ outline?: OutlineBoardOutline | null;
26
+ foreshadows?: OutlineBoardForeshadow[];
27
+ };
28
+
29
+ export type OutlineBoardVolume<T extends OutlineBoardChapter = OutlineBoardChapter> = {
30
+ id?: string | null;
31
+ title?: string | null;
32
+ sortOrder?: number | null;
33
+ chapters?: T[];
34
+ };
35
+
36
+ export type OutlineBoard<T extends OutlineBoardChapter = OutlineBoardChapter> = {
37
+ volumes?: Array<OutlineBoardVolume<T>>;
38
+ };
39
+
40
+ export type OutlineBoardState = {
41
+ query: string;
42
+ volumeId: string;
43
+ outlineStatus: "all" | "empty" | "draft" | "ready" | "completed";
44
+ foreshadowStatus: "all" | "none" | "unresolved" | "resolved" | "abandoned";
45
+ sort: "tree" | "status" | "foreshadows" | "title";
46
+ };
47
+
48
+ export declare function normalizeOutlineBoardState(value?: Partial<OutlineBoardState>): OutlineBoardState;
49
+
50
+ export declare function prepareOutlineBoard<T extends OutlineBoardChapter>(
51
+ board: OutlineBoard<T> | null | undefined,
52
+ value?: Partial<OutlineBoardState>
53
+ ): {
54
+ state: OutlineBoardState;
55
+ volumes: Array<OutlineBoardVolume<T> & { chapters: T[] }>;
56
+ totalChapterCount: number;
57
+ visibleChapterCount: number;
58
+ filtersActive: boolean;
59
+ };
60
+
61
+ export declare function outlineBoardUnresolvedCount(chapter: OutlineBoardChapter): number;