@waterwx/dsh-novel-forge 2.0.0-alpha.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +286 -285
- package/lib/client.js +19561 -19439
- package/lib/index.js +8159 -4456
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +0 -6
- package/lib/types/assets.d.ts +20 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +13 -3
- package/lib/types/client/api.d.ts +136 -27
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +15 -70
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorHome.d.ts +13 -11
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/SettingsView.d.ts +12 -9
- package/lib/types/client/panel/ShelfView.d.ts +10 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +21 -35
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +78 -103
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +26 -12
- package/lib/types/llm-live.d.ts +70 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +18 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/model-capability.d.ts +93 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +816 -601
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +9 -3
- package/lib/types/run.d.ts +23 -1
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +58 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +50 -25
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +0 -24
- package/src/assets.ts +1801 -879
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +131 -147
- package/src/bookshelf.ts +193 -167
- package/src/client/api.ts +767 -617
- package/src/client/chip-prefs.ts +121 -0
- package/src/client/docx.ts +32 -69
- package/src/client/index.ts +2 -0
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +15 -274
- package/src/client/mount.tsx +3 -2
- package/src/client/panel/AdaptModeView.tsx +535 -520
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +205 -207
- package/src/client/panel/AuthorHome.tsx +309 -204
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -352
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +302 -302
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +415 -0
- package/src/client/panel/ModelManager.tsx +205 -205
- package/src/client/panel/NovelPanel.tsx +3144 -2205
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -64
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/RunPanel.tsx +56 -24
- package/src/client/panel/SettingsView.tsx +175 -106
- package/src/client/panel/ShelfView.tsx +63 -39
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/market-radar-state.ts +79 -0
- package/src/client/panel/panel.module.css +5767 -4360
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +316 -314
- package/src/client/run-status.ts +171 -0
- package/src/client/sidebar-entry.ts +3 -3
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +1197 -1638
- package/src/global-assets.ts +58 -0
- package/src/home.ts +33 -0
- package/src/index.ts +181 -101
- package/src/llm-live.ts +245 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +35 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/model-capability.ts +215 -0
- package/src/novel-context.ts +286 -287
- package/src/output-budget.ts +147 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +835 -614
- package/src/revision.ts +283 -0
- package/src/routes.ts +1286 -910
- package/src/run.ts +113 -17
- package/src/settings-persist.ts +114 -0
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +186 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/guoman-2d-gufeng.webp +0 -0
- package/assets/styles/guoman-3d-tianyuan.webp +0 -0
- package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/hyperreal-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/sandiao-qban.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/urban-lightreal-3d.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/client/panel/FlowGuide.d.ts +0 -22
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
- package/lib/types/client/panel/ModelPicker.d.ts +0 -11
- package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/client/panel/PropLibrary.d.ts +0 -8
- package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
- package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/lib/types/manga-genre-rules.d.ts +0 -22
- package/lib/types/prompt-utils.d.ts +0 -35
- package/lib/types/review-policy.d.ts +0 -97
- package/lib/types/shot-language.d.ts +0 -46
- package/lib/types/story-beat-language.d.ts +0 -35
- package/lib/types/style-library.d.ts +0 -33
- package/skills/chapter-batch/SKILL.md +0 -81
- package/src/client/mount.tsx._new +0 -97
- package/src/client/mount.tsx._nf +0 -97
- package/src/client/mount.tsx._tmp +0 -97
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/client/panel/FlowGuide.tsx +0 -84
- package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
- package/src/client/panel/MangaWorkspace.tsx +0 -715
- package/src/client/panel/ProgressHomeView.tsx +0 -87
- package/src/client/panel/PropLibrary.tsx +0 -175
- package/src/client/panel/SceneLibrary.tsx +0 -453
- package/src/client/panel/StoryboardTab.tsx +0 -472
- package/src/client/panel/StyleCard.tsx +0 -90
- package/src/client/panel/StyleGallery.tsx +0 -58
- package/src/comic-presets.ts +0 -67
- package/src/manga-genre-rules.ts +0 -87
- package/src/prompt-utils.ts +0 -73
- package/src/review-policy.ts +0 -161
- package/src/shot-language.ts +0 -181
- package/src/story-beat-language.ts +0 -130
- package/src/style-library.ts +0 -84
package/src/llm-live.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM 实况 feed:以「每次 LLM 调用」为粒度,把生命周期事件(开始/流式正文/阶段变化/完成)
|
|
3
|
+
* 推给订阅者,并提供环形缓冲供新连上的客户端重放。
|
|
4
|
+
*
|
|
5
|
+
* 同时承担三件与用量有关的事(进程内,不落盘):
|
|
6
|
+
* 1) **用量账本**:每次调用结束后按用途累计 token / 耗时,供 /status 展示;
|
|
7
|
+
* 2) **Prompt 记录**:保存每次调用实际发送的 system + user(环形,按需查询);
|
|
8
|
+
* 3) **首字耗时**:调用方在拿到第一个文本增量时打点,用于观察模型响应速度。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BlockAssembler, TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import type {
|
|
13
|
+
LlmLiveFrame,
|
|
14
|
+
LlmPromptRecord,
|
|
15
|
+
LlmTokenUsage,
|
|
16
|
+
LlmUsageBucket,
|
|
17
|
+
LlmUsageSummary,
|
|
18
|
+
} from './protocol.ts'
|
|
19
|
+
|
|
20
|
+
export type { LlmLiveFrame }
|
|
21
|
+
|
|
22
|
+
/** 帧缓冲上限(新连接重放用)。 */
|
|
23
|
+
const MAX_BUFFER = 400
|
|
24
|
+
/** Prompt 记录上限(环形,避免长跑吃内存)。 */
|
|
25
|
+
const MAX_PROMPTS = 40
|
|
26
|
+
/** 单条 Prompt 记录的最大字符数(system + user 合计),超出截断并标记。 */
|
|
27
|
+
const MAX_PROMPT_CHARS = 60_000
|
|
28
|
+
|
|
29
|
+
let buffer: LlmLiveFrame[] = []
|
|
30
|
+
type Listener = (frame: LlmLiveFrame) => void
|
|
31
|
+
let listeners = new Set<Listener>()
|
|
32
|
+
let seq = 0
|
|
33
|
+
|
|
34
|
+
/** 进程内用量账本。 */
|
|
35
|
+
const usageTotals = {
|
|
36
|
+
calls: 0,
|
|
37
|
+
failed: 0,
|
|
38
|
+
inputTokens: 0,
|
|
39
|
+
outputTokens: 0,
|
|
40
|
+
reasoningTokens: 0,
|
|
41
|
+
totalTokens: 0,
|
|
42
|
+
elapsedMs: 0,
|
|
43
|
+
callsWithoutUsage: 0,
|
|
44
|
+
byLabel: new Map<string, LlmUsageBucket>(),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Prompt 记录(按 sessionId,保留最近 MAX_PROMPTS 条)。 */
|
|
48
|
+
const prompts = new Map<string, LlmPromptRecord>()
|
|
49
|
+
|
|
50
|
+
export function nextSessionId(): string {
|
|
51
|
+
seq++
|
|
52
|
+
return `ll-${Date.now().toString(36)}-${seq}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function subscribeLiveFeed(listener: Listener): () => void {
|
|
56
|
+
// 先重放现有缓冲(新连接能立即看到最近调用),再进入实时。
|
|
57
|
+
for (const frame of buffer) listener(frame)
|
|
58
|
+
listeners.add(listener)
|
|
59
|
+
return () => { listeners.delete(listener) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function emitLive(frame: LlmLiveFrame): void {
|
|
63
|
+
if (frame.type === 'session_completed') recordUsage(frame)
|
|
64
|
+
buffer.push(frame)
|
|
65
|
+
if (buffer.length > MAX_BUFFER) buffer = buffer.slice(-MAX_BUFFER)
|
|
66
|
+
for (const l of listeners) l(frame)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ------------------------------------------------------------------ 用量账本
|
|
70
|
+
|
|
71
|
+
/** 把 dsh-llm 的 usage 转成线上形状。 */
|
|
72
|
+
export function toLiveUsage(usage: TokenUsage | undefined): LlmTokenUsage | undefined {
|
|
73
|
+
if (usage === undefined) return undefined
|
|
74
|
+
return {
|
|
75
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
76
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
77
|
+
totalTokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
|
78
|
+
reasoningTokens: usage.reasoningTokens ?? 0,
|
|
79
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 记一次已完成的调用(由 emitLive 内部调用)。 */
|
|
84
|
+
function recordUsage(frame: LlmLiveFrame): void {
|
|
85
|
+
const failed = frame.phase === 'failed' ? 1 : 0
|
|
86
|
+
const usage = frame.usage
|
|
87
|
+
usageTotals.calls += 1
|
|
88
|
+
usageTotals.failed += failed
|
|
89
|
+
usageTotals.elapsedMs += frame.elapsedMs ?? 0
|
|
90
|
+
if (usage === undefined) {
|
|
91
|
+
usageTotals.callsWithoutUsage += 1
|
|
92
|
+
} else {
|
|
93
|
+
usageTotals.inputTokens += usage.inputTokens
|
|
94
|
+
usageTotals.outputTokens += usage.outputTokens
|
|
95
|
+
usageTotals.reasoningTokens += usage.reasoningTokens ?? 0
|
|
96
|
+
usageTotals.totalTokens += usage.totalTokens ?? usage.inputTokens + usage.outputTokens
|
|
97
|
+
}
|
|
98
|
+
const label = frame.label ?? '未标注调用'
|
|
99
|
+
const bucket = usageTotals.byLabel.get(label) ?? { label, calls: 0, failed: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, elapsedMs: 0 }
|
|
100
|
+
bucket.calls += 1
|
|
101
|
+
bucket.failed += failed
|
|
102
|
+
bucket.elapsedMs += frame.elapsedMs ?? 0
|
|
103
|
+
if (usage !== undefined) {
|
|
104
|
+
bucket.inputTokens += usage.inputTokens
|
|
105
|
+
bucket.outputTokens += usage.outputTokens
|
|
106
|
+
bucket.reasoningTokens += usage.reasoningTokens ?? 0
|
|
107
|
+
}
|
|
108
|
+
usageTotals.byLabel.set(label, bucket)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 本次运行的用量汇总(按耗时降序排列用途,便于看谁最贵)。 */
|
|
112
|
+
export function liveUsage(): LlmUsageSummary {
|
|
113
|
+
return {
|
|
114
|
+
calls: usageTotals.calls,
|
|
115
|
+
failed: usageTotals.failed,
|
|
116
|
+
inputTokens: usageTotals.inputTokens,
|
|
117
|
+
outputTokens: usageTotals.outputTokens,
|
|
118
|
+
reasoningTokens: usageTotals.reasoningTokens,
|
|
119
|
+
totalTokens: usageTotals.totalTokens,
|
|
120
|
+
elapsedMs: usageTotals.elapsedMs,
|
|
121
|
+
callsWithoutUsage: usageTotals.callsWithoutUsage,
|
|
122
|
+
byLabel: [...usageTotals.byLabel.values()].sort((a, b) =>
|
|
123
|
+
(b.inputTokens + b.outputTokens + b.reasoningTokens) - (a.inputTokens + a.outputTokens + a.reasoningTokens)),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 清零用量账本(面板「重新计数」用)。 */
|
|
128
|
+
export function resetLiveUsage(): void {
|
|
129
|
+
usageTotals.calls = 0
|
|
130
|
+
usageTotals.failed = 0
|
|
131
|
+
usageTotals.inputTokens = 0
|
|
132
|
+
usageTotals.outputTokens = 0
|
|
133
|
+
usageTotals.reasoningTokens = 0
|
|
134
|
+
usageTotals.totalTokens = 0
|
|
135
|
+
usageTotals.elapsedMs = 0
|
|
136
|
+
usageTotals.callsWithoutUsage = 0
|
|
137
|
+
usageTotals.byLabel.clear()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ------------------------------------------------------------- Prompt 记录
|
|
141
|
+
|
|
142
|
+
/** 记一条 Prompt(超长截断并标记)。 */
|
|
143
|
+
export function recordPrompt(input: { sessionId: string; label?: string; model?: string; system?: string; user?: string }): LlmPromptRecord {
|
|
144
|
+
const system = input.system ?? ''
|
|
145
|
+
const user = input.user ?? ''
|
|
146
|
+
const chars = system.length + user.length
|
|
147
|
+
let record: LlmPromptRecord = {
|
|
148
|
+
sessionId: input.sessionId,
|
|
149
|
+
label: input.label,
|
|
150
|
+
model: input.model,
|
|
151
|
+
at: new Date().toISOString(),
|
|
152
|
+
system,
|
|
153
|
+
user,
|
|
154
|
+
chars,
|
|
155
|
+
}
|
|
156
|
+
if (chars > MAX_PROMPT_CHARS) {
|
|
157
|
+
// 优先保留 user(真正随调用变化的输入),system 按剩余预算截断。
|
|
158
|
+
const userPart = user.slice(0, MAX_PROMPT_CHARS)
|
|
159
|
+
const rest = MAX_PROMPT_CHARS - userPart.length
|
|
160
|
+
record = { ...record, user: userPart, system: system.slice(0, Math.max(0, rest)), truncated: true }
|
|
161
|
+
}
|
|
162
|
+
prompts.set(record.sessionId, record)
|
|
163
|
+
while (prompts.size > MAX_PROMPTS) {
|
|
164
|
+
const oldest = prompts.keys().next().value
|
|
165
|
+
if (oldest === undefined) break
|
|
166
|
+
prompts.delete(oldest)
|
|
167
|
+
}
|
|
168
|
+
return record
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 取一条 Prompt 记录(不存在返回 undefined)。 */
|
|
172
|
+
export function livePrompt(sessionId: string): LlmPromptRecord | undefined {
|
|
173
|
+
return prompts.get(sessionId)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --------------------------------------------------------------- 调用打点
|
|
177
|
+
|
|
178
|
+
/** 一次调用的打点句柄。 */
|
|
179
|
+
export interface LiveCallHandle {
|
|
180
|
+
sessionId: string
|
|
181
|
+
startedAt: number
|
|
182
|
+
/** 用途(用于 session_completed 的用量分组)。 */
|
|
183
|
+
label: string
|
|
184
|
+
/** 实际使用的模型。 */
|
|
185
|
+
model: string
|
|
186
|
+
/** 首字耗时(毫秒),由 markFirstToken 写入。 */
|
|
187
|
+
firstTokenMs?: number
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 开始一次 LLM 调用:发 session_started、记 Prompt、起计时。
|
|
192
|
+
*
|
|
193
|
+
* @param input 用途/模型/实际发送的 system 与 user。
|
|
194
|
+
* @returns 打点句柄(结束时交给 endLiveCall)。
|
|
195
|
+
*/
|
|
196
|
+
export function beginLiveCall(input: { label: string; model: string; system?: string; user?: string }): LiveCallHandle {
|
|
197
|
+
const handle: LiveCallHandle = { sessionId: nextSessionId(), startedAt: Date.now(), label: input.label, model: input.model }
|
|
198
|
+
const record = recordPrompt({ sessionId: handle.sessionId, label: input.label, model: input.model, system: input.system, user: input.user })
|
|
199
|
+
emitLive({
|
|
200
|
+
type: 'session_started',
|
|
201
|
+
sessionId: handle.sessionId,
|
|
202
|
+
label: input.label,
|
|
203
|
+
model: input.model,
|
|
204
|
+
at: new Date().toISOString(),
|
|
205
|
+
context: { interactionId: handle.sessionId },
|
|
206
|
+
hasPrompt: true,
|
|
207
|
+
promptChars: record.chars,
|
|
208
|
+
})
|
|
209
|
+
emitLive({ type: 'phase_changed', sessionId: handle.sessionId, phase: 'streaming', phaseMessage: '模型正在返回内容', at: new Date().toISOString() })
|
|
210
|
+
return handle
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 首字打点(调用方在收到第一个文本增量时调用,只记第一次)。 */
|
|
214
|
+
export function markFirstToken(handle: LiveCallHandle): void {
|
|
215
|
+
if (handle.firstTokenMs === undefined) handle.firstTokenMs = Date.now() - handle.startedAt
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 结束一次 LLM 调用:从 assembler 取用量,发 session_completed(含 token/耗时/首字)。
|
|
220
|
+
*
|
|
221
|
+
* @param handle beginLiveCall 的句柄。
|
|
222
|
+
* @param assembler 本次调用的 BlockAssembler。
|
|
223
|
+
* @param extra 字符数/预览/失败信息等。
|
|
224
|
+
*/
|
|
225
|
+
export function endLiveCall(
|
|
226
|
+
handle: LiveCallHandle,
|
|
227
|
+
assembler: BlockAssembler,
|
|
228
|
+
extra: { chars?: number; preview?: string; phase?: 'completed' | 'failed'; error?: string } = {},
|
|
229
|
+
): void {
|
|
230
|
+
const usage = toLiveUsage(assembler.usage)
|
|
231
|
+
emitLive({
|
|
232
|
+
type: 'session_completed',
|
|
233
|
+
sessionId: handle.sessionId,
|
|
234
|
+
label: handle.label,
|
|
235
|
+
model: handle.model,
|
|
236
|
+
at: new Date().toISOString(),
|
|
237
|
+
totalChars: extra.chars,
|
|
238
|
+
preview: extra.preview,
|
|
239
|
+
phase: extra.phase ?? 'completed',
|
|
240
|
+
error: extra.error,
|
|
241
|
+
usage,
|
|
242
|
+
elapsedMs: Date.now() - handle.startedAt,
|
|
243
|
+
firstTokenMs: handle.firstTokenMs,
|
|
244
|
+
})
|
|
245
|
+
}
|
package/src/llm-retry.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型失败恢复规则(纯逻辑,可离线单测)。
|
|
3
|
+
*
|
|
4
|
+
* 解决的问题:批量连写时,主模型偶发限流/超时/额度耗尽会让**整批中断**,
|
|
5
|
+
* 而换一个模型重试一次往往就能过去。规则:
|
|
6
|
+
* 1) 只对「可重试」错误切换模型——用户主动取消、参数/契约类错误不重试;
|
|
7
|
+
* 2) 备用模型为空或与主模型相同则不切换;
|
|
8
|
+
* 3) 备用模型也失败时,抛**原始错误**(保留主因,不掩盖现场);
|
|
9
|
+
* 4) 每次调用最多切换一次。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 可重试错误特征(网络/超时/限流/服务端/额度类)。 */
|
|
13
|
+
const RETRYABLE_PATTERNS: readonly RegExp[] = [
|
|
14
|
+
/失败/, /超时/, /timeout/i,
|
|
15
|
+
/network|fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EAI_AGAIN|socket hang up|terminated/i,
|
|
16
|
+
/频繁|限流|rate\s*limit|too many requests|\b429\b|\b5\d\d\b/i,
|
|
17
|
+
/overload|unavailable|capacity|服务不可用|繁忙/i,
|
|
18
|
+
/quota|balance|credit|insufficient|额度|余额/i,
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/** 明确不可重试的特征(用户取消 / 契约类)。 */
|
|
22
|
+
const NON_RETRYABLE_PATTERNS: readonly RegExp[] = [
|
|
23
|
+
/\baborted\b|已取消|用户取消/i,
|
|
24
|
+
/不在计划中|尚未生成|参数|非法|无效|需要/i,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 判断一次 LLM 失败是否值得换模型重试。
|
|
29
|
+
*
|
|
30
|
+
* @param error 捕获到的异常。
|
|
31
|
+
* @returns 可重试时为 true。
|
|
32
|
+
*/
|
|
33
|
+
export function isRetryableLlmError(error: unknown): boolean {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
35
|
+
if (NON_RETRYABLE_PATTERNS.some(re => re.test(message))) return false
|
|
36
|
+
return RETRYABLE_PATTERNS.some(re => re.test(message))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 判断是否应当切换到备用模型。
|
|
41
|
+
*
|
|
42
|
+
* @param fallbackModel 配置的备用模型(空串 = 未配置)。
|
|
43
|
+
* @param primaryModel 本次实际使用的主模型。
|
|
44
|
+
* @param error 捕获到的异常。
|
|
45
|
+
* @returns 应当切换时为 true。
|
|
46
|
+
*/
|
|
47
|
+
export function shouldSwitchModel(fallbackModel: string | undefined, primaryModel: string, error: unknown): boolean {
|
|
48
|
+
const fallback = (fallbackModel ?? '').trim()
|
|
49
|
+
if (fallback === '' || fallback === primaryModel) return false
|
|
50
|
+
return isRetryableLlmError(error)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 用主模型执行;失败且满足切换条件时,用备用模型再执行一次。
|
|
55
|
+
*
|
|
56
|
+
* @param input 主模型 / 备用模型 / 实际执行函数(接收本次要用的模型 id)。
|
|
57
|
+
* @returns 执行结果(备用模型成功时返回其结果)。
|
|
58
|
+
* @throws 主模型失败且未切换时抛原错误;切换后仍失败时同样抛**主模型的原始错误**。
|
|
59
|
+
*/
|
|
60
|
+
export async function withModelFallback<T>(
|
|
61
|
+
input: { model: string; fallbackModel?: string; onSwitch?: (from: string, to: string, error: unknown) => void },
|
|
62
|
+
run: (model: string) => Promise<T>,
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
try {
|
|
65
|
+
return await run(input.model)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const fallback = (input.fallbackModel ?? '').trim()
|
|
68
|
+
if (fallback === '' || fallback === input.model || !isRetryableLlmError(error)) throw error
|
|
69
|
+
input.onSwitch?.(input.model, fallback, error)
|
|
70
|
+
try {
|
|
71
|
+
return await run(fallback)
|
|
72
|
+
} catch {
|
|
73
|
+
// 备用模型也失败:抛主模型的原始错误,避免掩盖第一现场。
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 热门题材雷达:真实榜单扫榜(聚合各平台榜单源,容错)。
|
|
3
|
+
*/
|
|
4
|
+
import { collectMarketSource, MARKET_RADAR_SOURCES, type CollectedRankingItem, type MarketRadarPlatform, type MarketRadarListSource } from './market-radar-sources.ts'
|
|
5
|
+
|
|
6
|
+
export interface MarketRadarGroup {
|
|
7
|
+
platform: MarketRadarPlatform
|
|
8
|
+
platformLabel: string
|
|
9
|
+
listKey: string
|
|
10
|
+
listLabel: string
|
|
11
|
+
status: 'ok' | 'error'
|
|
12
|
+
error?: string
|
|
13
|
+
items: CollectedRankingItem[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MarketScanResult {
|
|
17
|
+
scannedAt: string
|
|
18
|
+
groups: MarketRadarGroup[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function scanMarketRanking(platforms?: string[]): Promise<MarketScanResult> {
|
|
22
|
+
const wanted = new Set<string>(platforms?.length ? platforms : ['fanqie', 'qidian', 'jinjiang'])
|
|
23
|
+
const sources = MARKET_RADAR_SOURCES.filter(s => wanted.has(s.platform))
|
|
24
|
+
const groups: MarketRadarGroup[] = []
|
|
25
|
+
await Promise.all(sources.map(async (source: MarketRadarListSource) => {
|
|
26
|
+
try {
|
|
27
|
+
const items = await collectMarketSource(source)
|
|
28
|
+
groups.push({ platform: source.platform, platformLabel: source.platformLabel, listKey: source.listKey, listLabel: source.listLabel, status: 'ok', items })
|
|
29
|
+
} catch (error) {
|
|
30
|
+
groups.push({ platform: source.platform, platformLabel: source.platformLabel, listKey: source.listKey, listLabel: source.listLabel, status: 'error', error: (error as Error).message, items: [] })
|
|
31
|
+
}
|
|
32
|
+
}))
|
|
33
|
+
const result: MarketScanResult = { scannedAt: new Date().toISOString(), groups }
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「热门题材雷达」真实榜单抓取源(公开榜单页面的地址与选择器,属事实性配置)。
|
|
3
|
+
* 用全局 fetch 抓取公开榜单移动版页面,正则抽取元数据;无第三方依赖。
|
|
4
|
+
*/
|
|
5
|
+
export type MarketRadarPlatform = 'fanqie' | 'qidian' | 'jinjiang'
|
|
6
|
+
|
|
7
|
+
export interface MarketRadarListSource {
|
|
8
|
+
platform: MarketRadarPlatform
|
|
9
|
+
platformLabel: string
|
|
10
|
+
listKey: string
|
|
11
|
+
listLabel: string
|
|
12
|
+
channel: string
|
|
13
|
+
sourceUrl: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CollectedRankingItem {
|
|
17
|
+
rank: number
|
|
18
|
+
title: string
|
|
19
|
+
author?: string
|
|
20
|
+
category?: string
|
|
21
|
+
tags: string[]
|
|
22
|
+
synopsis?: string
|
|
23
|
+
heatLabel?: string
|
|
24
|
+
serialStatus?: string
|
|
25
|
+
sourceUrl: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const MARKET_RADAR_SOURCES: MarketRadarListSource[] = [
|
|
29
|
+
{ platform: 'fanqie', platformLabel: '番茄小说', listKey: 'reading', listLabel: '阅读榜', channel: 'general', sourceUrl: 'https://fanqienovel.com/rank' },
|
|
30
|
+
{ platform: 'fanqie', platformLabel: '番茄小说', listKey: 'new_book', listLabel: '新书榜', channel: 'general', sourceUrl: 'https://fanqienovel.com/rank/1_1' },
|
|
31
|
+
{ platform: 'qidian', platformLabel: '起点中文网', listKey: 'hotsales', listLabel: '畅销榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/hotsales/' },
|
|
32
|
+
{ platform: 'qidian', platformLabel: '起点中文网', listKey: 'monthly_ticket', listLabel: '月票榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/yuepiao/' },
|
|
33
|
+
{ platform: 'qidian', platformLabel: '起点中文网', listKey: 'new_book', listLabel: '新书榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/' },
|
|
34
|
+
{ platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'monthly', listLabel: '月度榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/5' },
|
|
35
|
+
{ platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'quarterly', listLabel: '季度榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/6' },
|
|
36
|
+
{ platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'new_author', listLabel: '新晋作者榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/29' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
40
|
+
amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodeEntities(value: string): string {
|
|
44
|
+
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (_, key: string) => {
|
|
45
|
+
if (key.startsWith('#')) {
|
|
46
|
+
const hexadecimal = key[1]?.toLowerCase() === 'x'
|
|
47
|
+
const code = Number.parseInt(key.slice(hexadecimal ? 2 : 1), hexadecimal ? 16 : 10)
|
|
48
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : ''
|
|
49
|
+
}
|
|
50
|
+
return NAMED_ENTITIES[key.toLowerCase()] ?? ''
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function plainText(value: string | undefined): string {
|
|
55
|
+
return decodeEntities((value ?? '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function absoluteUrl(base: string, value: string): string {
|
|
59
|
+
return new URL(value.startsWith('//') ? `https:${value}` : value, base).toString()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractTags(synopsis: string): string[] {
|
|
63
|
+
const bracketed = synopsis.match(/^[【〖\[]([^】〗\]]+)[】〗\]]/g) ?? []
|
|
64
|
+
return bracketed.flatMap((part) => plainText(part).replace(/^[【〖\[]|[】〗\]]$/g, '').split(/[++、|]/))
|
|
65
|
+
.map((tag) => tag.trim()).filter(Boolean).slice(0, 12)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function hasPrivateUseCharacters(value: string | null | undefined): boolean {
|
|
69
|
+
return /[\uE000-\uF8FF]/u.test(value ?? '')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseFanqieRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
|
|
73
|
+
const blocks = html.match(/<div class="rank-book-item">[\s\S]*?(?=<div class="rank-book-item">|<\/main>|<footer|<\/body>)/g) ?? []
|
|
74
|
+
return blocks.slice(0, 30).flatMap((block, index) => {
|
|
75
|
+
const titleMatch = block.match(/<div class="title">\s*<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
|
|
76
|
+
if (!titleMatch) return []
|
|
77
|
+
const synopsis = plainText(block.match(/<div class="desc abstract[^>]*>([\s\S]*?)<\/div>/)?.[1])
|
|
78
|
+
const footer = plainText(block.match(/<div class="book-item-footer">([\s\S]*?)<\/div>/)?.[1])
|
|
79
|
+
return [{
|
|
80
|
+
rank: Number(block.match(/book-item-index"><h1>(\d+)<\/h1>/)?.[1] ?? index + 1),
|
|
81
|
+
title: plainText(titleMatch[2]),
|
|
82
|
+
author: plainText(block.match(/<div class="author">[\s\S]*?<span[^>]*>([\s\S]*?)<\/span>/)?.[1]),
|
|
83
|
+
tags: extractTags(synopsis),
|
|
84
|
+
synopsis: synopsis.slice(0, 800),
|
|
85
|
+
heatLabel: footer.match(/在读[^\s,。]*/)?.[0],
|
|
86
|
+
serialStatus: footer.match(/连载中|已完结/)?.[0],
|
|
87
|
+
sourceUrl: absoluteUrl(source.sourceUrl, titleMatch[1]),
|
|
88
|
+
}]
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseFanqieDetail(html: string, item: CollectedRankingItem): CollectedRankingItem {
|
|
93
|
+
const title = plainText(html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/)?.[1])
|
|
94
|
+
const author = plainText(html.match(/author-name-text[^>]*>([\s\S]*?)<\/a>/)?.[1])
|
|
95
|
+
const synopsis = plainText(html.match(/<div class="page-abstract-content"[^>]*>\s*<p[^>]*>([\s\S]*?)<\/p>/)?.[1])
|
|
96
|
+
if (!title || hasPrivateUseCharacters(title)) throw new Error('作品详情页未提供可读书名')
|
|
97
|
+
return {
|
|
98
|
+
...item,
|
|
99
|
+
title,
|
|
100
|
+
author: author && !hasPrivateUseCharacters(author) ? author : undefined,
|
|
101
|
+
synopsis: synopsis && !hasPrivateUseCharacters(synopsis) ? synopsis.slice(0, 800) : undefined,
|
|
102
|
+
tags: synopsis && !hasPrivateUseCharacters(synopsis) ? extractTags(synopsis) : [],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseQidianRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
|
|
107
|
+
const escapedLabel = source.listLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
108
|
+
const itemPattern = new RegExp(`<a[^>]+href="([^"]*\\/book\\/[^"?]+)[^"]*"[^>]*>[\\s\\S]*?<h2[^>]+title="${escapedLabel}第(\\d+)位"[^>]*>([\\s\\S]*?)<\\/h2>[\\s\\S]*?<p[^>]*class="[^"]*subTitle[^"]*"[^>]*>([\\s\\S]*?)<\\/p>[\\s\\S]*?<\\/a>`, 'g')
|
|
109
|
+
return Array.from(html.matchAll(itemPattern)).slice(0, 30).map((match) => {
|
|
110
|
+
const subTitle = plainText(match[4])
|
|
111
|
+
const parts = subTitle.split('·').map((part) => part.trim()).filter(Boolean)
|
|
112
|
+
return {
|
|
113
|
+
rank: Number(match[2]),
|
|
114
|
+
title: plainText(match[3]),
|
|
115
|
+
author: parts[0],
|
|
116
|
+
category: parts[1],
|
|
117
|
+
tags: parts[1] ? [parts[1]] : [],
|
|
118
|
+
heatLabel: parts[2],
|
|
119
|
+
sourceUrl: absoluteUrl(source.sourceUrl, match[1]),
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseJinjiangRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
|
|
125
|
+
const items = Array.from(html.matchAll(/<li[^>]*>\s*<a href="(\/book2\/\d+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/li>/g))
|
|
126
|
+
return items.slice(0, 30).map((match, index) => ({
|
|
127
|
+
rank: index + 1,
|
|
128
|
+
title: plainText(match[2]),
|
|
129
|
+
tags: [],
|
|
130
|
+
sourceUrl: absoluteUrl(source.sourceUrl, match[1]),
|
|
131
|
+
}))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchHtml(url: string): Promise<string> {
|
|
135
|
+
const response = await fetch(url, {
|
|
136
|
+
headers: { 'user-agent': 'Mozilla/5.0 (compatible; dsh-novel-forge-radar/1.0; public-ranking-metadata-only)' },
|
|
137
|
+
signal: AbortSignal.timeout(20_000),
|
|
138
|
+
})
|
|
139
|
+
if (!response.ok) throw new Error(`榜单页面返回 HTTP ${response.status}`)
|
|
140
|
+
const bytes = await response.arrayBuffer()
|
|
141
|
+
const headerCharset = response.headers.get('content-type')?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
|
142
|
+
const metaProbe = new TextDecoder('latin1').decode(bytes.slice(0, 4096))
|
|
143
|
+
const metaCharset = metaProbe.match(/charset\s*=\s*["']?([\w-]+)/i)?.[1]
|
|
144
|
+
const charset = headerCharset || metaCharset || 'utf-8'
|
|
145
|
+
return new TextDecoder(charset).decode(bytes)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function hydrateFanqieItems(items: CollectedRankingItem[]): Promise<CollectedRankingItem[]> {
|
|
149
|
+
const hydrated: CollectedRankingItem[] = []
|
|
150
|
+
for (let offset = 0; offset < items.length; offset += 4) {
|
|
151
|
+
const batch = await Promise.all(items.slice(offset, offset + 4).map(async (item) => {
|
|
152
|
+
const containsObfuscatedText = [item.title, item.author, item.synopsis].some(hasPrivateUseCharacters)
|
|
153
|
+
if (!containsObfuscatedText) return item
|
|
154
|
+
try { return parseFanqieDetail(await fetchHtml(item.sourceUrl), item) }
|
|
155
|
+
catch { return null }
|
|
156
|
+
}))
|
|
157
|
+
hydrated.push(...batch.filter((item): item is CollectedRankingItem => item !== null))
|
|
158
|
+
}
|
|
159
|
+
return hydrated
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function collectMarketSource(source: MarketRadarListSource): Promise<CollectedRankingItem[]> {
|
|
163
|
+
const html = await fetchHtml(source.sourceUrl)
|
|
164
|
+
const parsers: Record<MarketRadarPlatform, (value: string, item: MarketRadarListSource) => CollectedRankingItem[]> = {
|
|
165
|
+
fanqie: parseFanqieRanking,
|
|
166
|
+
qidian: parseQidianRanking,
|
|
167
|
+
jinjiang: parseJinjiangRanking,
|
|
168
|
+
}
|
|
169
|
+
const parsed = parsers[source.platform](html, source)
|
|
170
|
+
const items = source.platform === 'fanqie' ? await hydrateFanqieItems(parsed) : parsed
|
|
171
|
+
if (items.length === 0) throw new Error('榜单页面结构可能已变化,未识别到公开作品元数据')
|
|
172
|
+
return items
|
|
173
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输出目录迁移(Migrate Output Dir)— 把一本书/默认输出目录的内容搬到新位置。
|
|
3
|
+
*
|
|
4
|
+
* 安全纪律(数据零丢失排序):
|
|
5
|
+
* 1. copy 全部内容到新目录(源目录始终原样保留);
|
|
6
|
+
* 2. 调用方完成配置写入 / 书架重指向后,最后一步才删源目录;
|
|
7
|
+
* 3. 中途任何失败,最坏情况只是新目录多一份副本,源数据零丢失。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, cpSync, rmSync, statSync } from 'node:fs'
|
|
11
|
+
import { join, resolve, sep } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** 目录内容清单(相对路径 + 总字节)。 */
|
|
14
|
+
export interface DirContents {
|
|
15
|
+
files: string[]
|
|
16
|
+
bytes: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 规范化路径用于比较(统一分隔符,Windows 忽略大小写)。 */
|
|
20
|
+
export function normalizeDir(dir: string): string {
|
|
21
|
+
const r = resolve(dir.trim())
|
|
22
|
+
return process.platform === 'win32' ? r.toLowerCase() : r
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 列出目录全部内容(递归;子目录以 `name/` 形式占位列出)。 */
|
|
26
|
+
export function listDirContents(dir: string): DirContents {
|
|
27
|
+
const files: string[] = []
|
|
28
|
+
let bytes = 0
|
|
29
|
+
const walk = (rel: string): void => {
|
|
30
|
+
const abs = rel === '' ? dir : join(dir, rel)
|
|
31
|
+
for (const name of readdirSync(abs, { withFileTypes: true })) {
|
|
32
|
+
const childRel = rel === '' ? name.name : `${rel}/${name.name}`
|
|
33
|
+
if (name.isDirectory()) {
|
|
34
|
+
files.push(`${childRel}/`)
|
|
35
|
+
walk(childRel)
|
|
36
|
+
} else {
|
|
37
|
+
files.push(childRel)
|
|
38
|
+
try { bytes += statSync(join(dir, childRel)).size } catch { /* 竞态删除则跳过 */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (existsSync(dir)) walk('')
|
|
43
|
+
return { files, bytes }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 把 from 全部内容复制到 to(源目录保留,删除由调用方在收尾执行)。
|
|
48
|
+
* 目标必须不存在或为空目录(防覆盖无关数据);禁止互相嵌套的目录对。
|
|
49
|
+
* @returns 复制的顶层条目数。
|
|
50
|
+
*/
|
|
51
|
+
export function copyDirContents(from: string, to: string): number {
|
|
52
|
+
if (!existsSync(from)) throw new Error(`源目录不存在:${from}`)
|
|
53
|
+
const nf = normalizeDir(from)
|
|
54
|
+
const nt = normalizeDir(to)
|
|
55
|
+
if (nf === nt) throw new Error('新目录与当前目录相同')
|
|
56
|
+
// 嵌套防护:copy 进入自身会无限递归 / 自我复制。
|
|
57
|
+
if (nt.startsWith(nf + sep.toLowerCase()) || nf.startsWith(nt + sep.toLowerCase()) ||
|
|
58
|
+
nt.startsWith(nf + '/') || nf.startsWith(nt + '/')) {
|
|
59
|
+
throw new Error('新目录不能是当前目录的子目录(或反之)')
|
|
60
|
+
}
|
|
61
|
+
if (existsSync(to)) {
|
|
62
|
+
const entries = readdirSync(to)
|
|
63
|
+
if (entries.length > 0) throw new Error(`目标目录非空(${entries.length} 个条目),为防覆盖已中止:${to}`)
|
|
64
|
+
} else {
|
|
65
|
+
mkdirSync(to, { recursive: true })
|
|
66
|
+
}
|
|
67
|
+
const entries = readdirSync(from)
|
|
68
|
+
if (entries.length === 0) return 0
|
|
69
|
+
cpSync(from, to, { recursive: true, errorOnExist: true, force: false })
|
|
70
|
+
return entries.length
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 删除源目录(仅复制 + 配置/书架重指向全部成功后调用)。 */
|
|
74
|
+
export function removeDir(dir: string): void {
|
|
75
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
|
76
|
+
}
|