From 15716638f58b159c45c551e0d178420a5b3cf971 Mon Sep 17 00:00:00 2001 From: xiongxiaoyang <1179705413@qq.com> Date: Tue, 20 Oct 2026 14:36:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(author):=20=E6=96=B0=E5=A2=9EAI=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +- doc/sql/20261020.sql | 71 ++ doc/sql/novel_plus.sql | 74 ++ .../novel/core/enums/ResponseStatus.java | 14 + .../java2nb/novel/entity/AiAuthorPref.java | 36 + .../java2nb/novel/entity/AiChapterPlan.java | 66 ++ .../java2nb/novel/entity/AiCreationPlan.java | 56 ++ .../java2nb/novel/entity/AiCreationTask.java | 66 ++ .../controller/AiAssistantController.java | 198 ++++ .../com/java2nb/novel/core/ai/AiJsonUtil.java | 134 +++ .../novel/core/ai/ChapterTextCheckUtil.java | 114 +++ .../novel/core/ai/PromptTemplateUtil.java | 70 ++ .../novel/core/config/ThreadPoolConfig.java | 9 + .../novel/mapper/FrontAiAuthorPrefMapper.java | 22 + .../mapper/FrontAiChapterPlanMapper.java | 67 ++ .../mapper/FrontAiCreationPlanMapper.java | 37 + .../mapper/FrontAiCreationTaskMapper.java | 61 ++ .../novel/service/AiAssistantService.java | 81 ++ .../service/impl/AiAssistantServiceImpl.java | 931 ++++++++++++++++++ .../java2nb/novel/vo/AiPlanProgressVO.java | 37 + .../src/main/resources/application.yml | 6 +- .../mapping/FrontAiAuthorPrefMapper.xml | 17 + .../mapping/FrontAiChapterPlanMapper.xml | 73 ++ .../mapping/FrontAiCreationPlanMapper.xml | 43 + .../mapping/FrontAiCreationTaskMapper.xml | 73 ++ .../prompts/ai-assistant/book_name.txt | 19 + .../prompts/ai-assistant/chapter_check.txt | 30 + .../prompts/ai-assistant/chapter_content.txt | 37 + .../ai-assistant/chapter_plan_batch.txt | 26 + .../prompts/ai-assistant/chapter_revise.txt | 27 + .../prompts/ai-assistant/chapter_summary.txt | 21 + .../prompts/ai-assistant/creative_plan.txt | 32 + .../src/main/resources/static/css/user.css | 3 + .../templates/author/ai_assistant.html | 816 +++++++++++++++ .../templates/author/author_income.html | 7 +- .../author/author_income_detail.html | 7 +- .../resources/templates/author/book_add.html | 7 +- .../templates/author/content_add.html | 7 +- .../templates/author/content_update.html | 5 +- .../resources/templates/author/index.html | 8 +- .../templates/author/index_list.html | 5 +- .../java2nb/novel/core/ai/AiJsonUtilTest.java | 86 ++ .../core/ai/ChapterTextCheckUtilTest.java | 76 ++ .../novel/core/ai/PromptTemplateUtilTest.java | 62 ++ templates/green/html/author/ai_assistant.html | 816 +++++++++++++++ .../green/html/author/author_income.html | 7 +- .../html/author/author_income_detail.html | 7 +- templates/green/html/author/book_add.html | 7 +- templates/green/html/author/content_add.html | 7 +- .../green/html/author/content_update.html | 5 +- templates/green/html/author/index.html | 8 +- templates/green/html/author/index_list.html | 5 +- templates/green/static/css/user.css | 3 + .../orange/html/author/ai_assistant.html | 816 +++++++++++++++ .../orange/html/author/author_income.html | 7 +- .../html/author/author_income_detail.html | 7 +- templates/orange/html/author/book_add.html | 7 +- templates/orange/html/author/content_add.html | 7 +- .../orange/html/author/content_update.html | 5 +- templates/orange/html/author/index.html | 8 +- templates/orange/html/author/index_list.html | 5 +- templates/orange/static/css/user.css | 3 + 62 files changed, 5310 insertions(+), 80 deletions(-) create mode 100644 novel-common/src/main/java/com/java2nb/novel/entity/AiAuthorPref.java create mode 100644 novel-common/src/main/java/com/java2nb/novel/entity/AiChapterPlan.java create mode 100644 novel-common/src/main/java/com/java2nb/novel/entity/AiCreationPlan.java create mode 100644 novel-common/src/main/java/com/java2nb/novel/entity/AiCreationTask.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/controller/AiAssistantController.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/core/ai/AiJsonUtil.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/core/ai/ChapterTextCheckUtil.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/core/ai/PromptTemplateUtil.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiAuthorPrefMapper.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiChapterPlanMapper.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiCreationPlanMapper.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiCreationTaskMapper.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/service/AiAssistantService.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/service/impl/AiAssistantServiceImpl.java create mode 100644 novel-front/src/main/java/com/java2nb/novel/vo/AiPlanProgressVO.java create mode 100644 novel-front/src/main/resources/mybatis/mapping/FrontAiAuthorPrefMapper.xml create mode 100644 novel-front/src/main/resources/mybatis/mapping/FrontAiChapterPlanMapper.xml create mode 100644 novel-front/src/main/resources/mybatis/mapping/FrontAiCreationPlanMapper.xml create mode 100644 novel-front/src/main/resources/mybatis/mapping/FrontAiCreationTaskMapper.xml create mode 100644 novel-front/src/main/resources/prompts/ai-assistant/book_name.txt create mode 100644 novel-front/src/main/resources/prompts/ai-assistant/chapter_check.txt create mode 100644 novel-front/src/main/resources/prompts/ai-assistant/chapter_content.txt create mode 100644 novel-front/src/main/resources/prompts/ai-assistant/chapter_plan_batch.txt create mode 100644 novel-front/src/main/resources/prompts/ai-assistant/chapter_revise.txt create mode 100644 novel-front/src/main/resources/prompts/ai-assistant/chapter_summary.txt create mode 100644 novel-front/src/main/resources/prompts/ai-assistant/creative_plan.txt create mode 100644 novel-front/src/main/resources/templates/author/ai_assistant.html create mode 100644 novel-front/src/test/java/com/java2nb/novel/core/ai/AiJsonUtilTest.java create mode 100644 novel-front/src/test/java/com/java2nb/novel/core/ai/ChapterTextCheckUtilTest.java create mode 100644 novel-front/src/test/java/com/java2nb/novel/core/ai/PromptTemplateUtilTest.java create mode 100644 templates/green/html/author/ai_assistant.html create mode 100644 templates/orange/html/author/ai_assistant.html diff --git a/README.md b/README.md index 6479bd2..04e321c 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,27 @@ novel-plus 5.x 已集成 Spring 官方最新发布的 Spring AI 框架,并推 1. v5.0.0 版本在小说章节发布页面的文本编辑器中集成了多项智能编辑功能,包括 AI 扩写、缩写、续写及文本润色等。这些功能的设计灵感来源于百家号文章编辑器中的 AI 助手。 2. v5.1.0 版本在小说发布页面,新增 AI 生成封面图功能。若作家未上传自定义封面图,系统将根据小说信息自动生成封面图。 +3. 最新版本(即将正式发布,可通过源码抢先体验)在作家后台新增「AI 长篇创作助手」,覆盖从创意问答、设定大纲到逐章正文创作的完整长篇小说创作工作流,详见下文小节。 -目前,AI 功能仍处于实验阶段,仅实现了基础的核心功能。我们非常重视用户的实际使用体验和反馈,未来将根据用户需求和使用情况,持续优化和调整该功能。如果用户反馈积极,我们计划进一步开发更高级的 AI 功能,例如自动生成有声小说、智能情节推荐等,以全面提升 novel-plus 的创作能力和用户体验。 +目前,AI 功能仍处于实验阶段。我们非常重视用户的实际使用体验和反馈,未来将根据用户需求和使用情况,持续优化和调整该功能。如果用户反馈积极,我们计划进一步开发更高级的 AI 功能,以全面提升 novel-plus 的创作能力和用户体验。 我们将持续关注 AI 技术的发展,并致力于将其与小说创作场景深度融合,为用户带来更智能、更便捷的创作工具。 +### AI 长篇创作助手 + +新版在作家后台内置了 AI 长篇小说创作助手,引导作者从一个创意出发,循序渐进地完成一部小说的创作: + +1. **创作问答**:填写题材、核心创意、主角设定与核心冲突即可开始,深度设定(世界观、文风、目标读者、章节数、每章字数)可跳过使用默认值,支持随机灵感一键生成创意。 +2. **书名建议**:AI 根据创意生成多个书名及推荐理由,可挑选、可自拟、可重新生成。 +3. **创作方案**:一次性生成包含故事简介、世界观、人物档案、故事总纲、分卷大纲、伏笔记录和章节规划的结构化方案,每个分区都可编辑、保存草稿或重新生成,作者确认后才进入正文创作阶段。 +4. **逐章创作**:每次只创作一章,AI 会自动参考总纲、当前章规划、人物档案、世界观规则、前文摘要和未回收伏笔,保持前后文一致;生成后可编辑,也可发起自动校验修订(字数、重复段落等程序检查 + 情节一致性 AI 检查,最多 3 轮)。 +5. **作者确认制**:所有章节正文仅在作者点击「确认保存」后才写入现有章节体系,AI 生成内容和草稿都不会覆盖已发布章节。 +6. **断点续写**:随时退出、刷新或中断,重新进入助手即可恢复创作进度、进行中任务和未保存草稿;支持从指定章节继续创作。 + +使用方式:登录作家后台,点击菜单「AI创作助手」开始新书创作,或在「我的小说」列表中对已有作品点击「AI创作」继续补充设定与续写。 + +[![novel-plus AI 创作工作流演示](http://xxyopen.com/images/resource/os/novel-plus/author_ai.png)](https://www.bilibili.com/video/BV1Exnw6dEdG) + novel-plus 项目默认使用的是第三方大模型服务平台[硅基流动](https://cloud.siliconflow.cn/i/DOgMRH9S)提供的 API(兼容 OpenAI 的相关接口,可直接通过 Spring AI 框架调用),采用的 AI 模型有对话模型`deepseek-ai/DeepSeek-R1-0528-Qwen3-8B`(DeepSeek-R1 的蒸馏版本,免费使用)和生图模型`Kwai-Kolors/Kolors`(快手 Kolors 团队开发的文本到图像生成模型,免费使用)。只需注册一个硅基流动账号,创建一个 API 密钥,并将其添加到 novel-plus 项目 novel-front 模块的 yaml 配置文件中,即可体验 novel-plus 项目的 AI 写作功能。 ```yaml @@ -113,10 +129,7 @@ spring: model: deepseek-ai/DeepSeek-R1-0528-Qwen3-8B ``` -⚠️ novel-plus 项目默认使用的都是免费 AI 模型,生成效果有限。如果对生成内容有更高的要求,建议选用付费的 AI 模型。 - - - +⚠️ novel-plus 项目默认使用的都是免费 AI 模型,生成效果有限。如果对生成质量和生成速度有更高的要求,建议选用付费的 AI 模型。 ## 免责声明 diff --git a/doc/sql/20261020.sql b/doc/sql/20261020.sql index 427df47..9c07e3f 100644 --- a/doc/sql/20261020.sql +++ b/doc/sql/20261020.sql @@ -15,3 +15,74 @@ CREATE TABLE `user_message` KEY `idx_user_time` (`user_id`, `create_time`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户消息表'; + + +CREATE TABLE `ai_creation_plan` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `author_id` bigint(20) NOT NULL COMMENT '作者ID', + `book_id` bigint(20) DEFAULT NULL COMMENT '关联小说ID(新建小说确认方案后回填)', + `stage` tinyint(1) NOT NULL DEFAULT 1 COMMENT '创作阶段:1创作问答中 2方案草稿 3方案已确认 4创作完成', + `creative_params` text COMMENT '创作问答参数JSON(题材/核心创意/主角/冲突/世界观/视角/主题/读者/章节数/字数等)', + `plan_content` longtext COMMENT '创作方案JSON(书名/卖点/简介/世界观/人物档案/总纲/分卷大纲/伏笔/章节规划)', + `outline_version` int(11) NOT NULL DEFAULT 0 COMMENT '大纲版本号(每次确认方案递增)', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_book` (`book_id`), + KEY `idx_author` (`author_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='AI创作方案表'; + +CREATE TABLE `ai_chapter_plan` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `plan_id` bigint(20) NOT NULL COMMENT '创作方案ID', + `chapter_no` int(11) NOT NULL COMMENT '章节序号(从1开始)', + `index_name` varchar(100) DEFAULT NULL COMMENT '章节标题', + `chapter_outline` text COMMENT '章节规划JSON(主要事件/冲突/人物变化/主线关系/结尾悬念)', + `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0待创作 1草稿已生成未保存 2已保存为正式章节 3保存中(事务内瞬时态)', + `index_id` bigint(20) DEFAULT NULL COMMENT '已保存的正式章节ID(book_index.id)', + `word_count` int(11) DEFAULT 0 COMMENT '已保存章节字数', + `summary` text COMMENT '已完成章节摘要(创作记忆)', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_plan_chapter` (`plan_id`, `chapter_no`), + KEY `idx_plan_status` (`plan_id`, `status`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='AI章节计划表'; + +CREATE TABLE `ai_creation_task` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `plan_id` bigint(20) NOT NULL COMMENT '创作方案ID', + `author_id` bigint(20) NOT NULL COMMENT '作者ID(权限隔离)', + `task_type` tinyint(1) NOT NULL COMMENT '任务类型:1书名建议 2创作方案 3章节正文 4章节校验修订 5章节摘要 6章节规划增量', + `chapter_no` int(11) NOT NULL DEFAULT 0 COMMENT '目标章节序号(非章节任务为0)', + `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0执行中 1成功 2失败', + `running_flag` tinyint(1) DEFAULT NULL COMMENT '执行中标记(执行中=1,完成置NULL,配合唯一索引防止同一目标重复提交)', + `result` longtext COMMENT '生成结果(结构化任务为JSON,正文任务为纯文本草稿)', + `error_msg` varchar(500) DEFAULT NULL COMMENT '最近一次失败原因', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_running` (`plan_id`, `task_type`, `chapter_no`, `running_flag`), + KEY `idx_plan` (`plan_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='AI创作生成任务表'; + +CREATE TABLE `ai_author_pref` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `author_id` bigint(20) NOT NULL COMMENT '作者ID', + `pref_content` text COMMENT '全局创作偏好JSON(常用题材/叙事视角/文风/章节长度习惯等)', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_author` (`author_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='AI创作全局偏好表'; + +alter table `book` modify column `pic_url` varchar(200) NOT NULL DEFAULT '/images/default.gif' COMMENT '小说封面'; + diff --git a/doc/sql/novel_plus.sql b/doc/sql/novel_plus.sql index 5fede06..c8313aa 100644 --- a/doc/sql/novel_plus.sql +++ b/doc/sql/novel_plus.sql @@ -3351,3 +3351,77 @@ CREATE TABLE `user_message` ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户消息表'; + + + +CREATE TABLE `ai_creation_plan` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `author_id` bigint(20) NOT NULL COMMENT '作者ID', + `book_id` bigint(20) DEFAULT NULL COMMENT '关联小说ID(新建小说确认方案后回填)', + `stage` tinyint(1) NOT NULL DEFAULT 1 COMMENT '创作阶段:1创作问答中 2方案草稿 3方案已确认 4创作完成', + `creative_params` text COMMENT '创作问答参数JSON(题材/核心创意/主角/冲突/世界观/视角/主题/读者/章节数/字数等)', + `plan_content` longtext COMMENT '创作方案JSON(书名/卖点/简介/世界观/人物档案/总纲/分卷大纲/伏笔/章节规划)', + `outline_version` int(11) NOT NULL DEFAULT 0 COMMENT '大纲版本号(每次确认方案递增)', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_book` (`book_id`), + KEY `idx_author` (`author_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='AI创作方案表'; + +CREATE TABLE `ai_chapter_plan` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `plan_id` bigint(20) NOT NULL COMMENT '创作方案ID', + `chapter_no` int(11) NOT NULL COMMENT '章节序号(从1开始)', + `index_name` varchar(100) DEFAULT NULL COMMENT '章节标题', + `chapter_outline` text COMMENT '章节规划JSON(主要事件/冲突/人物变化/主线关系/结尾悬念)', + `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0待创作 1草稿已生成未保存 2已保存为正式章节 3保存中(事务内瞬时态)', + `index_id` bigint(20) DEFAULT NULL COMMENT '已保存的正式章节ID(book_index.id)', + `word_count` int(11) DEFAULT 0 COMMENT '已保存章节字数', + `summary` text COMMENT '已完成章节摘要(创作记忆)', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_plan_chapter` (`plan_id`, `chapter_no`), + KEY `idx_plan_status` (`plan_id`, `status`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='AI章节计划表'; + +CREATE TABLE `ai_creation_task` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `plan_id` bigint(20) NOT NULL COMMENT '创作方案ID', + `author_id` bigint(20) NOT NULL COMMENT '作者ID(权限隔离)', + `task_type` tinyint(1) NOT NULL COMMENT '任务类型:1书名建议 2创作方案 3章节正文 4章节校验修订 5章节摘要 6章节规划增量', + `chapter_no` int(11) NOT NULL DEFAULT 0 COMMENT '目标章节序号(非章节任务为0)', + `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0执行中 1成功 2失败', + `running_flag` tinyint(1) DEFAULT NULL COMMENT '执行中标记(执行中=1,完成置NULL,配合唯一索引防止同一目标重复提交)', + `result` longtext COMMENT '生成结果(结构化任务为JSON,正文任务为纯文本草稿)', + `error_msg` varchar(500) DEFAULT NULL COMMENT '最近一次失败原因', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_running` (`plan_id`, `task_type`, `chapter_no`, `running_flag`), + KEY `idx_plan` (`plan_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='AI创作生成任务表'; + +CREATE TABLE `ai_author_pref` +( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `author_id` bigint(20) NOT NULL COMMENT '作者ID', + `pref_content` text COMMENT '全局创作偏好JSON(常用题材/叙事视角/文风/章节长度习惯等)', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_author` (`author_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='AI创作全局偏好表'; + +alter table `book` modify column `pic_url` varchar(200) NOT NULL DEFAULT '/images/default.gif' COMMENT '小说封面'; + + + diff --git a/novel-common/src/main/java/com/java2nb/novel/core/enums/ResponseStatus.java b/novel-common/src/main/java/com/java2nb/novel/core/enums/ResponseStatus.java index ab913fd..97ad943 100644 --- a/novel-common/src/main/java/com/java2nb/novel/core/enums/ResponseStatus.java +++ b/novel-common/src/main/java/com/java2nb/novel/core/enums/ResponseStatus.java @@ -56,6 +56,20 @@ public enum ResponseStatus implements IResultCode { LIST_DESC_TOO_LONG(6006, "书单简介不能超过300个字!"), BOOK_NOT_EXISTS(6007, "书籍不存在!"), + /** + * AI创作助手相关错误 + * */ + AI_PLAN_NOT_EXISTS(7001, "创作方案不存在或无权访问!"), + AI_TASK_RUNNING(7002, "该创作任务正在执行中,请勿重复提交!"), + AI_GEN_FAILED(7003, "AI生成失败,请重试!"), + AI_PLAN_INVALID(7004, "创作方案内容格式有误!"), + AI_PARAMS_INVALID(7005, "创作参数不合法!"), + AI_CHAPTER_SAVED(7006, "该章节已保存,请前往章节管理修改!"), + AI_PLAN_NOT_CONFIRMED(7007, "请先确认创作方案后再创作章节!"), + AI_CHAPTER_PLAN_NOT_EXISTS(7008, "该章节不在章节规划中,请先检查创作方案!"), + AI_DRAFT_NOT_EXISTS(7009, "暂无章节草稿,请先生成正文!"), + AI_GEN_TIMEOUT(7010, "AI生成超时,请重试或适当调低单章目标字数!"), + /** * 搜索引擎相关错误 * */ diff --git a/novel-common/src/main/java/com/java2nb/novel/entity/AiAuthorPref.java b/novel-common/src/main/java/com/java2nb/novel/entity/AiAuthorPref.java new file mode 100644 index 0000000..ce11ec1 --- /dev/null +++ b/novel-common/src/main/java/com/java2nb/novel/entity/AiAuthorPref.java @@ -0,0 +1,36 @@ +package com.java2nb.novel.entity; + +import lombok.Data; + +import java.util.Date; + +/** + * AI创作全局偏好 + * + * @author 11797 + */ +@Data +public class AiAuthorPref { + + private Long id; + + /** + * 作者ID + */ + private Long authorId; + + /** + * 全局创作偏好JSON(常用题材/叙事视角/文风/章节长度习惯等) + */ + private String prefContent; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/novel-common/src/main/java/com/java2nb/novel/entity/AiChapterPlan.java b/novel-common/src/main/java/com/java2nb/novel/entity/AiChapterPlan.java new file mode 100644 index 0000000..15caefd --- /dev/null +++ b/novel-common/src/main/java/com/java2nb/novel/entity/AiChapterPlan.java @@ -0,0 +1,66 @@ +package com.java2nb.novel.entity; + +import lombok.Data; + +import java.util.Date; + +/** + * AI章节计划 + * + * @author 11797 + */ +@Data +public class AiChapterPlan { + + private Long id; + + /** + * 创作方案ID + */ + private Long planId; + + /** + * 章节序号(从1开始) + */ + private Integer chapterNo; + + /** + * 章节标题 + */ + private String indexName; + + /** + * 章节规划JSON(主要事件/冲突/人物变化/主线关系/结尾悬念) + */ + private String chapterOutline; + + /** + * 状态:0待创作 1草稿已生成未保存 2已保存为正式章节 3保存中(事务内瞬时态) + */ + private Byte status; + + /** + * 已保存的正式章节ID(book_index.id) + */ + private Long indexId; + + /** + * 已保存章节字数 + */ + private Integer wordCount; + + /** + * 已完成章节摘要(创作记忆) + */ + private String summary; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/novel-common/src/main/java/com/java2nb/novel/entity/AiCreationPlan.java b/novel-common/src/main/java/com/java2nb/novel/entity/AiCreationPlan.java new file mode 100644 index 0000000..f2aebb3 --- /dev/null +++ b/novel-common/src/main/java/com/java2nb/novel/entity/AiCreationPlan.java @@ -0,0 +1,56 @@ +package com.java2nb.novel.entity; + +import lombok.Data; + +import java.util.Date; + +/** + * AI创作方案 + * + * @author 11797 + */ +@Data +public class AiCreationPlan { + + private Long id; + + /** + * 作者ID + */ + private Long authorId; + + /** + * 关联小说ID(新建小说确认方案后回填) + */ + private Long bookId; + + /** + * 创作阶段:1创作问答中 2方案草稿 3方案已确认 4创作完成 + */ + private Byte stage; + + /** + * 创作问答参数JSON + */ + private String creativeParams; + + /** + * 创作方案JSON(书名/卖点/简介/世界观/人物档案/总纲/分卷大纲/伏笔/章节规划) + */ + private String planContent; + + /** + * 大纲版本号(每次确认方案递增) + */ + private Integer outlineVersion; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/novel-common/src/main/java/com/java2nb/novel/entity/AiCreationTask.java b/novel-common/src/main/java/com/java2nb/novel/entity/AiCreationTask.java new file mode 100644 index 0000000..4a425f1 --- /dev/null +++ b/novel-common/src/main/java/com/java2nb/novel/entity/AiCreationTask.java @@ -0,0 +1,66 @@ +package com.java2nb.novel.entity; + +import lombok.Data; + +import java.util.Date; + +/** + * AI创作生成任务 + * + * @author 11797 + */ +@Data +public class AiCreationTask { + + private Long id; + + /** + * 创作方案ID + */ + private Long planId; + + /** + * 作者ID(权限隔离) + */ + private Long authorId; + + /** + * 任务类型:1书名建议 2创作方案 3章节正文 4章节校验修订 5章节摘要 6章节规划增量 + */ + private Byte taskType; + + /** + * 目标章节序号(非章节任务为0) + */ + private Integer chapterNo; + + /** + * 状态:0执行中 1成功 2失败 + */ + private Byte status; + + /** + * 执行中标记(执行中=1,完成置NULL,配合唯一索引防止同一目标重复提交) + */ + private Byte runningFlag; + + /** + * 生成结果(结构化任务为JSON,正文任务为纯文本草稿) + */ + private String result; + + /** + * 最近一次失败原因 + */ + private String errorMsg; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/novel-front/src/main/java/com/java2nb/novel/controller/AiAssistantController.java b/novel-front/src/main/java/com/java2nb/novel/controller/AiAssistantController.java new file mode 100644 index 0000000..b8684f1 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/controller/AiAssistantController.java @@ -0,0 +1,198 @@ +package com.java2nb.novel.controller; + +import com.java2nb.novel.core.bean.UserDetails; +import com.java2nb.novel.core.enums.ResponseStatus; +import com.java2nb.novel.entity.AiCreationPlan; +import com.java2nb.novel.entity.AiCreationTask; +import com.java2nb.novel.entity.Author; +import com.java2nb.novel.entity.Book; +import com.java2nb.novel.service.AiAssistantService; +import com.java2nb.novel.service.AuthorService; +import com.java2nb.novel.vo.AiPlanProgressVO; +import io.github.xxyopen.model.resp.RestResult; +import io.github.xxyopen.web.exception.BusinessException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * AI 长篇小说创作助手接口(作者后台) + * 所有接口均校验登录作者身份,方案/任务/章节操作在Service层按author_id做数据隔离 + * + * @author 11797 + */ +@RequestMapping("author/aiAssistant") +@RestController +@Slf4j +@RequiredArgsConstructor +public class AiAssistantController extends BaseController { + + private final AiAssistantService aiAssistantService; + + private final AuthorService authorService; + + /** + * 初始化或恢复创作方案(bookId=绑定现有小说;不传=新建创作) + */ + @PostMapping("initPlan") + public RestResult initPlan(@RequestParam(value = "planId", required = false) Long planId, + @RequestParam(value = "bookId", required = false) Long bookId, HttpServletRequest request) { + Author author = checkAuthor(request); + return RestResult.ok(aiAssistantService.initPlan(author.getId(), planId, bookId)); + } + + /** + * 保存创作问答参数 + */ + @PostMapping("saveParams") + public RestResult saveParams(@RequestParam("planId") Long planId, + @RequestParam("creativeParams") String creativeParams, HttpServletRequest request) { + Author author = checkAuthor(request); + aiAssistantService.saveParams(author.getId(), planId, creativeParams); + return RestResult.ok(); + } + + /** + * 提交异步生成任务(1书名 2创作方案 3章节正文 4校验修订 6章节规划增量) + */ + @PostMapping("startTask") + public RestResult startTask(@RequestParam("planId") Long planId, @RequestParam("taskType") Byte taskType, + @RequestParam(value = "chapterNo", required = false) Integer chapterNo, HttpServletRequest request) { + Author author = checkAuthor(request); + return RestResult.ok(aiAssistantService.startTask(author.getId(), planId, taskType, chapterNo)); + } + + /** + * 查询任务状态与结果(前端轮询) + */ + @GetMapping("task/{taskId}") + public RestResult getTask(@PathVariable("taskId") Long taskId, HttpServletRequest request) { + Author author = checkAuthor(request); + return RestResult.ok(aiAssistantService.getTask(author.getId(), taskId)); + } + + /** + * 查询创作进度(方案+章节计划+任务状态),用于页面加载恢复 + */ + @GetMapping("progress") + public RestResult progress(@RequestParam("planId") Long planId, HttpServletRequest request) { + Author author = checkAuthor(request); + return RestResult.ok(aiAssistantService.getProgress(author.getId(), planId)); + } + + /** + * 保存(编辑后的)创作方案草稿 + */ + @PostMapping("savePlanContent") + public RestResult savePlanContent(@RequestParam("planId") Long planId, + @RequestParam("planContent") String planContent, HttpServletRequest request) { + Author author = checkAuthor(request); + aiAssistantService.savePlanContent(author.getId(), planId, planContent); + return RestResult.ok(); + } + + /** + * 确认创作方案(新建小说时同步创建小说记录并拆分章节规划) + */ + @PostMapping("confirmPlan") + public RestResult confirmPlan(@RequestParam("planId") Long planId, + @RequestParam(value = "planContent", required = false) String planContent, + @RequestParam(value = "bookName", required = false) String bookName, + @RequestParam(value = "workDirection", required = false) Byte workDirection, + @RequestParam(value = "catId", required = false) Integer catId, + @RequestParam(value = "catName", required = false) String catName, HttpServletRequest request) { + Author author = checkAuthor(request); + Book bookInfo = new Book(); + bookInfo.setBookName(bookName); + bookInfo.setWorkDirection(workDirection); + bookInfo.setCatId(catId); + bookInfo.setCatName(catName); + aiAssistantService.confirmPlan(author.getId(), author.getPenName(), planId, planContent, bookInfo); + return RestResult.ok(); + } + + /** + * 修改单个章节规划(仅未保存章节) + */ + @PostMapping("updateChapterPlan") + public RestResult updateChapterPlan(@RequestParam("planId") Long planId, + @RequestParam("chapterNo") Integer chapterNo, @RequestParam("indexName") String indexName, + @RequestParam(value = "chapterOutline", required = false) String chapterOutline, + HttpServletRequest request) { + Author author = checkAuthor(request); + aiAssistantService.updateChapterPlan(author.getId(), planId, chapterNo, indexName, chapterOutline); + return RestResult.ok(); + } + + /** + * 保存作者编辑后的章节草稿(未正式保存) + */ + @PostMapping("saveDraft") + public RestResult saveDraft(@RequestParam("planId") Long planId, @RequestParam("chapterNo") Integer chapterNo, + @RequestParam("content") String content, HttpServletRequest request) { + Author author = checkAuthor(request); + aiAssistantService.saveDraft(author.getId(), planId, chapterNo, content); + return RestResult.ok(); + } + + /** + * 作者确认后保存章节正文到现有章节系统 + */ + @PostMapping("saveChapter") + public RestResult saveChapter(@RequestParam("planId") Long planId, @RequestParam("chapterNo") Integer chapterNo, + @RequestParam("indexName") String indexName, @RequestParam("content") String content, + @RequestParam(value = "isVip", required = false) Byte isVip, HttpServletRequest request) { + Author author = checkAuthor(request); + aiAssistantService.saveChapter(author.getId(), planId, chapterNo, indexName, content, isVip); + return RestResult.ok(); + } + + /** + * 程序化文本校验(字数与重复检测) + */ + @PostMapping("programCheck") + public RestResult> programCheck(@RequestParam("content") String content, + @RequestParam(value = "targetWords", required = false) Integer targetWords, HttpServletRequest request) { + checkAuthor(request); + return RestResult.ok(aiAssistantService.programCheck(content, targetWords)); + } + + /** + * 查询全局创作偏好 + */ + @GetMapping("pref") + public RestResult getPref(HttpServletRequest request) { + Author author = checkAuthor(request); + return RestResult.ok(aiAssistantService.getPref(author.getId())); + } + + /** + * 保存或清除全局创作偏好 + */ + @PostMapping("savePref") + public RestResult savePref(@RequestParam(value = "prefContent", required = false) String prefContent, + HttpServletRequest request) { + Author author = checkAuthor(request); + aiAssistantService.savePref(author.getId(), prefContent); + return RestResult.ok(); + } + + /** + * 与 AuthorController 一致的作者身份校验:未登录或作者状态异常时拒绝访问 + */ + private Author checkAuthor(HttpServletRequest request) { + UserDetails userDetails = getUserDetails(request); + if (userDetails == null) { + throw new BusinessException(ResponseStatus.NO_LOGIN); + } + Author author = authorService.queryAuthor(userDetails.getId()); + if (author == null || author.getStatus() == 1) { + throw new BusinessException(ResponseStatus.AUTHOR_STATUS_FORBIDDEN); + } + return author; + } + +} diff --git a/novel-front/src/main/java/com/java2nb/novel/core/ai/AiJsonUtil.java b/novel-front/src/main/java/com/java2nb/novel/core/ai/AiJsonUtil.java new file mode 100644 index 0000000..966ccb8 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/core/ai/AiJsonUtil.java @@ -0,0 +1,134 @@ +package com.java2nb.novel.core.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.java2nb.novel.core.enums.ResponseStatus; +import io.github.xxyopen.web.exception.BusinessException; + +/** + * AI 输出 JSON 提取与结构校验工具。 + * 模型输出不保证是合法 JSON(可能带解释文本或 markdown 围栏),统一在此处防御解析。 + * + * @author 11797 + */ +public final class AiJsonUtil { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AiJsonUtil() { + } + + /** + * 从模型输出中提取 JSON 对象并解析,失败抛出业务异常 + */ + public static JsonNode parseJsonFromModel(String raw) { + if (raw == null || raw.isBlank()) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + String cleaned = stripCodeFence(raw); + int start = cleaned.indexOf('{'); + int end = cleaned.lastIndexOf('}'); + if (start < 0 || end <= start) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + try { + return MAPPER.readTree(cleaned.substring(start, end + 1)); + } catch (Exception e) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + } + + /** + * 校验书名建议结果:names 数组非空且每项含 name + */ + public static void validateNames(JsonNode node) { + JsonNode names = node.path("names"); + if (!names.isArray() || names.isEmpty()) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + for (JsonNode item : names) { + requireText(item, "name"); + } + } + + /** + * 校验创作方案结构:简介、人物档案、总纲、章节规划必须存在且合法 + */ + public static void validatePlan(JsonNode node) { + requireText(node, "intro"); + JsonNode characters = node.path("characters"); + if (!characters.isArray() || characters.isEmpty()) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + JsonNode mainOutline = node.path("mainOutline"); + if (!mainOutline.isObject()) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + validateChapters(node); + } + + /** + * 校验章节规划数组:非空且每项含正整数 chapterNo 与标题 + */ + public static void validateChapters(JsonNode node) { + JsonNode chapters = node.path("chapters"); + if (!chapters.isArray() || chapters.isEmpty()) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + for (JsonNode chapter : chapters) { + if (chapter.path("chapterNo").asInt(0) <= 0) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + requireText(chapter, "indexName"); + } + } + + /** + * 校验章节检查结果结构:passed 布尔 + issues 数组 + */ + public static void validateCheck(JsonNode node) { + if (!node.has("passed") || !node.path("issues").isArray()) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + } + + /** + * 必填文本字段校验 + */ + public static void requireText(JsonNode node, String field) { + if (node.path(field).asText("").isBlank()) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + } + + /** + * 去掉 markdown 代码围栏 + */ + public static String stripCodeFence(String text) { + String cleaned = text.trim(); + if (cleaned.startsWith("```")) { + int firstLineEnd = cleaned.indexOf('\n'); + if (firstLineEnd > 0) { + cleaned = cleaned.substring(firstLineEnd + 1); + } + int fenceEnd = cleaned.lastIndexOf("```"); + if (fenceEnd >= 0) { + cleaned = cleaned.substring(0, fenceEnd); + } + } + return cleaned.trim(); + } + + /** + * 长文本截断(控制发送给模型的上下文规模) + */ + public static String truncate(String text, int maxLen) { + if (text == null || text.isBlank()) { + return ""; + } + if (text.length() <= maxLen) { + return text; + } + return text.substring(0, maxLen) + "……(已截断)"; + } +} diff --git a/novel-front/src/main/java/com/java2nb/novel/core/ai/ChapterTextCheckUtil.java b/novel-front/src/main/java/com/java2nb/novel/core/ai/ChapterTextCheckUtil.java new file mode 100644 index 0000000..e9ea824 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/core/ai/ChapterTextCheckUtil.java @@ -0,0 +1,114 @@ +package com.java2nb.novel.core.ai; + +import com.java2nb.novel.core.utils.StringUtil; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 章节正文程序化校验:字数统计规则与作者后台发布章节保持一致(StringUtil.getStrValidWordCount), + * 并做轻量化的重复段落/空内容检测。程序校验只兜底明显问题,质量判断交由 AI 校验。 + * + * @author 11797 + */ +public final class ChapterTextCheckUtil { + + /** + * 字数达标下限比例(低于目标字数70%视为问题) + */ + public static final double MIN_RATIO = 0.7; + + /** + * 字数达标上限比例(高于目标字数150%视为注水嫌疑) + */ + public static final double MAX_RATIO = 1.5; + + /** + * 参与重复检测的最小段落长度 + */ + private static final int MIN_PARAGRAPH_LEN = 15; + + /** + * 重复段落占比告警阈值 + */ + private static final double DUP_RATIO_THRESHOLD = 0.12; + + private ChapterTextCheckUtil() { + } + + /** + * 有效字数统计(与作者后台章节字数规则一致) + */ + public static int validWordCount(String content) { + return StringUtil.getStrValidWordCount(content == null ? "" : content); + } + + /** + * 程序化检查,返回问题列表;列表为空表示程序检查通过 + */ + public static List check(String content, int targetWords) { + List problems = new ArrayList<>(); + if (content == null || content.isBlank()) { + problems.add("章节正文为空"); + return problems; + } + int wordCount = validWordCount(content); + if (wordCount < 200) { + problems.add("正文有效字数仅" + wordCount + "字,明显不足"); + } else if (targetWords > 0) { + if (wordCount < targetWords * MIN_RATIO) { + problems.add("正文有效字数" + wordCount + "字,低于目标" + targetWords + "字的" + (int) (MIN_RATIO * 100) + "%"); + } else if (wordCount > targetWords * MAX_RATIO) { + problems.add("正文有效字数" + wordCount + "字,超过目标" + targetWords + "字的" + (int) (MAX_RATIO * 100) + "%,存在注水嫌疑"); + } + } + checkDuplicateParagraphs(content, problems); + return problems; + } + + /** + * 重复段落检测:长度达标的段落中,完全重复的段落占比超过阈值视为注水信号 + */ + private static void checkDuplicateParagraphs(String content, List problems) { + String[] lines = content.split("\\n"); + Map counter = new HashMap<>(); + int total = 0; + int duplicated = 0; + for (String line : lines) { + String paragraph = line.trim(); + if (paragraph.length() < MIN_PARAGRAPH_LEN) { + continue; + } + total++; + Integer cnt = counter.merge(paragraph, 1, Integer::sum); + if (cnt == 2) { + // 该段落重复出现一次,计入一次重复 + duplicated++; + } else if (cnt > 2) { + duplicated++; + } + } + if (total >= 5 && (double) duplicated / total > DUP_RATIO_THRESHOLD) { + problems.add("存在" + duplicated + "处重复段落,疑似内容复读"); + } + } + + /** + * AI 摘要失败时的程序化降级摘要:截取首个有效段落开头 + */ + public static String fallbackSummary(String content) { + if (content == null || content.isBlank()) { + return ""; + } + String cleaned = content.replaceAll("
", "\n").replaceAll(" ", " ").trim(); + // 去掉首行标题 + int firstBreak = cleaned.indexOf('\n'); + if (firstBreak > 0) { + cleaned = cleaned.substring(firstBreak + 1).trim(); + } + int end = Math.min(cleaned.length(), 120); + return cleaned.substring(0, end) + (cleaned.length() > end ? "……" : ""); + } +} diff --git a/novel-front/src/main/java/com/java2nb/novel/core/ai/PromptTemplateUtil.java b/novel-front/src/main/java/com/java2nb/novel/core/ai/PromptTemplateUtil.java new file mode 100644 index 0000000..2e9e521 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/core/ai/PromptTemplateUtil.java @@ -0,0 +1,70 @@ +package com.java2nb.novel.core.ai; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * AI 提示词模板工具:从 classpath:prompts/ai-assistant/ 加载模板并按 {key} 占位符渲染 + * + * @author 11797 + */ +@Slf4j +public final class PromptTemplateUtil { + + private static final String TEMPLATE_DIR = "prompts/ai-assistant/"; + + private static final Map TEMPLATE_CACHE = new ConcurrentHashMap<>(); + + /** + * 占位符缺省值 + */ + private static final String DEFAULT_VALUE = "未提供"; + + /** + * 占位符格式:{标识符},不会误匹配 JSON 示例中 {"key":... 形式的花括号 + */ + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([a-zA-Z][a-zA-Z0-9_]*)\\}"); + + private PromptTemplateUtil() { + } + + /** + * 加载模板内容(带缓存) + */ + public static String load(String key) { + return TEMPLATE_CACHE.computeIfAbsent(key, k -> { + try (InputStream is = new ClassPathResource(TEMPLATE_DIR + k + ".txt").getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("加载AI提示词模板失败: {}", k, e); + throw new IllegalStateException("提示词模板缺失: " + k); + } + }); + } + + /** + * 渲染模板,将 {key} 占位符替换为变量值,缺省变量填充为“未提供” + */ + public static String render(String key, Map vars) { + String template = load(key); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + StringBuilder sb = new StringBuilder(); + // 单次扫描原模板:未提供的变量填充为缺省值,已替换内容不再参与匹配,防止变量值含占位符时死循环 + while (matcher.find()) { + String value = vars.get(matcher.group(1)); + if (value == null || value.isBlank()) { + value = DEFAULT_VALUE; + } + matcher.appendReplacement(sb, Matcher.quoteReplacement(value)); + } + matcher.appendTail(sb); + return sb.toString(); + } +} diff --git a/novel-front/src/main/java/com/java2nb/novel/core/config/ThreadPoolConfig.java b/novel-front/src/main/java/com/java2nb/novel/core/config/ThreadPoolConfig.java index 6fdfb6a..b61c279 100644 --- a/novel-front/src/main/java/com/java2nb/novel/core/config/ThreadPoolConfig.java +++ b/novel-front/src/main/java/com/java2nb/novel/core/config/ThreadPoolConfig.java @@ -20,4 +20,13 @@ public class ThreadPoolConfig { , TimeUnit.SECONDS, new LinkedBlockingDeque<>(properties.getQueueSize()),new ThreadPoolExecutor.AbortPolicy()); } + /** + * AI创作生成任务专用线程池:长耗时模型调用与页面渲染线程隔离,避免阻塞前台页面加载 + */ + @Bean("aiTaskExecutor") + public ThreadPoolExecutor aiTaskExecutor(){ + return new ThreadPoolExecutor(1, 2, 60, TimeUnit.SECONDS, + new LinkedBlockingDeque<>(30), new ThreadPoolExecutor.AbortPolicy()); + } + } diff --git a/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiAuthorPrefMapper.java b/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiAuthorPrefMapper.java new file mode 100644 index 0000000..4a866d7 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiAuthorPrefMapper.java @@ -0,0 +1,22 @@ +package com.java2nb.novel.mapper; + +import com.java2nb.novel.entity.AiAuthorPref; +import org.apache.ibatis.annotations.Param; + +/** + * AI创作全局偏好 Mapper + * + * @author 11797 + */ +public interface FrontAiAuthorPrefMapper { + + /** + * 查询作者偏好 + */ + AiAuthorPref selectByAuthorId(@Param("authorId") Long authorId); + + /** + * 插入或更新偏好 + */ + int upsert(AiAuthorPref pref); +} diff --git a/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiChapterPlanMapper.java b/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiChapterPlanMapper.java new file mode 100644 index 0000000..8edc158 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiChapterPlanMapper.java @@ -0,0 +1,67 @@ +package com.java2nb.novel.mapper; + +import com.java2nb.novel.entity.AiChapterPlan; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * AI章节计划 Mapper + * + * @author 11797 + */ +public interface FrontAiChapterPlanMapper { + + /** + * 批量插入章节计划 + */ + int batchInsert(@Param("list") List list); + + /** + * 查询方案下全部章节计划 + */ + List listByPlanId(@Param("planId") Long planId); + + /** + * 查询指定章节计划 + */ + AiChapterPlan selectByPlanAndNo(@Param("planId") Long planId, @Param("chapterNo") Integer chapterNo); + + /** + * CAS将状态从 0/1 置为 3(保存中),防并发重复保存 + */ + int casMarkSaving(@Param("planId") Long planId, @Param("chapterNo") Integer chapterNo); + + /** + * 保存成功后置为已保存状态 + */ + int markSaved(@Param("planId") Long planId, @Param("chapterNo") Integer chapterNo, + @Param("indexId") Long indexId, @Param("wordCount") Integer wordCount); + + /** + * 草稿生成成功后置为草稿状态(仅未保存章节) + */ + int markDraft(@Param("planId") Long planId, @Param("chapterNo") Integer chapterNo); + + /** + * 更新章节规划(仅未保存章节) + */ + int updateOutline(@Param("planId") Long planId, @Param("chapterNo") Integer chapterNo, + @Param("indexName") String indexName, @Param("chapterOutline") String chapterOutline); + + /** + * 更新章节摘要 + */ + int updateSummary(@Param("planId") Long planId, @Param("chapterNo") Integer chapterNo, + @Param("summary") String summary); + + /** + * 删除方案下未保存的章节计划(重新拆分章节规划前调用) + */ + int deleteUnsaved(@Param("planId") Long planId); + + /** + * 查询小说最新章节ID(发布章节后回写index_id用) + */ + Long selectLatestIndexIdByBook(@Param("bookId") Long bookId); +} diff --git a/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiCreationPlanMapper.java b/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiCreationPlanMapper.java new file mode 100644 index 0000000..23f8ab2 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiCreationPlanMapper.java @@ -0,0 +1,37 @@ +package com.java2nb.novel.mapper; + +import com.java2nb.novel.entity.AiCreationPlan; +import org.apache.ibatis.annotations.Param; + +/** + * AI创作方案 Mapper + * + * @author 11797 + */ +public interface FrontAiCreationPlanMapper { + + /** + * 新增创作方案 + */ + int insert(AiCreationPlan plan); + + /** + * 按ID与作者查询(权限隔离) + */ + AiCreationPlan selectByIdAndAuthor(@Param("id") Long id, @Param("authorId") Long authorId); + + /** + * 按小说ID查询 + */ + AiCreationPlan selectByBookId(@Param("bookId") Long bookId); + + /** + * 查询作者最近一条未绑定小说的方案(新建创作时复用,避免重复创建空方案) + */ + AiCreationPlan selectLatestUnboundByAuthor(@Param("authorId") Long authorId); + + /** + * 更新方案(限定作者,stage/参数/方案内容/绑定小说等) + */ + int update(AiCreationPlan plan); +} diff --git a/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiCreationTaskMapper.java b/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiCreationTaskMapper.java new file mode 100644 index 0000000..c6adc0d --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/mapper/FrontAiCreationTaskMapper.java @@ -0,0 +1,61 @@ +package com.java2nb.novel.mapper; + +import com.java2nb.novel.entity.AiCreationTask; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * AI创作生成任务 Mapper + * + * @author 11797 + */ +public interface FrontAiCreationTaskMapper { + + /** + * 插入执行中的任务(running_flag=1,唯一索引防重复提交) + */ + int insert(AiCreationTask task); + + /** + * 插入已成功的正文草稿任务(作者手工编辑后落草稿,供校验修订使用) + */ + int insertDraft(AiCreationTask task); + + /** + * 按ID与作者查询(权限隔离) + */ + AiCreationTask selectByIdAndAuthor(@Param("id") Long id, @Param("authorId") Long authorId); + + /** + * 按ID查询(异步线程内部使用) + */ + AiCreationTask selectById(@Param("id") Long id); + + /** + * 任务结束(成功或失败),置running_flag为NULL释放占位 + */ + int finish(@Param("id") Long id, @Param("status") Byte status, @Param("result") String result, + @Param("errorMsg") String errorMsg); + + /** + * 服务启动时将所有执行中任务批量置为失败并释放防重占位(重启后线程已丢失,任务不可能再完成) + */ + int failAllRunning(@Param("errorMsg") String errorMsg); + + /** + * 查询方案下最近的执行中任务(页面恢复轮询用) + */ + List listRunning(@Param("planId") Long planId); + + /** + * 查询方案下最近N条任务(进度展示用,按ID倒序) + */ + List listRecent(@Param("planId") Long planId, @Param("limit") int limit); + + /** + * 查询指定目标最近一条已成功任务 + */ + AiCreationTask selectLatestSuccess(@Param("planId") Long planId, @Param("taskType") Byte taskType, + @Param("chapterNo") Integer chapterNo); +} diff --git a/novel-front/src/main/java/com/java2nb/novel/service/AiAssistantService.java b/novel-front/src/main/java/com/java2nb/novel/service/AiAssistantService.java new file mode 100644 index 0000000..7c30039 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/service/AiAssistantService.java @@ -0,0 +1,81 @@ +package com.java2nb.novel.service; + +import com.java2nb.novel.entity.AiCreationPlan; +import com.java2nb.novel.entity.AiCreationTask; +import com.java2nb.novel.entity.Book; +import com.java2nb.novel.vo.AiPlanProgressVO; + +import java.util.List; + +/** + * AI 长篇小说创作助手服务 + * + * @author 11797 + */ +public interface AiAssistantService { + + /** + * 初始化或恢复创作方案:指定bookId绑定现有小说;指定planId直接加载;否则复用/新建未绑定方案 + */ + AiCreationPlan initPlan(Long authorId, Long planId, Long bookId); + + /** + * 保存创作问答参数(自动补齐章节数/字数默认值) + */ + void saveParams(Long authorId, Long planId, String creativeParams); + + /** + * 提交异步生成任务,返回任务ID;同一目标执行中时拒绝重复提交 + */ + Long startTask(Long authorId, Long planId, Byte taskType, Integer chapterNo); + + /** + * 查询任务状态与结果(含超时中断兜底处理) + */ + AiCreationTask getTask(Long authorId, Long taskId); + + /** + * 查询创作进度(方案+章节计划+任务状态),用于页面恢复 + */ + AiPlanProgressVO getProgress(Long authorId, Long planId); + + /** + * 保存(编辑后的)创作方案草稿 + */ + void savePlanContent(Long authorId, Long planId, String planContent); + + /** + * 确认创作方案:新建小说时创建小说记录,并拆分章节规划(不影响已保存章节) + */ + void confirmPlan(Long authorId, String penName, Long planId, String planContent, Book bookInfo); + + /** + * 修改单个章节规划(仅未保存章节) + */ + void updateChapterPlan(Long authorId, Long planId, Integer chapterNo, String indexName, String chapterOutline); + + /** + * 保存作者编辑后的章节草稿(未正式保存,供校验修订使用) + */ + void saveDraft(Long authorId, Long planId, Integer chapterNo, String content); + + /** + * 作者确认后保存章节正文到现有章节系统,并触发创作记忆摘要更新 + */ + void saveChapter(Long authorId, Long planId, Integer chapterNo, String indexName, String content, Byte isVip); + + /** + * 程序化文本校验(字数与重复检测),返回问题列表 + */ + List programCheck(String content, Integer targetWords); + + /** + * 查询作者全局创作偏好 + */ + String getPref(Long authorId); + + /** + * 保存或清除作者全局创作偏好 + */ + void savePref(Long authorId, String prefContent); +} diff --git a/novel-front/src/main/java/com/java2nb/novel/service/impl/AiAssistantServiceImpl.java b/novel-front/src/main/java/com/java2nb/novel/service/impl/AiAssistantServiceImpl.java new file mode 100644 index 0000000..27fe20c --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/service/impl/AiAssistantServiceImpl.java @@ -0,0 +1,931 @@ +package com.java2nb.novel.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.java2nb.novel.core.ai.AiJsonUtil; +import com.java2nb.novel.core.ai.ChapterTextCheckUtil; +import com.java2nb.novel.core.ai.PromptTemplateUtil; +import com.java2nb.novel.core.enums.ResponseStatus; +import com.java2nb.novel.entity.*; +import com.java2nb.novel.mapper.FrontAiAuthorPrefMapper; +import com.java2nb.novel.mapper.FrontAiChapterPlanMapper; +import com.java2nb.novel.mapper.FrontAiCreationPlanMapper; +import com.java2nb.novel.mapper.FrontAiCreationTaskMapper; +import com.java2nb.novel.service.AiAssistantService; +import com.java2nb.novel.service.BookService; +import com.java2nb.novel.vo.AiPlanProgressVO; +import io.github.xxyopen.web.exception.BusinessException; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * AI 长篇小说创作助手服务实现。 + *

+ * 生成类工作全部落库为任务(ai_creation_task),在专用线程池内异步串行调用大模型, + * 前端通过轮询任务状态实现断点恢复;正文草稿保存在任务result字段(未保存), + * 作者确认后经现有 addBookContent 正式写入章节系统(已保存),二者严格区分。 + * + * @author 11797 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class AiAssistantServiceImpl implements AiAssistantService { + + /** 任务类型:书名建议 */ + public static final byte TYPE_BOOK_NAME = 1; + /** 任务类型:创作方案 */ + public static final byte TYPE_PLAN = 2; + /** 任务类型:章节正文 */ + public static final byte TYPE_CHAPTER = 3; + /** 任务类型:章节校验修订 */ + public static final byte TYPE_CHECK_REVISE = 4; + /** 任务类型:章节摘要(创作记忆) */ + public static final byte TYPE_SUMMARY = 5; + /** 任务类型:章节规划增量批次 */ + public static final byte TYPE_CHAPTER_PLAN_BATCH = 6; + + /** 阶段:创作问答中/方案草稿/方案已确认 */ + public static final byte STAGE_QA = 1; + public static final byte STAGE_PLAN_DRAFT = 2; + public static final byte STAGE_PLAN_CONFIRMED = 3; + + /** 章节计划状态:待创作/草稿/已保存/保存中 */ + public static final byte CHAPTER_TODO = 0; + public static final byte CHAPTER_DRAFT = 1; + public static final byte CHAPTER_SAVED = 2; + + /** 任务状态 */ + public static final byte TASK_RUNNING = 0; + public static final byte TASK_SUCCESS = 1; + public static final byte TASK_FAILED = 2; + + /** 单次方案生成最多输出的章节规划数 */ + private static final int MAX_FIRST_PLAN_CHAPTERS = 40; + /** 章节规划增量批次大小 */ + private static final int CHAPTER_PLAN_BATCH_SIZE = 30; + /** 校验修订最大轮数 */ + private static final int MAX_REVISE_ROUNDS = 3; + /** 异步任务硬超时(含排队等待容忍) */ + private static final Duration AI_TIMEOUT = Duration.ofMinutes(10); + /** 轮询侧判定任务中断的静默时间 */ + private static final long TASK_STALE_MILLIS = 12 * 60 * 1000L; + + private final FrontAiCreationPlanMapper planMapper; + + private final FrontAiChapterPlanMapper chapterPlanMapper; + + private final FrontAiCreationTaskMapper taskMapper; + + private final FrontAiAuthorPrefMapper prefMapper; + + private final BookService bookService; + + private final ChatClient chatClient; + + private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER = + new com.fasterxml.jackson.databind.ObjectMapper(); + + @Resource + private ThreadPoolExecutor aiTaskExecutor; + + @Override + public AiCreationPlan initPlan(Long authorId, Long planId, Long bookId) { + if (planId != null) { + return loadOwnedPlan(authorId, planId); + } + if (bookId != null) { + AiCreationPlan plan = planMapper.selectByBookId(bookId); + if (plan != null) { + if (!plan.getAuthorId().equals(authorId)) { + throw new BusinessException(ResponseStatus.AI_PLAN_NOT_EXISTS); + } + return plan; + } + //校验小说归属后绑定现有小说创建方案 + Book book = bookService.queryBookDetail(bookId); + if (book == null || !authorId.equals(book.getAuthorId())) { + throw new BusinessException(ResponseStatus.AI_PLAN_NOT_EXISTS); + } + return newPlan(authorId, bookId); + } + //新建创作:复用最近一个未绑定小说的方案,支持断点续写 + AiCreationPlan latest = planMapper.selectLatestUnboundByAuthor(authorId); + return latest != null ? latest : newPlan(authorId, null); + } + + private AiCreationPlan newPlan(Long authorId, Long bookId) { + AiCreationPlan plan = new AiCreationPlan(); + plan.setAuthorId(authorId); + plan.setBookId(bookId); + plan.setStage(STAGE_QA); + plan.setOutlineVersion(0); + planMapper.insert(plan); + return plan; + } + + @Override + public void saveParams(Long authorId, Long planId, String creativeParams) { + AiCreationPlan plan = loadOwnedPlan(authorId, planId); + JsonNode node = AiJsonUtil.parseJsonFromModel(creativeParams); + //必填项校验与默认值补齐 + for (String key : new String[]{"genre", "coreIdea", "protagonist", "conflict"}) { + if (node.path(key).asText("").isBlank()) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + } + ObjectNode obj = (ObjectNode) node; + int totalChapters = obj.path("totalChapters").asInt(60); + obj.put("totalChapters", Math.min(Math.max(totalChapters, 1), 1000)); + int chapterWords = obj.path("chapterWords").asInt(3000); + obj.put("chapterWords", Math.min(Math.max(chapterWords, 200), 20000)); + String normalized = obj.toString(); + if (normalized.length() > 8000) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + AiCreationPlan update = new AiCreationPlan(); + update.setId(plan.getId()); + update.setAuthorId(authorId); + update.setCreativeParams(normalized); + planMapper.update(update); + } + + @Override + public Long startTask(Long authorId, Long planId, Byte taskType, Integer chapterNo) { + if (taskType == null || taskType < TYPE_BOOK_NAME || taskType > TYPE_CHAPTER_PLAN_BATCH + || taskType == TYPE_SUMMARY) { + //摘要任务仅由系统在章节保存后内部触发 + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + AiCreationPlan plan = loadOwnedPlan(authorId, planId); + int no = chapterNo == null ? 0 : chapterNo; + precheckTask(plan, taskType, no); + + AiCreationTask task = new AiCreationTask(); + task.setPlanId(planId); + task.setAuthorId(authorId); + task.setTaskType(taskType); + task.setChapterNo(no); + try { + //running_flag唯一索引兜底防并发重复提交 + taskMapper.insert(task); + } catch (DuplicateKeyException e) { + throw new BusinessException(ResponseStatus.AI_TASK_RUNNING); + } + Long taskId = task.getId(); + try { + aiTaskExecutor.execute(() -> runTask(taskId)); + } catch (RejectedExecutionException e) { + taskMapper.finish(taskId, TASK_FAILED, null, "服务器繁忙,请稍后重试"); + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + return taskId; + } + + /** + * 按任务类型做前置条件校验 + */ + private void precheckTask(AiCreationPlan plan, byte taskType, int chapterNo) { + switch (taskType) { + case TYPE_BOOK_NAME, TYPE_PLAN -> { + if (plan.getCreativeParams() == null || plan.getCreativeParams().isBlank()) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + } + case TYPE_CHAPTER_PLAN_BATCH -> { + if (plan.getPlanContent() == null || plan.getPlanContent().isBlank() + || plan.getStage() < STAGE_PLAN_DRAFT || chapterNo <= 0) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + } + case TYPE_CHAPTER -> { + requireConfirmedChapter(plan, chapterNo); + } + case TYPE_CHECK_REVISE -> { + requireConfirmedChapter(plan, chapterNo); + //需要已有正文草稿(校验修订任务或正文任务最近一次成功结果) + AiCreationTask draft = latestDraftTask(plan.getId(), chapterNo); + if (draft == null) { + throw new BusinessException(ResponseStatus.AI_DRAFT_NOT_EXISTS); + } + } + default -> throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + } + + private void requireConfirmedChapter(AiCreationPlan plan, int chapterNo) { + if (plan.getStage() < STAGE_PLAN_CONFIRMED || chapterNo <= 0) { + throw new BusinessException(ResponseStatus.AI_PLAN_NOT_CONFIRMED); + } + if (plan.getPlanContent() == null || plan.getPlanContent().isBlank()) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + AiChapterPlan row = chapterPlanMapper.selectByPlanAndNo(plan.getId(), chapterNo); + if (row == null) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_PLAN_NOT_EXISTS); + } + } + + /** + * 取该章节最近一次可用草稿:优先校验修订结果,其次正文结果 + */ + private AiCreationTask latestDraftTask(Long planId, int chapterNo) { + AiCreationTask reviseTask = taskMapper.selectLatestSuccess(planId, TYPE_CHECK_REVISE, chapterNo); + if (reviseTask != null) { + AiCreationTask chapterTask = taskMapper.selectLatestSuccess(planId, TYPE_CHAPTER, chapterNo); + if (chapterTask == null || reviseTask.getId() > chapterTask.getId()) { + return reviseTask; + } + } + return taskMapper.selectLatestSuccess(planId, TYPE_CHAPTER, chapterNo); + } + + /** + * 异步线程内执行任务(独立于请求线程,失败仅落库不影响已保存数据) + */ + public void runTask(Long taskId) { + AiCreationTask task = taskMapper.selectById(taskId); + if (task == null || task.getStatus() != TASK_RUNNING) { + return; + } + try { + AiCreationPlan plan = planMapper.selectByIdAndAuthor(task.getPlanId(), task.getAuthorId()); + if (plan == null) { + throw new BusinessException(ResponseStatus.AI_PLAN_NOT_EXISTS); + } + String result = switch (task.getTaskType()) { + case TYPE_BOOK_NAME -> genBookName(plan); + case TYPE_PLAN -> genPlan(plan); + case TYPE_CHAPTER_PLAN_BATCH -> genChapterPlanBatch(plan, task); + case TYPE_CHAPTER -> genChapterContent(plan, task); + case TYPE_CHECK_REVISE -> genCheckRevise(plan, task); + case TYPE_SUMMARY -> genChapterSummary(plan, task); + default -> throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + }; + taskMapper.finish(taskId, TASK_SUCCESS, result, null); + } catch (Exception e) { + //不输出正文等用户内容到日志,只记录任务标识与原因 + log.warn("AI创作任务执行失败 taskId={}, type={}, planId={}, cause={}", taskId, task.getTaskType(), + task.getPlanId(), e.getClass().getSimpleName()); + //BusinessException的文本在resultCode里,getMessage()为空 + String msg = e instanceof BusinessException && ((BusinessException) e).getResultCode() != null + ? ((BusinessException) e).getResultCode().getMsg() : "AI生成失败,请重试"; + taskMapper.finish(taskId, TASK_FAILED, null, AiJsonUtil.truncate(msg, 400)); + } + } + + /** + * 服务启动后恢复任务状态:重启后线程池内的任务已全部丢失, + * 遗留的执行中任务批量置为失败并释放防重占位(running_flag),避免阻塞重新提交。 + * 仅限单实例部署;多实例场景需改为按启动时间窗口判定。 + */ + @EventListener(ApplicationReadyEvent.class) + public void failStaleRunningTasksOnStartup() { + try { + int failed = taskMapper.failAllRunning("服务已重启,任务被中断,请重试"); + if (failed > 0) { + log.info("AI创作任务启动恢复:已将{}个遗留执行中任务置为失败", failed); + } + } catch (Exception e) { + //恢复失败不阻断启动,轮询侧超时判定仍可兜底 + log.warn("AI创作任务启动恢复执行失败", e); + } + } + + @Override + public AiCreationTask getTask(Long authorId, Long taskId) { + AiCreationTask task = taskMapper.selectByIdAndAuthor(taskId, authorId); + if (task == null) { + throw new BusinessException(ResponseStatus.AI_PLAN_NOT_EXISTS); + } + //进程重启等导致任务滞留执行中状态时,超时判定为中断,允许重试 + if (task.getStatus() == TASK_RUNNING && task.getUpdateTime() != null + && System.currentTimeMillis() - task.getUpdateTime().getTime() > TASK_STALE_MILLIS) { + taskMapper.finish(taskId, TASK_FAILED, null, "任务超时或已中断,请重试"); + task = taskMapper.selectByIdAndAuthor(taskId, authorId); + } + return task; + } + + @Override + public AiPlanProgressVO getProgress(Long authorId, Long planId) { + AiCreationPlan plan = loadOwnedPlan(authorId, planId); + AiPlanProgressVO vo = new AiPlanProgressVO(); + vo.setPlan(plan); + vo.setChapterPlans(chapterPlanMapper.listByPlanId(planId)); + vo.setRunningTasks(taskMapper.listRunning(planId)); + List recent = taskMapper.listRecent(planId, 15); + //进度接口只回传任务状态摘要,正文草稿通过任务详情接口单独获取 + recent.forEach(t -> t.setResult(null)); + vo.setLatestTasks(recent); + return vo; + } + + @Override + public void savePlanContent(Long authorId, Long planId, String planContent) { + AiCreationPlan plan = loadOwnedPlan(authorId, planId); + String validated = validatePlanContent(planContent); + if (validated.length() > 150000) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + AiCreationPlan update = new AiCreationPlan(); + update.setId(plan.getId()); + update.setAuthorId(authorId); + update.setPlanContent(validated); + if (plan.getStage() < STAGE_PLAN_DRAFT) { + update.setStage(STAGE_PLAN_DRAFT); + } + planMapper.update(update); + } + + private String validatePlanContent(String planContent) { + JsonNode node = AiJsonUtil.parseJsonFromModel(planContent); + AiJsonUtil.validatePlan(node); + normalizeChapterNos(node.path("chapters"), 1); + return node.toString(); + } + + /** + * 章节序号防御性重排:模型编号异常时按数组顺序强制连续编号 + */ + private void normalizeChapterNos(JsonNode chapters, int startNo) { + if (!chapters.isArray()) { + return; + } + for (int i = 0; i < chapters.size(); i++) { + JsonNode chapter = chapters.get(i); + if (chapter instanceof ObjectNode obj && chapter.path("chapterNo").asInt(0) != startNo + i) { + obj.put("chapterNo", startNo + i); + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void confirmPlan(Long authorId, String penName, Long planId, String planContent, Book bookInfo) { + AiCreationPlan plan = loadOwnedPlan(authorId, planId); + String content = (planContent != null && !planContent.isBlank()) ? planContent : plan.getPlanContent(); + if (content == null || content.isBlank()) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + //校验并规范化(含章节序号重排) + JsonNode planNode = AiJsonUtil.parseJsonFromModel(validatePlanContent(content)); + + Long boundBookId = plan.getBookId(); + if (boundBookId == null) { + //为新小说创建记录,复用现有发布小说流程(含同名校验、AI封面生成) + if (bookInfo == null || bookInfo.getBookName() == null || bookInfo.getBookName().isBlank() + || bookInfo.getCatId() == null || bookInfo.getWorkDirection() == null) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + bookInfo.setBookDesc(convertText(planNode.path("intro").asText(""))); + bookService.addBook(bookInfo, authorId, penName); + boundBookId = bookInfo.getId(); + } + + //拆分章节规划:仅重建未保存章节,已保存章节保持不动 + List rows = buildChapterRows(plan.getId(), planNode.path("chapters")); + chapterPlanMapper.deleteUnsaved(plan.getId()); + if (!rows.isEmpty()) { + chapterPlanMapper.batchInsert(rows); + } + + AiCreationPlan update = new AiCreationPlan(); + update.setId(plan.getId()); + update.setAuthorId(authorId); + update.setBookId(boundBookId); + update.setPlanContent(planNode.toString()); + update.setStage(STAGE_PLAN_CONFIRMED); + update.setOutlineVersion((plan.getOutlineVersion() == null ? 0 : plan.getOutlineVersion()) + 1); + planMapper.update(update); + } + + private List buildChapterRows(Long planId, JsonNode chapters) { + List rows = new ArrayList<>(); + Set seen = new HashSet<>(); + for (JsonNode chapter : chapters) { + int chapterNo = chapter.path("chapterNo").asInt(0); + if (chapterNo <= 0 || !seen.add(chapterNo)) { + continue; + } + AiChapterPlan row = new AiChapterPlan(); + row.setPlanId(planId); + row.setChapterNo(chapterNo); + row.setIndexName(AiJsonUtil.truncate(chapter.path("indexName").asText(""), 100)); + ObjectNode outline = chapter.deepCopy(); + outline.remove("chapterNo"); + outline.remove("indexName"); + row.setChapterOutline(AiJsonUtil.truncate(outline.toString(), 4000)); + rows.add(row); + } + rows.sort(Comparator.comparingInt(AiChapterPlan::getChapterNo)); + return rows; + } + + @Override + public void updateChapterPlan(Long authorId, Long planId, Integer chapterNo, String indexName, + String chapterOutline) { + loadOwnedPlan(authorId, planId); + if (chapterNo == null || chapterNo <= 0 || indexName == null || indexName.isBlank() + || indexName.length() > 100 || chapterOutline != null && chapterOutline.length() > 4000) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + AiChapterPlan row = chapterPlanMapper.selectByPlanAndNo(planId, chapterNo); + if (row == null) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_PLAN_NOT_EXISTS); + } + if (row.getStatus() == CHAPTER_SAVED) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_SAVED); + } + chapterPlanMapper.updateOutline(planId, chapterNo, indexName.trim(), chapterOutline); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveChapter(Long authorId, Long planId, Integer chapterNo, String indexName, String content, + Byte isVip) { + AiCreationPlan plan = loadOwnedPlan(authorId, planId); + if (plan.getStage() < STAGE_PLAN_CONFIRMED || plan.getBookId() == null) { + throw new BusinessException(ResponseStatus.AI_PLAN_NOT_CONFIRMED); + } + if (chapterNo == null || chapterNo <= 0 || indexName == null || indexName.isBlank() + || indexName.length() > 100 || content == null || content.isBlank() || content.length() > 200000) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + //CAS 0/1 -> 3,同一章节并发/重复提交只有一次能进入 + if (chapterPlanMapper.casMarkSaving(planId, chapterNo) == 0) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_SAVED); + } + //正文格式转换与字数规则与现有发布章节流程保持一致 + String transformed = convertText(content); + int wordCount = ChapterTextCheckUtil.validWordCount(transformed); + bookService.addBookContent(plan.getBookId(), indexName.trim(), transformed, + isVip == null ? (byte) 0 : isVip, authorId); + Long indexId = chapterPlanMapper.selectLatestIndexIdByBook(plan.getBookId()); + if (indexId == null) { + //未产生章节(如归属校验未通过),回滚本次保存 + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + chapterPlanMapper.markSaved(planId, chapterNo, indexId, wordCount); + + //事务提交后再触发摘要任务,避免异步线程读不到未提交章节;摘要失败不影响已保存正文 + registerSummaryTrigger(authorId, planId, chapterNo); + } + + private void registerSummaryTrigger(Long authorId, Long planId, Integer chapterNo) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + triggerSummaryTask(authorId, planId, chapterNo); + } + }); + } else { + triggerSummaryTask(authorId, planId, chapterNo); + } + } + + private void triggerSummaryTask(Long authorId, Long planId, Integer chapterNo) { + try { + AiCreationTask task = new AiCreationTask(); + task.setPlanId(planId); + task.setAuthorId(authorId); + task.setTaskType(TYPE_SUMMARY); + task.setChapterNo(chapterNo); + taskMapper.insert(task); + aiTaskExecutor.execute(() -> runTask(task.getId())); + } catch (DuplicateKeyException e) { + log.debug("摘要任务已在执行中 planId={}, chapterNo={}", planId, chapterNo); + } catch (Exception e) { + log.warn("触发章节摘要任务失败 planId={}, chapterNo={}", planId, chapterNo); + } + } + + /** + * 作者手工编辑后落为服务端草稿:以成功状态的正文任务记录,后续校验修订以其为准 + */ + @Override + public void saveDraft(Long authorId, Long planId, Integer chapterNo, String content) { + AiCreationPlan plan = loadOwnedPlan(authorId, planId); + if (chapterNo == null || chapterNo <= 0 || content == null || content.isBlank() || content.length() > 200000) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + AiChapterPlan row = chapterPlanMapper.selectByPlanAndNo(planId, chapterNo); + if (row == null) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_PLAN_NOT_EXISTS); + } + if (row.getStatus() == CHAPTER_SAVED) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_SAVED); + } + AiCreationTask draft = new AiCreationTask(); + draft.setPlanId(planId); + draft.setAuthorId(authorId); + draft.setChapterNo(chapterNo); + draft.setResult(content); + taskMapper.insertDraft(draft); + chapterPlanMapper.markDraft(planId, chapterNo); + } + + @Override + public List programCheck(String content, Integer targetWords) { + return ChapterTextCheckUtil.check(content, targetWords == null ? 0 : targetWords); + } + + @Override + public String getPref(Long authorId) { + AiAuthorPref pref = prefMapper.selectByAuthorId(authorId); + return pref == null ? null : pref.getPrefContent(); + } + + @Override + public void savePref(Long authorId, String prefContent) { + if (prefContent != null && !prefContent.isBlank()) { + if (prefContent.length() > 4000) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + //允许作者清除偏好时传空 + AiJsonUtil.parseJsonFromModel(prefContent); + } + AiAuthorPref pref = new AiAuthorPref(); + pref.setAuthorId(authorId); + pref.setPrefContent(prefContent == null || prefContent.isBlank() ? "{}" : prefContent); + prefMapper.upsert(pref); + } + + // ------------------------------------------------------------------ + // 各类生成任务的实现(在 aiTaskExecutor 线程内执行) + // ------------------------------------------------------------------ + + private String genBookName(AiCreationPlan plan) { + JsonNode params = readParams(plan); + Map vars = new HashMap<>(); + vars.put("genre", text(params, "genre")); + vars.put("coreIdea", text(params, "coreIdea")); + vars.put("protagonist", text(params, "protagonist")); + vars.put("conflict", text(params, "conflict")); + vars.put("targetReaders", text(params, "targetReaders")); + String raw = callAi(PromptTemplateUtil.render("book_name", vars)); + JsonNode node = AiJsonUtil.parseJsonFromModel(raw); + AiJsonUtil.validateNames(node); + return node.toString(); + } + + private String genPlan(AiCreationPlan plan) { + JsonNode params = readParams(plan); + int totalChapters = params.path("totalChapters").asInt(60); + Map vars = new HashMap<>(); + vars.put("genre", text(params, "genre")); + vars.put("bookName", text(params, "bookName")); + vars.put("coreIdea", text(params, "coreIdea")); + vars.put("protagonist", text(params, "protagonist")); + vars.put("conflict", text(params, "conflict")); + vars.put("worldview", text(params, "worldview")); + vars.put("style", text(params, "style")); + vars.put("theme", text(params, "theme")); + vars.put("targetReaders", text(params, "targetReaders")); + vars.put("totalChapters", String.valueOf(totalChapters)); + vars.put("chapterWords", String.valueOf(params.path("chapterWords").asInt(3000))); + vars.put("extra", text(params, "extra")); + vars.put("pref", AiJsonUtil.truncate(getPref(plan.getAuthorId()), 500)); + vars.put("planChapterEnd", String.valueOf(Math.min(totalChapters, MAX_FIRST_PLAN_CHAPTERS))); + String raw = callAi(PromptTemplateUtil.render("creative_plan", vars)); + JsonNode node = AiJsonUtil.parseJsonFromModel(raw); + AiJsonUtil.validatePlan(node); + normalizeChapterNos(node.path("chapters"), 1); + return node.toString(); + } + + private String genChapterPlanBatch(AiCreationPlan plan, AiCreationTask task) { + JsonNode planNode = readPlanContent(plan); + JsonNode params = readParams(plan); + int totalChapters = params.path("totalChapters").asInt(60); + int startNo = task.getChapterNo(); + int endNo = Math.min(startNo + CHAPTER_PLAN_BATCH_SIZE - 1, totalChapters); + if (startNo > endNo) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + + //已有规划(方案JSON + 已拆分章节)压缩为标题清单供衔接参考 + StringBuilder existing = new StringBuilder(); + for (AiChapterPlan row : chapterPlanMapper.listByPlanId(plan.getId())) { + if (row.getChapterNo() < startNo) { + existing.append("第").append(row.getChapterNo()).append("章 ").append(row.getIndexName()).append('\n'); + } + } + Map vars = new HashMap<>(); + vars.put("bookName", text(planNode, "bookName")); + vars.put("genre", text(params, "genre")); + vars.put("intro", AiJsonUtil.truncate(text(planNode, "intro"), 1000)); + vars.put("mainOutline", AiJsonUtil.truncate(planNode.path("mainOutline").toString(), 1500)); + vars.put("volumes", AiJsonUtil.truncate(planNode.path("volumes").toString(), 1000)); + vars.put("characters", AiJsonUtil.truncate(planNode.path("characters").toString(), 2000)); + vars.put("foreshadows", AiJsonUtil.truncate(planNode.path("foreshadows").toString(), 1000)); + vars.put("chapterWords", String.valueOf(params.path("chapterWords").asInt(3000))); + vars.put("existingChapters", existing.isEmpty() ? "无" : AiJsonUtil.truncate(existing.toString(), 3000)); + vars.put("startNo", String.valueOf(startNo)); + vars.put("endNo", String.valueOf(endNo)); + vars.put("batchCount", String.valueOf(endNo - startNo + 1)); + String raw = callAi(PromptTemplateUtil.render("chapter_plan_batch", vars)); + JsonNode node = AiJsonUtil.parseJsonFromModel(raw); + AiJsonUtil.validateChapters(node); + normalizeChapterNos(node.path("chapters"), startNo); + return node.toString(); + } + + private String genChapterContent(AiCreationPlan plan, AiCreationTask task) { + AiChapterPlan row = chapterPlanMapper.selectByPlanAndNo(plan.getId(), task.getChapterNo()); + if (row == null) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_PLAN_NOT_EXISTS); + } + Map vars = buildChapterVars(plan, row); + String raw = AiJsonUtil.stripCodeFence(callAi(PromptTemplateUtil.render("chapter_content", vars))); + if (raw.isBlank() || raw.length() > 200000) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + chapterPlanMapper.markDraft(plan.getId(), task.getChapterNo()); + return raw; + } + + /** + * 构建单章创作上下文:结构化设定 + 前文摘要 + 上章结尾 + 当前章节规划,控制规模 + */ + private Map buildChapterVars(AiCreationPlan plan, AiChapterPlan row) { + JsonNode planNode = readPlanContent(plan); + JsonNode params = readParams(plan); + Map vars = new HashMap<>(); + vars.put("genre", text(params, "genre")); + vars.put("intro", AiJsonUtil.truncate(text(planNode, "intro"), 800)); + vars.put("worldview", AiJsonUtil.truncate(text(planNode, "worldview"), 2000)); + vars.put("characters", AiJsonUtil.truncate(planNode.path("characters").toString(), 2500)); + vars.put("style", text(params, "style")); + vars.put("extra", text(params, "extra")); + vars.put("pref", AiJsonUtil.truncate(getPref(plan.getAuthorId()), 500)); + vars.put("mainOutline", AiJsonUtil.truncate(planNode.path("mainOutline").toString(), 1500)); + vars.put("volume", AiJsonUtil.truncate(planNode.path("volumes").toString(), 1200)); + vars.put("foreshadows", buildPendingForeshadows(planNode, row.getChapterNo())); + vars.put("recentSummaries", buildRecentSummaries(plan, row.getChapterNo())); + vars.put("prevTail", buildPrevTail(plan, row.getChapterNo())); + vars.put("chapterNo", String.valueOf(row.getChapterNo())); + vars.put("indexName", row.getIndexName()); + vars.put("chapterOutline", AiJsonUtil.truncate( + row.getChapterOutline() == null ? "" : row.getChapterOutline(), 1200)); + vars.put("chapterWords", String.valueOf(params.path("chapterWords").asInt(3000))); + return vars; + } + + private String buildPendingForeshadows(JsonNode planNode, int chapterNo) { + JsonNode foreshadows = planNode.path("foreshadows"); + if (!foreshadows.isArray() || foreshadows.isEmpty()) { + return "无"; + } + ArrayNode pending = MAPPER.createArrayNode(); + for (JsonNode f : foreshadows) { + if (f.path("payoffChapter").asInt(0) >= chapterNo && pending.size() < 8) { + pending.add(f); + } + } + return pending.isEmpty() ? "无(均已按计划处理或已越过回收章节)" + : AiJsonUtil.truncate(pending.toString(), 1200); + } + + private String buildRecentSummaries(AiCreationPlan plan, int chapterNo) { + List rows = chapterPlanMapper.listByPlanId(plan.getId()); + List before = rows.stream() + .filter(r -> r.getChapterNo() < chapterNo) + .toList(); + StringBuilder sb = new StringBuilder(); + //已有规划章节:取最近5条摘要 + List withSummary = before.stream() + .filter(r -> r.getSummary() != null && !r.getSummary().isBlank()) + .toList(); + for (AiChapterPlan r : withSummary.subList(Math.max(0, withSummary.size() - 5), withSummary.size())) { + sb.append("第").append(r.getChapterNo()).append("章 ").append(r.getIndexName()) + .append(":").append(r.getSummary()).append('\n'); + } + //绑定现有小说且规划尚未推进到已写章节时,提示续写背景 + if (before.isEmpty() && plan.getBookId() != null) { + List indexes = bookService.queryIndexList(plan.getBookId(), null, 1, null); + if (indexes != null && !indexes.isEmpty()) { + sb.append("注意:本书已有").append(indexes.size()) + .append("章既有正文,本次为续写创作,请从现有内容结尾自然衔接,不得重复或推翻已写情节。\n"); + } + } + return sb.isEmpty() ? "无(本书开篇)" : AiJsonUtil.truncate(sb.toString(), 2000); + } + + private String buildPrevTail(AiCreationPlan plan, int chapterNo) { + Long indexId = null; + //优先取规划内最近一个已保存章节 + Optional prevSaved = chapterPlanMapper.listByPlanId(plan.getId()).stream() + .filter(r -> r.getChapterNo() < chapterNo && r.getStatus() == CHAPTER_SAVED && r.getIndexId() != null) + .max(Comparator.comparingInt(AiChapterPlan::getChapterNo)); + if (prevSaved.isPresent()) { + indexId = prevSaved.get().getIndexId(); + } else if (plan.getBookId() != null) { + //续写场景:取现有小说最新章节 + Book book = bookService.queryBookDetail(plan.getBookId()); + if (book != null) { + indexId = book.getLastIndexId(); + } + } + if (indexId == null) { + return "无(本书开篇章节)"; + } + String content = unescapeContent(bookService.queryIndexContent(indexId, plan.getAuthorId())); + if (content.isBlank()) { + return "无"; + } + int start = Math.max(0, content.length() - 500); + return AiJsonUtil.truncate(content.substring(start), 500); + } + + private String genCheckRevise(AiCreationPlan plan, AiCreationTask task) { + AiChapterPlan row = chapterPlanMapper.selectByPlanAndNo(plan.getId(), task.getChapterNo()); + if (row == null) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_PLAN_NOT_EXISTS); + } + AiCreationTask draftTask = latestDraftTask(plan.getId(), task.getChapterNo()); + if (draftTask == null) { + throw new BusinessException(ResponseStatus.AI_DRAFT_NOT_EXISTS); + } + String content = extractDraftContent(draftTask); + JsonNode params = readParams(plan); + int targetWords = params.path("chapterWords").asInt(3000); + Map vars = buildChapterVars(plan, row); + + boolean passed = false; + JsonNode issues = MAPPER.createArrayNode(); + int round = 0; + for (round = 1; round <= MAX_REVISE_ROUNDS; round++) { + List progProblems = ChapterTextCheckUtil.check(content, targetWords); + JsonNode checkNode = AiJsonUtil.parseJsonFromModel(callAi( + renderWithContent("chapter_check", vars, content))); + AiJsonUtil.validateCheck(checkNode); + issues = checkNode.path("issues"); + passed = checkNode.path("passed").asBoolean(false) && progProblems.isEmpty(); + if (passed || round == MAX_REVISE_ROUNDS) { + //最后一轮检查未通过时保留当前版本交作者处理,不再无限修订 + break; + } + String mergedIssues = mergeIssues(issues, progProblems); + vars.put("issues", mergedIssues); + String revised = AiJsonUtil.stripCodeFence( + callAi(renderWithContent("chapter_revise", vars, content))); + if (revised.isBlank() || revised.length() > 200000) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + content = revised; + } + chapterPlanMapper.markDraft(plan.getId(), task.getChapterNo()); + ObjectNode result = MAPPER.createObjectNode(); + result.put("passed", passed); + result.put("rounds", round); + result.set("issues", issues.deepCopy()); + result.put("content", content); + return result.toString(); + } + + private String mergeIssues(JsonNode aiIssues, List progProblems) { + StringBuilder sb = new StringBuilder(); + for (JsonNode issue : aiIssues) { + sb.append("[").append(text(issue, "type")).append("] ") + .append(text(issue, "description")).append(" 建议:") + .append(text(issue, "suggestion")).append('\n'); + } + for (String p : progProblems) { + sb.append("[程序校验] ").append(p).append('\n'); + } + return AiJsonUtil.truncate(sb.toString(), 3000); + } + + private String renderWithContent(String template, Map vars, String content) { + Map copy = new HashMap<>(vars); + copy.put("content", AiJsonUtil.truncate(content, 30000)); + return PromptTemplateUtil.render(template, copy); + } + + /** + * 从草稿任务结果提取正文:正文任务为纯文本,校验修订任务为JSON的content字段 + */ + private String extractDraftContent(AiCreationTask draftTask) { + String raw = draftTask.getResult(); + if (raw == null || raw.isBlank()) { + throw new BusinessException(ResponseStatus.AI_DRAFT_NOT_EXISTS); + } + if (draftTask.getTaskType() == TYPE_CHECK_REVISE) { + return AiJsonUtil.parseJsonFromModel(raw).path("content").asText(""); + } + return raw; + } + + private String genChapterSummary(AiCreationPlan plan, AiCreationTask task) { + AiChapterPlan row = chapterPlanMapper.selectByPlanAndNo(plan.getId(), task.getChapterNo()); + if (row == null || row.getStatus() != CHAPTER_SAVED || row.getIndexId() == null) { + throw new BusinessException(ResponseStatus.AI_CHAPTER_PLAN_NOT_EXISTS); + } + String content = unescapeContent(bookService.queryIndexContent(row.getIndexId(), plan.getAuthorId())); + if (content.isBlank()) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + String summary; + try { + Map vars = new HashMap<>(); + vars.put("indexName", row.getIndexName()); + vars.put("content", AiJsonUtil.truncate(content, 8000)); + summary = AiJsonUtil.stripCodeFence(callAi(PromptTemplateUtil.render("chapter_summary", vars))); + summary = AiJsonUtil.truncate(summary, 500); + } catch (Exception e) { + //摘要失败降级为程序截取,不影响已保存正文 + log.warn("AI章节摘要失败,降级程序截取 planId={}, chapterNo={}", plan.getId(), task.getChapterNo()); + summary = ChapterTextCheckUtil.fallbackSummary(content); + } + chapterPlanMapper.updateSummary(plan.getId(), task.getChapterNo(), summary); + return summary; + } + + // ------------------------------------------------------------------ + // 公共辅助 + // ------------------------------------------------------------------ + + /** + * 流式聚合调用模型:与现有AI功能同一客户端与配置,分段响应规避长文本读超时 + */ + private String callAi(String prompt) { + List chunks; + try { + chunks = chatClient.prompt().user(prompt).stream().content() + .collectList().block(AI_TIMEOUT); + } catch (IllegalStateException e) { + //block(Duration)超时,模型响应挂起或过长,转为业务异常让任务明确失败可重试 + throw new BusinessException(ResponseStatus.AI_GEN_TIMEOUT); + } + String text = chunks == null ? "" : String.join("", chunks).trim(); + if (text.isEmpty()) { + throw new BusinessException(ResponseStatus.AI_GEN_FAILED); + } + return text; + } + + private AiCreationPlan loadOwnedPlan(Long authorId, Long planId) { + AiCreationPlan plan = planMapper.selectByIdAndAuthor(planId, authorId); + if (plan == null) { + throw new BusinessException(ResponseStatus.AI_PLAN_NOT_EXISTS); + } + return plan; + } + + private JsonNode readParams(AiCreationPlan plan) { + if (plan.getCreativeParams() == null || plan.getCreativeParams().isBlank()) { + return MAPPER.createObjectNode(); + } + try { + return MAPPER.readTree(plan.getCreativeParams()); + } catch (Exception e) { + throw new BusinessException(ResponseStatus.AI_PARAMS_INVALID); + } + } + + private JsonNode readPlanContent(AiCreationPlan plan) { + if (plan.getPlanContent() == null || plan.getPlanContent().isBlank()) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + try { + return MAPPER.readTree(plan.getPlanContent()); + } catch (Exception e) { + throw new BusinessException(ResponseStatus.AI_PLAN_INVALID); + } + } + + private String text(JsonNode node, String field) { + return node.path(field).asText(""); + } + + /** + * 与现有发布流程一致的正文格式转换 + */ + private String convertText(String text) { + return text.replaceAll("\\n", "
").replaceAll("\\s", " "); + } + + /** + * 存储格式还原为纯文本(供模型阅读与摘要) + */ + private String unescapeContent(String content) { + return content == null ? "" : content.replace("
", "\n").replace(" ", " "); + } +} diff --git a/novel-front/src/main/java/com/java2nb/novel/vo/AiPlanProgressVO.java b/novel-front/src/main/java/com/java2nb/novel/vo/AiPlanProgressVO.java new file mode 100644 index 0000000..c254643 --- /dev/null +++ b/novel-front/src/main/java/com/java2nb/novel/vo/AiPlanProgressVO.java @@ -0,0 +1,37 @@ +package com.java2nb.novel.vo; + +import com.java2nb.novel.entity.AiChapterPlan; +import com.java2nb.novel.entity.AiCreationPlan; +import com.java2nb.novel.entity.AiCreationTask; +import lombok.Data; + +import java.util.List; + +/** + * AI创作助手进度视图(用于页面加载时恢复创作状态) + * + * @author 11797 + */ +@Data +public class AiPlanProgressVO { + + /** + * 创作方案 + */ + private AiCreationPlan plan; + + /** + * 章节计划列表 + */ + private List chapterPlans; + + /** + * 执行中的任务(前端恢复轮询) + */ + private List runningTasks; + + /** + * 最近任务摘要(不含result正文,展示状态与失败原因) + */ + private List latestTasks; +} diff --git a/novel-front/src/main/resources/application.yml b/novel-front/src/main/resources/application.yml index c656ddb..c40d3b4 100644 --- a/novel-front/src/main/resources/application.yml +++ b/novel-front/src/main/resources/application.yml @@ -23,7 +23,7 @@ xss: # 排除链接(多个用逗号分隔) excludes: /system/notice/* # 匹配链接 (多个用逗号分隔) - urlPatterns: /book/addCommentReply,/book/addBookComment,/book/addParagraphComment,/user/addFeedBack,/author/addBook,/author/addBookContent,/author/updateBookContent,/author/register.html,/bookList/create,/bookList/update,/bookList/addBook + urlPatterns: /book/addCommentReply,/book/addBookComment,/book/addParagraphComment,/user/addFeedBack,/author/addBook,/author/addBookContent,/author/updateBookContent,/author/register.html,/bookList/create,/bookList/update,/bookList/addBook,/author/aiAssistant/saveParams,/author/aiAssistant/savePlanContent,/author/aiAssistant/confirmPlan,/author/aiAssistant/updateChapterPlan,/author/aiAssistant/saveDraft,/author/aiAssistant/saveChapter,/author/aiAssistant/savePref author: @@ -51,11 +51,11 @@ spring: image: enabled: true base-url: https://api.siliconflow.cn - api-key: sk-jjtixmivxaccndqgkqfkbgkzvmbctdxogcrfbjzfttbouitt + api-key: sk-uytoztqwpggppdrsfyuvkgztrmgxpktfqshpontczeefzqqi options: model: Kwai-Kolors/Kolors response_format: URL - api-key: sk-jjtixmivxaccndqgkqfkbgkzvmbctdxogcrfbjzfttbouitt + api-key: sk-uytoztqwpggppdrsfyuvkgztrmgxpktfqshpontczeefzqqi base-url: https://api.siliconflow.cn chat: options: diff --git a/novel-front/src/main/resources/mybatis/mapping/FrontAiAuthorPrefMapper.xml b/novel-front/src/main/resources/mybatis/mapping/FrontAiAuthorPrefMapper.xml new file mode 100644 index 0000000..5ec9f5c --- /dev/null +++ b/novel-front/src/main/resources/mybatis/mapping/FrontAiAuthorPrefMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + insert into ai_author_pref (author_id, pref_content, create_time, update_time) + values (#{authorId}, #{prefContent}, now(), now()) + on duplicate key update pref_content = values(pref_content), update_time = now() + + + diff --git a/novel-front/src/main/resources/mybatis/mapping/FrontAiChapterPlanMapper.xml b/novel-front/src/main/resources/mybatis/mapping/FrontAiChapterPlanMapper.xml new file mode 100644 index 0000000..917349a --- /dev/null +++ b/novel-front/src/main/resources/mybatis/mapping/FrontAiChapterPlanMapper.xml @@ -0,0 +1,73 @@ + + + + + + id, plan_id, chapter_no, index_name, chapter_outline, status, index_id, word_count, summary, create_time, update_time + + + + insert into ai_chapter_plan (plan_id, chapter_no, index_name, chapter_outline, status, create_time, update_time) + values + + (#{item.planId}, #{item.chapterNo}, #{item.indexName}, #{item.chapterOutline}, 0, now(), now()) + + + + + + + + + update ai_chapter_plan + set status = 3, update_time = now() + where plan_id = #{planId} and chapter_no = #{chapterNo} and status in (0, 1) + + + + update ai_chapter_plan + set status = 2, index_id = #{indexId}, word_count = #{wordCount}, update_time = now() + where plan_id = #{planId} and chapter_no = #{chapterNo} and status = 3 + + + + update ai_chapter_plan + set status = 1, update_time = now() + where plan_id = #{planId} and chapter_no = #{chapterNo} and status = 0 + + + + update ai_chapter_plan + set index_name = #{indexName}, chapter_outline = #{chapterOutline}, update_time = now() + where plan_id = #{planId} and chapter_no = #{chapterNo} and status in (0, 1) + + + + update ai_chapter_plan + set summary = #{summary}, update_time = now() + where plan_id = #{planId} and chapter_no = #{chapterNo} + + + + delete from ai_chapter_plan + where plan_id = #{planId} and status in (0, 1) + + + + + diff --git a/novel-front/src/main/resources/mybatis/mapping/FrontAiCreationPlanMapper.xml b/novel-front/src/main/resources/mybatis/mapping/FrontAiCreationPlanMapper.xml new file mode 100644 index 0000000..ff01ee2 --- /dev/null +++ b/novel-front/src/main/resources/mybatis/mapping/FrontAiCreationPlanMapper.xml @@ -0,0 +1,43 @@ + + + + + + insert into ai_creation_plan (author_id, book_id, stage, creative_params, plan_content, outline_version, create_time, update_time) + values (#{authorId}, #{bookId}, #{stage}, #{creativeParams}, #{planContent}, #{outlineVersion}, now(), now()) + + + + + + + + + + update ai_creation_plan + + book_id = #{bookId}, + stage = #{stage}, + creative_params = #{creativeParams}, + plan_content = #{planContent}, + outline_version = #{outlineVersion}, + update_time = now() + + where id = #{id} and author_id = #{authorId} + + + diff --git a/novel-front/src/main/resources/mybatis/mapping/FrontAiCreationTaskMapper.xml b/novel-front/src/main/resources/mybatis/mapping/FrontAiCreationTaskMapper.xml new file mode 100644 index 0000000..66abebd --- /dev/null +++ b/novel-front/src/main/resources/mybatis/mapping/FrontAiCreationTaskMapper.xml @@ -0,0 +1,73 @@ + + + + + + id, plan_id, author_id, task_type, chapter_no, status, running_flag, result, error_msg, create_time, update_time + + + + insert into ai_creation_task (plan_id, author_id, task_type, chapter_no, status, running_flag, create_time, update_time) + values (#{planId}, #{authorId}, #{taskType}, #{chapterNo}, 0, 1, now(), now()) + + + + insert into ai_creation_task (plan_id, author_id, task_type, chapter_no, status, running_flag, result, create_time, update_time) + values (#{planId}, #{authorId}, 3, #{chapterNo}, 1, null, #{result}, now(), now()) + + + + + + + + update ai_creation_task + set status = #{status}, + running_flag = null, + result = #{result}, + error_msg = #{errorMsg}, + update_time = now() + where id = #{id} + + + + update ai_creation_task + set status = 2, + running_flag = null, + error_msg = #{errorMsg}, + update_time = now() + where status = 0 and running_flag = 1 + + + + + + + + + diff --git a/novel-front/src/main/resources/prompts/ai-assistant/book_name.txt b/novel-front/src/main/resources/prompts/ai-assistant/book_name.txt new file mode 100644 index 0000000..5c159f6 --- /dev/null +++ b/novel-front/src/main/resources/prompts/ai-assistant/book_name.txt @@ -0,0 +1,19 @@ +你是一位资深网文编辑和书名策划,擅长为长篇小说起出既贴合内容又有吸引力的书名。 + +【小说信息】 +题材:{genre} +核心创意:{coreIdea} +主角设定:{protagonist} +核心冲突:{conflict} +目标读者:{targetReaders} + +【任务】 +根据以上信息构思 8 个中文网文小说书名,要求: +1. 书名贴合题材气质,能体现核心创意或主角特质,避免空泛。 +2. 8 个书名风格多样化:既有直白点题型,也有意象含蓄型,避免雷同。 +3. 每个书名不超过 12 个字,不得包含书名号。 +4. 每个书名给出一句推荐理由(不超过 40 字)。 + +【输出格式】 +只输出如下 JSON,不要输出任何其他内容、解释或 markdown 代码块标记: +{"names":[{"name":"书名","reason":"推荐理由"}]} diff --git a/novel-front/src/main/resources/prompts/ai-assistant/chapter_check.txt b/novel-front/src/main/resources/prompts/ai-assistant/chapter_check.txt new file mode 100644 index 0000000..5c73e8a --- /dev/null +++ b/novel-front/src/main/resources/prompts/ai-assistant/chapter_check.txt @@ -0,0 +1,30 @@ +你是一位严格的小说责编,负责审查网文章节草稿的质量与一致性。请逐项检查,只报告有明确依据的问题,不要吹毛求疵,也不要泛泛而谈。 + +【本章应完成的规划】 +章节标题:{indexName} +章节规划:{chapterOutline} +目标字数:约{chapterWords}字 + +【必须遵守的设定】 +世界观与规则:{worldview} +人物档案:{characters} +前情摘要: +{recentSummaries} +上一章结尾(衔接参考): +{prevTail} + +【待检查正文】 +{content} + +【检查项】 +1. 是否完成本章规划的主要事件与冲突,情节有无实质推进。 +2. 人物行为、知识范围、能力是否与人物档案一致。 +3. 是否与前文事实、时间线或世界观规则矛盾。 +4. 是否存在明显重复(情节/语句复读)、逻辑断裂或无意义注水。 +5. 与上一章的衔接是否自然,结尾是否有合理阅读动力。 +6. 字数是否在目标范围(±30%)内。 + +【输出格式】 +只输出如下 JSON,不要输出任何其他内容或 markdown 代码块标记: +{"passed":true,"issues":[{"type":"问题类型","description":"具体问题及所在位置描述","suggestion":"可执行的修改建议"}]} +规则:无任何问题时 passed 为 true 且 issues 为空数组;passed 为 false 当且仅当存在至少一个必须修改的问题。 diff --git a/novel-front/src/main/resources/prompts/ai-assistant/chapter_content.txt b/novel-front/src/main/resources/prompts/ai-assistant/chapter_content.txt new file mode 100644 index 0000000..4d8c9e4 --- /dev/null +++ b/novel-front/src/main/resources/prompts/ai-assistant/chapter_content.txt @@ -0,0 +1,37 @@ +你是一位{genre}题材的资深网文作家,现在要为长篇连载小说创作正文。写作必须遵守以下创作原则: +- 展示而非讲述:通过人物行动、对话、环境和具体细节展现情绪与性格,不要直接陈述“他很愤怒”这类感受。 +- 冲突推动剧情:按“目标-冲突-选择-代价-结果”推进本章事件,避免大段空泛说明。 +- 人物行为必须符合其动机、知识范围、能力设定和既定世界观规则。 +- 章内情节必须有实质推进,禁止重复前文、注水、为凑字数反复铺陈同一情绪。 +- 避免模板化表达、AI 腔套话、刻意煽情和滥用排比;叙事节奏贴合{genre}题材与本书文风。 +- 章节结尾提供自然的阅读动力(悬念、转折或情绪余韵),但不机械地强行制造危机。 + +【小说设定(必须遵守)】 +故事简介:{intro} +世界观与规则:{worldview} +人物档案:{characters} +叙事视角与文风要求:{style} +作者补充要求:{extra} +作者长期偏好:{pref} + +【故事进度】 +故事总纲:{mainOutline} +本卷大纲:{volume} +前情摘要(此前章节): +{recentSummaries} +上一章结尾原文(衔接参考): +{prevTail} +未解决伏笔(若本章按计划应埋设/回收请落实,不应强行处理): +{foreshadows} + +【本章写作任务】 +章节编号:第{chapterNo}章 +章节标题:{indexName} +本章规划:{chapterOutline} +目标字数:约{chapterWords}字(允许上下浮动20%,严禁灌水凑字) + +【输出要求】 +1. 直接输出本章正文,第一行输出“{indexName}”,从第二行起为正文内容。 +2. 段落之间用换行分隔,每段开头不要添加缩进符号或全角空格。 +3. 不要输出任何解释、标题编号前缀、作者注、大纲复述或 markdown 标记。 +4. 内容需与前文事实一致,并为后续情节保留发展空间。 diff --git a/novel-front/src/main/resources/prompts/ai-assistant/chapter_plan_batch.txt b/novel-front/src/main/resources/prompts/ai-assistant/chapter_plan_batch.txt new file mode 100644 index 0000000..bf1e626 --- /dev/null +++ b/novel-front/src/main/resources/prompts/ai-assistant/chapter_plan_batch.txt @@ -0,0 +1,26 @@ +你是一位资深网文作家,擅长为长篇小说规划逻辑连贯、层层递进的章节大纲。 + +【小说基本信息】 +书名:{bookName} +题材:{genre} +故事简介:{intro} +故事总纲:{mainOutline} +分卷大纲:{volumes} +人物档案:{characters} +伏笔记录:{foreshadows} +每章目标字数:{chapterWords} + +【已有章节规划(供衔接参考,勿重复内容)】 +{existingChapters} + +【任务】 +继续规划第 {startNo} 章到第 {endNo} 章的章节规划,共 {batchCount} 章,要求: +1. 与已有章节自然衔接,情节继续向总纲的下一阶段推进,不得重复已规划章节的事件。 +2. 每章包含:章节标题(不带“第X章”前缀)、主要事件、主要冲突、人物变化、与主线关系、结尾阅读动力。 +3. 遵循伏笔记录,在合适章节安排埋设或回收。 +4. 章节规划必须围绕主线,拒绝注水和无意义桥段。 + +【输出格式】 +只输出如下 JSON,不要输出任何其他内容、解释或 markdown 代码块标记: +{"chapters":[{"chapterNo":0,"indexName":"","events":"","conflict":"","charChange":"","relation":"","ending":""}]} +(chapterNo 必须从 {startNo} 连续编号到 {endNo}) diff --git a/novel-front/src/main/resources/prompts/ai-assistant/chapter_revise.txt b/novel-front/src/main/resources/prompts/ai-assistant/chapter_revise.txt new file mode 100644 index 0000000..b9677bb --- /dev/null +++ b/novel-front/src/main/resources/prompts/ai-assistant/chapter_revise.txt @@ -0,0 +1,27 @@ +你是一位网文修改专家。下面的章节草稿被责编指出了具体问题,请在保持原有情节走向、人物设定和文风的前提下进行修订。 + +【修订原则】 +1. 只针对清单中的问题修改,不要重写无关情节,不要改变章节结尾走向。 +2. 润色优先提升自然度、叙事节奏和细节具体性,禁止机械替换同义词式的表面修改。 +3. 修订后仍须完成本章规划目标,字数保持在与目标相符的范围。 +4. 段落之间用换行分隔,段落开头不加缩进符号。 + +【本章规划】 +章节标题:{indexName} +章节规划:{chapterOutline} +目标字数:约{chapterWords}字 + +【责编问题清单】 +{issues} + +【必须遵守的设定摘要】 +世界观与规则:{worldview} +人物档案:{characters} +前情摘要: +{recentSummaries} + +【原草稿】 +{content} + +【输出要求】 +直接输出修订后的完整章节正文(第一行为章节标题“{indexName}”,其后为正文)。不要输出任何解释、问题复述或 markdown 标记。 diff --git a/novel-front/src/main/resources/prompts/ai-assistant/chapter_summary.txt b/novel-front/src/main/resources/prompts/ai-assistant/chapter_summary.txt new file mode 100644 index 0000000..8a1de20 --- /dev/null +++ b/novel-front/src/main/resources/prompts/ai-assistant/chapter_summary.txt @@ -0,0 +1,21 @@ +你是一位小说编辑,擅长提炼章节关键信息,为后续创作维护“创作记忆”。 + +【任务】 +阅读下面已定稿的章节正文,输出 150 字以内的章节摘要,供后续章节创作时参考。摘要必须包含: +1. 本章推进的关键情节与结果(只记影响后续的事实)。 +2. 人物状态或关系的重要变化(受伤、获得物品、立场转变、新信息等)。 +3. 新埋设或回收的伏笔线索。 +4. 章末遗留的未决问题。 + +【注意】 +- 只依据正文中明确出现的信息,不得推测未发生的情节。 +- 不复述环境描写和心理活动细节,不写评价性语言。 + +【章节信息】 +章节标题:{indexName} + +【章节正文】 +{content} + +【输出要求】 +直接输出摘要文本一段,不要任何前缀、解释或 markdown 标记。 diff --git a/novel-front/src/main/resources/prompts/ai-assistant/creative_plan.txt b/novel-front/src/main/resources/prompts/ai-assistant/creative_plan.txt new file mode 100644 index 0000000..3613437 --- /dev/null +++ b/novel-front/src/main/resources/prompts/ai-assistant/creative_plan.txt @@ -0,0 +1,32 @@ +你是一位资深网文作家和故事策划,擅长从零构建逻辑自洽、可长期连载的长篇小说创作方案。 + +【作者提供的创作信息】 +题材:{genre} +书名:{bookName} +核心创意:{coreIdea} +主角设定:{protagonist} +核心冲突:{conflict} +世界观及故事背景要求:{worldview} +叙事视角与写作风格:{style} +核心主题:{theme} +目标读者:{targetReaders} +预计总章节数:{totalChapters} +每章目标字数:{chapterWords} +其他补充要求:{extra} +作者长期创作偏好(尽量贴合但不强求):{pref} + +【任务】 +输出一份完整的长篇小说创作方案,必须满足: +1. 各部分相互一致:人物动机、世界规则、故事主线不得互相矛盾。 +2. 故事总纲按“开端-发展-转折-高潮-结局”五段推进,结局必须呼应核心冲突、人物成长和主题。 +3. 人物档案包含 4-8 个关键人物(主角、重要配角、对手等),每人写明身份、性格、动机、目标、与其他人物的关系、人物弧光(成长变化轨迹)。 +4. 世界观按题材需要生成:玄幻/仙侠/科幻需包含力量或科技体系与重要组织;现实/言情等题材可简写为时代背景与生活圈。 +5. 分卷大纲:若预计总章节数不小于 30 则划分 2-4 卷并给出每卷主线任务,否则 volumes 返回空数组。 +6. 伏笔记录 3-6 条:每条写明伏笔内容、埋设章节、计划回收章节,回收章节必须大于埋设章节且不超过总章节数。 +7. 章节规划:本次只输出第 1 章到第 {planChapterEnd} 章的规划。每章包含:章节标题(不带“第X章”前缀)、主要事件(推动主线的具体情节,拒绝注水)、主要冲突、人物变化、与主线关系、结尾阅读动力(悬念/转折/情绪钩子,不必每章强行制造危机)。 +8. 章节之间衔接自然,情节持续推进,同一冲突不得在多章中重复解决。 +9. 信息不足时基于题材惯例合理补全,不得空缺关键字段。 + +【输出格式】 +只输出如下结构的 JSON,不要输出任何其他内容、解释或 markdown 代码块标记: +{"bookName":"书名","sellingPoints":"一句话核心卖点","targetReaders":"目标读者","intro":"300字以内故事简介,含核心剧情、主角目标、主要矛盾","worldview":"世界观与背景设定","characters":[{"name":"","role":"主角/配角/对手","identity":"","personality":"","motivation":"","goal":"","relations":"","arc":""}],"mainOutline":{"opening":"","development":"","turning":"","climax":"","ending":""},"volumes":[{"title":"","range":"第1-15章","summary":""}],"foreshadows":[{"content":"","plantChapter":1,"payoffChapter":20}],"chapters":[{"chapterNo":1,"indexName":"","events":"","conflict":"","charChange":"","relation":"与主线关系","ending":"结尾阅读动力"}]} diff --git a/novel-front/src/main/resources/static/css/user.css b/novel-front/src/main/resources/static/css/user.css index 1f8d112..eaf81d3 100644 --- a/novel-front/src/main/resources/static/css/user.css +++ b/novel-front/src/main/resources/static/css/user.css @@ -45,6 +45,9 @@ .my_l .link_4 { background-position:32px -314px } .my_l .link_5 { background-position:32px -356px } .my_l .link_6 { background-position:32px -397px } +.my_l .link_ai { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233eaf7c' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='5' y='8' width='14' height='11' rx='3'/%3E%3Ccircle cx='12' cy='4.6' r='1.4' fill='%233eaf7c' stroke='none'/%3E%3Cpath d='M12 6v2'/%3E%3Ccircle cx='9.4' cy='13' r='1' fill='%233eaf7c' stroke='none'/%3E%3Ccircle cx='14.6' cy='13' r='1' fill='%233eaf7c' stroke='none'/%3E%3Cpath d='M10 16.2h4'/%3E%3Cpath d='M3.4 12.4v2.2M20.6 12.4v2.2'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } +.my_l .link_book { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233eaf7c' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 7.5C10.5 6 8 5.4 4.5 5.7v12C8 17.5 10.5 18 12 19.5M12 7.5c1.5-1.5 4-2.1 7.5-1.8v12c-3.5-.2-6 .3-7.5 1.8'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } +.my_l .link_income { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233eaf7c' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='6' width='18' height='12' rx='2'/%3E%3Cpath d='M9.3 9.3l2.7 3.2 2.7-3.2M12 12.5v2.8M9.9 13.9h4.2'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } .my_l .link_7 { background-position:32px -440px } .my_l .link_8 { background-position:32px -481px } .my_r { width:739px; padding:0 30px 30px; float:right; border-left:1px solid #efefef; min-height:470px } diff --git a/novel-front/src/main/resources/templates/author/ai_assistant.html b/novel-front/src/main/resources/templates/author/ai_assistant.html new file mode 100644 index 0000000..5220178 --- /dev/null +++ b/novel-front/src/main/resources/templates/author/ai_assistant.html @@ -0,0 +1,816 @@ + + + + + + AI创作助手-作家管理系统 + + + + + + +

+ +
+ +
+
+ +
+
    +
  • ① 创意问答
  • +
  • ② 设定与大纲
  • +
  • ③ 章节创作
  • +
+ +
加载中…
+
+
+ 重试 + 忽略 +
+ + +
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • 随机灵感 + 使用默认参数
  • +
+
深度设定(可选,可跳过)
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
书名
+ +
点击AI建议书名可直接填入
+ +
+ + +
+
+ 生成创作方案 + 重新生成方案 + 生成后可编辑、保存,确认前不会写入任何章节 +
+
    +
  • +
  • +
  • +
  • +
  • +
+
结构化设定(JSON,可编辑)
+
    +
  • +
  • +
  • +
  • +
+
章节规划
+
+ + + +
章号标题本章规划(事件/冲突/变化/主线/结尾)操作
+
+
+ + +
确认后将按方案拆分章节规划;新建小说会同时创建小说记录(沿用现有发布流程,含自动生成封面)
+
+ + +
+
+ + + +
章号标题状态字数操作
+
+
每次只生成一章,确认保存后才会写入正式章节
+ +
+
章节正文
+
+ + +
+ +
+ +
+
+
+
+
+ + + + + + + + + diff --git a/novel-front/src/main/resources/templates/author/author_income.html b/novel-front/src/main/resources/templates/author/author_income.html index 8f4f25f..f6f8f62 100644 --- a/novel-front/src/main/resources/templates/author/author_income.html +++ b/novel-front/src/main/resources/templates/author/author_income.html @@ -28,10 +28,9 @@ diff --git a/novel-front/src/main/resources/templates/author/author_income_detail.html b/novel-front/src/main/resources/templates/author/author_income_detail.html index c33c497..4b90460 100644 --- a/novel-front/src/main/resources/templates/author/author_income_detail.html +++ b/novel-front/src/main/resources/templates/author/author_income_detail.html @@ -28,10 +28,9 @@ diff --git a/novel-front/src/main/resources/templates/author/book_add.html b/novel-front/src/main/resources/templates/author/book_add.html index 9908825..25a00cb 100644 --- a/novel-front/src/main/resources/templates/author/book_add.html +++ b/novel-front/src/main/resources/templates/author/book_add.html @@ -31,10 +31,9 @@ diff --git a/novel-front/src/main/resources/templates/author/content_add.html b/novel-front/src/main/resources/templates/author/content_add.html index 2754cad..92d8dd2 100644 --- a/novel-front/src/main/resources/templates/author/content_add.html +++ b/novel-front/src/main/resources/templates/author/content_add.html @@ -101,10 +101,9 @@ diff --git a/novel-front/src/main/resources/templates/author/content_update.html b/novel-front/src/main/resources/templates/author/content_update.html index 2b2da87..5c20578 100644 --- a/novel-front/src/main/resources/templates/author/content_update.html +++ b/novel-front/src/main/resources/templates/author/content_update.html @@ -28,8 +28,9 @@ diff --git a/novel-front/src/main/resources/templates/author/index.html b/novel-front/src/main/resources/templates/author/index.html index 2c1034f..2fa1422 100644 --- a/novel-front/src/main/resources/templates/author/index.html +++ b/novel-front/src/main/resources/templates/author/index.html @@ -46,10 +46,9 @@ @@ -222,6 +221,7 @@ " " + "" + + "" + "" + (book.bookStatus == 0 ? ("") : "") + " "); diff --git a/novel-front/src/main/resources/templates/author/index_list.html b/novel-front/src/main/resources/templates/author/index_list.html index b1f1b3c..8c8884c 100644 --- a/novel-front/src/main/resources/templates/author/index_list.html +++ b/novel-front/src/main/resources/templates/author/index_list.html @@ -42,8 +42,9 @@ diff --git a/novel-front/src/test/java/com/java2nb/novel/core/ai/AiJsonUtilTest.java b/novel-front/src/test/java/com/java2nb/novel/core/ai/AiJsonUtilTest.java new file mode 100644 index 0000000..8af766f --- /dev/null +++ b/novel-front/src/test/java/com/java2nb/novel/core/ai/AiJsonUtilTest.java @@ -0,0 +1,86 @@ +package com.java2nb.novel.core.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import io.github.xxyopen.web.exception.BusinessException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * AI输出JSON防御性解析与结构校验测试 + */ +class AiJsonUtilTest { + + @Test + void parseJsonFromModel_shouldParsePureJson() { + JsonNode node = AiJsonUtil.parseJsonFromModel("{\"a\":1}"); + assertEquals(1, node.path("a").asInt()); + } + + @Test + void parseJsonFromModel_shouldStripCodeFence() { + String raw = "```json\n{\"names\":[{\"name\":\"测试\",\"reason\":\"r\"}]}\n```"; + JsonNode node = AiJsonUtil.parseJsonFromModel(raw); + assertEquals("测试", node.path("names").get(0).path("name").asText()); + } + + @Test + void parseJsonFromModel_shouldExtractFromNoisyText() { + String raw = "好的,以下是结果:\n{\"passed\":true,\"issues\":[]}\n以上。"; + JsonNode node = AiJsonUtil.parseJsonFromModel(raw); + assertTrue(node.path("passed").asBoolean()); + } + + @Test + void parseJsonFromModel_shouldThrowOnInvalidJson() { + assertThrows(BusinessException.class, () -> AiJsonUtil.parseJsonFromModel("这不是JSON")); + assertThrows(BusinessException.class, () -> AiJsonUtil.parseJsonFromModel("")); + assertThrows(BusinessException.class, () -> AiJsonUtil.parseJsonFromModel(null)); + assertThrows(BusinessException.class, () -> AiJsonUtil.parseJsonFromModel("{\"broken\":")); + } + + @Test + void validatePlan_shouldAcceptCompletePlan() { + String json = "{\"intro\":\"简介\",\"characters\":[{\"name\":\"甲\"}]," + + "\"mainOutline\":{\"opening\":\"o\"}," + + "\"chapters\":[{\"chapterNo\":1,\"indexName\":\"开端\"}]}"; + JsonNode node = AiJsonUtil.parseJsonFromModel(json); + assertDoesNotThrow(() -> AiJsonUtil.validatePlan(node)); + } + + @Test + void validatePlan_shouldRejectMissingParts() { + // 缺章节规划 + String noChapters = "{\"intro\":\"简介\",\"characters\":[],\"mainOutline\":{},\"chapters\":[]}"; + assertThrows(BusinessException.class, + () -> AiJsonUtil.validatePlan(AiJsonUtil.parseJsonFromModel(noChapters))); + // 章节缺标题 + String noTitle = "{\"intro\":\"简介\",\"characters\":[{}],\"mainOutline\":{}," + + "\"chapters\":[{\"chapterNo\":1}]}"; + assertThrows(BusinessException.class, + () -> AiJsonUtil.validatePlan(AiJsonUtil.parseJsonFromModel(noTitle))); + // 章节号非法 + String badNo = "{\"intro\":\"简介\",\"characters\":[{}],\"mainOutline\":{}," + + "\"chapters\":[{\"chapterNo\":0,\"indexName\":\"x\"}]}"; + assertThrows(BusinessException.class, + () -> AiJsonUtil.validatePlan(AiJsonUtil.parseJsonFromModel(badNo))); + } + + @Test + void validateNames_shouldRejectEmptyNames() { + assertThrows(BusinessException.class, + () -> AiJsonUtil.validateNames(AiJsonUtil.parseJsonFromModel("{\"names\":[]}"))); + assertDoesNotThrow(() -> AiJsonUtil.validateNames( + AiJsonUtil.parseJsonFromModel("{\"names\":[{\"name\":\"书\"}]}"))); + } + + @Test + void truncate_shouldCutLongText() { + assertEquals("", AiJsonUtil.truncate(null, 10)); + assertEquals("abc", AiJsonUtil.truncate("abc", 10)); + String longText = "a".repeat(100); + String cut = AiJsonUtil.truncate(longText, 10); + assertTrue(cut.startsWith("aaaaaaaaaa")); + assertTrue(cut.contains("已截断")); + } +} diff --git a/novel-front/src/test/java/com/java2nb/novel/core/ai/ChapterTextCheckUtilTest.java b/novel-front/src/test/java/com/java2nb/novel/core/ai/ChapterTextCheckUtilTest.java new file mode 100644 index 0000000..de08b17 --- /dev/null +++ b/novel-front/src/test/java/com/java2nb/novel/core/ai/ChapterTextCheckUtilTest.java @@ -0,0 +1,76 @@ +package com.java2nb.novel.core.ai; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 章节正文程序化校验测试 + */ +class ChapterTextCheckUtilTest { + + @Test + void validWordCount_shouldMatchAuthorRule() { + //与作者后台 StringUtil.getStrValidWordCount 规则一致:中文按字、英文按词、数字按组 + assertEquals(5, ChapterTextCheckUtil.validWordCount("你好呀世界")); + assertEquals(0, ChapterTextCheckUtil.validWordCount(null)); + } + + @Test + void check_shouldFlagEmptyContent() { + assertTrue(ChapterTextCheckUtil.check("", 3000).contains("章节正文为空")); + assertTrue(ChapterTextCheckUtil.check(null, 3000).contains("章节正文为空")); + } + + @Test + void check_shouldFlagTooShort() { + List problems = ChapterTextCheckUtil.check("很短的一段", 3000); + assertFalse(problems.isEmpty()); + assertTrue(problems.get(0).contains("明显不足")); + } + + @Test + void check_shouldFlagBelowMinRatio() { + //1000字 < 3000*0.7 + String content = "字".repeat(1000); + List problems = ChapterTextCheckUtil.check(content, 3000); + assertTrue(problems.stream().anyMatch(p -> p.contains("低于目标"))); + } + + @Test + void check_shouldPassNormalLength() { + String content = "字".repeat(2500); + List problems = ChapterTextCheckUtil.check(content, 3000); + assertTrue(problems.isEmpty(), "2500字相对目标3000字应在允许范围: " + problems); + } + + @Test + void check_shouldFlagOverMaxRatio() { + String content = "字".repeat(5000); + List problems = ChapterTextCheckUtil.check(content, 3000); + assertTrue(problems.stream().anyMatch(p -> p.contains("注水嫌疑"))); + } + + @Test + void check_shouldFlagDuplicateParagraphs() { + String dup = "他缓缓抬起头,目光穿过人群,落在了那柄旧剑上。"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 10; i++) { + sb.append(dup).append('\n'); + } + //补足字数避免先触发字数问题 + sb.append("续".repeat(2500)); + List problems = ChapterTextCheckUtil.check(sb.toString(), 2600); + assertTrue(problems.stream().anyMatch(p -> p.contains("重复段落"))); + } + + @Test + void fallbackSummary_shouldStripTitleAndLimitLength() { + String content = "第一章 初入江湖\n" + "少年负剑出门," .repeat(50); + String summary = ChapterTextCheckUtil.fallbackSummary(content); + assertFalse(summary.startsWith("第一章")); + assertTrue(summary.length() <= 123); + } +} diff --git a/novel-front/src/test/java/com/java2nb/novel/core/ai/PromptTemplateUtilTest.java b/novel-front/src/test/java/com/java2nb/novel/core/ai/PromptTemplateUtilTest.java new file mode 100644 index 0000000..7254e33 --- /dev/null +++ b/novel-front/src/test/java/com/java2nb/novel/core/ai/PromptTemplateUtilTest.java @@ -0,0 +1,62 @@ +package com.java2nb.novel.core.ai; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 提示词模板加载与渲染测试 + */ +class PromptTemplateUtilTest { + + private static final String[] ALL_TEMPLATES = { + "book_name", "creative_plan", "chapter_plan_batch", + "chapter_content", "chapter_check", "chapter_revise", "chapter_summary"}; + + @Test + void load_shouldFindAllTemplatesOnClasspath() { + for (String key : ALL_TEMPLATES) { + String tpl = PromptTemplateUtil.load(key); + assertNotNull(tpl); + assertFalse(tpl.isBlank(), "模板不应为空: " + key); + } + } + + @Test + void render_shouldReplacePlaceholders() { + Map vars = new HashMap<>(); + vars.put("genre", "玄幻"); + String rendered = PromptTemplateUtil.render("book_name", vars); + assertTrue(rendered.contains("题材:玄幻")); + assertFalse(rendered.contains("{genre}")); + } + + @Test + void render_shouldFillMissingVarWithDefault() { + Map vars = new HashMap<>(); + String rendered = PromptTemplateUtil.render("book_name", vars); + assertTrue(rendered.contains("题材:未提供")); + } + + @Test + void render_shouldNotLoopOnSelfReferenceValue() { + //变量值本身包含占位符时不应死循环或二次替换 + Map vars = new HashMap<>(); + vars.put("coreIdea", "这是一个含{coreIdea}字样的创意"); + String rendered = assertTimeout(java.time.Duration.ofSeconds(5), + () -> PromptTemplateUtil.render("book_name", vars)); + assertTrue(rendered.contains("这是一个含{coreIdea}字样的创意")); + } + + @Test + void render_shouldKeepUnrelatedBraces() { + //输出格式示例中的JSON花括号不应被破坏 + Map vars = new HashMap<>(); + vars.put("genre", "都市"); + String rendered = PromptTemplateUtil.render("book_name", vars); + assertTrue(rendered.contains("{\"names\":")); + } +} diff --git a/templates/green/html/author/ai_assistant.html b/templates/green/html/author/ai_assistant.html new file mode 100644 index 0000000..5220178 --- /dev/null +++ b/templates/green/html/author/ai_assistant.html @@ -0,0 +1,816 @@ + + + + + + AI创作助手-作家管理系统 + + + + + + +
+ +
+ +
+
+ +
+
    +
  • ① 创意问答
  • +
  • ② 设定与大纲
  • +
  • ③ 章节创作
  • +
+ +
加载中…
+
+
+ 重试 + 忽略 +
+ + +
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • 随机灵感 + 使用默认参数
  • +
+
深度设定(可选,可跳过)
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
书名
+ +
点击AI建议书名可直接填入
+ +
+ + +
+
+ 生成创作方案 + 重新生成方案 + 生成后可编辑、保存,确认前不会写入任何章节 +
+
    +
  • +
  • +
  • +
  • +
  • +
+
结构化设定(JSON,可编辑)
+
    +
  • +
  • +
  • +
  • +
+
章节规划
+
+ + + +
章号标题本章规划(事件/冲突/变化/主线/结尾)操作
+
+
+ + +
确认后将按方案拆分章节规划;新建小说会同时创建小说记录(沿用现有发布流程,含自动生成封面)
+
+ + +
+
+ + + +
章号标题状态字数操作
+
+
每次只生成一章,确认保存后才会写入正式章节
+ +
+
章节正文
+
+ + +
+ +
+ +
+
+
+
+
+ + + + + + + + + diff --git a/templates/green/html/author/author_income.html b/templates/green/html/author/author_income.html index 8f4f25f..f6f8f62 100644 --- a/templates/green/html/author/author_income.html +++ b/templates/green/html/author/author_income.html @@ -28,10 +28,9 @@ diff --git a/templates/green/html/author/author_income_detail.html b/templates/green/html/author/author_income_detail.html index c33c497..4b90460 100644 --- a/templates/green/html/author/author_income_detail.html +++ b/templates/green/html/author/author_income_detail.html @@ -28,10 +28,9 @@ diff --git a/templates/green/html/author/book_add.html b/templates/green/html/author/book_add.html index eef2383..fae9649 100644 --- a/templates/green/html/author/book_add.html +++ b/templates/green/html/author/book_add.html @@ -31,10 +31,9 @@ diff --git a/templates/green/html/author/content_add.html b/templates/green/html/author/content_add.html index 2754cad..92d8dd2 100644 --- a/templates/green/html/author/content_add.html +++ b/templates/green/html/author/content_add.html @@ -101,10 +101,9 @@ diff --git a/templates/green/html/author/content_update.html b/templates/green/html/author/content_update.html index 2b2da87..5c20578 100644 --- a/templates/green/html/author/content_update.html +++ b/templates/green/html/author/content_update.html @@ -28,8 +28,9 @@ diff --git a/templates/green/html/author/index.html b/templates/green/html/author/index.html index 00a6c63..843b982 100644 --- a/templates/green/html/author/index.html +++ b/templates/green/html/author/index.html @@ -42,10 +42,9 @@ @@ -211,6 +210,7 @@ " " + "章节管理
" + + "AI创作
" + "薪酬查询
" + "作品信息" + " "); diff --git a/templates/green/html/author/index_list.html b/templates/green/html/author/index_list.html index b1f1b3c..8c8884c 100644 --- a/templates/green/html/author/index_list.html +++ b/templates/green/html/author/index_list.html @@ -42,8 +42,9 @@ diff --git a/templates/green/static/css/user.css b/templates/green/static/css/user.css index 1f8d112..eaf81d3 100644 --- a/templates/green/static/css/user.css +++ b/templates/green/static/css/user.css @@ -45,6 +45,9 @@ .my_l .link_4 { background-position:32px -314px } .my_l .link_5 { background-position:32px -356px } .my_l .link_6 { background-position:32px -397px } +.my_l .link_ai { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233eaf7c' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='5' y='8' width='14' height='11' rx='3'/%3E%3Ccircle cx='12' cy='4.6' r='1.4' fill='%233eaf7c' stroke='none'/%3E%3Cpath d='M12 6v2'/%3E%3Ccircle cx='9.4' cy='13' r='1' fill='%233eaf7c' stroke='none'/%3E%3Ccircle cx='14.6' cy='13' r='1' fill='%233eaf7c' stroke='none'/%3E%3Cpath d='M10 16.2h4'/%3E%3Cpath d='M3.4 12.4v2.2M20.6 12.4v2.2'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } +.my_l .link_book { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233eaf7c' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 7.5C10.5 6 8 5.4 4.5 5.7v12C8 17.5 10.5 18 12 19.5M12 7.5c1.5-1.5 4-2.1 7.5-1.8v12c-3.5-.2-6 .3-7.5 1.8'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } +.my_l .link_income { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233eaf7c' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='6' width='18' height='12' rx='2'/%3E%3Cpath d='M9.3 9.3l2.7 3.2 2.7-3.2M12 12.5v2.8M9.9 13.9h4.2'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } .my_l .link_7 { background-position:32px -440px } .my_l .link_8 { background-position:32px -481px } .my_r { width:739px; padding:0 30px 30px; float:right; border-left:1px solid #efefef; min-height:470px } diff --git a/templates/orange/html/author/ai_assistant.html b/templates/orange/html/author/ai_assistant.html new file mode 100644 index 0000000..5220178 --- /dev/null +++ b/templates/orange/html/author/ai_assistant.html @@ -0,0 +1,816 @@ + + + + + + AI创作助手-作家管理系统 + + + + + + +
+ +
+ +
+
+ +
+
    +
  • ① 创意问答
  • +
  • ② 设定与大纲
  • +
  • ③ 章节创作
  • +
+ +
加载中…
+
+
+ 重试 + 忽略 +
+ + +
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • 随机灵感 + 使用默认参数
  • +
+
深度设定(可选,可跳过)
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
书名
+ +
点击AI建议书名可直接填入
+ +
+ + +
+
+ 生成创作方案 + 重新生成方案 + 生成后可编辑、保存,确认前不会写入任何章节 +
+
    +
  • +
  • +
  • +
  • +
  • +
+
结构化设定(JSON,可编辑)
+
    +
  • +
  • +
  • +
  • +
+
章节规划
+
+ + + +
章号标题本章规划(事件/冲突/变化/主线/结尾)操作
+
+
+ + +
确认后将按方案拆分章节规划;新建小说会同时创建小说记录(沿用现有发布流程,含自动生成封面)
+
+ + +
+
+ + + +
章号标题状态字数操作
+
+
每次只生成一章,确认保存后才会写入正式章节
+ +
+
章节正文
+
+ + +
+ +
+ +
+
+
+
+
+ + + + + + + + + diff --git a/templates/orange/html/author/author_income.html b/templates/orange/html/author/author_income.html index 7c74aaf..cc7fdfb 100644 --- a/templates/orange/html/author/author_income.html +++ b/templates/orange/html/author/author_income.html @@ -27,10 +27,9 @@ diff --git a/templates/orange/html/author/author_income_detail.html b/templates/orange/html/author/author_income_detail.html index 1ea36a8..4a8b94e 100644 --- a/templates/orange/html/author/author_income_detail.html +++ b/templates/orange/html/author/author_income_detail.html @@ -27,10 +27,9 @@ diff --git a/templates/orange/html/author/book_add.html b/templates/orange/html/author/book_add.html index eef2383..fae9649 100644 --- a/templates/orange/html/author/book_add.html +++ b/templates/orange/html/author/book_add.html @@ -31,10 +31,9 @@ diff --git a/templates/orange/html/author/content_add.html b/templates/orange/html/author/content_add.html index 183bee0..e65d126 100644 --- a/templates/orange/html/author/content_add.html +++ b/templates/orange/html/author/content_add.html @@ -28,10 +28,9 @@ diff --git a/templates/orange/html/author/content_update.html b/templates/orange/html/author/content_update.html index 2b2da87..5c20578 100644 --- a/templates/orange/html/author/content_update.html +++ b/templates/orange/html/author/content_update.html @@ -28,8 +28,9 @@ diff --git a/templates/orange/html/author/index.html b/templates/orange/html/author/index.html index c6e9b4e..6b6e543 100644 --- a/templates/orange/html/author/index.html +++ b/templates/orange/html/author/index.html @@ -42,10 +42,9 @@ @@ -193,6 +192,7 @@ " " + "章节管理
" + + "AI创作
" + "薪酬查询
" + "作品信息" + " "); diff --git a/templates/orange/html/author/index_list.html b/templates/orange/html/author/index_list.html index ad07954..aeb436d 100644 --- a/templates/orange/html/author/index_list.html +++ b/templates/orange/html/author/index_list.html @@ -42,8 +42,9 @@ diff --git a/templates/orange/static/css/user.css b/templates/orange/static/css/user.css index 0d73bfe..43a78d2 100644 --- a/templates/orange/static/css/user.css +++ b/templates/orange/static/css/user.css @@ -45,6 +45,9 @@ .my_l .link_4 { background-position:32px -314px } .my_l .link_5 { background-position:32px -356px } .my_l .link_6 { background-position:32px -397px } +.my_l .link_ai { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23f8820a' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='5' y='8' width='14' height='11' rx='3'/%3E%3Ccircle cx='12' cy='4.6' r='1.4' fill='%23f8820a' stroke='none'/%3E%3Cpath d='M12 6v2'/%3E%3Ccircle cx='9.4' cy='13' r='1' fill='%23f8820a' stroke='none'/%3E%3Ccircle cx='14.6' cy='13' r='1' fill='%23f8820a' stroke='none'/%3E%3Cpath d='M10 16.2h4'/%3E%3Cpath d='M3.4 12.4v2.2M20.6 12.4v2.2'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } +.my_l .link_book { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23f8820a' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 7.5C10.5 6 8 5.4 4.5 5.7v12C8 17.5 10.5 18 12 19.5M12 7.5c1.5-1.5 4-2.1 7.5-1.8v12c-3.5-.2-6 .3-7.5 1.8'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } +.my_l .link_income { background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23f8820a' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='6' width='18' height='12' rx='2'/%3E%3Cpath d='M9.3 9.3l2.7 3.2 2.7-3.2M12 12.5v2.8M9.9 13.9h4.2'/%3E%3C/svg%3E"); background-position:31px center; background-size:22px 22px } .my_l .link_7 { background-position:32px -440px } .my_l .link_8 { background-position:32px -481px } .my_r { width:739px; padding:0 30px 30px; float:right; border-left:1px solid #efefef; min-height:470px }