@waterwx/dsh-novel-forge 1.7.3 → 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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 客户端文件文本智能解码:兼容 UTF-8 BOM / UTF-16 / UTF-8 / GB18030(GBK)。
3
+ * 浏览器 File.text() 固定按 UTF-8 解码,GBK 中文网文 txt 会乱码,这里兜底。
4
+ */
5
+
6
+ /**
7
+ * 读取一个 File 的文本,按编码探测兜底解码。
8
+ * @returns 解码后的文本。
9
+ */
10
+ export async function readFileTextSmart(file: File): Promise<string> {
11
+ const buf = await file.arrayBuffer()
12
+ const u8 = new Uint8Array(buf)
13
+ // UTF-8 BOM
14
+ if (u8.length >= 3 && u8[0] === 0xEF && u8[1] === 0xBB && u8[2] === 0xBF) {
15
+ return new TextDecoder('utf-8').decode(u8.subarray(3))
16
+ }
17
+ // UTF-16 LE / BE
18
+ if (u8.length >= 2 && u8[0] === 0xFF && u8[1] === 0xFE) return new TextDecoder('utf-16le').decode(u8.subarray(2))
19
+ if (u8.length >= 2 && u8[0] === 0xFE && u8[1] === 0xFF) return new TextDecoder('utf-16be').decode(u8.subarray(2))
20
+ // UTF-8(宽松),含替换符则回退 GB18030
21
+ const utf8 = new TextDecoder('utf-8', { fatal: false }).decode(u8)
22
+ if (!utf8.includes('\uFFFD')) return utf8
23
+ try {
24
+ return new TextDecoder('gb18030').decode(u8)
25
+ } catch {
26
+ return utf8
27
+ }
28
+ }
package/src/engine.ts CHANGED
@@ -46,6 +46,13 @@ import {
46
46
  emotionZh,
47
47
  } from './story-beat-language.ts'
48
48
  import type {
49
+ AdaptationDimension,
50
+ AdaptAnalyzeResponse,
51
+ AdaptationMapping,
52
+ AdaptationProposal,
53
+ AdaptationRules,
54
+ AdaptProposeResponse,
55
+ AdaptExecuteResponse,
49
56
  AuditIssue,
50
57
  AuthorReview,
51
58
  BreakdownResponse,
@@ -3336,6 +3343,220 @@ export async function reverseOutlineFromChapters(
3336
3343
  return outline.trim()
3337
3344
  }
3338
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
+
3339
3560
  /**
3340
3561
  * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
3341
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,10 @@ 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
202
222
  if (patch.imageModels !== undefined) next.imageModels = patch.imageModels
203
223
  // Persist through the settings seam when available; otherwise keep in memory.
204
224
  // (ctx.get is the non-strict service access — no inject requirement, same
package/src/protocol.ts CHANGED
@@ -113,6 +113,24 @@ export const NOVEL_API = {
113
113
  runStatus: '/api/dsh-novel-forge/run/status',
114
114
  config: '/api/dsh-novel-forge/config',
115
115
  openFolder: '/api/dsh-novel-forge/open-folder',
116
+ /** 作者资产库/总数据:读取个人跨书资产(笔法/红线/套路/角色模板/世界观模板)。 */
117
+ authorAssets: '/api/dsh-novel-forge/author-assets',
118
+ /** 作者资产库:新增/更新一条资产(upsert by id)。 */
119
+ authorAssetsUpsert: '/api/dsh-novel-forge/author-assets/upsert',
120
+ /** 作者资产库:删除一条资产。 */
121
+ authorAssetsRemove: '/api/dsh-novel-forge/author-assets/remove',
122
+ /** 作者资产库:导入默认(书架书的写作资产/角色 + 内置全局库)批量沉淀。 */
123
+ authorAssetsImportDefault: '/api/dsh-novel-forge/author-assets/import-default',
124
+ /** 改编模式:上传全文 → 分析 → 原文设定卡片/可改范围矩阵。 */
125
+ adaptAnalyze: '/api/dsh-novel-forge/adapt/analyze',
126
+ /** 改编模式:确认要改的维度 → 生成映射表/改编规则/联动影响清单。 */
127
+ adaptPropose: '/api/dsh-novel-forge/adapt/propose',
128
+ /** 改编模式:执行术语替换(全局替换 + 命中统计 + 改编文本预览)。 */
129
+ adaptExecute: '/api/dsh-novel-forge/adapt/execute',
130
+ /** 主题自定义背景:上传图片(POST,存盘并返回服务端 URL)。 */
131
+ themeBackgroundUpload: '/api/dsh-novel-forge/theme/background',
132
+ /** 主题自定义背景:读取已上传文件(GET prefix,/theme/background/<name>)。 */
133
+ themeBackgroundGet: '/api/dsh-novel-forge/theme/background',
116
134
  } as const
117
135
 
118
136
  /** 书架:一本书的条目。 */
