@waterwx/dsh-novel-forge 1.7.2 → 1.8.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/lib/client.js +3788 -600
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1001 -83
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/author-assets.d.ts +25 -0
  7. package/lib/types/client/api.d.ts +18 -0
  8. package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
  9. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  10. package/lib/types/client/panel/AuthorHome.d.ts +20 -0
  11. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  12. package/lib/types/client/panel/SettingsView.d.ts +14 -0
  13. package/lib/types/client/text.d.ts +9 -0
  14. package/lib/types/engine.d.ts +24 -1
  15. package/lib/types/index.d.ts +8 -0
  16. package/lib/types/protocol.d.ts +206 -10
  17. package/lib/types/shot-language.d.ts +46 -0
  18. package/lib/types/story-beat-language.d.ts +35 -0
  19. package/package.json +4 -2
  20. package/skills/chapter-batch/SKILL.md +81 -0
  21. package/src/author-assets.ts +166 -0
  22. package/src/client/api.ts +41 -0
  23. package/src/client/mount.tsx._new +97 -0
  24. package/src/client/mount.tsx._nf +97 -0
  25. package/src/client/mount.tsx._tmp +97 -0
  26. package/src/client/panel/AdaptModeView.tsx +204 -0
  27. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  28. package/src/client/panel/AuthorHome.tsx +93 -0
  29. package/src/client/panel/CreateBookView.tsx +4 -0
  30. package/src/client/panel/ImportModal.tsx +13 -3
  31. package/src/client/panel/NovelPanel.tsx +48 -16
  32. package/src/client/panel/ProgressHomeView.tsx +87 -0
  33. package/src/client/panel/RunPanel.tsx +1 -1
  34. package/src/client/panel/SceneLibrary.tsx +1 -1
  35. package/src/client/panel/SettingsView.tsx +285 -0
  36. package/src/client/panel/ShelfView.tsx +2 -2
  37. package/src/client/panel/StoryboardTab.tsx +42 -10
  38. package/src/client/panel/panel.module.css +325 -0
  39. package/src/client/sidebar-entry.ts +1 -1
  40. package/src/client/text.ts +28 -0
  41. package/src/engine.ts +256 -14
  42. package/src/index.ts +21 -0
  43. package/src/protocol.ts +218 -10
  44. package/src/routes.ts +204 -7
  45. package/src/run.ts +5 -4
  46. package/src/shot-language.ts +181 -0
  47. package/src/story-beat-language.ts +130 -0
  48. package/lib/types/client/panel/RoleVisualPanel.d.ts +0 -15
package/src/engine.ts CHANGED
@@ -30,7 +30,29 @@ import type { Context } from '@deepseek-ai/cordis'
30
30
  import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
31
31
  import { getComicStylePrompt } from './comic-presets.ts'
32
32
  import { findStyle, styleKeywords } from './style-library.ts'
