|
@@ -1,1408 +0,0 @@
|
|
|
-/**
|
|
|
|
|
- * LangGraph 书籍生成器
|
|
|
|
|
- * 使用 @langchain/langgraph v1.2.8 API
|
|
|
|
|
- * 状态通过数据库传递,LangGraph 只负责流程控制
|
|
|
|
|
- * 支持生成过程中额度监控和中断保存
|
|
|
|
|
- */
|
|
|
|
|
-
|
|
|
|
|
-import { BookGenerationState, ChapterResult } from './langgraph-types';
|
|
|
|
|
-import { OutlineChapter } from './book-generator.types';
|
|
|
|
|
-import { bookStore } from './book-generator.store';
|
|
|
|
|
-import { prisma } from '../../models';
|
|
|
|
|
-import { Annotation, StateGraph, END } from '@langchain/langgraph';
|
|
|
|
|
-import { callLLM, callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../services/llm';
|
|
|
|
|
-import { createBookTools } from '../../services/llm/book-tools';
|
|
|
|
|
-import { checkQuotaForWords, markGenerationInterrupted, getGeneratedWordCount } from '../subscription/subscription.service';
|
|
|
|
|
-
|
|
|
|
|
-// ============ 状态定义(借鉴 OpenMAIC Annotation 模式)============
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 进度 reducer:只增不减,防止中间步骤回退导致进度丢失
|
|
|
|
|
- */
|
|
|
|
|
-const maxReducer = (prev: number, update: number) => Math.max(prev, update);
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 章节完成数 reducer:累加而非覆盖
|
|
|
|
|
- */
|
|
|
|
|
-const appendReducer = <T>(prev: T[], update: T | T[] | undefined) => {
|
|
|
|
|
- if (!update) return prev;
|
|
|
|
|
- const items = Array.isArray(update) ? update : [update];
|
|
|
|
|
- return [...prev, ...items];
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const GraphState = Annotation.Root({
|
|
|
|
|
- bookId: Annotation<string>({
|
|
|
|
|
- reducer: (_prev, update) => update ?? _prev,
|
|
|
|
|
- default: () => '' as string,
|
|
|
|
|
- }),
|
|
|
|
|
- topic: Annotation<string>({
|
|
|
|
|
- reducer: (_prev, update) => update ?? _prev,
|
|
|
|
|
- default: () => '' as string,
|
|
|
|
|
- }),
|
|
|
|
|
- bookScale: Annotation<string>({
|
|
|
|
|
- reducer: (_prev, update) => update ?? _prev,
|
|
|
|
|
- default: () => '标准教程' as string,
|
|
|
|
|
- }),
|
|
|
|
|
- description: Annotation<string>({
|
|
|
|
|
- reducer: (_prev, update) => update ?? _prev,
|
|
|
|
|
- default: () => '' as string,
|
|
|
|
|
- }),
|
|
|
|
|
- /** 当前正在处理的章节号 */
|
|
|
|
|
- currentChapter: Annotation<number>({
|
|
|
|
|
- reducer: maxReducer,
|
|
|
|
|
- default: () => 0,
|
|
|
|
|
- }),
|
|
|
|
|
- /** 已成功完成的章节数(只增不减) */
|
|
|
|
|
- completedChapters: Annotation<number[]>({
|
|
|
|
|
- reducer: appendReducer,
|
|
|
|
|
- default: () => [] as number[],
|
|
|
|
|
- }),
|
|
|
|
|
- finished: Annotation<boolean>({
|
|
|
|
|
- reducer: (_prev, update) => update ?? _prev,
|
|
|
|
|
- default: () => false,
|
|
|
|
|
- }),
|
|
|
|
|
- error: Annotation<string | undefined>({
|
|
|
|
|
- reducer: (_prev, update) => update ?? _prev,
|
|
|
|
|
- default: () => undefined,
|
|
|
|
|
- }),
|
|
|
|
|
- /** 生成进度 0-100,只增不减 */
|
|
|
|
|
- progress: Annotation<number>({
|
|
|
|
|
- reducer: maxReducer,
|
|
|
|
|
- default: () => 0,
|
|
|
|
|
- }),
|
|
|
|
|
- /** 失败章节列表,通过 reducer 合并而非覆盖 */
|
|
|
|
|
- failedChapters: Annotation<number[]>({
|
|
|
|
|
- reducer: appendReducer,
|
|
|
|
|
- default: () => [] as number[],
|
|
|
|
|
- }),
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-// ============ 进度常量 ============
|
|
|
|
|
-
|
|
|
|
|
-const PROGRESS = {
|
|
|
|
|
- OUTLINE_DONE: 5,
|
|
|
|
|
- SECTIONS_DONE: 10,
|
|
|
|
|
- SUBSECTIONS_DONE: 15,
|
|
|
|
|
- CONTENT_START: 15,
|
|
|
|
|
- CONTENT_END: 95,
|
|
|
|
|
- FOREWORD_DONE: 95,
|
|
|
|
|
- AFTERWORD_DONE: 100,
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// ============ 规模配置 ============
|
|
|
|
|
-
|
|
|
|
|
-const SCALE_CHAPTER_RANGE = {
|
|
|
|
|
- '800': { min: 1, max: 1 }, '2000': { min: 1, max: 1 }, '5000': { min: 1, max: 1 },
|
|
|
|
|
- 小册子: { min: 3, max: 8 }, 标准教程: { min: 5, max: 15 }, 系统教材: { min: 10, max: 20 },
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const SCALE_DESC: Record<string, string> = {
|
|
|
|
|
- '800': '短文,约800字', '2000': '短文,约2000字', '5000': '短文,约5000字',
|
|
|
|
|
- 小册子: '小册子,1-5万字,3-8章', 标准教程: '标准教程,5-15万字,5-15章', 系统教材: '系统教材,15-30万字,10-20章',
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// ============ 提示词模板(借鉴 OpenMAIC:System 详细定义角色,User 传递参数)============
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 大纲生成系统提示词
|
|
|
|
|
- * OpenMAIC 模式:系统提示词详细,用户提示词简洁
|
|
|
|
|
- */
|
|
|
|
|
-const OUTLINE_SYSTEM_PROMPT = `你是一位专业的图书策划编辑,擅长为各类主题设计清晰、严谨、有逻辑递进的书籍大纲。
|
|
|
|
|
-
|
|
|
|
|
-## 你的职责
|
|
|
|
|
-根据用户提供的书名、规模和主题,设计一份完整的书籍大纲。
|
|
|
|
|
-
|
|
|
|
|
-## 工作要求
|
|
|
|
|
-1. 分析主题复杂度,在允许的章节范围内决定最合适的章节数
|
|
|
|
|
-2. 章节之间必须有清晰的逻辑递进关系(由浅入深、由理论到实践等)
|
|
|
|
|
-3. 每章的知识点要具体,不能泛泛而谈
|
|
|
|
|
-4. 预估字数要符合章节内容量
|
|
|
|
|
-5. 核心知识点应独立成章,不要过度合并
|
|
|
|
|
-
|
|
|
|
|
-## ⚠️ 输出格式要求(非常重要)
|
|
|
|
|
-1. 必须且只能返回纯 JSON,不要任何解释、思考过程或其他文字
|
|
|
|
|
-2. 不要使用 markdown 代码块标记(\`\`\`json)
|
|
|
|
|
-3. 不要包含任何标签(如<details>、<think> 等)
|
|
|
|
|
-4. 直接从 { 开始,到 } 结束
|
|
|
|
|
-5. JSON 必须合法,可以被 JSON.parse 解析
|
|
|
|
|
-
|
|
|
|
|
-## JSON 格式
|
|
|
|
|
-{
|
|
|
|
|
- "mainTheme": "主题一句话描述",
|
|
|
|
|
- "structureLogic": "章节组织逻辑说明",
|
|
|
|
|
- "chapters": [
|
|
|
|
|
- {
|
|
|
|
|
- "number": 1,
|
|
|
|
|
- "title": "章节标题",
|
|
|
|
|
- "summary": "章节摘要(2-3句话)",
|
|
|
|
|
- "keyPoints": ["知识点1", "知识点2"],
|
|
|
|
|
- "estimatedWords": 2000
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
-}`;
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 章节写作系统提示词(支持工具调用)
|
|
|
|
|
- */
|
|
|
|
|
-const CHAPTER_SYSTEM_PROMPT = `你是一位专业的书籍作者,擅长撰写结构严谨、内容丰富、通俗易懂的章节内容。
|
|
|
|
|
-
|
|
|
|
|
-## 你的职责
|
|
|
|
|
-撰写指定章节的正文内容。
|
|
|
|
|
-
|
|
|
|
|
-## 工具说明
|
|
|
|
|
-你可以调用以下工具来提升写作质量:
|
|
|
|
|
-- get_existing_chapters:查看已写好的章节摘要,避免内容重复,保持风格一致
|
|
|
|
|
-- get_book_outline:查看全书大纲,了解当前章节在全书中的位置
|
|
|
|
|
-- report_chapter_issue:发现内容问题时报告,然后重新生成改进版本
|
|
|
|
|
-
|
|
|
|
|
-## 写作要求
|
|
|
|
|
-1. 先调用 get_book_outline 了解整体结构
|
|
|
|
|
-2. 如果不是第一章,调用 get_existing_chapters 查看前几章内容,避免重复
|
|
|
|
|
-3. 正文要有深度,不能只是列举要点,要有解释、案例、分析
|
|
|
|
|
-4. 字数尽量达到预估字数要求
|
|
|
|
|
-5. 直接输出正文内容,不要输出任何 JSON 或 markdown 格式说明`;
|
|
|
|
|
-
|
|
|
|
|
-// ============ 节/小节生成提示词============
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 节大纲生成系统提示词
|
|
|
|
|
- * 根据章的大纲,生成该章下各节的详细大纲
|
|
|
|
|
- */
|
|
|
|
|
-const SECTION_SYSTEM_PROMPT = `你是一位专业的图书策划编辑,擅长为书籍章节设计清晰的内部结构。
|
|
|
|
|
-
|
|
|
|
|
-## 你的职责
|
|
|
|
|
-根据给定的章主题和概述,设计该章下各节的详细大纲。
|
|
|
|
|
-
|
|
|
|
|
-## 输出格式
|
|
|
|
|
-必须返回 JSON,不要包含任何 markdown 代码块标记:
|
|
|
|
|
-{
|
|
|
|
|
- "sections": [
|
|
|
|
|
- {
|
|
|
|
|
- "number": 1,
|
|
|
|
|
- "title": "节标题",
|
|
|
|
|
- "summary": "本节概述(1-2句话)",
|
|
|
|
|
- "keyPoints": ["要点1", "要点2"],
|
|
|
|
|
- "estimatedWords": 1500
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
-}`;
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 小节大纲生成系统提示词
|
|
|
|
|
- * 根据节的大纲,生成该节下各小节的详细大纲
|
|
|
|
|
- */
|
|
|
|
|
-const SUBSECTION_SYSTEM_PROMPT = `你是一位专业的图书策划编辑,擅长为书籍小节设计具体的知识点结构。
|
|
|
|
|
-
|
|
|
|
|
-## 你的职责
|
|
|
|
|
-根据给定的节主题和概述,设计该节下各小节的详细大纲。
|
|
|
|
|
-
|
|
|
|
|
-## 输出格式
|
|
|
|
|
-必须返回 JSON,不要包含任何 markdown 代码块标记:
|
|
|
|
|
-{
|
|
|
|
|
- "subsections": [
|
|
|
|
|
- {
|
|
|
|
|
- "number": 1,
|
|
|
|
|
- "title": "小节标题",
|
|
|
|
|
- "summary": "本小节概述(1-2句话)",
|
|
|
|
|
- "keyPoints": ["要点1", "要点2"],
|
|
|
|
|
- "estimatedWords": 800
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
-}`;
|
|
|
|
|
-
|
|
|
|
|
-// ============ 前言/后记提示词(OpenMAIC 模式:System 详细定义,User 传参)============
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 前言系统提示词
|
|
|
|
|
- * 借鉴 OpenMAIC:详细定义角色 + 格式约束 + 质量要求
|
|
|
|
|
- */
|
|
|
|
|
-const FOREWORD_SYSTEM_PROMPT = `你是一位资深作家,擅长撰写引人入胜的书籍前言。
|
|
|
|
|
-
|
|
|
|
|
-## 你的职责
|
|
|
|
|
-为书籍撰写一篇精彩的前言,吸引读者继续阅读。
|
|
|
|
|
-
|
|
|
|
|
-## 质量要求
|
|
|
|
|
-1. 篇幅 300-500 字,语言流畅有感染力
|
|
|
|
|
-2. 开篇要有亮点,能抓住读者注意力(可用故事、名言、问题等切入)
|
|
|
|
|
-3. 简要介绍本书的主题、价值和特色,但不剧透核心内容
|
|
|
|
|
-4. 语气真诚、有热情,让读者感受到作者对主题的热爱
|
|
|
|
|
-5. 可以分享写作缘由或目标读者定位
|
|
|
|
|
-
|
|
|
|
|
-## 格式要求
|
|
|
|
|
-- 直接输出正文,不要加"前言"标题
|
|
|
|
|
-- 不要使用 markdown 格式标记(不加 #、**、- 等)
|
|
|
|
|
-- 不要在结尾写"希望读者..."之类的客套话
|
|
|
|
|
-- 直接开始叙述,第一句就要有吸引力`;
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 后记系统提示词
|
|
|
|
|
- */
|
|
|
|
|
-const AFTERWORD_SYSTEM_PROMPT = `你是一位资深作家,擅长撰写令人回味的书籍后记。
|
|
|
|
|
-
|
|
|
|
|
-## 你的职责
|
|
|
|
|
-为书籍撰写一篇有余韵的后记,让读者有所收获和思考。
|
|
|
|
|
-
|
|
|
|
|
-## 质量要求
|
|
|
|
|
-1. 篇幅 300-500 字,收尾有力
|
|
|
|
|
-2. 可以总结全书核心观点,但要用自己的话提炼而非重复
|
|
|
|
|
-3. 分享写作过程中的感悟、挑战或有趣发现
|
|
|
|
|
-4. 给读者留下思考空间或行动指引
|
|
|
|
|
-5. 语气真诚、谦逊,有深度但不说教
|
|
|
|
|
-
|
|
|
|
|
-## 格式要求
|
|
|
|
|
-- 直接输出正文,不要加"后记"标题
|
|
|
|
|
-- 不要使用 markdown 格式标记
|
|
|
|
|
-- 不要写"感谢读者"之类的套话
|
|
|
|
|
-- 结尾要有力量感,可以是金句、问题或开放性思考`;
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 小节内容写作提示词
|
|
|
|
|
- */
|
|
|
|
|
-const SUBSECTION_CONTENT_SYSTEM_PROMPT = `你是一位专业的书籍作者,擅长撰写结构严谨、内容丰富、通俗易懂的小节内容。
|
|
|
|
|
-
|
|
|
|
|
-## 你的职责
|
|
|
|
|
-撰写指定小节的正文内容。
|
|
|
|
|
-
|
|
|
|
|
-## 工具说明
|
|
|
|
|
-你可以调用以下工具来提升写作质量:
|
|
|
|
|
-- get_existing_chapters:查看已写好的章节摘要,避免内容重复,保持风格一致
|
|
|
|
|
-- get_book_outline:查看全书大纲,了解当前章节在全书中的位置
|
|
|
|
|
-- report_chapter_issue:发现内容问题时报告,然后重新生成改进版本
|
|
|
|
|
-
|
|
|
|
|
-## 写作要求
|
|
|
|
|
-1. 先调用 get_book_outline 了解整体结构
|
|
|
|
|
-2. 调用 get_existing_chapters 查看前几章内容,避免重复
|
|
|
|
|
-3. 正文要有深度,不能只是列举要点,要有解释、案例、分析
|
|
|
|
|
-4. 字数尽量达到预估字数要求
|
|
|
|
|
-5. 直接输出正文内容,不要输出任何 JSON 或 markdown 格式标记`;
|
|
|
|
|
-
|
|
|
|
|
-// ============ 动态提示词配置表 ============
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 书籍类型提示词配置
|
|
|
|
|
- */
|
|
|
|
|
-const BOOK_TYPE_PROMPTS: Record<string, string> = {
|
|
|
|
|
- '教材': `
|
|
|
|
|
-## 教材编写要求
|
|
|
|
|
-1. 章节结构: 基础篇→核心篇→进阶篇→实践篇
|
|
|
|
|
-2. 每章应包含: 学习目标、正文内容、案例分析、本章小结、习题
|
|
|
|
|
-3. 知识点要系统全面,由浅入深,循序渐进
|
|
|
|
|
-4. 理论与实践相结合,注重可操作性
|
|
|
|
|
-5. 适合课堂教学使用,有明确的教学目标
|
|
|
|
|
-`,
|
|
|
|
|
- '技术教程': `
|
|
|
|
|
-## 技术教程要求
|
|
|
|
|
-1. 章节结构: 环境搭建→基础知识→核心功能→进阶应用→实战项目
|
|
|
|
|
-2. 每章应包含: 知识点讲解、代码示例、实践练习、常见问题
|
|
|
|
|
-3. 步骤要详细清晰,读者可以直接跟着操作
|
|
|
|
|
-4. 代码示例要完整,有注释说明
|
|
|
|
|
-5. 注重实战,有完整的项目案例
|
|
|
|
|
-`,
|
|
|
|
|
- '小说': `
|
|
|
|
|
-## 小说创作要求
|
|
|
|
|
-1. 章节结构: 开端→发展→高潮→结局(可根据类型调整)
|
|
|
|
|
-2. 每章应包含: 情节推进、人物塑造、冲突设置、悬念铺垫
|
|
|
|
|
-3. 情节要紧凑有张力,人物形象鲜明
|
|
|
|
|
-4. 注重故事节奏,有起承转合
|
|
|
|
|
-5. 结尾要有余韵,给读者留下想象空间
|
|
|
|
|
-`,
|
|
|
|
|
- '商业': `
|
|
|
|
|
-## 商业书籍要求
|
|
|
|
|
-1. 章节结构: 提出问题→分析原因→解决方案→案例验证→行动指南
|
|
|
|
|
-2. 每章应包含: 核心观点、理论分析、实际案例、操作方法
|
|
|
|
|
-3. 观点要鲜明有说服力,有数据或案例支撑
|
|
|
|
|
-4. 注重实用性,读者可以直接应用
|
|
|
|
|
-5. 语言要通俗易懂,避免过于学术化
|
|
|
|
|
-`,
|
|
|
|
|
- '科普': `
|
|
|
|
|
-## 科普读物要求
|
|
|
|
|
-1. 章节结构: 现象引入→原理揭秘→实际应用→未来展望
|
|
|
|
|
-2. 每章应包含: 趣味故事、科学原理、生活应用、知识拓展
|
|
|
|
|
-3. 语言要生动有趣,用比喻解释复杂概念
|
|
|
|
|
-4. 注重知识性和趣味性的平衡
|
|
|
|
|
-5. 激发读者的好奇心和探索欲
|
|
|
|
|
-`,
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 目标读者提示词配置
|
|
|
|
|
- */
|
|
|
|
|
-const AUDIENCE_PROMPTS: Record<string, string> = {
|
|
|
|
|
- '儿童': `
|
|
|
|
|
-## 读者定位: 儿童(6-12岁)
|
|
|
|
|
-1. 语言要生动形象,充满趣味性
|
|
|
|
|
-2. 多用比喻、故事和生活中的例子
|
|
|
|
|
-3. 避免复杂的专业术语
|
|
|
|
|
-4. 句子要短小简单,易于理解
|
|
|
|
|
-5. 可以加入互动元素,激发好奇心
|
|
|
|
|
-`,
|
|
|
|
|
- '青少年': `
|
|
|
|
|
-## 读者定位: 青少年(13-18岁)
|
|
|
|
|
-1. 语言要通俗易懂,但不失准确性
|
|
|
|
|
-2. 可以引入基本概念,但要解释清楚
|
|
|
|
|
-3. 多用年轻人感兴趣的话题和例子
|
|
|
|
|
-4. 注重启发思考,培养兴趣
|
|
|
|
|
-5. 难度适中,循序渐进
|
|
|
|
|
-`,
|
|
|
|
|
- '初学者': `
|
|
|
|
|
-## 读者定位: 初学者
|
|
|
|
|
-1. 从最基础的概念开始讲解
|
|
|
|
|
-2. 避免使用过多专业术语,必要时解释术语
|
|
|
|
|
-3. 多用比喻和生活中的例子
|
|
|
|
|
-4. 每章知识点不要过多,循序渐进
|
|
|
|
|
-`,
|
|
|
|
|
- '大学生': `
|
|
|
|
|
-## 读者定位: 大学生
|
|
|
|
|
-1. 可以系统性地讲解理论知识
|
|
|
|
|
-2. 适当使用专业术语,但首次出现时要解释
|
|
|
|
|
-3. 注重理论与实践相结合
|
|
|
|
|
-4. 有明确的章节学习目标和总结
|
|
|
|
|
-5. 可以加入思考题和练习题
|
|
|
|
|
-`,
|
|
|
|
|
- '研究生': `
|
|
|
|
|
-## 读者定位: 研究生
|
|
|
|
|
-1. 可以直接使用专业术语,不需要过多解释
|
|
|
|
|
-2. 注重深度和广度,涵盖前沿技术/理论
|
|
|
|
|
-3. 可以有较多的高级内容和最佳实践
|
|
|
|
|
-4. 注重系统性和完整性
|
|
|
|
|
-5. 包含研究方法和学术讨论
|
|
|
|
|
-`,
|
|
|
|
|
- '专业人士': `
|
|
|
|
|
-## 读者定位: 专业人士
|
|
|
|
|
-1. 可以直接使用专业术语,不需要过多解释
|
|
|
|
|
-2. 注重深度和广度,涵盖前沿技术/理论
|
|
|
|
|
-3. 可以有较多的高级内容和最佳实践
|
|
|
|
|
-4. 注重系统性和完整性
|
|
|
|
|
-`,
|
|
|
|
|
- '大众读者': `
|
|
|
|
|
-## 读者定位: 大众读者
|
|
|
|
|
-1. 语言要通俗易懂,避免过于专业
|
|
|
|
|
-2. 多讲故事和案例,少讲理论
|
|
|
|
|
-3. 注重趣味性和可读性
|
|
|
|
|
-4. 每章内容要贴近生活,有实用性
|
|
|
|
|
-`,
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 内容深度提示词配置
|
|
|
|
|
- */
|
|
|
|
|
-const DEPTH_PROMPTS: Record<string, string> = {
|
|
|
|
|
- '入门': `
|
|
|
|
|
-## 内容深度: 入门级
|
|
|
|
|
-1. 覆盖面广但深度适中
|
|
|
|
|
-2. 重点在于建立知识框架
|
|
|
|
|
-3. 不涉及过于复杂的细节
|
|
|
|
|
-4. 适合快速了解全貌
|
|
|
|
|
-5. 多用比喻和简单例子
|
|
|
|
|
-`,
|
|
|
|
|
- '基础': `
|
|
|
|
|
-## 内容深度: 基础级
|
|
|
|
|
-1. 讲解基本概念和原理
|
|
|
|
|
-2. 循序渐进,由浅入深
|
|
|
|
|
-3. 每个概念都要解释清楚
|
|
|
|
|
-4. 有基础的实例说明
|
|
|
|
|
-5. 适合有一定了解的读者
|
|
|
|
|
-`,
|
|
|
|
|
- '进阶': `
|
|
|
|
|
-## 内容深度: 进阶级
|
|
|
|
|
-1. 在基础之上深入讲解
|
|
|
|
|
-2. 包含高级技巧和最佳实践
|
|
|
|
|
-3. 有实际案例分析
|
|
|
|
|
-4. 适合有一定基础的读者
|
|
|
|
|
-`,
|
|
|
|
|
- '高级': `
|
|
|
|
|
-## 内容深度: 高级级
|
|
|
|
|
-1. 深入探讨专业领域
|
|
|
|
|
-2. 包含前沿技术和高级应用
|
|
|
|
|
-3. 有大量技术细节和优化技巧
|
|
|
|
|
-4. 适合专业人士深入学习
|
|
|
|
|
-`,
|
|
|
|
|
- '专家': `
|
|
|
|
|
-## 内容深度: 专家级
|
|
|
|
|
-1. 深入探讨底层原理
|
|
|
|
|
-2. 包含前沿研究和高级应用
|
|
|
|
|
-3. 有大量技术细节和优化技巧
|
|
|
|
|
-4. 适合专业人士和研究人员
|
|
|
|
|
-`,
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 行业领域提示词配置
|
|
|
|
|
- */
|
|
|
|
|
-const INDUSTRY_PROMPTS: Record<string, string> = {
|
|
|
|
|
- 'IT/计算机': `
|
|
|
|
|
-## 行业领域: IT/计算机
|
|
|
|
|
-1. 可以包含代码示例和技术架构图
|
|
|
|
|
-2. 注重技术的实用性和可操作性
|
|
|
|
|
-3. 关注最新技术趋势和最佳实践
|
|
|
|
|
-4. 有完整的开发环境搭建指导
|
|
|
|
|
-`,
|
|
|
|
|
- '金融': `
|
|
|
|
|
-## 行业领域: 金融
|
|
|
|
|
-1. 注重数据分析和案例研究
|
|
|
|
|
-2. 包含实际的市场案例
|
|
|
|
|
-3. 关注风险管理和合规要求
|
|
|
|
|
-4. 有明确的投资策略或方法论
|
|
|
|
|
-`,
|
|
|
|
|
- '医学': `
|
|
|
|
|
-## 行业领域: 医学
|
|
|
|
|
-1. 注重科学性和严谨性
|
|
|
|
|
-2. 基于循证医学
|
|
|
|
|
-3. 包含临床案例
|
|
|
|
|
-4. 关注最新医学进展
|
|
|
|
|
-`,
|
|
|
|
|
- '教育': `
|
|
|
|
|
-## 行业领域: 教育
|
|
|
|
|
-1. 注重教学方法和学习效果
|
|
|
|
|
-2. 包含教学设计案例
|
|
|
|
|
-3. 关注教育心理学应用
|
|
|
|
|
-4. 有可操作的教学方案
|
|
|
|
|
-`,
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// ============ 工具函数 ============
|
|
|
|
|
-
|
|
|
|
|
-function countWords(text: string): number {
|
|
|
|
|
- return (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 需求分析: 从用户描述中提取关键信息
|
|
|
|
|
- */
|
|
|
|
|
-interface BookRequirements {
|
|
|
|
|
- bookType?: string; // 教材/技术教程/小说/商业/科普
|
|
|
|
|
- audience?: string; // 初学者/专业人士/大众读者
|
|
|
|
|
- depth?: string; // 入门/进阶/专家
|
|
|
|
|
- industry?: string; // IT/金融/医学/教育
|
|
|
|
|
- specialNeeds?: string[]; // 特殊需求: 有案例/有代码/有习题等
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function analyzeRequirements(topic: string, description?: string): BookRequirements {
|
|
|
|
|
- const text = `${topic} ${description || ''}`.toLowerCase();
|
|
|
|
|
- const req: BookRequirements = {
|
|
|
|
|
- specialNeeds: [],
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // 分析书籍类型
|
|
|
|
|
- if (/教材|课程|教学|大学|学校/i.test(text)) {
|
|
|
|
|
- req.bookType = '教材';
|
|
|
|
|
- } else if (/教程|实战|开发|编程|代码/i.test(text)) {
|
|
|
|
|
- req.bookType = '技术教程';
|
|
|
|
|
- } else if (/小说|故事|文学|传记|散文/i.test(text)) {
|
|
|
|
|
- req.bookType = '小说';
|
|
|
|
|
- } else if (/商业|管理|营销|创业|投资|经济/i.test(text)) {
|
|
|
|
|
- req.bookType = '商业';
|
|
|
|
|
- } else if (/科普|百科|科学|探索|揭秘/i.test(text)) {
|
|
|
|
|
- req.bookType = '科普';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 分析目标读者
|
|
|
|
|
- if (/儿童|小学生/i.test(text)) {
|
|
|
|
|
- req.audience = '儿童';
|
|
|
|
|
- } else if (/青少年|中学生|高中生/i.test(text)) {
|
|
|
|
|
- req.audience = '青少年';
|
|
|
|
|
- } else if (/初学者|入门|新手|零基础|小白/i.test(text)) {
|
|
|
|
|
- req.audience = '初学者';
|
|
|
|
|
- } else if (/大学生|本科生/i.test(text)) {
|
|
|
|
|
- req.audience = '大学生';
|
|
|
|
|
- } else if (/研究生|硕士|博士/i.test(text)) {
|
|
|
|
|
- req.audience = '研究生';
|
|
|
|
|
- } else if (/专业|高级|深入|专家|研究/i.test(text)) {
|
|
|
|
|
- req.audience = '专业人士';
|
|
|
|
|
- } else if (/大众|通俗|普及|广泛/i.test(text)) {
|
|
|
|
|
- req.audience = '大众读者';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 分析知识难度
|
|
|
|
|
- if (/入门|零基础|简单|易懂/i.test(text)) {
|
|
|
|
|
- req.depth = '入门';
|
|
|
|
|
- } else if (/基础|基本概念|循序渐进/i.test(text)) {
|
|
|
|
|
- req.depth = '基础';
|
|
|
|
|
- } else if (/进阶|深入|系统全面/i.test(text)) {
|
|
|
|
|
- req.depth = '进阶';
|
|
|
|
|
- } else if (/高级|专业深入|前沿/i.test(text)) {
|
|
|
|
|
- req.depth = '高级';
|
|
|
|
|
- } else if (/专家|研究|底层原理/i.test(text)) {
|
|
|
|
|
- req.depth = '专家';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 分析行业领域
|
|
|
|
|
- if (/计算机|IT|编程|软件|互联网|AI|人工智能/i.test(text)) {
|
|
|
|
|
- req.industry = 'IT/计算机';
|
|
|
|
|
- } else if (/金融|股票|投资|银行|证券/i.test(text)) {
|
|
|
|
|
- req.industry = '金融';
|
|
|
|
|
- } else if (/医学|医疗|健康|临床|疾病/i.test(text)) {
|
|
|
|
|
- req.industry = '医学';
|
|
|
|
|
- } else if (/教育|教学|学习|学校|课程/i.test(text)) {
|
|
|
|
|
- req.industry = '教育';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 分析特殊需求
|
|
|
|
|
- if (/案例|实例/i.test(text)) req.specialNeeds.push('有案例');
|
|
|
|
|
- if (/代码|示例/i.test(text)) req.specialNeeds.push('有代码');
|
|
|
|
|
- if (/习题|练习|作业/i.test(text)) req.specialNeeds.push('有习题');
|
|
|
|
|
- if (/图表|插图|配图/i.test(text)) req.specialNeeds.push('有图表');
|
|
|
|
|
- if (/项目|实战|实践/i.test(text)) req.specialNeeds.push('有项目实战');
|
|
|
|
|
-
|
|
|
|
|
- return req;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 动态组装提示词
|
|
|
|
|
- */
|
|
|
|
|
-function buildDynamicPrompt(topic: string, bookScale: string, description?: string): string {
|
|
|
|
|
- const req = analyzeRequirements(topic, description);
|
|
|
|
|
-
|
|
|
|
|
- // 基础提示词
|
|
|
|
|
- let prompt = OUTLINE_SYSTEM_PROMPT;
|
|
|
|
|
-
|
|
|
|
|
- // 根据书籍类型添加
|
|
|
|
|
- if (req.bookType && BOOK_TYPE_PROMPTS[req.bookType]) {
|
|
|
|
|
- prompt += BOOK_TYPE_PROMPTS[req.bookType];
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 根据目标读者添加
|
|
|
|
|
- if (req.audience && AUDIENCE_PROMPTS[req.audience]) {
|
|
|
|
|
- prompt += AUDIENCE_PROMPTS[req.audience];
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 根据内容深度添加
|
|
|
|
|
- if (req.depth && DEPTH_PROMPTS[req.depth]) {
|
|
|
|
|
- prompt += DEPTH_PROMPTS[req.depth];
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 根据行业领域添加
|
|
|
|
|
- if (req.industry && INDUSTRY_PROMPTS[req.industry]) {
|
|
|
|
|
- prompt += INDUSTRY_PROMPTS[req.industry];
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 特殊需求
|
|
|
|
|
- if (req.specialNeeds.length > 0) {
|
|
|
|
|
- prompt += `\n## 特殊要求\n`;
|
|
|
|
|
- prompt += `根据用户需求,本书应包含: ${req.specialNeeds.join('、')}\n`;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`[DynamicPrompt] 检测到需求:`, req);
|
|
|
|
|
-
|
|
|
|
|
- return prompt;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 构建大纲生成消息(动态提示词)
|
|
|
|
|
- */
|
|
|
|
|
-function buildOutlineMessages(topic: string, bookScale: string, description?: string): ChatMessage[] {
|
|
|
|
|
- const range = SCALE_CHAPTER_RANGE[bookScale as keyof typeof SCALE_CHAPTER_RANGE] || { min: 5, max: 10 };
|
|
|
|
|
-
|
|
|
|
|
- // 使用动态提示词
|
|
|
|
|
- const systemPrompt = buildDynamicPrompt(topic, bookScale, description);
|
|
|
|
|
-
|
|
|
|
|
- return [
|
|
|
|
|
- { role: 'system', content: systemPrompt },
|
|
|
|
|
- {
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- content: `书名:《${topic}》\n规模:${SCALE_DESC[bookScale] || '中篇'}\n章节范围:${range.min}-${range.max}章\n${description ? `需求描述:${description}\n` : ''}\n请生成书籍大纲。`,
|
|
|
|
|
- },
|
|
|
|
|
- ];
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 构建节大纲生成消息
|
|
|
|
|
- */
|
|
|
|
|
-function buildSectionMessages(chapterTitle: string, chapterSummary: string, keyPoints: string[]): ChatMessage[] {
|
|
|
|
|
- return [
|
|
|
|
|
- { role: 'system', content: SECTION_SYSTEM_PROMPT },
|
|
|
|
|
- {
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- content: `章标题:${chapterTitle}\n章概述:${chapterSummary}\n核心知识点:${keyPoints.join('、')}\n\n请为该章设计节的大纲(通常每章 2-4 节)。`,
|
|
|
|
|
- },
|
|
|
|
|
- ];
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 构建小节大纲生成消息
|
|
|
|
|
- */
|
|
|
|
|
-function buildSubsectionMessages(sectionTitle: string, sectionSummary: string, keyPoints: string[]): ChatMessage[] {
|
|
|
|
|
- return [
|
|
|
|
|
- { role: 'system', content: SUBSECTION_SYSTEM_PROMPT },
|
|
|
|
|
- {
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- content: `节标题:${sectionTitle}\n节概述:${sectionSummary}\n核心知识点:${keyPoints.join('、')}\n\n请为该节设计小节的大纲(通常每节 2-4 小节)。`,
|
|
|
|
|
- },
|
|
|
|
|
- ];
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 构建章节写作消息(配合工具调用)
|
|
|
|
|
- */
|
|
|
|
|
-function buildChapterMessages(topic: string, chapter: OutlineChapter): ChatMessage[] {
|
|
|
|
|
- return [
|
|
|
|
|
- { role: 'system', content: CHAPTER_SYSTEM_PROMPT },
|
|
|
|
|
- {
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- content: `请撰写《${topic}》第${chapter.number}章。\n章节标题:${chapter.title}\n章节概述:${chapter.summary}\n核心知识点:${chapter.keyPoints.join('、')}\n预估字数:${chapter.estimatedWords}字`,
|
|
|
|
|
- },
|
|
|
|
|
- ];
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function buildForewordMessages(topic: string): ChatMessage[] {
|
|
|
|
|
- return [
|
|
|
|
|
- { role: 'system', content: FOREWORD_SYSTEM_PROMPT },
|
|
|
|
|
- { role: 'user', content: `请为《${topic}》撰写前言。` },
|
|
|
|
|
- ];
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function buildAfterwordMessages(topic: string): ChatMessage[] {
|
|
|
|
|
- return [
|
|
|
|
|
- { role: 'system', content: AFTERWORD_SYSTEM_PROMPT },
|
|
|
|
|
- { role: 'user', content: `请为《${topic}》撰写后记。` },
|
|
|
|
|
- ];
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function parseOutline(jsonStr: string): any {
|
|
|
|
|
- if (!jsonStr || typeof jsonStr !== 'string') {
|
|
|
|
|
- console.error('[OutlineParser] 输入为空或非字符串');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- // 尝试多种 JSON 提取策略(借鉴 OpenMAIC partial-json 思路)
|
|
|
|
|
- let data: any;
|
|
|
|
|
-
|
|
|
|
|
- // 策略1:直接解析(最理想情况,LLM 直接输出纯 JSON)
|
|
|
|
|
- try {
|
|
|
|
|
- data = JSON.parse(jsonStr.trim());
|
|
|
|
|
- } catch {
|
|
|
|
|
- // 策略2:提取 JSON 对象(处理 LLM 加了 markdown 标记或多余文字)
|
|
|
|
|
- const match = jsonStr.match(/\{[\s\S]*\}/);
|
|
|
|
|
- if (!match) {
|
|
|
|
|
- console.error('[OutlineParser] 未找到 JSON 对象');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- try {
|
|
|
|
|
- data = JSON.parse(match[0]);
|
|
|
|
|
- } catch (parseErr) {
|
|
|
|
|
- console.error('[OutlineParser] JSON 解析失败:', parseErr);
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 验证必要字段
|
|
|
|
|
- if (!data || typeof data !== 'object') {
|
|
|
|
|
- console.error('[OutlineParser] 解析结果非对象');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- if (!Array.isArray(data.chapters)) {
|
|
|
|
|
- console.error('[OutlineParser] chapters 字段缺失或非数组');
|
|
|
|
|
- // 尝试兼容:若顶层就是章节数组
|
|
|
|
|
- if (Array.isArray(data)) {
|
|
|
|
|
- data = { chapters: data };
|
|
|
|
|
- } else {
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- mainTheme: data.mainTheme || '主题待定',
|
|
|
|
|
- structureLogic: data.structureLogic || '由浅入深',
|
|
|
|
|
- chapters: data.chapters.map((c: any, i: number) => ({
|
|
|
|
|
- number: c.number || i + 1,
|
|
|
|
|
- title: c.title || `第${i + 1}章`,
|
|
|
|
|
- summary: typeof c.summary === 'string' ? c.summary : '',
|
|
|
|
|
- keyPoints: Array.isArray(c.keyPoints) ? c.keyPoints : [],
|
|
|
|
|
- estimatedWords: typeof c.estimatedWords === 'number' ? c.estimatedWords : 1000,
|
|
|
|
|
- })),
|
|
|
|
|
- };
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- console.error('[OutlineParser] 未知错误:', err);
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 解析节大纲
|
|
|
|
|
- */
|
|
|
|
|
-function parseSections(jsonStr: string): { sections: any[] } | null {
|
|
|
|
|
- if (!jsonStr || typeof jsonStr !== 'string') {
|
|
|
|
|
- console.error('[SectionParser] 输入为空或非字符串');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- let data: any;
|
|
|
|
|
- let cleanedStr = jsonStr.trim();
|
|
|
|
|
-
|
|
|
|
|
- // 调试:打印前50个字符的编码
|
|
|
|
|
- const debugStr = cleanedStr.substring(0, Math.min(50, cleanedStr.length));
|
|
|
|
|
- console.log('[SectionParser] 原始前50字符:', JSON.stringify(debugStr));
|
|
|
|
|
-
|
|
|
|
|
- // 清理 JSON 字符串中的非法控制字符和多余空白
|
|
|
|
|
- cleanedStr = cleanedStr.replace(/[\x00-\x1F\x7F]/g, (char) => {
|
|
|
|
|
- if (char === '\n') return ' ';
|
|
|
|
|
- if (char === '\r') return ' ';
|
|
|
|
|
- if (char === '\t') return ' ';
|
|
|
|
|
- return ' ';
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // 移除 markdown 代码块标记
|
|
|
|
|
- cleanedStr = cleanedStr.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
|
|
|
|
|
-
|
|
|
|
|
- // 移除思考标签
|
|
|
|
|
- cleanedStr = cleanedStr.replace(/^[\s\S]*?<blockquote>\s*[\s\S]*?<\/blockquote>\s*/, '');
|
|
|
|
|
- const secThinkIdx = cleanedStr.indexOf('</think>');
|
|
|
|
|
- if (secThinkIdx === 0) cleanedStr = cleanedStr.substring(secThinkIdx + 8).trim();
|
|
|
|
|
-
|
|
|
|
|
- console.log('[SectionParser] 清理后:', cleanedStr.substring(0, Math.min(100, cleanedStr.length)));
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- data = JSON.parse(cleanedStr);
|
|
|
|
|
- } catch (firstErr) {
|
|
|
|
|
- console.log('[SectionParser] 首次解析失败,尝试正则提取');
|
|
|
|
|
- // 尝试找到 JSON 对象
|
|
|
|
|
- const match = cleanedStr.match(/\{[\s\S]*\}/);
|
|
|
|
|
- if (!match) {
|
|
|
|
|
- console.error('[SectionParser] 未找到 JSON 对象');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- try {
|
|
|
|
|
- const jsonCandidate = match[0];
|
|
|
|
|
- console.log('[SectionParser] 正则提取:', jsonCandidate.substring(0, Math.min(100, jsonCandidate.length)));
|
|
|
|
|
- data = JSON.parse(jsonCandidate);
|
|
|
|
|
- } catch (parseErr) {
|
|
|
|
|
- console.error('[SectionParser] JSON 解析失败:', parseErr, '原始:', cleanedStr.substring(0, 200));
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (!data || typeof data !== 'object') {
|
|
|
|
|
- console.error('[SectionParser] 解析结果非对象');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 兼容:如果顶层就是数组
|
|
|
|
|
- if (Array.isArray(data)) {
|
|
|
|
|
- data = { sections: data };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 兼容多种字段名:sections, Sections, section_list 等
|
|
|
|
|
- let sectionsArray = data.sections || data.Sections || data.section_list || data.chapter_sections;
|
|
|
|
|
-
|
|
|
|
|
- if (!Array.isArray(sectionsArray)) {
|
|
|
|
|
- console.error('[SectionParser] sections 字段缺失或非数组,实际keys:', Object.keys(data));
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- sections: sectionsArray.map((s: any, i: number) => ({
|
|
|
|
|
- number: s.number || i + 1,
|
|
|
|
|
- title: s.title || `第${i + 1}节`,
|
|
|
|
|
- summary: typeof s.summary === 'string' ? s.summary : '',
|
|
|
|
|
- keyPoints: Array.isArray(s.keyPoints) ? s.keyPoints : [],
|
|
|
|
|
- estimatedWords: typeof s.estimatedWords === 'number' ? s.estimatedWords : 1000,
|
|
|
|
|
- })),
|
|
|
|
|
- };
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- console.error('[SectionParser] 未知错误:', err);
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 解析小节大纲
|
|
|
|
|
- */
|
|
|
|
|
-function parseSubsections(jsonStr: string): { subsections: any[] } | null {
|
|
|
|
|
- if (!jsonStr || typeof jsonStr !== 'string') {
|
|
|
|
|
- console.error('[SubsectionParser] 输入为空或非字符串');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- let data: any;
|
|
|
|
|
- let cleanedStr = jsonStr.trim();
|
|
|
|
|
-
|
|
|
|
|
- // 调试:打印前80个字符
|
|
|
|
|
- console.log('[SubsectionParser] 原始前80字符:', JSON.stringify(cleanedStr.substring(0, 80)));
|
|
|
|
|
-
|
|
|
|
|
- // 清理 JSON 字符串中的非法控制字符
|
|
|
|
|
- cleanedStr = cleanedStr.replace(/[\x00-\x1F\x7F]/g, (char) => {
|
|
|
|
|
- // 将控制字符替换为转义的换行符或空格
|
|
|
|
|
- if (char === '\n') return '\\n';
|
|
|
|
|
- if (char === '\r') return '\\r';
|
|
|
|
|
- if (char === '\t') return '\\t';
|
|
|
|
|
- return ' ';
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // 移除 markdown 代码块标记
|
|
|
|
|
- cleanedStr = cleanedStr.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
|
|
|
|
|
-
|
|
|
|
|
- // 移除思考标签
|
|
|
|
|
- cleanedStr = cleanedStr.replace(/^[\s\S]*?<blockquote>\s*[\s\S]*?<\/blockquote>\s*/, '');
|
|
|
|
|
- // 如果以 开头,说明思考标签在JSON之前,需要移除
|
|
|
|
|
- // 修复:</think>是结束标签,</think>是开始标签
|
|
|
|
|
- const thinkEndIdx = cleanedStr.indexOf('</think>');
|
|
|
|
|
- if (thinkEndIdx !== -1) {
|
|
|
|
|
- cleanedStr = cleanedStr.substring(thinkEndIdx + 8).trim();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log('[SubsectionParser] 处理后前100字符:', JSON.stringify(cleanedStr.substring(0, 100)));
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- data = JSON.parse(cleanedStr);
|
|
|
|
|
- } catch {
|
|
|
|
|
- // 使用括号计数法提取JSON对象
|
|
|
|
|
- const firstBrace = cleanedStr.indexOf('{');
|
|
|
|
|
- if (firstBrace === -1) {
|
|
|
|
|
- console.error('[SubsectionParser] 未找到 JSON 对象');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- let jsonCandidate = '';
|
|
|
|
|
- let depth = 0;
|
|
|
|
|
- let started = false;
|
|
|
|
|
- for (let i = firstBrace; i < cleanedStr.length; i++) {
|
|
|
|
|
- const ch = cleanedStr[i];
|
|
|
|
|
- if (ch === '{') {
|
|
|
|
|
- depth++;
|
|
|
|
|
- started = true;
|
|
|
|
|
- } else if (ch === '}') {
|
|
|
|
|
- depth--;
|
|
|
|
|
- }
|
|
|
|
|
- if (started) jsonCandidate += ch;
|
|
|
|
|
- if (started && depth === 0) break;
|
|
|
|
|
- }
|
|
|
|
|
- if (!jsonCandidate) {
|
|
|
|
|
- console.error('[SubsectionParser] JSON 对象提取失败');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- try {
|
|
|
|
|
- data = JSON.parse(jsonCandidate);
|
|
|
|
|
- } catch (parseErr) {
|
|
|
|
|
- console.error('[SubsectionParser] JSON 解析失败:', parseErr);
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (!data || typeof data !== 'object') {
|
|
|
|
|
- console.error('[SubsectionParser] 解析结果非对象');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 兼容:如果顶层就是数组
|
|
|
|
|
- if (Array.isArray(data)) {
|
|
|
|
|
- data = { subsections: data };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 兼容多种字段名:subsections, Subsections, subsection_list 等
|
|
|
|
|
- let subsectionsArray = data.subsections || data.Subsections || data.subsection_list || data.section_subsections;
|
|
|
|
|
-
|
|
|
|
|
- if (!Array.isArray(subsectionsArray)) {
|
|
|
|
|
- console.error('[SubsectionParser] subsections 字段缺失或非数组');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- subsections: subsectionsArray.map((s: any, i: number) => ({
|
|
|
|
|
- number: s.number || i + 1,
|
|
|
|
|
- title: s.title || `第${i + 1}小节`,
|
|
|
|
|
- summary: typeof s.summary === 'string' ? s.summary : '',
|
|
|
|
|
- keyPoints: Array.isArray(s.keyPoints) ? s.keyPoints : [],
|
|
|
|
|
- estimatedWords: typeof s.estimatedWords === 'number' ? s.estimatedWords : 500,
|
|
|
|
|
- })),
|
|
|
|
|
- };
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- console.error('[SubsectionParser] 未知错误:', err);
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ============ LangGraph 节点 ============
|
|
|
|
|
-
|
|
|
|
|
-async function generateOutlineNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
|
|
|
|
|
- console.log('[LangGraph] 生成大纲, bookId:', state.bookId, 'scale:', state.bookScale);
|
|
|
|
|
-
|
|
|
|
|
- const messages = buildOutlineMessages(state.topic, state.bookScale, state.description);
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const response = await callLLMWithMessages(messages);
|
|
|
|
|
- const outline = parseOutline(response);
|
|
|
|
|
- if (!outline) throw new Error('大纲解析失败');
|
|
|
|
|
-
|
|
|
|
|
- const totalChapters = outline.chapters.length;
|
|
|
|
|
- await bookStore.update(state.bookId, { totalChapters, outlineJson: JSON.stringify(outline), status: 'planning', progress: PROGRESS.OUTLINE_DONE });
|
|
|
|
|
- await bookStore.createChapters(state.bookId, outline.chapters.map(c => ({
|
|
|
|
|
- number: c.number, title: c.title, summary: c.summary, keyPoints: c.keyPoints, estimatedWords: c.estimatedWords,
|
|
|
|
|
- })));
|
|
|
|
|
-
|
|
|
|
|
- console.log('[LangGraph] 大纲生成完成,章节数:', outline.chapters.length);
|
|
|
|
|
- return { progress: PROGRESS.OUTLINE_DONE };
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('[LangGraph] 大纲生成失败:', error);
|
|
|
|
|
- return { error: error instanceof Error ? error.message : '失败', finished: true };
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 生成节大纲节点(二级大纲)
|
|
|
|
|
- * 为每章生成节的大纲
|
|
|
|
|
- */
|
|
|
|
|
-async function generateSectionsNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
|
|
|
|
|
- console.log('[LangGraph] 生成节大纲, bookId:', state.bookId);
|
|
|
|
|
-
|
|
|
|
|
- const book = await bookStore.getById(state.bookId);
|
|
|
|
|
- if (!book || !book.outline) {
|
|
|
|
|
- console.log('[LangGraph] 无大纲,跳过节生成');
|
|
|
|
|
- return { finished: true, progress: PROGRESS.SECTIONS_DONE };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- // 更新大纲结构:为每章生成节
|
|
|
|
|
- const updatedChapters = [];
|
|
|
|
|
-
|
|
|
|
|
- for (const chapter of book.outline.chapters) {
|
|
|
|
|
- console.log(`[LangGraph] 为第${chapter.number}章「${chapter.title}」生成节大纲`);
|
|
|
|
|
- const messages = buildSectionMessages(chapter.title, chapter.summary, chapter.keyPoints || []);
|
|
|
|
|
-
|
|
|
|
|
- // 添加重试机制,最多重试2次
|
|
|
|
|
- let parsed = null;
|
|
|
|
|
- let lastError = null;
|
|
|
|
|
- for (let retry = 0; retry < 3; retry++) {
|
|
|
|
|
- try {
|
|
|
|
|
- const response = await callLLMWithMessages(messages);
|
|
|
|
|
- parsed = parseSections(response);
|
|
|
|
|
-
|
|
|
|
|
- if (parsed && parsed.sections.length > 0) {
|
|
|
|
|
- console.log(`[LangGraph] 第${chapter.number}章节大纲解析成功,共${parsed.sections.length}节`);
|
|
|
|
|
- break;
|
|
|
|
|
- } else {
|
|
|
|
|
- console.warn(`[LangGraph] 第${chapter.number}章节大纲解析失败(第${retry + 1}次),重试中...`);
|
|
|
|
|
- lastError = new Error('解析结果为空');
|
|
|
|
|
- }
|
|
|
|
|
- } catch (sectionErr) {
|
|
|
|
|
- console.error(`[LangGraph] 第${chapter.number}章节大纲生成失败(第${retry + 1}次):`, sectionErr);
|
|
|
|
|
- lastError = sectionErr;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (parsed && parsed.sections.length > 0) {
|
|
|
|
|
- // 为每节创建数据库记录(level=2)
|
|
|
|
|
- const bookIdNum = parseInt(state.bookId);
|
|
|
|
|
- // 使用更精确的查询条件,确保找到正确的章
|
|
|
|
|
- const chapterRecord = await prisma.bookChapter.findFirst({
|
|
|
|
|
- where: {
|
|
|
|
|
- bookId: bookIdNum,
|
|
|
|
|
- parentId: null,
|
|
|
|
|
- level: 1,
|
|
|
|
|
- number: chapter.number
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- if (chapterRecord) {
|
|
|
|
|
- for (const section of parsed.sections) {
|
|
|
|
|
- await bookStore.createChapterItem(bookIdNum, {
|
|
|
|
|
- number: section.number,
|
|
|
|
|
- title: section.title,
|
|
|
|
|
- summary: section.summary,
|
|
|
|
|
- keyPoints: section.keyPoints,
|
|
|
|
|
- estimatedWords: section.estimatedWords,
|
|
|
|
|
- }, chapterRecord.id, 2);
|
|
|
|
|
-
|
|
|
|
|
- console.log(`[LangGraph] - 第${chapter.number}章第${section.number}节「${section.title}」`);
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- console.error(`[LangGraph] 未找到第${chapter.number}章的数据库记录`);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 更新大纲 JSON
|
|
|
|
|
- updatedChapters.push({
|
|
|
|
|
- ...chapter,
|
|
|
|
|
- sections: parsed.sections,
|
|
|
|
|
- });
|
|
|
|
|
- } else {
|
|
|
|
|
- console.warn(`[LangGraph] 第${chapter.number}章节大纲解析失败,跳过。错误:`, lastError);
|
|
|
|
|
- updatedChapters.push(chapter);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 保存更新后的大纲
|
|
|
|
|
- const updatedOutline = { ...book.outline, chapters: updatedChapters };
|
|
|
|
|
- await bookStore.update(state.bookId, {
|
|
|
|
|
- outlineJson: JSON.stringify(updatedOutline),
|
|
|
|
|
- progress: PROGRESS.SECTIONS_DONE,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- console.log('[LangGraph] 节大纲生成完成');
|
|
|
|
|
- return { progress: PROGRESS.SECTIONS_DONE };
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('[LangGraph] 节大纲生成失败:', error);
|
|
|
|
|
- return { error: error instanceof Error ? error.message : '失败', finished: true };
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 生成小节大纲节点(三级大纲)
|
|
|
|
|
- * 为每节生成小节的大纲
|
|
|
|
|
- */
|
|
|
|
|
-async function generateSubsectionsNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
|
|
|
|
|
- console.log('[LangGraph] 生成小节大纲, bookId:', state.bookId);
|
|
|
|
|
-
|
|
|
|
|
- const book = await bookStore.getById(state.bookId);
|
|
|
|
|
- if (!book || !book.outline) {
|
|
|
|
|
- console.log('[LangGraph] 无大纲,跳过小节生成');
|
|
|
|
|
- return { finished: true, progress: PROGRESS.SUBSECTIONS_DONE };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const updatedChapters = [];
|
|
|
|
|
-
|
|
|
|
|
- for (const chapter of book.outline.chapters) {
|
|
|
|
|
- const updatedSections = [];
|
|
|
|
|
-
|
|
|
|
|
- if (chapter.sections && chapter.sections.length > 0) {
|
|
|
|
|
- for (const section of chapter.sections) {
|
|
|
|
|
- console.log(`[LangGraph] 为第${chapter.number}章第${section.number}节「${section.title}」生成小节大纲`);
|
|
|
|
|
- const messages = buildSubsectionMessages(section.title, section.summary || '', section.keyPoints || []);
|
|
|
|
|
-
|
|
|
|
|
- // 添加重试机制,最多重试2次
|
|
|
|
|
- let parsed = null;
|
|
|
|
|
- let lastError = null;
|
|
|
|
|
- for (let retry = 0; retry < 3; retry++) {
|
|
|
|
|
- try {
|
|
|
|
|
- const response = await callLLMWithMessages(messages);
|
|
|
|
|
- parsed = parseSubsections(response);
|
|
|
|
|
-
|
|
|
|
|
- if (parsed && parsed.subsections.length > 0) {
|
|
|
|
|
- console.log(`[LangGraph] 第${chapter.number}章第${section.number}节小节大纲解析成功,共${parsed.subsections.length}小节`);
|
|
|
|
|
- break;
|
|
|
|
|
- } else {
|
|
|
|
|
- console.warn(`[LangGraph] 第${chapter.number}章第${section.number}节小节大纲解析失败(第${retry + 1}次),重试中...`);
|
|
|
|
|
- lastError = new Error('解析结果为空');
|
|
|
|
|
- }
|
|
|
|
|
- } catch (subErr) {
|
|
|
|
|
- console.error(`[LangGraph] 第${chapter.number}章第${section.number}节小节大纲生成失败(第${retry + 1}次):`, subErr);
|
|
|
|
|
- lastError = subErr;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (parsed && parsed.subsections.length > 0) {
|
|
|
|
|
- // 为每小节创建数据库记录(level=3)
|
|
|
|
|
- const bookIdNum = parseInt(state.bookId);
|
|
|
|
|
- // 先找到章的数据库记录
|
|
|
|
|
- const chapterRecord = await prisma.bookChapter.findFirst({
|
|
|
|
|
- where: {
|
|
|
|
|
- bookId: bookIdNum,
|
|
|
|
|
- parentId: null,
|
|
|
|
|
- level: 1,
|
|
|
|
|
- number: chapter.number
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- if (!chapterRecord) {
|
|
|
|
|
- console.error(`[LangGraph] 未找到第${chapter.number}章的数据库记录`);
|
|
|
|
|
- updatedSections.push(section);
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 再找到节的数据库记录
|
|
|
|
|
- const sectionRecord = await prisma.bookChapter.findFirst({
|
|
|
|
|
- where: {
|
|
|
|
|
- bookId: bookIdNum,
|
|
|
|
|
- parentId: chapterRecord.id,
|
|
|
|
|
- level: 2,
|
|
|
|
|
- number: section.number
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- if (sectionRecord) {
|
|
|
|
|
- for (const subsection of parsed.subsections) {
|
|
|
|
|
- await bookStore.createChapterItem(bookIdNum, {
|
|
|
|
|
- number: subsection.number,
|
|
|
|
|
- title: subsection.title,
|
|
|
|
|
- summary: subsection.summary,
|
|
|
|
|
- keyPoints: subsection.keyPoints,
|
|
|
|
|
- estimatedWords: subsection.estimatedWords,
|
|
|
|
|
- }, sectionRecord.id, 3);
|
|
|
|
|
-
|
|
|
|
|
- console.log(`[LangGraph] - 第${chapter.number}章第${section.number}节第${subsection.number}小节「${subsection.title}」`);
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- console.error(`[LangGraph] 未找到第${chapter.number}章第${section.number}节的数据库记录`);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- updatedSections.push({
|
|
|
|
|
- ...section,
|
|
|
|
|
- subsections: parsed.subsections,
|
|
|
|
|
- });
|
|
|
|
|
- } else {
|
|
|
|
|
- console.warn(`[LangGraph] 第${chapter.number}章第${section.number}节小节大纲解析失败,跳过。错误:`, lastError);
|
|
|
|
|
- updatedSections.push(section);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- updatedChapters.push({
|
|
|
|
|
- ...chapter,
|
|
|
|
|
- sections: updatedSections.length > 0 ? updatedSections : chapter.sections,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 保存更新后的大纲
|
|
|
|
|
- const updatedOutline = { ...book.outline, chapters: updatedChapters };
|
|
|
|
|
- await bookStore.update(state.bookId, {
|
|
|
|
|
- outlineJson: JSON.stringify(updatedOutline),
|
|
|
|
|
- progress: PROGRESS.SUBSECTIONS_DONE,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- console.log('[LangGraph] 小节大纲生成完成');
|
|
|
|
|
- return { progress: PROGRESS.SUBSECTIONS_DONE };
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('[LangGraph] 小节大纲生成失败:', error);
|
|
|
|
|
- return { error: error instanceof Error ? error.message : '失败', finished: true };
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function writeChaptersNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
|
|
|
|
|
- console.log('[LangGraph] 生成章节内容, bookId:', state.bookId);
|
|
|
|
|
-
|
|
|
|
|
- const book = await bookStore.getById(state.bookId);
|
|
|
|
|
- if (!book || !book.outline) {
|
|
|
|
|
- console.log('[LangGraph] 无大纲或书籍,跳过章节生成');
|
|
|
|
|
- return { finished: true, progress: PROGRESS.CONTENT_END };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const bookIdNum = parseInt(state.bookId);
|
|
|
|
|
-
|
|
|
|
|
- // 筛选出未完成内容的叶节点(支持断点续传)
|
|
|
|
|
- // 叶节点判断:contentStatus !== 'completed' 表示内容未生成
|
|
|
|
|
- const allSubsections = await prisma.bookChapter.findMany({
|
|
|
|
|
- where: { bookId: bookIdNum, level: 3 },
|
|
|
|
|
- orderBy: [
|
|
|
|
|
- { parentId: 'asc' },
|
|
|
|
|
- { number: 'asc' }
|
|
|
|
|
- ],
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const subsections = allSubsections.filter((s: any) => s.contentStatus !== 'completed');
|
|
|
|
|
- const completedCount = allSubsections.length - subsections.length;
|
|
|
|
|
-
|
|
|
|
|
- if (completedCount > 0) {
|
|
|
|
|
- console.log(`[LangGraph] ✅ 跳过 ${completedCount} 个已生成内容的小节,待生成 ${subsections.length} 个`);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (subsections.length === 0) {
|
|
|
|
|
- console.log('[LangGraph] ✅ 所有小节内容已完成,跳过内容生成');
|
|
|
|
|
- return { finished: true, progress: PROGRESS.CONTENT_END };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 构建父节点映射,便于获取上下文
|
|
|
|
|
- const chapterMap = new Map<number, any>();
|
|
|
|
|
- const sectionMap = new Map<number, any>();
|
|
|
|
|
-
|
|
|
|
|
- // 重新查询章节和节来构建映射
|
|
|
|
|
- const chaptersAndSections = await prisma.bookChapter.findMany({
|
|
|
|
|
- where: {
|
|
|
|
|
- bookId: bookIdNum,
|
|
|
|
|
- level: { in: [1, 2] }
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- chaptersAndSections.forEach(c => {
|
|
|
|
|
- if (c.level === 1) chapterMap.set(c.id, c);
|
|
|
|
|
- if (c.level === 2) sectionMap.set(c.id, c);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const chapters: ChapterResult[] = [];
|
|
|
|
|
- const failedChapters: number[] = [];
|
|
|
|
|
-
|
|
|
|
|
- // 创建书籍工具(让 LLM 可查询上下文、避免重复)
|
|
|
|
|
- const bookTools = createBookTools(state.bookId, bookStore);
|
|
|
|
|
-
|
|
|
|
|
- // 获取当前已生成的字数(用于断点续传)
|
|
|
|
|
- let currentWordCount = await getGeneratedWordCount(state.bookId);
|
|
|
|
|
- console.log(`[LangGraph] 当前已生成字数: ${currentWordCount}`);
|
|
|
|
|
- console.log(`[LangGraph] 待生成小节数: ${subsections.length}`);
|
|
|
|
|
-
|
|
|
|
|
- // 计算总小节数用于进度(从已完成数量开始)
|
|
|
|
|
- const totalSubsections = allSubsections.length;
|
|
|
|
|
- let completedSubsections = completedCount; // 从已完成的数量开始
|
|
|
|
|
-
|
|
|
|
|
- for (const subsection of subsections) {
|
|
|
|
|
- const parentSection = sectionMap.get(subsection.parentId || 0);
|
|
|
|
|
- const parentChapter = parentSection ? chapterMap.get(parentSection.parentId || 0) : null;
|
|
|
|
|
-
|
|
|
|
|
- // ===== 额度监控 =====
|
|
|
|
|
- try {
|
|
|
|
|
- const quotaCheck = await checkQuotaForWords(book.userId || 1, currentWordCount);
|
|
|
|
|
- if (!quotaCheck.sufficient) {
|
|
|
|
|
- console.warn(`[LangGraph] ⚠️ 额度不足,中断生成: ${quotaCheck.reason}`);
|
|
|
|
|
- await markGenerationInterrupted(state.bookId, subsection.number - 1, currentWordCount);
|
|
|
|
|
- return {
|
|
|
|
|
- currentChapter: subsection.number,
|
|
|
|
|
- progress: Math.round((completedSubsections / totalSubsections) * 80) + 15,
|
|
|
|
|
- error: `额度不足中断:${quotaCheck.reason},已保存进度`
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- } catch (quotaErr) {
|
|
|
|
|
- console.warn(`[LangGraph] 额度检查失败,继续生成`);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 构建小节写作上下文
|
|
|
|
|
- const chapterTitle = parentChapter?.title || '未知章';
|
|
|
|
|
- const sectionTitle = parentSection?.title || '未知节';
|
|
|
|
|
- const chapterSummary = parentChapter?.summary || '';
|
|
|
|
|
- const sectionSummary = parentSection?.summary || '';
|
|
|
|
|
-
|
|
|
|
|
- console.log(`[LangGraph] 生成${chapterTitle} - ${sectionTitle} - ${subsection.title}`);
|
|
|
|
|
- const messages = buildSubsectionContentMessages(state.topic, chapterTitle, chapterSummary, sectionTitle, sectionSummary, subsection);
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- let content: string;
|
|
|
|
|
- try {
|
|
|
|
|
- const result = await callLLMWithTools(messages, bookTools);
|
|
|
|
|
- content = result.text;
|
|
|
|
|
- if (result.toolCalls.length > 0) {
|
|
|
|
|
- console.log(`[LangGraph] 小节「${subsection.title}」使用了 ${result.toolCalls.length} 次工具调用`);
|
|
|
|
|
- }
|
|
|
|
|
- } catch (toolErr: any) {
|
|
|
|
|
- console.log(`[LangGraph] 工具调用不可用,降级为普通调用`);
|
|
|
|
|
- content = await callLLMWithMessages(messages);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const wordCount = countWords(content);
|
|
|
|
|
- currentWordCount += wordCount;
|
|
|
|
|
- completedSubsections++;
|
|
|
|
|
-
|
|
|
|
|
- // 保存小节内容
|
|
|
|
|
- await bookStore.updateChapterById(subsection.id, {
|
|
|
|
|
- content,
|
|
|
|
|
- wordCount,
|
|
|
|
|
- contentStatus: 'completed' // 内容生成完成
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // 更新进度
|
|
|
|
|
- const progress = Math.round((completedSubsections / totalSubsections) * (PROGRESS.CONTENT_END - PROGRESS.CONTENT_START)) + PROGRESS.CONTENT_START;
|
|
|
|
|
- await bookStore.update(state.bookId, { progress, status: 'generating' });
|
|
|
|
|
- console.log(`[LangGraph] 小节「${subsection.title}」内容完成 (${completedSubsections}/${totalSubsections}),累计${currentWordCount}字,进度${progress}%`);
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- const errorMsg = error instanceof Error ? error.message : '失败';
|
|
|
|
|
- await bookStore.updateChapterById(subsection.id, {
|
|
|
|
|
- contentStatus: 'failed',
|
|
|
|
|
- contentError: errorMsg
|
|
|
|
|
- });
|
|
|
|
|
- failedChapters.push(subsection.number);
|
|
|
|
|
- console.error(`[LangGraph] 小节「${subsection.title}」内容失败:`, errorMsg);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 更新所有父节和章的状态为 completed
|
|
|
|
|
- await prisma.bookChapter.updateMany({
|
|
|
|
|
- where: { bookId: bookIdNum, level: { in: [1, 2] } },
|
|
|
|
|
- data: { status: 'completed' }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- currentChapter: chaptersAndSections.filter(c => c.level === 1).length,
|
|
|
|
|
- progress: PROGRESS.CONTENT_END,
|
|
|
|
|
- error: failedChapters.length > 0 ? `小节${failedChapters.join(',')}失败` : undefined
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 构建小节内容生成消息
|
|
|
|
|
- */
|
|
|
|
|
-function buildSubsectionContentMessages(topic: string, chapterTitle: string, chapterSummary: string, sectionTitle: string, sectionSummary: string, subsection: any): ChatMessage[] {
|
|
|
|
|
- return [
|
|
|
|
|
- { role: 'system', content: SUBSECTION_CONTENT_SYSTEM_PROMPT },
|
|
|
|
|
- {
|
|
|
|
|
- role: 'user',
|
|
|
|
|
- content: `书名:《${topic}》
|
|
|
|
|
-章标题:${chapterTitle}
|
|
|
|
|
-章概述:${chapterSummary || ''}
|
|
|
|
|
-节标题:${sectionTitle}
|
|
|
|
|
-节概述:${sectionSummary}
|
|
|
|
|
-小节标题:${subsection.title}
|
|
|
|
|
-小节概述:${subsection.summary || ''}
|
|
|
|
|
-核心知识点:${(subsection.keyPoints || []).join('、')}
|
|
|
|
|
-预估字数:${subsection.estimatedWords || 500}字
|
|
|
|
|
-
|
|
|
|
|
-请撰写该小节的正文内容。`,
|
|
|
|
|
- },
|
|
|
|
|
- ];
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ============ LangGraph 节点 ============
|
|
|
|
|
-
|
|
|
|
|
-async function writeForewordNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
|
|
|
|
|
- console.log('[LangGraph] 生成前言, bookId:', state.bookId);
|
|
|
|
|
- try {
|
|
|
|
|
- const messages = buildForewordMessages(state.topic);
|
|
|
|
|
- const foreword = await callLLMWithMessages(messages);
|
|
|
|
|
- await bookStore.update(state.bookId, { foreword, progress: PROGRESS.FOREWORD_DONE });
|
|
|
|
|
- return { progress: PROGRESS.FOREWORD_DONE };
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('[LangGraph] 前言生成失败:', error);
|
|
|
|
|
- return { progress: PROGRESS.FOREWORD_DONE };
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function writeAfterwordNode(state: typeof GraphState.State): Promise<Partial<typeof GraphState.State>> {
|
|
|
|
|
- console.log('[LangGraph] 生成后记, bookId:', state.bookId);
|
|
|
|
|
- try {
|
|
|
|
|
- const messages = buildAfterwordMessages(state.topic);
|
|
|
|
|
- const afterword = await callLLMWithMessages(messages);
|
|
|
|
|
- await bookStore.update(state.bookId, { afterword });
|
|
|
|
|
- return { finished: true, progress: PROGRESS.AFTERWORD_DONE };
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('[LangGraph] 后记生成失败:', error);
|
|
|
|
|
- return { finished: true, progress: PROGRESS.AFTERWORD_DONE };
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ============ 创建工作流 ============
|
|
|
|
|
-
|
|
|
|
|
-function createGraph() {
|
|
|
|
|
- const workflow = new StateGraph(GraphState)
|
|
|
|
|
- .addNode('generate_outline', generateOutlineNode)
|
|
|
|
|
- .addNode('generate_sections', generateSectionsNode)
|
|
|
|
|
- .addNode('generate_subsections', generateSubsectionsNode)
|
|
|
|
|
- .addNode('write_chapters', writeChaptersNode)
|
|
|
|
|
- .addNode('write_foreword', writeForewordNode)
|
|
|
|
|
- .addNode('write_afterword', writeAfterwordNode)
|
|
|
|
|
- .setEntryPoint('generate_outline')
|
|
|
|
|
- // 流程:章大纲 → 节大纲 → 小节大纲 → 内容生成 → 前言 → 后记
|
|
|
|
|
- .addEdge('generate_outline', 'generate_sections')
|
|
|
|
|
- .addEdge('generate_sections', 'generate_subsections')
|
|
|
|
|
- .addEdge('generate_subsections', 'write_chapters')
|
|
|
|
|
- .addEdge('write_chapters', 'write_foreword')
|
|
|
|
|
- .addEdge('write_foreword', 'write_afterword')
|
|
|
|
|
- .addEdge('write_afterword', END);
|
|
|
|
|
-
|
|
|
|
|
- return workflow.compile();
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ============ 主类 ============
|
|
|
|
|
-
|
|
|
|
|
-export class LangGraphBookGenerator {
|
|
|
|
|
- private graph = createGraph();
|
|
|
|
|
-
|
|
|
|
|
- async generate(bookId: string, topic: string, bookScale: string = 'medium'): Promise<void> {
|
|
|
|
|
- const initialState = { bookId, topic, bookScale, currentChapter: 0, finished: false, error: undefined, progress: 0 };
|
|
|
|
|
- await bookStore.update(bookId, { status: 'generating', progress: 0 });
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const stream = await this.graph.stream(initialState);
|
|
|
|
|
- for await (const step of stream) {
|
|
|
|
|
- console.log('[LangGraph] Step:', Object.keys(step));
|
|
|
|
|
- }
|
|
|
|
|
- await bookStore.update(bookId, { status: 'completed', progress: 100 });
|
|
|
|
|
- await bookStore.publishAlbum(bookId);
|
|
|
|
|
- console.log('[LangGraph] 书籍生成完成');
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('[LangGraph] 生成失败:', error);
|
|
|
|
|
- await bookStore.update(bookId, { status: 'failed', errorMsg: error instanceof Error ? error.message : '生成失败' });
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export const langGraphGenerator = new LangGraphBookGenerator();
|
|
|
|
|
-
|
|
|
|
|
-// 重新导出 book-type-config 中的函数供外部使用
|
|
|
|
|
-export { getScaleConfig } from './book-type-config';
|
|
|