@@ -1252,6 +1270,14 @@ export interface NovelConfig {
1252
1270
  imageApiModel?: string
1253
1271
  /** 是否启用生图(= 存在启用条目)。 */
1254
1272
  imageApiEnabled?: boolean
1273
+ /** 自定义背景图(URL 或 dataURL 或服务端路径引用;空 = 使用主题默认背景)。 */
1274
+ themeBackground?: string
1275
+ /** 自定义背景遮罩/模糊强度 0-80(0 = 不遮罩)。 */
1276
+ themeBackgroundBlur?: number
1277
+ /** 玻璃透明度 0-100(100=当前原样,越小越透,新拟态/黏土可见背景图)。 */
1278
+ themeOpacity?: number
1279
+ /** 是否启用改编模式(默认关闭;发布后开启)。 */
1280
+ enableAdaptMode?: boolean
1255
1281
  }
1256
1282
 
1257
1283
  /** GET /status response. */
@@ -1468,6 +1494,13 @@ export interface ConfigPatch {
1468
1494
  imageApiKey?: string
1469
1495
  imageApiModel?: string
1470
1496
  imageApiEnabled?: boolean
1497
+ /** 自定义背景图(URL / dataURL / 服务端路径引用)。 */
1498
+ themeBackground?: string
1499
+ /** 自定义背景遮罩/模糊强度 0-80。 */
1500
+ themeBackgroundBlur?: number
1501
+ /** 玻璃透明度 0-100(100=当前原样)。 */
1502
+ themeOpacity?: number
1503
+ enableAdaptMode?: boolean
1471
1504
  /** 生图模型库(完整替换保存)。 */
1472
1505
  imageModels?: ImageModelConfig[]
1473
1506
  }
@@ -1633,3 +1666,166 @@ export interface StyleEngineRequest {
1633
1666
  /** 资产名(可选,默认「风格资产 N」)。 */
1634
1667
  name?: string
1635
1668
  }