33
+ import {
34
+ normalizeShotSize,
35
+ normalizeCameras,
36
+ normalizeComposition,
37
+ normalizeLightings,
38
+ sizeZh,
39
+ cameraZh,
40
+ lightZh,
41
+ } from './shot-language.ts'
42
+ import {
43
+ normalizeStoryFunction,
44
+ normalizeEmotions,
45
+ functionZh,
46
+ emotionZh,
47
+ } from './story-beat-language.ts'
33
48
  import type {
49
+ AdaptationDimension,
50
+ AdaptAnalyzeResponse,
51
+ AdaptationMapping,
52
+ AdaptationProposal,
53
+ AdaptationRules,
54
+ AdaptProposeResponse,
55
+ AdaptExecuteResponse,
34
56
  AuditIssue,
35
57
  AuthorReview,
36
58
  BreakdownResponse,
@@ -3033,8 +3055,11 @@ export async function generateStoryboardTable(
3033
3055
  const system = [
3034
3056
  '你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。',
3035
3057
  '任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。',
3036
- '输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态", "characters": ["出镜角色名"]}]}',
3037
- '景别取值:远景/全景/中景/近景/特写。',
3058
+ '输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "composition": "构图", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态", "characters": ["出镜角色名"]}]}',
3059
+ '景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。',
3060
+ '运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。',
3061
+ '光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。',
3062
+ '构图取值(可选,只能从这些词中选一):三分法/中心对称/引导线/前景遮挡/低机位/俯拍/对称构图。',
3038
3063
  '硬性要求:',
3039
3064
  '【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):',
3040
3065
  ...styleLines,
@@ -3059,23 +3084,23 @@ export async function generateStoryboardTable(
3059
3084
  '==== 章节正文(画面细节以此为准) ====',
3060
3085
  body.replace(/^#\s+.*$/m, '').trim().slice(0, 3000),
3061
3086
  ].filter(s => s !== '').join('\n\n')
3062
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
3087
+ const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
3063
3088
  const raw = parseJsonObject<{ shots?: unknown }>(text)
3064
3089
  const beatIds = new Set(skeleton.beats.map(b => b.id))
3065
- const shotKinds = new Set(['远景', '全景', '中景', '近景', '特写'])
3066
3090
  const shots: StoryboardShot[] = Array.isArray(raw.shots)
3067
3091
  ? raw.shots
3068
3092
  .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
3069
3093
  .map((entry, i) => ({
3070
3094
  id: 's' + (i + 1),
3071
3095
  beatId: beatIds.has(entry.beatId as string) ? entry.beatId as string : skeleton.beats[0]!.id,
3072
- shot: shotKinds.has(entry.shot as string) ? entry.shot as string : '中景',
3073
- camera: typeof entry.camera === 'string' ? entry.camera.trim().slice(0, 80) : '',
3096
+ shot: normalizeShotSize(entry.shot as string | undefined),
3097
+ camera: normalizeCameras(entry.camera as string | undefined),
3098
+ composition: normalizeComposition(entry.composition as string | undefined),
3074
3099
  duration: typeof entry.duration === 'number' && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
3075
3100
  visual: typeof entry.visual === 'string' ? entry.visual.trim().slice(0, 300) : '',
3076
3101
  line: typeof entry.line === 'string' ? entry.line.trim().slice(0, 120) : '',
3077
3102
  sound: typeof entry.sound === 'string' ? entry.sound.trim().slice(0, 80) : '',
3078
- light: typeof entry.light === 'string' ? entry.light.trim().slice(0, 80) : '',
3103
+ light: normalizeLightings(entry.light as string | undefined),
3079
3104
  prevState: typeof entry.prevState === 'string' ? entry.prevState.trim().slice(0, 150) : '',
3080
3105
  nextState: typeof entry.nextState === 'string' ? entry.nextState.trim().slice(0, 150) : '',
3081
3106
  characters: sanitizeCharacters(entry.characters, 4),
@@ -3152,12 +3177,12 @@ export async function generateStoryboardPrompts(
3152
3177
  const ref = c.imageUrl !== undefined ? '(参考图)' : ''
3153
3178
  return c.name + ':' + anchor + ref
3154
3179
  }).join(';')
3155
- return `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
3180
+ return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
3156
3181
  出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}
3157
3182
  定妆:${makeUp !== '' ? makeUp : '(未绑定漫剧卡)'}
3158
3183
  画面:${s.visual}
3159
3184
  台词:${s.line !== '' ? s.line : '(无)'}
3160
- 光效:${s.light !== '' ? s.light : '(无)'}
3185
+ 光效:${lightZh(s.light) !== '' ? lightZh(s.light) : '(无)'}
3161
3186
  承接:${s.prevState} → ${s.nextState}`
3162
3187
  }).join('\n\n')
3163
3188
  const system = [
@@ -3180,7 +3205,7 @@ export async function generateStoryboardPrompts(
3180
3205
  shotLines,
3181
3206
  '只输出 JSON 对象。',
3182
3207
  ].filter(x => x !== '').join('\n\n')
3183
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
3208
+ const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
3184
3209
  const raw = parseJsonObject<{ prompts?: unknown }>(text)
3185
3210
  const shotIds = new Set(table.shots.map(s => s.id))
3186
3211
  const shotBinding = new Map<string, string[]>(table.shots.map(s => [s.id, s.mangaRoleIds ?? []]))
@@ -3222,7 +3247,9 @@ export async function generateStoryboardSkeleton(
3222
3247
  const system = [
3223
3248
  '你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。',
3224
3249
  '任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。',
3225
- '输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(如 压抑→隐忍→惊惧)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}], "characters": ["出镜角色名1", "出镜角色名2"]}',
3250
+ '输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(情绪词数组)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}], "characters": ["出镜角色名1", "出镜角色名2"]}',
3251
+ '情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。',
3252
+ '功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。',
3226
3253
  '硬性要求:',
3227
3254
  '1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。',
3228
3255
  '2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。',
@@ -3240,15 +3267,16 @@ export async function generateStoryboardSkeleton(
3240
3267
  const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
3241
3268
  const raw = parseJsonObject<{ arc?: unknown; beats?: unknown; characters?: unknown }>(text)
3242
3269
  const arc = typeof raw.arc === 'string' ? raw.arc.trim().slice(0, 200) : ''
3243
- const functions = new Set(['铺垫', '冲突', '转折', '高潮', '收束', '伏笔', '人物塑造'])
3244
3270
  const beats: StoryboardBeat[] = Array.isArray(raw.beats)
3245
3271
  ? raw.beats
3246
3272
  .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
3247
3273
  .map((entry, i) => ({
3248
3274
  id: 'b' + (i + 1),
3249
3275
  event: typeof entry.event === 'string' ? entry.event.trim().slice(0, 200) : '',
3250
- emotion: typeof entry.emotion === 'string' ? entry.emotion.trim().slice(0, 100) : '',
3251
- function: functions.has(entry.function as string) ? entry.function as string : '铺垫',
3276
+ emotion: Array.isArray(entry.emotion)
3277
+ ? normalizeEmotions(entry.emotion.filter((x): x is string => typeof x === 'string').join('→'))
3278
+ : normalizeEmotions(typeof entry.emotion === 'string' ? entry.emotion : undefined),
3279
+ function: normalizeStoryFunction(typeof entry.function === 'string' ? entry.function : undefined),
3252
3280
  cause: typeof entry.cause === 'string' && entry.cause.trim() !== '' ? entry.cause.trim().slice(0, 150) : undefined,
3253
3281
  }))
3254
3282
  .filter(b => b.event !== '')
@@ -3315,6 +3343,220 @@ export async function reverseOutlineFromChapters(
3315
3343
  return outline.trim()
3316
3344
  }
3317
3345
 
3346
+ /**
3347
+ * 改编模式 P0:全文分析 → 原文设定卡片 / 可改范围矩阵。
3348
+ * 拆章统计 + 取样正文,让 LLM 一次输出结构化 JSON:
3349
+ * { bookName, outline, dimensions: [{key,title,mutability,current,evidence,candidates,impact,risk}] }。
3350
+ */
3351
+ export async function analyzeAdaptation(
3352
+ ctx: Context,
3353
+ config: NovelConfig,
3354
+ text: string,
3355
+ ): Promise<AdaptAnalyzeResponse> {
3356
+ const chapters = splitBookText(text).filter(c => c.body.length >= 50)
3357
+ if (chapters.length === 0) throw new Error('未能从全文拆出章节(内容过短或无章节结构)')
3358
+
3359
+ // 取样:取首/1/3/2/3/尾各章正文节选,控制 token 预算。
3360
+ const sampleBodies: string[] = []
3361
+ const n = chapters.length
3362
+ const pick = (i: number): void => {
3363
+ const c = chapters[i]
3364
+ if (c !== undefined) sampleBodies.push('第' + c.no + '章《' + (c.title || '无题') + '》\n' + c.body.slice(0, 1200))
3365
+ }
3366
+ pick(0)
3367
+ if (n > 3) { pick(Math.floor(n / 3)); pick(Math.floor((2 * n) / 3)) }
3368
+ if (n > 1) pick(n - 1)
3369
+
3370
+ const system = '你是改编策划分析助手。'
3371
+ const user = [
3372
+ '你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。',
3373
+ '输出合法 JSON 对象:',
3374
+ '{"bookName": "书名", "outline": "一句话主线梗概(100字内)", "dimensions": [{"key":"realm","title":"大世界","mutability":"big|small|free|locked|visual","current":"当前值","evidence":"证据(出现章节/频次)","candidates":["候选1","候选2"],"impact":"改了会影响什么","risk":"high|medium|low"}]}',
3375
+ 'dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。',
3376
+ 'mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。',
3377
+ '每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。',
3378
+ '重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。',
3379
+ ].join('\n')
3380
+ const textOut = await complete(ctx, config, {
3381
+ system,
3382
+ user: user + '\n\n' + sampleBodies.join('\n\n---\n\n'),
3383
+ temperature: 0.3,
3384
+ maxTokens: Math.max(config.maxTokens, 6000),
3385
+ reasoning: config.analysisReasoning ?? 'low',
3386
+ })
3387
+ const raw = parseJsonObject<{ bookName?: unknown; outline?: unknown; dimensions?: unknown }>(textOut)
3388
+ const bookName = typeof raw.bookName === 'string' ? raw.bookName.trim() : '未命名'
3389
+ const dims = Array.isArray(raw.dimensions)
3390
+ ? raw.dimensions
3391
+ .filter((d): d is Record<string, unknown> => typeof d === 'object' && d !== null)
3392
+ .map(d => normalizeAdaptationDimension(d))
3393
+ .filter((d): d is AdaptationDimension => d !== null)
3394
+ : []
3395
+ // 反推大纲:独立 LLM 调用,从章节正文节选生成全书总纲(Markdown)。
3396
+ let outline: string | undefined
3397
+ try {
3398
+ outline = await reverseOutlineFromAdaptationText(ctx, config, chapters)
3399
+ } catch {
3400
+ outline = typeof raw.outline === 'string' && raw.outline.trim() !== '' ? raw.outline.trim() : undefined
3401
+ }
3402
+ return {
3403
+ bookName,
3404
+ chapters: chapters.length,
3405
+ outline,
3406
+ dimensions: dims,
3407
+ note: '基于节选分析(首/中/末取样)+ 反推大纲。如需逐章全文级深度分析请后续启用全文流式入口。',
3408
+ }
3409
+ }
3410
+
3411
+ /** 校验并归一化一行的改编维度数据(来自 LLM)。 */
3412
+ function normalizeAdaptationDimension(d: Record<string, unknown>): AdaptationDimension | null {
3413
+ const key = typeof d.key === 'string' ? d.key : ''
3414
+ const title = typeof d.title === 'string' ? d.title : ''
3415
+ if (key === '' || title === '') return null
3416
+ const mutability = (['locked', 'big', 'small', 'free', 'visual'] as const).includes(d.mutability as AdaptationDimension['mutability'])
3417
+ ? d.mutability as AdaptationDimension['mutability']
3418
+ : 'small'
3419
+ const risk = (['high', 'medium', 'low'] as const).includes(d.risk as AdaptationDimension['risk'])
3420
+ ? d.risk as AdaptationDimension['risk']
3421
+ : 'medium'
3422
+ return {
3423
+ key,
3424
+ title,
3425
+ mutability,
3426
+ current: typeof d.current === 'string' ? d.current : '',
3427
+ evidence: typeof d.evidence === 'string' ? d.evidence : undefined,
3428
+ candidates: Array.isArray(d.candidates) ? d.candidates.filter((x): x is string => typeof x === 'string') : [],
3429
+ impact: typeof d.impact === 'string' ? d.impact : '',
3430
+ risk,
3431
+ }
3432
+ }
3433
+
3434
+ /** 从全文拆出的章节节选反推全书总纲(Markdown),用于改编 P0 的「反推大纲」。 */
3435
+ async function reverseOutlineFromAdaptationText(
3436
+ ctx: Context,
3437
+ config: NovelConfig,
3438
+ chapters: Array<{ no: number; title: string; body: string }>,
3439
+ ): Promise<string> {
3440
+ // 均匀采样最多 20 章,每章正文前 600 字,控制成本。
3441
+ const n = chapters.length
3442
+ const sample: string[] = []
3443
+ const count = Math.min(n, 20)
3444
+ for (let i = 0; i < count; i++) {
3445
+ const idx = Math.floor((i * n) / count)
3446
+ const c = chapters[idx]
3447
+ if (c === undefined) continue
3448
+ sample.push('第' + c.no + '章《' + (c.title || '无题') + '》\n' + c.body.slice(0, 600))
3449
+ }
3450
+ const system = [
3451
+ '你是一位经验丰富的小说主编。根据下面若干章节的正文节选,反推出这本书的总纲(可作为后续改编与章节续写的骨架)。',
3452
+ '要求:',
3453
+ '1. 第一行写《书名》(可从正文推断,否则《未命名》)。',
3454
+ '2. 按故事弧线划分卷/部分:每卷给卷名与主旨(标注覆盖章节范围)。',
3455
+ '3. 对每章列出:章节号 + 标题 + 一句话核心情节。',
3456
+ '4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。',
3457
+ '5. 输出纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要寒暄,不要其他输出。',
3458
+ ].join('\n')
3459
+ const user = sample.join('\n\n---\n\n')
3460
+ const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
3461
+ return text.trim()
3462
+ }
3463
+
3464
+ /** 改编方案:由用户勾选的维度与新值,生成 LLM 映射表/规则/影响清单。 */
3465
+ export async function proposeAdaptation(
3466
+ ctx: Context,
3467
+ config: NovelConfig,
3468
+ text: string,
3469
+ selections: Array<{ key: string; title: string; current: string; target: string; mutability: string }>,
3470
+ dimensions?: AdaptationDimension[],
3471
+ ): Promise<AdaptProposeResponse> {
3472
+ const selLines = selections.map(s => '- ' + s.title + '(' + s.key + '):' + s.current + ' → ' + s.target).join('\n')
3473
+ const dimLines = (dimensions ?? []).map(d => '- ' + d.title + ':' + d.current + '(可改度:' + d.mutability + ',风险:' + d.risk + ')').join('\n')
3474
+ const system = '你是改编策划。'
3475
+ const user = [
3476
+ '下面给出改编决策:用户想改哪些维度、改成什么值。请生成一份可执行的「改编方案」。',
3477
+ '要求输出合法 JSON 对象:',
3478
+ '{"mappings": [{"source":"原值","target":"新值","scope":"name|realm|faction|term|other","note":"说明"}], "rules": {"preserve":["必须保留的要素"],"change":["允许改变的要素"],"constraints":["改编红线/一致性要求"]}, "impacts": [{"item":"受影响项","detail":"说明","risk":"high|medium|low","chapters":[章号]}]}',
3479
+ 'mappings 需把用户确认的新值展开为「原→新」条目(如主角名/境界名/势力名/术语),并补充用户未填但关联的必改项(如改了修为体系名,相关的境界名一并列映射)。',
3480
+ 'rules.preserve 至少包含:故事骨架、人物动机、伏笔逻辑、爽点结构。',
3481
+ 'impacts 列出每个改动会影响的内容(术语/角色/章节/伏笔),能定位章号尽量定位;无法定位则给章节区间提示。',
3482
+ '重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。',
3483
+ '',
3484
+ '用户要改:',
3485
+ selLines,
3486
+ '',
3487
+ '原文可改矩阵(已分析):',
3488
+ dimLines,
3489
+ ].join('\n')
3490
+ const out = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
3491
+ const raw = parseJsonObject<{ mappings?: unknown; rules?: unknown; impacts?: unknown }>(out)
3492
+ const mappings = Array.isArray(raw.mappings)
3493
+ ? raw.mappings
3494
+ .filter((m): m is Record<string, unknown> => typeof m === 'object' && m !== null)
3495
+ .map(m => normalizeAdaptationMapping(m))
3496
+ .filter((m): m is AdaptationMapping => m !== null)
3497
+ : []
3498
+ const rules = normalizeAdaptationRules(raw.rules)
3499
+ const impacts = Array.isArray(raw.impacts)
3500
+ ? raw.impacts
3501
+ .filter((i): i is Record<string, unknown> => typeof i === 'object' && i !== null)
3502
+ .map(i => normalizeAdaptationImpact(i))
3503
+ : []
3504
+ return { proposal: { mappings, rules, impacts } }
3505
+ }
3506
+
3507
+ /** 剧本术语替换执行:按映射表做精确替换并统计命中。 */
3508
+ export function applyAdaptationReplacements(
3509
+ text: string,
3510
+ mappings: AdaptationMapping[],
3511
+ ): { adaptedText: string; hits: Array<{ source: string; target: string; count: number }> } {
3512
+ const seen = new Set<string>()
3513
+ const unique = mappings.filter(m => {
3514
+ const s = m.source.trim()
3515
+ if (s === '' || s === m.target.trim()) return false
3516
+ if (seen.has(s)) return false
3517
+ seen.add(s)
3518
+ return true
3519
+ }).sort((a, b) => b.source.length - a.source.length)
3520
+ let adapted = text
3521
+ const hits: Array<{ source: string; target: string; count: number }> = []
3522
+ for (const m of unique) {
3523
+ const count = adapted.split(m.source).length - 1
3524
+ if (count > 0) adapted = adapted.split(m.source).join(m.target)
3525
+ hits.push({ source: m.source, target: m.target, count })
3526
+ }
3527
+ return { adaptedText: adapted, hits }
3528
+ }
3529
+
3530
+ /** 校验归一化一条映射(来自 LLM)。 */
3531
+ function normalizeAdaptationMapping(m: Record<string, unknown>): AdaptationMapping | null {
3532
+ const source = typeof m.source === 'string' ? m.source.trim() : ''
3533
+ const target = typeof m.target === 'string' ? m.target.trim() : ''
3534
+ if (source === '' || target === '') return null
3535
+ const scope = (['name', 'realm', 'faction', 'term', 'other'] as const).includes(m.scope as AdaptationMapping['scope'])
3536
+ ? m.scope as AdaptationMapping['scope']
3537
+ : 'other'
3538
+ return { source, target, scope, note: typeof m.note === 'string' ? m.note : undefined }
3539
+ }
3540
+
3541
+ /** 校验归一化改编规则(来自 LLM)。 */
3542
+ function normalizeAdaptationRules(r: unknown): AdaptationRules {
3543
+ const obj = typeof r === 'object' && r !== null ? r as Record<string, unknown> : {}
3544
+ const arr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
3545
+ return { preserve: arr(obj.preserve), change: arr(obj.change), constraints: arr(obj.constraints) }
3546
+ }
3547
+
3548
+ /** 校验归一化一条影响项(来自 LLM)。 */
3549
+ function normalizeAdaptationImpact(i: Record<string, unknown>): { item: string; detail: string; risk: 'high' | 'medium' | 'low'; chapters?: number[] } {
3550
+ const risk = (['high', 'medium', 'low'] as const).includes(i.risk as 'high' | 'medium' | 'low') ? i.risk as 'high' | 'medium' | 'low' : 'medium'
3551
+ const chapters = Array.isArray(i.chapters) ? i.chapters.filter((x): x is number => typeof x === 'number') : undefined
3552
+ return {
3553
+ item: typeof i.item === 'string' ? i.item : '',
3554
+ detail: typeof i.detail === 'string' ? i.detail : '',
3555
+ risk,
3556
+ chapters: chapters !== undefined && chapters.length > 0 ? chapters : undefined,
3557
+ }
3558
+ }
3559
+
3318
3560
  /**
3319
3561
  * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
3320
3562
  * 批量生成时整体开销约省 25%)。
package/src/index.ts CHANGED
@@ -70,6 +70,14 @@ export interface Config {
70
70
  imageApiModel?: string
71
71
  /** 是否启用豆包生图(旧字段,兼容迁移用)。 */
72
72
  imageApiEnabled?: boolean
73
+ /** 自定义背景图(URL / dataURL / 服务端路径引用)。 */
74
+ themeBackground?: string
75
+ /** 自定义背景遮罩/模糊强度 0-80。 */
76
+ themeBackgroundBlur?: number
77
+ /** 玻璃透明度 0-100(100=当前原样)。 */
78
+ themeOpacity?: number
79
+ /** 是否启用改编模式(默认关闭)。 */
80
+ enableAdaptMode?: boolean
73
81
  }
74
82
 
75
83
  export const Config: z<Config> = z.object({
@@ -98,6 +106,10 @@ export const Config: z<Config> = z.object({
98
106
  imageApiKey: z.string().default(''),
99
107
  imageApiModel: z.string().default(''),
100
108
  imageApiEnabled: z.boolean().default(false),
109
+ themeBackground: z.string().default(''),
110
+ themeBackgroundBlur: z.number().default(0),
111
+ themeOpacity: z.number().default(100),
112
+ enableAdaptMode: z.boolean().default(false),
101
113
  })
102
114
 
103
115
  /** Schema defaults, re-read for hand-built test contexts. */
@@ -157,6 +169,10 @@ export function resolveConfig(value: Partial<Config> | undefined): NovelConfig {
157
169
  return active?.model ?? value?.imageApiModel
158
170
  })(),
159
171
  imageApiEnabled: value?.imageApiEnabled ?? false,
172
+ themeBackground: value?.themeBackground ?? '',
173
+ themeBackgroundBlur: value?.themeBackgroundBlur ?? 0,
174
+ themeOpacity: value?.themeOpacity ?? 100,
175
+ enableAdaptMode: value?.enableAdaptMode ?? false,
160
176
  }
161
177
  }
162
178
 
@@ -199,6 +215,11 @@ export function apply(ctx: Context, config?: Config): void {
199
215
  if (patch.imageApiKey !== undefined) next.imageApiKey = patch.imageApiKey
200
216
  if (patch.imageApiModel !== undefined) next.imageApiModel = patch.imageApiModel
201
217
  if (patch.imageApiEnabled !== undefined) next.imageApiEnabled = patch.imageApiEnabled
218
+ if (patch.themeBackground !== undefined) next.themeBackground = patch.themeBackground
219
+ if (patch.themeBackgroundBlur !== undefined) next.themeBackgroundBlur = patch.themeBackgroundBlur
220
+ if (patch.themeOpacity !== undefined) next.themeOpacity = patch.themeOpacity
221
+ if (patch.enableAdaptMode !== undefined) next.enableAdaptMode = patch.enableAdaptMode
222
+ if (patch.imageModels !== undefined) next.imageModels = patch.imageModels
202
223
  // Persist through the settings seam when available; otherwise keep in memory.
203
224
  // (ctx.get is the non-strict service access — no inject requirement, same
204
225
  // pattern installSettingsSection itself uses.)