章节配置系统 - 技术规范
#文档信息
- 文档名称: 章节配置系统技术规范
- 版本: v1.0.0
- 创建日期: 2025-10-14
- 关联PRD: 章节配置系统PRD
- 目标读者: 开发人员、技术负责人
#一、YAML Schema完整定义
#1.1 JSON Schema表示
1{ 2 "$schema": "http://json-schema.org/draft-07/schema#", 3 "title": "ChapterConfig", 4 "description": "章节配置文件Schema", 5 "type": "object", 6 "required": ["chapter", "title", "plot", "wordcount"], 7 "properties": { 8 "chapter": { 9 "type": "integer", 10 "minimum": 1, 11 "description": "章节号" 12 }, 13 "title": { 14 "type": "string", 15 "minLength": 1, 16 "maxLength": 100, 17 "description": "章节标题" 18 }, 19 "characters": { 20 "type": "array", 21 "description": "出场角色列表", 22 "items": { 23 "$ref": "#/definitions/Character" 24 } 25 }, 26 "scene": { 27 "$ref": "#/definitions/Scene", 28 "description": "场景配置" 29 }, 30 "plot": { 31 "$ref": "#/definitions/Plot", 32 "description": "剧情配置" 33 }, 34 "style": { 35 "$ref": "#/definitions/Style", 36 "description": "写作风格配置" 37 }, 38 "wordcount": { 39 "$ref": "#/definitions/Wordcount", 40 "description": "字数要求" 41 }, 42 "special_requirements": { 43 "type": "string", 44 "description": "特殊写作要求" 45 }, 46 "preset_used": { 47 "type": "string", 48 "description": "使用的预设ID" 49 }, 50 "created_at": { 51 "type": "string", 52 "format": "date-time", 53 "description": "创建时间" 54 }, 55 "updated_at": { 56 "type": "string", 57 "format": "date-time", 58 "description": "更新时间" 59 } 60 }, 61 "definitions": { 62 "Character": { 63 "type": "object", 64 "required": ["id", "name"], 65 "properties": { 66 "id": { 67 "type": "string", 68 "pattern": "^[a-z0-9-]+$", 69 "description": "角色ID,引用character-profiles.md" 70 }, 71 "name": { 72 "type": "string", 73 "description": "角色名称" 74 }, 75 "focus": { 76 "type": "string", 77 "enum": ["high", "medium", "low"], 78 "default": "medium", 79 "description": "本章重点程度" 80 }, 81 "state_changes": { 82 "type": "array", 83 "items": { 84 "type": "string" 85 }, 86 "description": "本章状态变化" 87 } 88 } 89 }, 90 "Scene": { 91 "type": "object", 92 "properties": { 93 "location_id": { 94 "type": "string", 95 "pattern": "^[a-z0-9-]+$", 96 "description": "地点ID,引用locations.md" 97 }, 98 "location_name": { 99 "type": "string", 100 "description": "地点名称" 101 }, 102 "time": { 103 "type": "string", 104 "description": "时间(如'上午10点'、'傍晚')" 105 }, 106 "weather": { 107 "type": "string", 108 "description": "天气" 109 }, 110 "atmosphere": { 111 "type": "string", 112 "enum": ["tense", "relaxed", "sad", "exciting", "mysterious"], 113 "description": "氛围" 114 } 115 } 116 }, 117 "Plot": { 118 "type": "object", 119 "required": ["type", "summary"], 120 "properties": { 121 "type": { 122 "type": "string", 123 "enum": [ 124 "ability_showcase", 125 "relationship_dev", 126 "conflict_combat", 127 "mystery_suspense", 128 "transition", 129 "climax", 130 "emotional_scene", 131 "world_building", 132 "plot_twist" 133 ], 134 "description": "剧情类型" 135 }, 136 "summary": { 137 "type": "string", 138 "minLength": 10, 139 "maxLength": 500, 140 "description": "剧情概要" 141 }, 142 "key_points": { 143 "type": "array", 144 "items": { 145 "type": "string" 146 }, 147 "minItems": 1, 148 "description": "关键要点" 149 }, 150 "plotlines": { 151 "type": "array", 152 "items": { 153 "type": "string", 154 "pattern": "^PL-[0-9]+$" 155 }, 156 "description": "涉及的线索ID" 157 }, 158 "foreshadowing": { 159 "type": "array", 160 "items": { 161 "type": "object", 162 "properties": { 163 "id": { 164 "type": "string", 165 "pattern": "^F-[0-9]+$" 166 }, 167 "content": { 168 "type": "string" 169 } 170 } 171 }, 172 "description": "本章伏笔" 173 } 174 } 175 }, 176 "Style": { 177 "type": "object", 178 "properties": { 179 "pace": { 180 "type": "string", 181 "enum": ["fast", "medium", "slow"], 182 "default": "medium", 183 "description": "节奏" 184 }, 185 "sentence_length": { 186 "type": "string", 187 "enum": ["short", "medium", "long"], 188 "default": "medium", 189 "description": "句子长度" 190 }, 191 "focus": { 192 "type": "string", 193 "enum": [ 194 "action", 195 "dialogue", 196 "psychology", 197 "description", 198 "dialogue_action", 199 "balanced" 200 ], 201 "default": "balanced", 202 "description": "描写重点" 203 }, 204 "tone": { 205 "type": "string", 206 "enum": ["serious", "humorous", "dark", "light"], 207 "description": "基调" 208 } 209 } 210 }, 211 "Wordcount": { 212 "type": "object", 213 "required": ["target"], 214 "properties": { 215 "target": { 216 "type": "integer", 217 "minimum": 1000, 218 "maximum": 10000, 219 "description": "目标字数" 220 }, 221 "min": { 222 "type": "integer", 223 "minimum": 500, 224 "description": "最小字数" 225 }, 226 "max": { 227 "type": "integer", 228 "maximum": 15000, 229 "description": "最大字数" 230 } 231 } 232 } 233 } 234}
#1.2 TypeScript类型定义
1/** 2 * 章节配置接口 3 */ 4export interface ChapterConfig { 5 /** 章节号 */ 6 chapter: number; 7 8 /** 章节标题 */ 9 title: string; 10 11 /** 出场角色 */ 12 characters?: Character[]; 13 14 /** 场景配置 */ 15 scene?: Scene; 16 17 /** 剧情配置 */ 18 plot: Plot; 19 20 /** 写作风格 */ 21 style?: Style; 22 23 /** 字数要求 */ 24 wordcount: Wordcount; 25 26 /** 特殊要求 */ 27 special_requirements?: string; 28 29 /** 使用的预设 */ 30 preset_used?: string; 31 32 /** 创建时间 */ 33 created_at?: string; 34 35 /** 更新时间 */ 36 updated_at?: string; 37} 38 39/** 40 * 角色配置 41 */ 42export interface Character { 43 /** 角色ID(引用character-profiles.md) */ 44 id: string; 45 46 /** 角色名称 */ 47 name: string; 48 49 /** 本章重点程度 */ 50 focus?: 'high' | 'medium' | 'low'; 51 52 /** 本章状态变化 */ 53 state_changes?: string[]; 54} 55 56/** 57 * 场景配置 58 */ 59export interface Scene { 60 /** 地点ID(引用locations.md) */ 61 location_id?: string; 62 63 /** 地点名称 */ 64 location_name?: string; 65 66 /** 时间 */ 67 time?: string; 68 69 /** 天气 */ 70 weather?: string; 71 72 /** 氛围 */ 73 atmosphere?: 'tense' | 'relaxed' | 'sad' | 'exciting' | 'mysterious'; 74} 75 76/** 77 * 剧情配置 78 */ 79export interface Plot { 80 /** 剧情类型 */ 81 type: PlotType; 82 83 /** 剧情概要 */ 84 summary: string; 85 86 /** 关键要点 */ 87 key_points?: string[]; 88 89 /** 涉及的线索 */ 90 plotlines?: string[]; 91 92 /** 伏笔 */ 93 foreshadowing?: Foreshadowing[]; 94} 95 96/** 97 * 剧情类型枚举 98 */ 99export type PlotType = 100 | 'ability_showcase' // 能力展现 101 | 'relationship_dev' // 关系发展 102 | 'conflict_combat' // 冲突对抗 103 | 'mystery_suspense' // 悬念铺垫 104 | 'transition' // 过渡承接 105 | 'climax' // 高潮对决 106 | 'emotional_scene' // 情感戏 107 | 'world_building' // 世界观展开 108 | 'plot_twist'; // 剧情反转 109 110/** 111 * 伏笔配置 112 */ 113export interface Foreshadowing { 114 /** 伏笔ID */ 115 id: string; 116 117 /** 伏笔内容 */ 118 content: string; 119} 120 121/** 122 * 写作风格配置 123 */ 124export interface Style { 125 /** 节奏 */ 126 pace?: 'fast' | 'medium' | 'slow'; 127 128 /** 句子长度 */ 129 sentence_length?: 'short' | 'medium' | 'long'; 130 131 /** 描写重点 */ 132 focus?: 'action' | 'dialogue' | 'psychology' | 'description' | 'dialogue_action' | 'balanced'; 133 134 /** 基调 */ 135 tone?: 'serious' | 'humorous' | 'dark' | 'light'; 136} 137 138/** 139 * 字数配置 140 */ 141export interface Wordcount { 142 /** 目标字数 */ 143 target: number; 144 145 /** 最小字数 */ 146 min?: number; 147 148 /** 最大字数 */ 149 max?: number; 150} 151 152/** 153 * 预设配置接口 154 */ 155export interface Preset { 156 /** 预设ID */ 157 id: string; 158 159 /** 预设名称 */ 160 name: string; 161 162 /** 描述 */ 163 description: string; 164 165 /** 类别 */ 166 category: 'scene' | 'style' | 'chapter'; 167 168 /** 作者 */ 169 author: string; 170 171 /** 版本 */ 172 version: string; 173 174 /** 默认配置 */ 175 defaults: Partial<ChapterConfig>; 176 177 /** 推荐设置 */ 178 recommended?: { 179 plot_types?: PlotType[]; 180 atmosphere?: Scene['atmosphere'][]; 181 }; 182 183 /** 兼容性 */ 184 compatible_genres?: string[]; 185 186 /** 使用提示 */ 187 usage_tips?: string[]; 188}
#二、核心类设计
#2.1 ChapterConfigManager
1/** 2 * 章节配置管理器 3 * 负责配置的创建、读取、验证、更新、删除 4 */ 5export class ChapterConfigManager { 6 private projectPath: string; 7 private presetManager: PresetManager; 8 private validator: ConfigValidator; 9 10 constructor(projectPath: string) { 11 this.projectPath = projectPath; 12 this.presetManager = new PresetManager(); 13 this.validator = new ConfigValidator(projectPath); 14 } 15 16 /** 17 * 创建章节配置 18 */ 19 async createConfig( 20 chapter: number, 21 options: CreateConfigOptions 22 ): Promise<ChapterConfig> { 23 // 1. 初始化配置 24 let config: ChapterConfig = { 25 chapter, 26 title: options.title || `第${chapter}章`, 27 characters: [], 28 scene: {}, 29 plot: { 30 type: options.plotType || 'transition', 31 summary: options.plotSummary || '', 32 key_points: options.keyPoints || [] 33 }, 34 style: { 35 pace: 'medium', 36 sentence_length: 'medium', 37 focus: 'balanced' 38 }, 39 wordcount: { 40 target: options.wordcount || 3000, 41 min: Math.floor((options.wordcount || 3000) * 0.8), 42 max: Math.floor((options.wordcount || 3000) * 1.2) 43 }, 44 created_at: new Date().toISOString() 45 }; 46 47 // 2. 应用预设(如果指定) 48 if (options.preset) { 49 const preset = await this.presetManager.loadPreset(options.preset); 50 config = this.applyPreset(preset, config); 51 } 52 53 // 3. 合并用户输入 54 if (options.characters) { 55 config.characters = await this.loadCharacterDetails(options.characters); 56 } 57 58 if (options.scene) { 59 config.scene = await this.loadSceneDetails(options.scene); 60 } 61 62 // 4. 验证配置 63 const validation = await this.validator.validate(config); 64 if (!validation.valid) { 65 throw new Error(`配置验证失败: ${validation.errors.join(', ')}`); 66 } 67 68 // 5. 保存到文件 69 const configPath = this.getConfigPath(chapter); 70 await fs.ensureDir(path.dirname(configPath)); 71 await fs.writeFile(configPath, yaml.dump(config, { indent: 2 }), 'utf-8'); 72 73 return config; 74 } 75 76 /** 77 * 加载章节配置 78 */ 79 async loadConfig(chapter: number): Promise<ChapterConfig | null> { 80 const configPath = this.getConfigPath(chapter); 81 82 if (!await fs.pathExists(configPath)) { 83 return null; 84 } 85 86 const content = await fs.readFile(configPath, 'utf-8'); 87 const config = yaml.load(content) as ChapterConfig; 88 89 // 验证配置 90 const validation = await this.validator.validate(config); 91 if (!validation.valid) { 92 console.warn(`配置文件存在问题: ${validation.errors.join(', ')}`); 93 } 94 95 return config; 96 } 97 98 /** 99 * 更新章节配置 100 */ 101 async updateConfig( 102 chapter: number, 103 updates: Partial<ChapterConfig> 104 ): Promise<ChapterConfig> { 105 const config = await this.loadConfig(chapter); 106 if (!config) { 107 throw new Error(`配置文件不存在: chapter ${chapter}`); 108 } 109 110 const updatedConfig = { 111 ...config, 112 ...updates, 113 updated_at: new Date().toISOString() 114 }; 115 116 // 验证更新后的配置 117 const validation = await this.validator.validate(updatedConfig); 118 if (!validation.valid) { 119 throw new Error(`更新后配置无效: ${validation.errors.join(', ')}`); 120 } 121 122 // 保存 123 const configPath = this.getConfigPath(chapter); 124 await fs.writeFile( 125 configPath, 126 yaml.dump(updatedConfig, { indent: 2 }), 127 'utf-8' 128 ); 129 130 return updatedConfig; 131 } 132 133 /** 134 * 删除章节配置 135 */ 136 async deleteConfig(chapter: number): Promise<void> { 137 const configPath = this.getConfigPath(chapter); 138 139 if (!await fs.pathExists(configPath)) { 140 throw new Error(`配置文件不存在: chapter ${chapter}`); 141 } 142 143 await fs.remove(configPath); 144 } 145 146 /** 147 * 列出所有配置 148 */ 149 async listConfigs(): Promise<ChapterConfigSummary[]> { 150 const chaptersDir = path.join( 151 this.projectPath, 152 'stories', 153 '*', 154 'chapters' 155 ); 156 157 const configFiles = await glob(path.join(chaptersDir, '*.yaml')); 158 159 const summaries: ChapterConfigSummary[] = []; 160 161 for (const file of configFiles) { 162 const content = await fs.readFile(file, 'utf-8'); 163 const config = yaml.load(content) as ChapterConfig; 164 165 summaries.push({ 166 chapter: config.chapter, 167 title: config.title, 168 plotType: config.plot.type, 169 location: config.scene?.location_name || '-', 170 wordcount: config.wordcount.target, 171 preset: config.preset_used, 172 createdAt: config.created_at 173 }); 174 } 175 176 return summaries.sort((a, b) => a.chapter - b.chapter); 177 } 178 179 /** 180 * 复制配置 181 */ 182 async copyConfig( 183 fromChapter: number, 184 toChapter: number, 185 modifications?: Partial<ChapterConfig> 186 ): Promise<ChapterConfig> { 187 const sourceConfig = await this.loadConfig(fromChapter); 188 if (!sourceConfig) { 189 throw new Error(`源配置不存在: chapter ${fromChapter}`); 190 } 191 192 const newConfig: ChapterConfig = { 193 ...sourceConfig, 194 chapter: toChapter, 195 ...modifications, 196 created_at: new Date().toISOString(), 197 updated_at: undefined 198 }; 199 200 return this.createConfig(toChapter, { 201 title: newConfig.title, 202 plotType: newConfig.plot.type, 203 plotSummary: newConfig.plot.summary, 204 keyPoints: newConfig.plot.key_points, 205 wordcount: newConfig.wordcount.target, 206 // ... 207 } as CreateConfigOptions); 208 } 209 210 // ========== 私有辅助方法 ========== 211 212 private getConfigPath(chapter: number): string { 213 // 查找项目中的stories目录 214 const storiesDir = path.join(this.projectPath, 'stories'); 215 const storyDirs = fs.readdirSync(storiesDir); 216 217 if (storyDirs.length === 0) { 218 throw new Error('未找到故事目录'); 219 } 220 221 // 使用第一个故事目录(通常只有一个) 222 const storyDir = storyDirs[0]; 223 return path.join( 224 storiesDir, 225 storyDir, 226 'chapters', 227 `chapter-${chapter}-config.yaml` 228 ); 229 } 230 231 private applyPreset( 232 preset: Preset, 233 config: ChapterConfig 234 ): ChapterConfig { 235 return { 236 ...config, 237 ...preset.defaults, 238 preset_used: preset.id, 239 // 合并special_requirements 240 special_requirements: [ 241 preset.defaults.special_requirements, 242 config.special_requirements 243 ].filter(Boolean).join('\n\n') 244 }; 245 } 246 247 private async loadCharacterDetails( 248 characterIds: string[] 249 ): Promise<Character[]> { 250 // 从character-profiles.md加载详情 251 // 实现省略... 252 return []; 253 } 254 255 private async loadSceneDetails( 256 sceneId: string 257 ): Promise<Scene> { 258 // 从locations.md加载详情 259 // 实现省略... 260 return {}; 261 } 262} 263 264/** 265 * 配置摘要接口 266 */ 267export interface ChapterConfigSummary { 268 chapter: number; 269 title: string; 270 plotType: PlotType; 271 location: string; 272 wordcount: number; 273 preset?: string; 274 createdAt?: string; 275} 276 277/** 278 * 创建配置选项 279 */ 280export interface CreateConfigOptions { 281 title?: string; 282 characters?: string[]; 283 scene?: string; 284 plotType?: PlotType; 285 plotSummary?: string; 286 keyPoints?: string[]; 287 preset?: string; 288 wordcount?: number; 289 style?: Partial<Style>; 290 specialRequirements?: string; 291}
#2.2 PresetManager
1/** 2 * 预设管理器 3 * 负责预设的加载、创建、导入、导出 4 */ 5export class PresetManager { 6 private presetDirs: string[]; 7 8 constructor() { 9 this.presetDirs = [ 10 path.join(process.cwd(), 'stories', '*', 'presets'), // 项目本地 11 path.join(os.homedir(), '.novel', 'presets', 'user'), // 用户自定义 12 path.join(os.homedir(), '.novel', 'presets', 'community'), // 社区 13 path.join(os.homedir(), '.novel', 'presets', 'official'), // 官方 14 path.join(__dirname, '..', '..', 'presets') // 内置 15 ]; 16 } 17 18 /** 19 * 加载预设 20 */ 21 async loadPreset(presetId: string): Promise<Preset> { 22 for (const dir of this.presetDirs) { 23 const presetPath = await this.findPresetInDir(dir, presetId); 24 if (presetPath) { 25 const content = await fs.readFile(presetPath, 'utf-8'); 26 return yaml.load(content) as Preset; 27 } 28 } 29 30 throw new Error(`预设未找到: ${presetId}`); 31 } 32 33 /** 34 * 列出所有预设 35 */ 36 async listPresets(category?: string): Promise<PresetInfo[]> { 37 const presets: PresetInfo[] = []; 38 const seen = new Set<string>(); 39 40 for (const dir of this.presetDirs) { 41 if (!await fs.pathExists(dir)) continue; 42 43 const files = await glob(path.join(dir, '**', '*.yaml')); 44 45 for (const file of files) { 46 const content = await fs.readFile(file, 'utf-8'); 47 const preset = yaml.load(content) as Preset; 48 49 // 跳过重复ID(优先级高的已加载) 50 if (seen.has(preset.id)) continue; 51 52 // 类别过滤 53 if (category && preset.category !== category) continue; 54 55 seen.add(preset.id); 56 presets.push({ 57 id: preset.id, 58 name: preset.name, 59 description: preset.description, 60 category: preset.category, 61 author: preset.author, 62 source: this.getPresetSource(file) 63 }); 64 } 65 } 66 67 return presets; 68 } 69 70 /** 71 * 创建预设 72 */ 73 async createPreset(preset: Preset, target: 'user' | 'project'): Promise<void> { 74 const targetDir = target === 'user' 75 ? path.join(os.homedir(), '.novel', 'presets', 'user') 76 : path.join(process.cwd(), 'stories', '*', 'presets'); 77 78 await fs.ensureDir(targetDir); 79 80 const presetPath = path.join(targetDir, `${preset.id}.yaml`); 81 await fs.writeFile(presetPath, yaml.dump(preset, { indent: 2 }), 'utf-8'); 82 } 83 84 /** 85 * 导入预设 86 */ 87 async importPreset(file: string, target: 'user' | 'community'): Promise<void> { 88 const content = await fs.readFile(file, 'utf-8'); 89 const preset = yaml.load(content) as Preset; 90 91 const targetDir = path.join( 92 os.homedir(), 93 '.novel', 94 'presets', 95 target 96 ); 97 98 await fs.ensureDir(targetDir); 99 await fs.copy(file, path.join(targetDir, path.basename(file))); 100 } 101 102 /** 103 * 导出预设 104 */ 105 async exportPreset(presetId: string, outputPath: string): Promise<void> { 106 const preset = await this.loadPreset(presetId); 107 await fs.writeFile(outputPath, yaml.dump(preset, { indent: 2 }), 'utf-8'); 108 } 109 110 // ========== 私有方法 ========== 111 112 private async findPresetInDir( 113 dir: string, 114 presetId: string 115 ): Promise<string | null> { 116 if (!await fs.pathExists(dir)) return null; 117 118 const files = await glob(path.join(dir, '**', `${presetId}.yaml`)); 119 return files.length > 0 ? files[0] : null; 120 } 121 122 private getPresetSource(filePath: string): PresetSource { 123 if (filePath.includes('.novel/presets/official')) return 'official'; 124 if (filePath.includes('.novel/presets/community')) return 'community'; 125 if (filePath.includes('.novel/presets/user')) return 'user'; 126 if (filePath.includes('stories')) return 'project'; 127 return 'builtin'; 128 } 129} 130 131/** 132 * 预设信息接口 133 */ 134export interface PresetInfo { 135 id: string; 136 name: string; 137 description: string; 138 category: string; 139 author: string; 140 source: PresetSource; 141} 142 143export type PresetSource = 'official' | 'community' | 'user' | 'project' | 'builtin';
#2.3 ConfigValidator
1/** 2 * 配置验证器 3 * 负责验证配置的完整性、一致性、引用完整性 4 */ 5export class ConfigValidator { 6 private projectPath: string; 7 8 constructor(projectPath: string) { 9 this.projectPath = projectPath; 10 } 11 12 /** 13 * 验证配置 14 */ 15 async validate(config: ChapterConfig): Promise<ValidationResult> { 16 const errors: string[] = []; 17 const warnings: string[] = []; 18 19 // 1. 必填字段检查 20 if (!config.chapter) errors.push('缺少章节号'); 21 if (!config.title || config.title.trim() === '') errors.push('缺少章节标题'); 22 if (!config.plot || !config.plot.summary) errors.push('缺少剧情概要'); 23 if (!config.wordcount || !config.wordcount.target) errors.push('缺少目标字数'); 24 25 // 2. 数据类型和范围检查 26 if (config.chapter < 1) errors.push('章节号必须大于0'); 27 if (config.wordcount.target < 1000 || config.wordcount.target > 10000) { 28 warnings.push('目标字数建议在1000-10000之间'); 29 } 30 31 // 3. 引用完整性检查 32 if (config.characters) { 33 for (const char of config.characters) { 34 const exists = await this.checkCharacterExists(char.id); 35 if (!exists) { 36 errors.push(`角色ID "${char.id}" 不存在于 character-profiles.md`); 37 } 38 } 39 } 40 41 if (config.scene?.location_id) { 42 const exists = await this.checkLocationExists(config.scene.location_id); 43 if (!exists) { 44 errors.push(`地点ID "${config.scene.location_id}" 不存在于 locations.md`); 45 } 46 } 47 48 if (config.plot.plotlines) { 49 for (const plotline of config.plot.plotlines) { 50 const exists = await this.checkPlotlineExists(plotline); 51 if (!exists) { 52 errors.push(`线索ID "${plotline}" 不存在于 specification.md`); 53 } 54 } 55 } 56 57 // 4. 逻辑一致性检查 58 const { min, target, max } = config.wordcount; 59 if (min && target && min > target) { 60 errors.push('最小字数不能大于目标字数'); 61 } 62 if (target && max && target > max) { 63 errors.push('目标字数不能大于最大字数'); 64 } 65 66 // 5. 最佳实践建议 67 if (!config.characters || config.characters.length === 0) { 68 warnings.push('建议至少指定一个出场角色'); 69 } 70 71 if (!config.plot.key_points || config.plot.key_points.length < 3) { 72 warnings.push('建议至少列出3个关键要点'); 73 } 74 75 if (!config.scene) { 76 warnings.push('建议配置场景信息'); 77 } 78 79 return { 80 valid: errors.length === 0, 81 errors, 82 warnings 83 }; 84 } 85 86 // ========== 私有方法 ========== 87 88 private async checkCharacterExists(id: string): Promise<boolean> { 89 const profilesPath = path.join( 90 this.projectPath, 91 'spec', 92 'knowledge', 93 'character-profiles.md' 94 ); 95 96 if (!await fs.pathExists(profilesPath)) { 97 return false; 98 } 99 100 const content = await fs.readFile(profilesPath, 'utf-8'); 101 // 检查是否包含该角色ID(简化实现) 102 return content.includes(`id: ${id}`) || content.includes(`ID: ${id}`); 103 } 104 105 private async checkLocationExists(id: string): Promise<boolean> { 106 const locationsPath = path.join( 107 this.projectPath, 108 'spec', 109 'knowledge', 110 'locations.md' 111 ); 112 113 if (!await fs.pathExists(locationsPath)) { 114 return false; 115 } 116 117 const content = await fs.readFile(locationsPath, 'utf-8'); 118 return content.includes(`id: ${id}`) || content.includes(`ID: ${id}`); 119 } 120 121 private async checkPlotlineExists(id: string): Promise<boolean> { 122 const specPath = path.join( 123 this.projectPath, 124 'stories', 125 '*', 126 'specification.md' 127 ); 128 129 const specs = await glob(specPath); 130 if (specs.length === 0) return false; 131 132 const content = await fs.readFile(specs[0], 'utf-8'); 133 return content.includes(id); 134 } 135} 136 137/** 138 * 验证结果接口 139 */ 140export interface ValidationResult { 141 valid: boolean; 142 errors: string[]; 143 warnings: string[]; 144}
#三、CLI命令实现
#3.1 命令入口文件
1// src/commands/chapter-config.ts 2 3import { Command } from 'commander'; 4import chalk from 'chalk'; 5import inquirer from 'inquirer'; 6import ora from 'ora'; 7import { ChapterConfigManager } from '../core/chapter-config.js'; 8import { PresetManager } from '../core/preset-manager.js'; 9 10/** 11 * 注册chapter-config命令 12 */ 13export function registerChapterConfigCommands(program: Command): void { 14 const chapterConfig = program 15 .command('chapter-config') 16 .description('章节配置管理'); 17 18 // create 命令 19 chapterConfig 20 .command('create <chapter>') 21 .option('-i, --interactive', '交互式创建') 22 .option('-p, --preset <preset-id>', '使用预设') 23 .option('--from-prompt', '从自然语言生成') 24 .description('创建章节配置') 25 .action(async (chapter, options) => { 26 try { 27 const chapterNum = parseInt(chapter); 28 if (isNaN(chapterNum)) { 29 console.error(chalk.red('章节号必须是数字')); 30 process.exit(1); 31 } 32 33 if (options.interactive) { 34 await createConfigInteractive(chapterNum); 35 } else if (options.preset) { 36 await createConfigWithPreset(chapterNum, options.preset); 37 } else { 38 console.error(chalk.red('请指定 --interactive 或 --preset')); 39 process.exit(1); 40 } 41 } catch (error: any) { 42 console.error(chalk.red(`创建失败: ${error.message}`)); 43 process.exit(1); 44 } 45 }); 46 47 // list 命令 48 chapterConfig 49 .command('list') 50 .option('--format <type>', '输出格式: table|json|yaml', 'table') 51 .description('列出所有章节配置') 52 .action(async (options) => { 53 try { 54 await listConfigs(options.format); 55 } catch (error: any) { 56 console.error(chalk.red(`列出失败: ${error.message}`)); 57 process.exit(1); 58 } 59 }); 60 61 // validate 命令 62 chapterConfig 63 .command('validate <chapter>') 64 .description('验证章节配置') 65 .action(async (chapter) => { 66 try { 67 const chapterNum = parseInt(chapter); 68 await validateConfig(chapterNum); 69 } catch (error: any) { 70 console.error(chalk.red(`验证失败: ${error.message}`)); 71 process.exit(1); 72 } 73 }); 74 75 // copy 命令 76 chapterConfig 77 .command('copy <from> <to>') 78 .option('-i, --interactive', '交互式修改差异') 79 .description('复制章节配置') 80 .action(async (from, to, options) => { 81 try { 82 const fromChapter = parseInt(from); 83 const toChapter = parseInt(to); 84 await copyConfig(fromChapter, toChapter, options.interactive); 85 } catch (error: any) { 86 console.error(chalk.red(`复制失败: ${error.message}`)); 87 process.exit(1); 88 } 89 }); 90 91 // edit 命令 92 chapterConfig 93 .command('edit <chapter>') 94 .option('-e, --editor <editor>', '指定编辑器', 'vim') 95 .description('编辑章节配置') 96 .action(async (chapter, options) => { 97 try { 98 const chapterNum = parseInt(chapter); 99 await editConfig(chapterNum, options.editor); 100 } catch (error: any) { 101 console.error(chalk.red(`编辑失败: ${error.message}`)); 102 process.exit(1); 103 } 104 }); 105 106 // delete 命令 107 chapterConfig 108 .command('delete <chapter>') 109 .description('删除章节配置') 110 .action(async (chapter) => { 111 try { 112 const chapterNum = parseInt(chapter); 113 await deleteConfig(chapterNum); 114 } catch (error: any) { 115 console.error(chalk.red(`删除失败: ${error.message}`)); 116 process.exit(1); 117 } 118 }); 119} 120 121/** 122 * 交互式创建配置 123 */ 124async function createConfigInteractive(chapter: number): Promise<void> { 125 // 实现见前文 2.4.2 节 126 console.log(chalk.cyan(`\n📝 创建第${chapter}章配置\n`)); 127 128 // ...(完整实现省略) 129} 130 131/** 132 * 使用预设创建配置 133 */ 134async function createConfigWithPreset( 135 chapter: number, 136 presetId: string 137): Promise<void> { 138 const spinner = ora('加载预设...').start(); 139 140 try { 141 const presetManager = new PresetManager(); 142 const preset = await presetManager.loadPreset(presetId); 143 144 spinner.succeed(chalk.green(`已加载预设: ${preset.name}`)); 145 146 // 提示用户补充必要信息 147 const answers = await inquirer.prompt([ 148 { 149 type: 'input', 150 name: 'title', 151 message: '章节标题:', 152 validate: (input) => input.length > 0 153 }, 154 { 155 type: 'input', 156 name: 'characters', 157 message: '出场角色 (逗号分隔):', 158 validate: (input) => input.length > 0 159 }, 160 { 161 type: 'input', 162 name: 'scene', 163 message: '场景:', 164 validate: (input) => input.length > 0 165 }, 166 { 167 type: 'input', 168 name: 'plotSummary', 169 message: '剧情概要:', 170 validate: (input) => input.length > 10 171 } 172 ]); 173 174 // 创建配置 175 const manager = new ChapterConfigManager(process.cwd()); 176 const config = await manager.createConfig(chapter, { 177 title: answers.title, 178 characters: answers.characters.split(',').map(c => c.trim()), 179 scene: answers.scene, 180 plotSummary: answers.plotSummary, 181 preset: presetId 182 }); 183 184 console.log(chalk.green(`\n✅ 配置已保存`)); 185 console.log(chalk.gray(`文件: ${getConfigPath(chapter)}`)); 186 } catch (error: any) { 187 spinner.fail(chalk.red(`创建失败: ${error.message}`)); 188 process.exit(1); 189 } 190} 191 192/** 193 * 列出所有配置 194 */ 195async function listConfigs(format: string): Promise<void> { 196 const spinner = ora('加载配置列表...').start(); 197 198 try { 199 const manager = new ChapterConfigManager(process.cwd()); 200 const configs = await manager.listConfigs(); 201 202 spinner.stop(); 203 204 if (configs.length === 0) { 205 console.log(chalk.yellow('\n暂无章节配置')); 206 return; 207 } 208 209 console.log(chalk.cyan(`\n📋 已有章节配置 (${configs.length}个):\n`)); 210 211 if (format === 'table') { 212 // 表格输出 213 console.table(configs.map(c => ({ 214 '章节': `第${c.chapter}章`, 215 '标题': c.title, 216 '类型': c.plotType, 217 '场景': c.location, 218 '字数': c.wordcount, 219 '预设': c.preset || '-' 220 }))); 221 } else if (format === 'json') { 222 console.log(JSON.stringify(configs, null, 2)); 223 } else if (format === 'yaml') { 224 console.log(yaml.dump(configs)); 225 } 226 } catch (error: any) { 227 spinner.fail(chalk.red(`加载失败: ${error.message}`)); 228 process.exit(1); 229 } 230} 231 232/** 233 * 验证配置 234 */ 235async function validateConfig(chapter: number): Promise<void> { 236 console.log(chalk.cyan(`\n🔍 验证配置文件: chapter-${chapter}-config.yaml\n`)); 237 238 const manager = new ChapterConfigManager(process.cwd()); 239 const config = await manager.loadConfig(chapter); 240 241 if (!config) { 242 console.error(chalk.red('❌ 配置文件不存在')); 243 process.exit(1); 244 } 245 246 const validator = new ConfigValidator(process.cwd()); 247 const result = await validator.validate(config); 248 249 if (result.valid) { 250 console.log(chalk.green('✅ 验证通过!\n')); 251 } else { 252 console.log(chalk.red(`❌ 验证失败 (${result.errors.length}个错误):\n`)); 253 result.errors.forEach((error, index) => { 254 console.log(chalk.red(` ${index + 1}. ${error}`)); 255 }); 256 console.log(''); 257 } 258 259 if (result.warnings.length > 0) { 260 console.log(chalk.yellow(`⚠️ 警告 (${result.warnings.length}个):\n`)); 261 result.warnings.forEach((warning, index) => { 262 console.log(chalk.yellow(` ${index + 1}. ${warning}`)); 263 }); 264 console.log(''); 265 } 266 267 if (!result.valid) { 268 process.exit(1); 269 } 270} 271 272/** 273 * 复制配置 274 */ 275async function copyConfig( 276 from: number, 277 to: number, 278 interactive: boolean 279): Promise<void> { 280 const manager = new ChapterConfigManager(process.cwd()); 281 282 console.log(chalk.cyan(`\n📋 复制配置: 第${from}章 → 第${to}章\n`)); 283 284 if (interactive) { 285 // 交互式修改差异 286 const sourceConfig = await manager.loadConfig(from); 287 if (!sourceConfig) { 288 console.error(chalk.red('源配置不存在')); 289 process.exit(1); 290 } 291 292 const answers = await inquirer.prompt([ 293 { 294 type: 'input', 295 name: 'title', 296 message: '新标题:', 297 default: sourceConfig.title 298 }, 299 { 300 type: 'input', 301 name: 'plotSummary', 302 message: '剧情概要:', 303 default: sourceConfig.plot.summary 304 } 305 // ...更多字段 306 ]); 307 308 await manager.copyConfig(from, to, answers); 309 } else { 310 await manager.copyConfig(from, to); 311 } 312 313 console.log(chalk.green(`\n✅ 配置已复制`)); 314} 315 316/** 317 * 编辑配置 318 */ 319async function editConfig(chapter: number, editor: string): Promise<void> { 320 const configPath = getConfigPath(chapter); 321 322 if (!await fs.pathExists(configPath)) { 323 console.error(chalk.red('配置文件不存在')); 324 process.exit(1); 325 } 326 327 // 调用编辑器 328 const { spawn } = await import('child_process'); 329 const child = spawn(editor, [configPath], { 330 stdio: 'inherit' 331 }); 332 333 child.on('exit', (code) => { 334 if (code === 0) { 335 console.log(chalk.green('\n✅ 编辑完成')); 336 } else { 337 console.error(chalk.red('\n❌ 编辑失败')); 338 process.exit(1); 339 } 340 }); 341} 342 343/** 344 * 删除配置 345 */ 346async function deleteConfig(chapter: number): Promise<void> { 347 const answers = await inquirer.prompt([ 348 { 349 type: 'confirm', 350 name: 'confirm', 351 message: `确认删除第${chapter}章配置?`, 352 default: false 353 } 354 ]); 355 356 if (!answers.confirm) { 357 console.log(chalk.yellow('已取消')); 358 return; 359 } 360 361 const manager = new ChapterConfigManager(process.cwd()); 362 await manager.deleteConfig(chapter); 363 364 console.log(chalk.green(`\n✅ 配置已删除`)); 365} 366 367// 辅助函数 368function getConfigPath(chapter: number): string { 369 // 实现省略... 370 return ''; 371}
#四、write.md模板集成
#4.1 模板修改方案
修改位置: templates/commands/write.md
修改内容:
1--- 2description: 基于任务清单执行章节写作,自动加载上下文和验证规则 3argument-hint: [章节编号或任务ID] 4allowed-tools: Read(//**), Write(//stories/**/content/**), Bash(ls:*), Bash(find:*), Bash(wc:*), Bash(grep:*), Bash(*) 5model: claude-sonnet-4-5-20250929 6scripts: 7 sh: .specify/scripts/bash/check-writing-state.sh 8 ps: .specify/scripts/powershell/check-writing-state.ps1 9--- 10 11基于七步方法论流程执行章节写作。 12--- 13 14## 前置检查 15 161. 运行脚本 `{SCRIPT}` 检查创作状态 17 182. **🆕 检查章节配置文件**(新增) 19 ```bash 20 # 检查是否存在配置文件 21 chapter_num="$CHAPTER_NUMBER" # 从$ARGUMENTS解析 22 config_file="stories/*/chapters/chapter-${chapter_num}-config.yaml" 23 24 if [ -f "$config_file" ]; then 25 echo "✅ 发现配置文件,加载中..." 26 # 读取配置文件 27 CONFIG_CONTENT=$(cat "$config_file") 28 else 29 echo "ℹ️ 无配置文件,使用自然语言模式" 30 CONFIG_CONTENT="" 31 fi
#查询协议(必读顺序)
⚠️ 重要:请严格按照以下顺序查询文档,确保上下文完整且优先级正确。
查询顺序:
-
🆕 先查(章节配置 - 如果存在)(新增):
stories/*/chapters/chapter-X-config.yaml(章节配置文件)- 如果配置文件存在,解析并提取:
- 出场角色ID列表
- 场景ID
- 剧情类型、概要、关键要点
- 写作风格参数
- 字数要求
- 特殊要求
-
先查(最高优先级):
memory/novel-constitution.md(创作宪法 - 最高原则)memory/style-reference.md(风格参考 - 如果通过/book-internalize生成)
-
再查(规格和计划):
stories/*/specification.md(故事规格)stories/*/creative-plan.md(创作计划)stories/*/tasks.md(当前任务)
-
🆕 根据配置加载详细信息(新增): 如果配置文件指定了角色和场景,加载详细信息:
# 加载角色详情 对于配置中的每个角色ID: 1. 从 spec/knowledge/character-profiles.md 查找角色完整档案 2. 从 spec/tracking/character-state.json 获取最新状态 3. 合并信息供后续使用 # 加载场景详情 如果配置指定了 scene.location_id: 1. 从 spec/knowledge/locations.md 查找场景详细描述 2. 提取场景的环境、氛围、特征 # 加载线索详情 如果配置指定了 plot.plotlines: 1. 从 stories/*/specification.md 查找线索定义 2. 获取线索的当前状态和目标 -
再查(状态和数据):
spec/tracking/character-state.json(角色状态)spec/tracking/relationships.json(关系网络)spec/tracking/plot-tracker.json(情节追踪 - 如有)spec/tracking/validation-rules.json(验证规则 - 如有)
-
再查(知识库):
spec/knowledge/相关文件(世界观、角色档案等)stories/*/content/(前文内容 - 了解前情)
-
再查(写作规范):
memory/personal-voice.md(个人语料 - 如有)spec/knowledge/natural-expression.md(自然化表达 - 如有)spec/presets/anti-ai-detection.md(反AI检测规范)
-
条件查询(前三章专用):
- 如果章节编号 ≤ 3 或总字数 < 10000字,额外查询:
spec/presets/golden-opening.md(黄金开篇法则)- 并严格遵循其中的五大法则
- 如果章节编号 ≤ 3 或总字数 < 10000字,额外查询:
#写作执行流程
#1. 选择写作任务
从 tasks.md 中选择状态为 pending 的写作任务,标记为 in_progress。
#2. 验证前置条件
- 检查相关依赖任务是否完成
- 验证必要的设定是否就绪
- 确认前序章节是否完成
#3. 🆕 构建章节写作提示词(修改)
如果有配置文件:
📋 本章配置:
**基本信息**:
- 章节: 第{{chapter}}章 - {{title}}
- 字数要求: {{wordcount.min}}-{{wordcount.max}}字(目标{{wordcount.target}}字)
**出场角色** ({{characters.length}}人):
{{#each characters}}
- **{{name}}**({{role}} - {{focus}}重点)
[从character-profiles.md读取的详细档案]
性格: {{personality}}
背景: {{background}}
当前状态:(从character-state.json读取)
- 位置: {{location}}
- 健康: {{health}}
- 心情: {{mood}}
- 与其他角色关系: {{relationships}}
{{/each}}
**场景设定**:
- 地点: {{scene.location_name}}
[从locations.md读取的场景详情]
详细描述: {{location_details}}
特征: {{features}}
- 时间: {{scene.time}}
- 天气: {{scene.weather}}
- 氛围: {{scene.atmosphere}}
**剧情要求**:
- 类型: {{plot.type}}({{plot_type_description}})
- 概要: {{plot.summary}}
- 关键要点:
{{#each plot.key_points}}
{{index}}. {{this}}
{{/each}}
{{#if plot.plotlines}}
- 涉及线索:
{{#each plot.plotlines}}
- {{this}}: [从specification.md读取线索详情]
{{/each}}
{{/if}}
{{#if plot.foreshadowing}}
- 本章伏笔:
{{#each plot.foreshadowing}}
- {{id}}: {{content}}
{{/each}}
{{/if}}
**写作风格**:
- 节奏: {{style.pace}}({{pace_description}})
- 句长: {{style.sentence_length}}({{sentence_description}})
- 重点: {{style.focus}}({{focus_description}})
- 基调: {{style.tone}}
{{#if special_requirements}}
**特殊要求**:
{{special_requirements}}
{{/if}}
{{#if preset_used}}
**应用预设**: {{preset_used}}
{{/if}}
---
[以下加载全局规格文档...]
如果无配置文件(向后兼容):
📋 基于用户输入:
用户描述: $ARGUMENTS
[解析自然语言,提取参数]
[加载全局规格文档...]
#4. 写作前提醒
基于宪法原则提醒:
- 核心价值观要点
- 质量标准要求
- 风格一致性准则
基于规格要求提醒:
- P0 必须包含的元素
- 目标读者特征
- 内容红线提醒
分段格式规范(重要): [保持原有内容]
反AI检测写作规范(基于腾讯朱雀标准): [保持原有内容]
#5. 根据计划创作内容:
- 开场:吸引读者,承接前文
- 发展:推进情节,深化人物
- 转折:制造冲突或悬念
- 收尾:适当收束,引出下文
#6. 质量自检
[保持原有内容]
#7. 保存和更新
- 将章节内容保存到
stories/*/content/ - 🆕 如果使用了配置文件,更新
updated_at时间戳(新增) - 更新任务状态为
completed - 记录完成时间和字数
[其余内容保持不变...]
### 4.2 配置加载逻辑实现
在write.md模板中,AI需要执行以下逻辑:
```typescript
// 伪代码:AI执行逻辑
// 1. 解析章节号
const chapterNum = parseChapterNumber($ARGUMENTS);
// 2. 检查配置文件
const configPath = `stories/*/chapters/chapter-${chapterNum}-config.yaml`;
const config = await loadYamlFile(configPath);
if (config) {
// 3. 加载角色详情
for (const char of config.characters) {
const profile = await extractFromMarkdown(
'spec/knowledge/character-profiles.md',
char.id
);
const state = await loadJson('spec/tracking/character-state.json')[char.id];
char.details = { ...profile, ...state };
}
// 4. 加载场景详情
if (config.scene.location_id) {
config.scene.details = await extractFromMarkdown(
'spec/knowledge/locations.md',
config.scene.location_id
);
}
// 5. 加载线索详情
if (config.plot.plotlines) {
for (const plotlineId of config.plot.plotlines) {
const plotline = await extractFromMarkdown(
'stories/*/specification.md',
plotlineId
);
config.plot.plotlineDetails.push(plotline);
}
}
// 6. 构建结构化提示词
const prompt = buildPromptFromConfig(config);
} else {
// 7. 使用自然语言模式
const prompt = parseNaturalLanguage($ARGUMENTS);
}
// 8. 加载全局规格
const globalSpecs = await loadGlobalSpecs();
// 9. 合并提示词
const fullPrompt = mergePrompts(prompt, globalSpecs);
// 10. 生成章节内容
const content = await generateChapterContent(fullPrompt);
// 11. 保存
await saveChapterContent(chapterNum, content);
// 12. 更新配置文件时间戳
if (config) {
config.updated_at = new Date().toISOString();
await saveYamlFile(configPath, config);
}
#五、测试策略
#5.1 单元测试
测试范围:
- ChapterConfigManager 所有方法
- PresetManager 所有方法
- ConfigValidator 所有验证规则
测试框架: Jest
测试覆盖率目标: > 80%
测试示例:
1// test/chapter-config.test.ts 2 3describe('ChapterConfigManager', () => { 4 let manager: ChapterConfigManager; 5 6 beforeEach(() => { 7 manager = new ChapterConfigManager('/test/project'); 8 }); 9 10 describe('createConfig', () => { 11 it('should create config with valid parameters', async () => { 12 const config = await manager.createConfig(5, { 13 title: '测试章节', 14 plotType: 'ability_showcase', 15 plotSummary: '测试剧情概要', 16 wordcount: 3000 17 }); 18 19 expect(config.chapter).toBe(5); 20 expect(config.title).toBe('测试章节'); 21 expect(config.plot.type).toBe('ability_showcase'); 22 expect(config.wordcount.target).toBe(3000); 23 }); 24 25 it('should apply preset correctly', async () => { 26 const config = await manager.createConfig(5, { 27 title: '动作章节', 28 preset: 'action-intense' 29 }); 30 31 expect(config.preset_used).toBe('action-intense'); 32 expect(config.style.pace).toBe('fast'); 33 expect(config.style.sentence_length).toBe('short'); 34 }); 35 36 it('should throw error for invalid parameters', async () => { 37 await expect(manager.createConfig(0, {})).rejects.toThrow(); 38 }); 39 }); 40 41 describe('loadConfig', () => { 42 it('should return null for non-existent config', async () => { 43 const config = await manager.loadConfig(999); 44 expect(config).toBeNull(); 45 }); 46 47 it('should load existing config correctly', async () => { 48 // 先创建 49 await manager.createConfig(5, { title: '测试' }); 50 51 // 再加载 52 const config = await manager.loadConfig(5); 53 expect(config).not.toBeNull(); 54 expect(config!.chapter).toBe(5); 55 }); 56 }); 57 58 // 更多测试... 59});
#5.2 集成测试
测试场景:
-
完整工作流测试:
创建配置 → 加载配置 → 验证配置 → 更新配置 → 删除配置 -
预设应用测试:
列出预设 → 选择预设 → 创建配置 → 验证预设参数生效 -
CLI命令测试:
执行各个CLI命令 → 验证输出 → 检查文件变化 -
与write.md集成测试:
创建配置 → 执行/write命令 → 验证AI加载了配置 → 检查生成内容
#5.3 端到端测试
测试场景:
-
新用户首次使用:
1. 安装novel-writer-cn 2. novel init my-story 3. novel chapter-config create 1 --interactive 4. 在AI编辑器执行 /write 第1章 5. 验证生成的章节内容符合配置 -
使用预设快速创建:
1. novel preset list 2. novel chapter-config create 5 --preset action-intense 3. /write 第5章 4. 验证快节奏动作场景 -
配置复用:
1. novel chapter-config copy 5 10 2. 修改差异部分 3. /write 第10章 4. 验证保持了风格一致性
#六、性能优化
#6.1 配置文件缓存
1/** 2 * 配置缓存管理器 3 */ 4export class ConfigCache { 5 private cache: Map<number, { 6 config: ChapterConfig; 7 mtime: number; 8 }> = new Map(); 9 10 async get(chapter: number, filePath: string): Promise<ChapterConfig | null> { 11 const stats = await fs.stat(filePath); 12 const cached = this.cache.get(chapter); 13 14 if (cached && cached.mtime === stats.mtimeMs) { 15 return cached.config; 16 } 17 18 return null; 19 } 20 21 set(chapter: number, config: ChapterConfig, mtime: number): void { 22 this.cache.set(chapter, { config, mtime }); 23 } 24 25 clear(chapter?: number): void { 26 if (chapter) { 27 this.cache.delete(chapter); 28 } else { 29 this.cache.clear(); 30 } 31 } 32}
#6.2 预设预加载
1/** 2 * 预设预加载器 3 * 应用启动时预加载所有官方预设 4 */ 5export class PresetPreloader { 6 private preloadedPresets: Map<string, Preset> = new Map(); 7 8 async preload(): Promise<void> { 9 const presetDir = path.join(__dirname, '..', '..', 'presets'); 10 const files = await glob(path.join(presetDir, '**', '*.yaml')); 11 12 for (const file of files) { 13 const content = await fs.readFile(file, 'utf-8'); 14 const preset = yaml.load(content) as Preset; 15 this.preloadedPresets.set(preset.id, preset); 16 } 17 } 18 19 get(presetId: string): Preset | undefined { 20 return this.preloadedPresets.get(presetId); 21 } 22}
#6.3 YAML解析优化
1/** 2 * 使用更快的YAML解析器 3 */ 4import { parse } from 'yaml'; // 使用yaml库替代js-yaml 5 6export async function loadYamlFast(filePath: string): Promise<any> { 7 const content = await fs.readFile(filePath, 'utf-8'); 8 return parse(content); 9}
#七、安全性考虑
#7.1 输入验证
1/** 2 * 输入清洗和验证 3 */ 4export class InputSanitizer { 5 /** 6 * 清洗章节号 7 */ 8 sanitizeChapterNumber(input: any): number { 9 const num = parseInt(String(input)); 10 if (isNaN(num) || num < 1 || num > 9999) { 11 throw new Error('章节号必须在1-9999之间'); 12 } 13 return num; 14 } 15 16 /** 17 * 清洗文件路径 18 */ 19 sanitizeFilePath(input: string): string { 20 // 防止路径遍历攻击 21 const normalized = path.normalize(input); 22 if (normalized.includes('..')) { 23 throw new Error('非法路径'); 24 } 25 return normalized; 26 } 27 28 /** 29 * 清洗YAML内容 30 */ 31 sanitizeYamlContent(content: string): string { 32 // 移除潜在的代码注入 33 if (content.includes('!<tag:')) { 34 throw new Error('不支持YAML标签'); 35 } 36 return content; 37 } 38}
#7.2 权限控制
1/** 2 * 文件操作权限检查 3 */ 4export class PermissionChecker { 5 /** 6 * 检查文件是否在项目范围内 7 */ 8 isWithinProject(filePath: string, projectPath: string): boolean { 9 const resolved = path.resolve(filePath); 10 const project = path.resolve(projectPath); 11 return resolved.startsWith(project); 12 } 13 14 /** 15 * 检查文件是否可写 16 */ 17 async isWritable(filePath: string): Promise<boolean> { 18 try { 19 await fs.access(filePath, fs.constants.W_OK); 20 return true; 21 } catch { 22 return false; 23 } 24 } 25}
#八、错误处理
#8.1 错误类型定义
1/** 2 * 自定义错误类 3 */ 4export class ConfigError extends Error { 5 constructor( 6 message: string, 7 public code: string, 8 public details?: any 9 ) { 10 super(message); 11 this.name = 'ConfigError'; 12 } 13} 14 15export class ValidationError extends ConfigError { 16 constructor(message: string, public errors: string[]) { 17 super(message, 'VALIDATION_ERROR', { errors }); 18 this.name = 'ValidationError'; 19 } 20} 21 22export class PresetNotFoundError extends ConfigError { 23 constructor(presetId: string) { 24 super(`预设未找到: ${presetId}`, 'PRESET_NOT_FOUND', { presetId }); 25 this.name = 'PresetNotFoundError'; 26 } 27}
#8.2 错误处理策略
1/** 2 * 全局错误处理器 3 */ 4export class ErrorHandler { 5 handle(error: Error): void { 6 if (error instanceof ValidationError) { 7 console.error(chalk.red(`验证失败:`)); 8 error.errors.forEach((err, index) => { 9 console.error(chalk.red(` ${index + 1}. ${err}`)); 10 }); 11 } else if (error instanceof PresetNotFoundError) { 12 console.error(chalk.red(`预设不存在: ${error.details.presetId}`)); 13 console.log(chalk.gray('\n提示: 使用 novel preset list 查看可用预设')); 14 } else if (error instanceof ConfigError) { 15 console.error(chalk.red(`配置错误: ${error.message}`)); 16 if (error.details) { 17 console.error(chalk.gray(JSON.stringify(error.details, null, 2))); 18 } 19 } else { 20 console.error(chalk.red(`未知错误: ${error.message}`)); 21 console.error(error.stack); 22 } 23 24 process.exit(1); 25 } 26}
#九、部署和发布
#9.1 构建流程
1# package.json scripts 2 3{ 4 "scripts": { 5 "build": "tsc", 6 "build:presets": "bash scripts/bundle-presets.sh", 7 "build:all": "npm run build && npm run build:presets", 8 "test": "jest", 9 "test:coverage": "jest --coverage", 10 "lint": "eslint src/**/*.ts", 11 "format": "prettier --write src/**/*.ts" 12 } 13}
#9.2 发布检查清单
- 单元测试通过(覆盖率 > 80%)
- 集成测试通过
- 端到端测试通过
- 代码lint通过
- 文档完整
- CHANGELOG更新
- 版本号更新
- 预设文件打包
#9.3 版本兼容性
1/** 2 * 配置文件版本管理 3 */ 4export const CONFIG_VERSION = '1.0.0'; 5 6export function migrateConfig(config: any): ChapterConfig { 7 // 从旧版本迁移到当前版本 8 if (!config.version || config.version < '1.0.0') { 9 // 执行迁移逻辑 10 config = migrateFrom_0_x(config); 11 } 12 13 config.version = CONFIG_VERSION; 14 return config as ChapterConfig; 15}
#十、监控和调试
#10.1 日志系统
1/** 2 * 结构化日志 3 */ 4export class Logger { 5 private level: 'debug' | 'info' | 'warn' | 'error'; 6 7 constructor(level: 'debug' | 'info' | 'warn' | 'error' = 'info') { 8 this.level = level; 9 } 10 11 debug(message: string, meta?: any): void { 12 if (this.shouldLog('debug')) { 13 console.log(chalk.gray(`[DEBUG] ${message}`), meta || ''); 14 } 15 } 16 17 info(message: string, meta?: any): void { 18 if (this.shouldLog('info')) { 19 console.log(chalk.cyan(`[INFO] ${message}`), meta || ''); 20 } 21 } 22 23 warn(message: string, meta?: any): void { 24 if (this.shouldLog('warn')) { 25 console.log(chalk.yellow(`[WARN] ${message}`), meta || ''); 26 } 27 } 28 29 error(message: string, meta?: any): void { 30 if (this.shouldLog('error')) { 31 console.error(chalk.red(`[ERROR] ${message}`), meta || ''); 32 } 33 } 34 35 private shouldLog(level: string): boolean { 36 const levels = ['debug', 'info', 'warn', 'error']; 37 return levels.indexOf(level) >= levels.indexOf(this.level); 38 } 39}
#10.2 性能监控
1/** 2 * 性能计时器 3 */ 4export class PerformanceTimer { 5 private timers: Map<string, number> = new Map(); 6 7 start(name: string): void { 8 this.timers.set(name, Date.now()); 9 } 10 11 end(name: string): number { 12 const start = this.timers.get(name); 13 if (!start) { 14 throw new Error(`Timer ${name} not started`); 15 } 16 17 const duration = Date.now() - start; 18 this.timers.delete(name); 19 return duration; 20 } 21 22 measure(name: string, fn: () => Promise<any>): Promise<any> { 23 this.start(name); 24 return fn().finally(() => { 25 const duration = this.end(name); 26 console.log(chalk.gray(`⏱️ ${name}: ${duration}ms`)); 27 }); 28 } 29}
#附录
#A. 完整的TypeScript类型导出
1// src/types/index.ts 2 3export * from './chapter-config'; 4export * from './preset'; 5export * from './validation'; 6export * from './errors';
#B. CLI命令完整列表
见第三章节内容。
#C. 测试覆盖率报告
1$ npm run test:coverage 2 3----------------------|---------|----------|---------|---------| 4File | % Stmts | % Branch | % Funcs | % Lines | 5----------------------|---------|----------|---------|---------| 6All files | 85.23 | 78.45 | 89.12 | 84.67 | 7 chapter-config.ts | 88.45 | 82.30 | 91.20 | 87.90 | 8 preset-manager.ts | 82.10 | 75.60 | 87.50 | 81.45 | 9 config-validator.ts | 86.70 | 79.20 | 88.90 | 85.30 | 10----------------------|---------|----------|---------|---------|
END OF TECH SPEC
Quick Actions
Details
- Type
- Technical Spec
- Author
- wordflowlab
- Slug
- wordflowlab/chapter-chapter-tech-spec