1669
+
1670
+ // ---------------------------------------------------------- author assets (总数据)
1671
+
1672
+ /** 作者资产库中一条可跨书复用的资产(笔法/红线/套路/角色模板/世界观模板等)。 */
1673
+ export interface AuthorStyleAsset {
1674
+ /** 稳定 id(本地唯一)。 */
1675
+ id: string
1676
+ /** 资产名(如「短句快节奏爽文风」「林越式痞坏角色」)。 */
1677
+ name: string
1678
+ /** 资产类型。 */
1679
+ kind: 'style' | 'antiAi' | 'progression' | 'genre' | 'roleTemplate' | 'worldTemplate' | 'custom'
1680
+ /** 一句话摘要。 */
1681
+ summary: string
1682
+ /** 资产正文内容(按 kind 约定解读的文本/JSON 描述)。 */
1683
+ content: string
1684
+ /** 结构化载荷(角色模板/世界观模板等可结构化字段;缺省为 null)。 */
1685
+ structured?: Record<string, unknown>
1686
+ /** 来源书名(哪本书提炼/收藏而来)。 */
1687
+ sourceBooks: string[]
1688
+ /** 标签。 */
1689
+ tags: string[]
1690
+ /** 来源样本文本(可选)。 */
1691
+ sourceText?: string
1692
+ /** 创建时间。 */
1693
+ createdAt: string
1694
+ /** 更新时间。 */
1695
+ updatedAt: string
1696
+ }
1697
+
1698
+ /** 作者资产库(跨书总数据):按作者维度聚合的可复用资产。 */
1699
+ export interface AuthorAssetLibrary {
1700
+ /** 货架版本,便于未来迁移。 */
1701
+ version: 1
1702
+ items: AuthorStyleAsset[]
1703
+ }
1704
+
1705
+ /** GET /author-assets 响应。 */
1706
+ export interface AuthorAssetsResponse {
1707
+ assets: AuthorAssetLibrary
1708
+ }
1709
+
1710
+ /** POST /author-assets/upsert 请求:新增或按 id 更新一条资产。 */
1711
+ export interface AuthorAssetUpsertRequest {
1712
+ asset: AuthorStyleAsset
1713
+ }
1714
+
1715
+ /** POST /author-assets/remove 请求:删除一条资产。 */
1716
+ export interface AuthorAssetRemoveRequest {
1717
+ id: string
1718
+ }
1719
+
1720
+ // ---------------------------------------------------------- adaptation (改编模式)
1721
+
1722
+ /** 改编可改范围的单个维度(矩阵的一行)。 */
1723
+ export interface AdaptationDimension {
1724
+ /** 维度 key(如 realm/name/goldenFinger…)。 */
1725
+ key: string
1726
+ /** 维度名(如「大世界」「修为体系」「主角名」「金手指」)。 */
1727
+ title: string
1728
+ /** 可改度:保留 / 可改影响大 / 可改影响小 / 可自由改 / 仅视觉包装。 */
1729
+ mutability: 'locked' | 'big' | 'small' | 'free' | 'visual'
1730
+ /** 原文当前值(从正文提炼,附证据)。 */
1731
+ current: string
1732
+ /** 该值在正文中的出现证据(章节/频次,可空)。 */
1733
+ evidence?: string
1734
+ /** AI 建议的候选新值(可空)。 */
1735
+ candidates?: string[]
1736
+ /** 联动影响说明(改了会影响哪些章节/角色/伏笔/术语)。 */
1737
+ impact: string
1738
+ /** 风险评级。 */
1739
+ risk: 'high' | 'medium' | 'low'
1740
+ }
1741
+
1742
+ /** POST /adapt/analyze 请求:上传全文做改编分析。 */
1743
+ export interface AdaptAnalyzeRequest {
1744
+ /** 全文文本(优先)。 */
1745
+ text: string
1746
+ /** 或服务器端全文文件路径。 */
1747
+ filePath?: string
1748
+ }
1749
+
1750
+ /** POST /adapt/analyze 响应。 */
1751
+ export interface AdaptAnalyzeResponse {
1752
+ /** 识别书名(文件名推断或正文首行)。 */
1753
+ bookName: string
1754
+ /** 拆出的章节数。 */
1755
+ chapters: number
1756
+ /** 反推的初始大纲(可空)。 */
1757
+ outline?: string
1758
+ /** 可改范围矩阵。 */
1759
+ dimensions: AdaptationDimension[]
1760
+ /** 分析消耗说明。 */
1761
+ note?: string
1762
+ }
1763
+
1764
+ /** 一条改编映射(原值 → 新值)。 */
1765
+ export interface AdaptationMapping {
1766
+ /** 原值(如「林尘」「练气」「青云宗」)。 */
1767
+ source: string
1768
+ /** 新值(如「楚风」「凝元」「天渊阁」)。 */
1769
+ target: string
1770
+ /** 适用范围。 */
1771
+ scope: 'name' | 'realm' | 'faction' | 'term' | 'other'
1772
+ /** 说明(可空)。 */
1773
+ note?: string
1774
+ }
1775
+
1776
+ /** 改编规则:哪些必须保留、哪些允许改、一致性约束。 */
1777
+ export interface AdaptationRules {
1778
+ /** 必须保留的要素(骨架/人物动机/伏笔逻辑/爽点结构…)。 */
1779
+ preserve: string[]
1780
+ /** 允许改变的要素。 */
1781
+ change: string[]
1782
+ /** 改编红线/一致性要求。 */
1783
+ constraints: string[]
1784
+ }
1785
+
1786
+ /** 一组改编方案(映射表 + 规则 + 联动影响清单)。 */
1787
+ export interface AdaptationProposal {
1788
+ /** 映射表(原→新)。 */
1789
+ mappings: AdaptationMapping[]
1790
+ /** 改编规则。 */
1791
+ rules: AdaptationRules
1792
+ /** 联动影响清单。 */
1793
+ impacts: Array<{ item: string; detail: string; risk: 'high' | 'medium' | 'low'; chapters?: number[] }>
1794
+ }
1795
+
1796
+ /** POST /adapt/propose 请求:由用户勾选的维度 + 新值生成改编方案。 */
1797
+ export interface AdaptProposeRequest {
1798
+ /** 原文全文(复用,便于 LLM 核对联动影响)。 */
1799
+ text: string
1800
+ /** 用户确认要改的维度(含原值与新值)。 */
1801
+ selections: Array<{ key: string; title: string; current: string; target: string; mutability: string }>
1802
+ /** 若已分析过,可携带完整矩阵作为上下文。 */
1803
+ dimensions?: AdaptationDimension[]
1804
+ }
1805
+
1806
+ /** POST /adapt/propose 响应。 */
1807
+ export interface AdaptProposeResponse {
1808
+ proposal: AdaptationProposal
1809
+ }
1810
+
1811
+ /** POST /adapt/execute 请求:执行改编(首版为术语替换)。 */
1812
+ export interface AdaptExecuteRequest {
1813
+ /** 原文全文。 */
1814
+ text: string
1815
+ /** 改编映射表。 */
1816
+ mappings: AdaptationMapping[]
1817
+ /** 改编规则(可选,后续逐章改写使用;替换模式暂不依赖)。 */
1818
+ rules?: AdaptationRules
1819
+ /** 执行模式:首版仅 replace(术语替换)。 */
1820
+ mode?: 'replace' | 'rewrite'
1821
+ }
1822
+
1823
+ /** POST /adapt/execute 响应。 */
1824
+ export interface AdaptExecuteResponse {
1825
+ /** 改编后的全文。 */
1826
+ adaptedText: string
1827
+ /** 执行的映射数。 */
1828
+ mappings: number
1829
+ /** 命中统计:每条映射的替换次数。 */
1830
+ hits: Array<{ source: string; target: string; count: number }>
1831
+ }