feat(author): 新增AI创作助手

This commit is contained in:
xiongxiaoyang committed 2026-10-20 14:36:43 +08:00
1 parent 2c92b10684
commit 15716638f5
62 files changed
+5310 -80

No files matched your search

+18 -5
View File
@@ -88,11 +88,27 @@ novel-plus 5.x 已集成 Spring 官方最新发布的 Spring AI 框架,并推
1. v5.0.0 版本在小说章节发布页面的文本编辑器中集成了多项智能编辑功能,包括 AI 扩写、缩写、续写及文本润色等。这些功能的设计灵感来源于百家号文章编辑器中的 AI 助手。 1. v5.0.0 版本在小说章节发布页面的文本编辑器中集成了多项智能编辑功能,包括 AI 扩写、缩写、续写及文本润色等。这些功能的设计灵感来源于百家号文章编辑器中的 AI 助手。
2. v5.1.0 版本在小说发布页面,新增 AI 生成封面图功能。若作家未上传自定义封面图,系统将根据小说信息自动生成封面图。 2. v5.1.0 版本在小说发布页面,新增 AI 生成封面图功能。若作家未上传自定义封面图,系统将根据小说信息自动生成封面图。
3. 最新版本(即将正式发布,可通过源码抢先体验)在作家后台新增「AI 长篇创作助手」,覆盖从创意问答、设定大纲到逐章正文创作的完整长篇小说创作工作流,详见下文小节。
目前,AI 功能仍处于实验阶段,仅实现了基础的核心功能。我们非常重视用户的实际使用体验和反馈,未来将根据用户需求和使用情况,持续优化和调整该功能。如果用户反馈积极,我们计划进一步开发更高级的 AI 功能,例如自动生成有声小说、智能情节推荐等,以全面提升 novel-plus 的创作能力和用户体验。 目前,AI 功能仍处于实验阶段。我们非常重视用户的实际使用体验和反馈,未来将根据用户需求和使用情况,持续优化和调整该功能。如果用户反馈积极,我们计划进一步开发更高级的 AI 功能,以全面提升 novel-plus 的创作能力和用户体验。
我们将持续关注 AI 技术的发展,并致力于将其与小说创作场景深度融合,为用户带来更智能、更便捷的创作工具。 我们将持续关注 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 写作功能。 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 ```yaml
@@ -113,10 +129,7 @@ spring:
model: deepseek-ai/DeepSeek-R1-0528-Qwen3-8B model: deepseek-ai/DeepSeek-R1-0528-Qwen3-8B
``` ```
⚠️ novel-plus 项目默认使用的都是免费 AI 模型,生成效果有限。如果对生成内容有更高的要求,建议选用付费的 AI 模型。 ⚠️ novel-plus 项目默认使用的都是免费 AI 模型,生成效果有限。如果对生成质量和生成速度有更高的要求,建议选用付费的 AI 模型。
## 免责声明 ## 免责声明
+71
View File
@@ -15,3 +15,74 @@ CREATE TABLE `user_message`
KEY `idx_user_time` (`user_id`, `create_time`) KEY `idx_user_time` (`user_id`, `create_time`)
) ENGINE = InnoDB ) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='用户消息表'; 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 '小说封面';
+74
View File
@@ -3351,3 +3351,77 @@ CREATE TABLE `user_message`
) ENGINE = InnoDB ) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='用户消息表'; 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 '小说封面';
@@ -56,6 +56,20 @@ public enum ResponseStatus implements IResultCode {
LIST_DESC_TOO_LONG(6006, "书单简介不能超过300个字!"), LIST_DESC_TOO_LONG(6006, "书单简介不能超过300个字!"),
BOOK_NOT_EXISTS(6007, "书籍不存在!"), 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生成超时,请重试或适当调低单章目标字数!"),
/** /**
* 搜索引擎相关错误 * 搜索引擎相关错误
* */ * */
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<AiCreationPlan> 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<Void> 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<Long> 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<AiCreationTask> getTask(@PathVariable("taskId") Long taskId, HttpServletRequest request) {
Author author = checkAuthor(request);
return RestResult.ok(aiAssistantService.getTask(author.getId(), taskId));
}
/**
* 查询创作进度(方案+章节计划+任务状态),用于页面加载恢复
*/
@GetMapping("progress")
public RestResult<AiPlanProgressVO> progress(@RequestParam("planId") Long planId, HttpServletRequest request) {
Author author = checkAuthor(request);
return RestResult.ok(aiAssistantService.getProgress(author.getId(), planId));
}
/**
* 保存(编辑后的)创作方案草稿
*/
@PostMapping("savePlanContent")
public RestResult<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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<List<String>> 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<String> getPref(HttpServletRequest request) {
Author author = checkAuthor(request);
return RestResult.ok(aiAssistantService.getPref(author.getId()));
}
/**
* 保存或清除全局创作偏好
*/
@PostMapping("savePref")
public RestResult<Void> 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;
}
}
@@ -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) + "……(已截断)";
}
}
@@ -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<String> check(String content, int targetWords) {
List<String> 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<String> problems) {
String[] lines = content.split("\\n");
Map<String, Integer> 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("<br>", "\n").replaceAll("&nbsp;", " ").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 ? "……" : "");
}
}
@@ -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<String, String> 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<String, String> 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();
}
}
@@ -20,4 +20,13 @@ public class ThreadPoolConfig {
, TimeUnit.SECONDS, new LinkedBlockingDeque<>(properties.getQueueSize()),new ThreadPoolExecutor.AbortPolicy()); , 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());
}
} }
@@ -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);
}
@@ -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<AiChapterPlan> list);
/**
* 查询方案下全部章节计划
*/
List<AiChapterPlan> 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);
}
@@ -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);
}
@@ -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<AiCreationTask> listRunning(@Param("planId") Long planId);
/**
* 查询方案下最近N条任务(进度展示用,按ID倒序)
*/
List<AiCreationTask> listRecent(@Param("planId") Long planId, @Param("limit") int limit);
/**
* 查询指定目标最近一条已成功任务
*/
AiCreationTask selectLatestSuccess(@Param("planId") Long planId, @Param("taskType") Byte taskType,
@Param("chapterNo") Integer chapterNo);
}
@@ -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<String> programCheck(String content, Integer targetWords);
/**
* 查询作者全局创作偏好
*/
String getPref(Long authorId);
/**
* 保存或清除作者全局创作偏好
*/
void savePref(Long authorId, String prefContent);
}
@@ -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 长篇小说创作助手服务实现。
* <p>
* 生成类工作全部落库为任务(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<AiCreationTask> 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<AiChapterPlan> 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<AiChapterPlan> buildChapterRows(Long planId, JsonNode chapters) {
List<AiChapterPlan> rows = new ArrayList<>();
Set<Integer> 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<String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> buildChapterVars(AiCreationPlan plan, AiChapterPlan row) {
JsonNode planNode = readPlanContent(plan);
JsonNode params = readParams(plan);
Map<String, String> 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<AiChapterPlan> rows = chapterPlanMapper.listByPlanId(plan.getId());
List<AiChapterPlan> before = rows.stream()
.filter(r -> r.getChapterNo() < chapterNo)
.toList();
StringBuilder sb = new StringBuilder();
//已有规划章节:取最近5条摘要
List<AiChapterPlan> 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<BookIndex> 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<AiChapterPlan> 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<String, String> vars = buildChapterVars(plan, row);
boolean passed = false;
JsonNode issues = MAPPER.createArrayNode();
int round = 0;
for (round = 1; round <= MAX_REVISE_ROUNDS; round++) {
List<String> 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<String> 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<String, String> vars, String content) {
Map<String, String> 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<String, String> 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<String> 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", "<br>").replaceAll("\\s", "&nbsp;");
}
/**
* 存储格式还原为纯文本(供模型阅读与摘要)
*/
private String unescapeContent(String content) {
return content == null ? "" : content.replace("<br>", "\n").replace("&nbsp;", " ");
}
}
@@ -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<AiChapterPlan> chapterPlans;
/**
* 执行中的任务(前端恢复轮询)
*/
private List<AiCreationTask> runningTasks;
/**
* 最近任务摘要(不含result正文,展示状态与失败原因)
*/
private List<AiCreationTask> latestTasks;
}
@@ -23,7 +23,7 @@ xss:
# 排除链接(多个用逗号分隔) # 排除链接(多个用逗号分隔)
excludes: /system/notice/* 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: author:
@@ -51,11 +51,11 @@ spring:
image: image:
enabled: true enabled: true
base-url: https://api.siliconflow.cn base-url: https://api.siliconflow.cn
api-key: sk-jjtixmivxaccndqgkqfkbgkzvmbctdxogcrfbjzfttbouitt api-key: sk-uytoztqwpggppdrsfyuvkgztrmgxpktfqshpontczeefzqqi
options: options:
model: Kwai-Kolors/Kolors model: Kwai-Kolors/Kolors
response_format: URL response_format: URL
api-key: sk-jjtixmivxaccndqgkqfkbgkzvmbctdxogcrfbjzfttbouitt api-key: sk-uytoztqwpggppdrsfyuvkgztrmgxpktfqshpontczeefzqqi
base-url: https://api.siliconflow.cn base-url: https://api.siliconflow.cn
chat: chat:
options: options:
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.java2nb.novel.mapper.FrontAiAuthorPrefMapper">
<select id="selectByAuthorId" resultType="com.java2nb.novel.entity.AiAuthorPref">
select id, author_id, pref_content, create_time, update_time
from ai_author_pref
where author_id = #{authorId}
</select>
<insert id="upsert" parameterType="com.java2nb.novel.entity.AiAuthorPref">
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()
</insert>
</mapper>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.java2nb.novel.mapper.FrontAiChapterPlanMapper">
<sql id="baseColumns">
id, plan_id, chapter_no, index_name, chapter_outline, status, index_id, word_count, summary, create_time, update_time
</sql>
<insert id="batchInsert">
insert into ai_chapter_plan (plan_id, chapter_no, index_name, chapter_outline, status, create_time, update_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.planId}, #{item.chapterNo}, #{item.indexName}, #{item.chapterOutline}, 0, now(), now())
</foreach>
</insert>
<select id="listByPlanId" resultType="com.java2nb.novel.entity.AiChapterPlan">
select <include refid="baseColumns"/>
from ai_chapter_plan
where plan_id = #{planId}
order by chapter_no
</select>
<select id="selectByPlanAndNo" resultType="com.java2nb.novel.entity.AiChapterPlan">
select <include refid="baseColumns"/>
from ai_chapter_plan
where plan_id = #{planId} and chapter_no = #{chapterNo}
</select>
<update id="casMarkSaving">
update ai_chapter_plan
set status = 3, update_time = now()
where plan_id = #{planId} and chapter_no = #{chapterNo} and status in (0, 1)
</update>
<update id="markSaved">
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>
<update id="markDraft">
update ai_chapter_plan
set status = 1, update_time = now()
where plan_id = #{planId} and chapter_no = #{chapterNo} and status = 0
</update>
<update id="updateOutline">
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>
<update id="updateSummary">
update ai_chapter_plan
set summary = #{summary}, update_time = now()
where plan_id = #{planId} and chapter_no = #{chapterNo}
</update>
<delete id="deleteUnsaved">
delete from ai_chapter_plan
where plan_id = #{planId} and status in (0, 1)
</delete>
<select id="selectLatestIndexIdByBook" resultType="java.lang.Long">
select id
from book_index
where book_id = #{bookId}
order by index_num desc
limit 1
</select>
</mapper>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.java2nb.novel.mapper.FrontAiCreationPlanMapper">
<insert id="insert" parameterType="com.java2nb.novel.entity.AiCreationPlan" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
<select id="selectByIdAndAuthor" resultType="com.java2nb.novel.entity.AiCreationPlan">
select id, author_id, book_id, stage, creative_params, plan_content, outline_version, create_time, update_time
from ai_creation_plan
where id = #{id} and author_id = #{authorId}
</select>
<select id="selectByBookId" resultType="com.java2nb.novel.entity.AiCreationPlan">
select id, author_id, book_id, stage, creative_params, plan_content, outline_version, create_time, update_time
from ai_creation_plan
where book_id = #{bookId}
</select>
<select id="selectLatestUnboundByAuthor" resultType="com.java2nb.novel.entity.AiCreationPlan">
select id, author_id, book_id, stage, creative_params, plan_content, outline_version, create_time, update_time
from ai_creation_plan
where author_id = #{authorId} and book_id is null
order by id desc
limit 1
</select>
<update id="update" parameterType="com.java2nb.novel.entity.AiCreationPlan">
update ai_creation_plan
<set>
<if test="bookId != null">book_id = #{bookId},</if>
<if test="stage != null">stage = #{stage},</if>
<if test="creativeParams != null">creative_params = #{creativeParams},</if>
<if test="planContent != null">plan_content = #{planContent},</if>
<if test="outlineVersion != null">outline_version = #{outlineVersion},</if>
update_time = now()
</set>
where id = #{id} and author_id = #{authorId}
</update>
</mapper>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.java2nb.novel.mapper.FrontAiCreationTaskMapper">
<sql id="baseColumns">
id, plan_id, author_id, task_type, chapter_no, status, running_flag, result, error_msg, create_time, update_time
</sql>
<insert id="insert" parameterType="com.java2nb.novel.entity.AiCreationTask" useGeneratedKeys="true" keyProperty="id">
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>
<insert id="insertDraft" parameterType="com.java2nb.novel.entity.AiCreationTask" useGeneratedKeys="true" keyProperty="id">
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())
</insert>
<select id="selectByIdAndAuthor" resultType="com.java2nb.novel.entity.AiCreationTask">
select <include refid="baseColumns"/>
from ai_creation_task
where id = #{id} and author_id = #{authorId}
</select>
<select id="selectById" resultType="com.java2nb.novel.entity.AiCreationTask">
select <include refid="baseColumns"/>
from ai_creation_task
where id = #{id}
</select>
<update id="finish">
update ai_creation_task
set status = #{status},
running_flag = null,
result = #{result},
error_msg = #{errorMsg},
update_time = now()
where id = #{id}
</update>
<update id="failAllRunning">
update ai_creation_task
set status = 2,
running_flag = null,
error_msg = #{errorMsg},
update_time = now()
where status = 0 and running_flag = 1
</update>
<select id="listRunning" resultType="com.java2nb.novel.entity.AiCreationTask">
select <include refid="baseColumns"/>
from ai_creation_task
where plan_id = #{planId} and status = 0
order by id desc
</select>
<select id="listRecent" resultType="com.java2nb.novel.entity.AiCreationTask">
select <include refid="baseColumns"/>
from ai_creation_task
where plan_id = #{planId}
order by id desc
limit #{limit}
</select>
<select id="selectLatestSuccess" resultType="com.java2nb.novel.entity.AiCreationTask">
select <include refid="baseColumns"/>
from ai_creation_task
where plan_id = #{planId} and task_type = #{taskType} and chapter_no = #{chapterNo} and status = 1
order by id desc
limit 1
</select>
</mapper>
@@ -0,0 +1,19 @@
你是一位资深网文编辑和书名策划,擅长为长篇小说起出既贴合内容又有吸引力的书名。
【小说信息】
题材:{genre}
核心创意:{coreIdea}
主角设定:{protagonist}
核心冲突:{conflict}
目标读者:{targetReaders}
【任务】
根据以上信息构思 8 个中文网文小说书名,要求:
1. 书名贴合题材气质,能体现核心创意或主角特质,避免空泛。
2. 8 个书名风格多样化:既有直白点题型,也有意象含蓄型,避免雷同。
3. 每个书名不超过 12 个字,不得包含书名号。
4. 每个书名给出一句推荐理由(不超过 40 字)。
【输出格式】
只输出如下 JSON,不要输出任何其他内容、解释或 markdown 代码块标记:
{"names":[{"name":"书名","reason":"推荐理由"}]}
@@ -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 当且仅当存在至少一个必须修改的问题。
@@ -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. 内容需与前文事实一致,并为后续情节保留发展空间。
@@ -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})
@@ -0,0 +1,27 @@
你是一位网文修改专家。下面的章节草稿被责编指出了具体问题,请在保持原有情节走向、人物设定和文风的前提下进行修订。
【修订原则】
1. 只针对清单中的问题修改,不要重写无关情节,不要改变章节结尾走向。
2. 润色优先提升自然度、叙事节奏和细节具体性,禁止机械替换同义词式的表面修改。
3. 修订后仍须完成本章规划目标,字数保持在与目标相符的范围。
4. 段落之间用换行分隔,段落开头不加缩进符号。
【本章规划】
章节标题:{indexName}
章节规划:{chapterOutline}
目标字数:约{chapterWords}字
【责编问题清单】
{issues}
【必须遵守的设定摘要】
世界观与规则:{worldview}
人物档案:{characters}
前情摘要:
{recentSummaries}
【原草稿】
{content}
【输出要求】
直接输出修订后的完整章节正文(第一行为章节标题“{indexName}”,其后为正文)。不要输出任何解释、问题复述或 markdown 标记。
@@ -0,0 +1,21 @@
你是一位小说编辑,擅长提炼章节关键信息,为后续创作维护“创作记忆”。
【任务】
阅读下面已定稿的章节正文,输出 150 字以内的章节摘要,供后续章节创作时参考。摘要必须包含:
1. 本章推进的关键情节与结果(只记影响后续的事实)。
2. 人物状态或关系的重要变化(受伤、获得物品、立场转变、新信息等)。
3. 新埋设或回收的伏笔线索。
4. 章末遗留的未决问题。
【注意】
- 只依据正文中明确出现的信息,不得推测未发生的情节。
- 不复述环境描写和心理活动细节,不写评价性语言。
【章节信息】
章节标题:{indexName}
【章节正文】
{content}
【输出要求】
直接输出摘要文本一段,不要任何前缀、解释或 markdown 标记。
@@ -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":"结尾阅读动力"}]}
@@ -45,6 +45,9 @@
.my_l .link_4 { background-position:32px -314px } .my_l .link_4 { background-position:32px -314px }
.my_l .link_5 { background-position:32px -356px } .my_l .link_5 { background-position:32px -356px }
.my_l .link_6 { background-position:32px -397px } .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_7 { background-position:32px -440px }
.my_l .link_8 { background-position:32px -481px } .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 } .my_r { width:739px; padding:0 30px 30px; float:right; border-left:1px solid #efefef; min-height:470px }
@@ -0,0 +1,816 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>AI创作助手-作家管理系统</title>
<link rel="stylesheet" href="/css/base.css?v=1"/>
<link rel="stylesheet" href="/css/user.css"/>
<style type="text/css">
.ai-wrap { padding-bottom: 40px; }
.ai-steps { border-bottom: 1px solid #e5e5e5; margin-bottom: 15px; padding: 0; overflow: hidden; }
.ai-steps li { float: left; list-style: none; padding: 10px 22px; color: #666; cursor: pointer; font-size: 15px; border-bottom: 2px solid transparent; }
.ai-steps li.on { color: #3eaf7c; border-bottom-color: #3eaf7c; font-weight: bold; }
.ai-panel { display: none; }
.ai-panel.show { display: block; }
.ai-form li { list-style: none; margin-bottom: 12px; }
.ai-form label { display: inline-block; width: 110px; vertical-align: top; color: #555; }
.ai-form input.t { width: 320px; height: 30px; line-height: 30px; border: 1px solid #dcdcdc; padding: 0 6px; }
.ai-form select { width: 334px; height: 32px; border: 1px solid #dcdcdc; }
.ai-form textarea { width: 620px; height: 70px; border: 1px solid #dcdcdc; padding: 6px; }
.ai-btn { display: inline-block; padding: 6px 16px; border-radius: 4px; background: #3eaf7c; color: #fff !important; border: 1px solid #3eaf7c; cursor: pointer; font-size: 14px; margin-right: 8px; }
.ai-btn:hover { opacity: .9; }
.ai-btn.gray { background: #fff; color: #3eaf7c !important; }
.ai-btn[disabled] { opacity: .5; cursor: not-allowed; }
.ai-chip { display: inline-block; padding: 4px 12px; margin: 4px 6px 4px 0; border: 1px solid #3eaf7c; border-radius: 16px; color: #3eaf7c; cursor: pointer; background: #fff; }
.ai-chip.sel { background: #3eaf7c; color: #fff !important; }
.ai-tip { color: #999; font-size: 13px; margin: 6px 0; }
.ai-running { display: none; padding: 10px 14px; margin-bottom: 12px; background: #f2fbf6; border: 1px solid #b7e5cd; border-radius: 4px; color: #3eaf7c; }
.ai-error { display: none; padding: 10px 14px; margin-bottom: 12px; background: #fff5f5; border: 1px solid #ffc9c9; border-radius: 4px; color: #d9534f; }
.ai-progress { padding: 8px 14px; margin-bottom: 12px; background: #f7f7f7; border-radius: 4px; color: #666; font-size: 13px; }
table.ai-table { width: 100%; border-collapse: collapse; }
table.ai-table th, table.ai-table td { border: 1px solid #e5e5e5; padding: 6px 8px; font-size: 13px; text-align: left; }
table.ai-table th { background: #f2fbf6; color: #3eaf7c; }
table.ai-table td input.t { width: 96%; height: 26px; border: 1px solid #dcdcdc; padding: 0 4px; }
table.ai-table td textarea { width: 96%; height: 48px; border: 1px solid #dcdcdc; padding: 4px; }
.st0 { color: #999; } .st1 { color: #e6a23c; } .st2 { color: #3eaf7c; } .st3 { color: #4a90d9; }
#editorArea { display: none; }
#editorArea input.t { width: 420px; }
#editorArea textarea { width: 96%; height: 420px; padding: 8px; font-size: 14px; line-height: 1.7; }
.sec-title { font-weight: bold; color: #3eaf7c; margin: 14px 0 6px; border-left: 3px solid #3eaf7c; padding-left: 8px; }
@media screen and (max-width: 720px) {
.ai-form textarea, .ai-form input.t, .ai-form select { width: 96%; }
#editorArea textarea { height: 320px; }
.ai-steps li { padding: 8px 10px; font-size: 13px; }
table.ai-table { display: block; overflow-x: auto; white-space: nowrap; }
}
</style>
</head>
<body>
<div class="header">
<div class="mainNav" id="mainNav">
<div class="box_center cf" style="text-align: center;height: 44px;line-height: 48px;color: #fff;font-size: 16px;">
小说精品屋作家管理
</div>
</div>
</div>
<div class="main box_center cf">
<div class="userBox cf">
<div class="my_l">
<ul class="log_list">
<li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_ai on" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul>
</div>
<div class="my_r ai-wrap">
<ul class="ai-steps cf" id="stepNav">
<li data-step="1" class="on">① 创意问答</li>
<li data-step="2">② 设定与大纲</li>
<li data-step="3">③ 章节创作</li>
</ul>
<div class="ai-progress" id="progressBar">加载中…</div>
<div class="ai-running" id="runningTip"><span id="runningText"></span></div>
<div class="ai-error" id="errorTip"><span id="errorText"></span>
<a class="ai-btn gray" id="retryBtn" style="margin-left:10px;">重试</a>
<a style="cursor:pointer;color:#999;" onclick="$('#errorTip').hide();">忽略</a>
</div>
<!-- 第一步:创意问答 -->
<div class="ai-panel show" id="panel1">
<ul class="ai-form">
<li><label>小说题材<span style="color:red">*</span></label>
<select id="genre">
<option value="">请选择</option>
<option>玄幻</option><option>仙侠</option><option>都市</option>
<option>历史</option><option>科幻</option><option>悬疑</option>
<option>言情</option><option>武侠</option><option>现实</option><option>其他</option>
</select></li>
<li><label>核心创意<span style="color:red">*</span></label>
<textarea id="coreIdea" placeholder="你希望讲一个什么样的故事?一两句话即可"></textarea></li>
<li><label>主角设定<span style="color:red">*</span></label>
<textarea id="protagonist" placeholder="身份、性格、能力、目标等"></textarea></li>
<li><label>核心冲突<span style="color:red">*</span></label>
<textarea id="conflict" placeholder="主角面临的主要矛盾或挑战"></textarea></li>
<li><a class="ai-btn gray" onclick="randomIdea()">随机灵感</a>
<a class="ai-btn gray" onclick="fillDefaultParams()">使用默认参数</a></li>
</ul>
<div class="sec-title">深度设定(可选,可跳过)</div>
<ul class="ai-form">
<li><label>世界观/背景</label><textarea id="worldview" placeholder="选填,留空由AI按题材合理构建"></textarea></li>
<li><label>视角与文风</label>
<select id="style">
<option value="">默认(第三人称,网文节奏)</option>
<option>第三人称,轻松幽默</option><option>第三人称,严肃正剧</option>
<option>第一人称,强代入感</option><option>多视角切换</option>
</select></li>
<li><label>核心主题</label><input class="t" id="theme" placeholder="如:成长与救赎"/></li>
<li><label>目标读者</label><input class="t" id="targetReaders" placeholder="如:18-30岁男频读者"/></li>
<li><label>预计总章节数</label><input class="t" id="totalChapters" value="60"/>
<label style="width:90px;">每章目标字数</label><input class="t" id="chapterWords" value="3000" style="width:100px;"/></li>
<li><label>补充要求</label><textarea id="extra" placeholder="其他想告诉AI的要求,选填"></textarea></li>
</ul>
<div class="sec-title">书名</div>
<div style="margin-bottom:10px;">
<input class="t" id="bookName" placeholder="自行输入,或使用AI建议"/>
<a class="ai-btn gray" onclick="genBookNames()">生成书名建议</a>
<a class="ai-btn gray" onclick="genBookNames()">换一批</a>
</div>
<div id="nameChips" class="ai-tip">点击AI建议书名可直接填入</div>
<div style="margin-top:15px;">
<a class="ai-btn" onclick="saveParams(true)">保存并去生成方案</a>
<a class="ai-btn gray" onclick="saveParams(false)">仅保存参数</a>
</div>
</div>
<!-- 第二步:设定与大纲 -->
<div class="ai-panel" id="panel2">
<div style="margin-bottom:10px;">
<a class="ai-btn" onclick="genPlan()">生成创作方案</a>
<a class="ai-btn gray" onclick="genPlan()">重新生成方案</a>
<span class="ai-tip">生成后可编辑、保存,确认前不会写入任何章节</span>
</div>
<ul class="ai-form">
<li><label>书名</label><input class="t" id="pBookName"/></li>
<li><label>核心卖点</label><input class="t" id="pSelling" style="width:520px;"/></li>
<li><label>目标读者</label><input class="t" id="pReaders"/></li>
<li><label>故事简介</label><textarea id="pIntro"></textarea></li>
<li><label>世界观设定</label><textarea id="pWorld" style="height:100px;"></textarea></li>
</ul>
<div class="sec-title">结构化设定(JSON,可编辑)</div>
<ul class="ai-form">
<li><label>人物档案</label><textarea id="pCharacters" style="height:120px;"></textarea></li>
<li><label>故事总纲</label><textarea id="pMainOutline" style="height:90px;"></textarea></li>
<li><label>分卷大纲</label><textarea id="pVolumes"></textarea></li>
<li><label>伏笔记录</label><textarea id="pForeshadows"></textarea></li>
</ul>
<div class="sec-title">章节规划</div>
<div style="overflow-x:auto;">
<table class="ai-table" id="chapterPlanTable">
<thead><tr><th style="width:50px;">章号</th><th style="width:160px;">标题</th><th>本章规划(事件/冲突/变化/主线/结尾)</th><th style="width:60px;">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="ai-tip" id="planTip"></div>
<div style="margin:10px 0;">
<a class="ai-btn gray" onclick="genMoreChapters()">生成后续章节规划</a>
</div>
<div>
<a class="ai-btn" onclick="savePlanContent()">保存方案草稿</a>
<a class="ai-btn" onclick="openConfirmDialog()">确认方案</a>
</div>
<div class="ai-tip">确认后将按方案拆分章节规划;新建小说会同时创建小说记录(沿用现有发布流程,含自动生成封面)</div>
</div>
<!-- 第三步:章节创作 -->
<div class="ai-panel" id="panel3">
<div style="overflow-x:auto;">
<table class="ai-table" id="chapterListTable">
<thead><tr><th style="width:50px;">章号</th><th>标题</th><th style="width:80px;">状态</th><th style="width:70px;">字数</th><th style="width:230px;">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="ai-tip" id="chapterTip">每次只生成一章,确认保存后才会写入正式章节</div>
<div id="editorArea">
<div class="sec-title" id="editorTitle">章节正文</div>
<div style="margin-bottom:8px;">
<label>标题:</label><input class="t" id="eIndexName"/>
<label style="width:auto;margin-left:10px;"><input type="checkbox" id="eIsVip"/> VIP章节</label>
</div>
<textarea id="eContent" placeholder="生成或手工输入正文…"></textarea>
<div class="ai-tip" id="eMeta"></div>
<div style="margin:10px 0;">
<a class="ai-btn" id="saveBtn" onclick="saveChapter()">确认保存章节</a>
<a class="ai-btn gray" onclick="checkRevise()">校验并修订</a>
<a class="ai-btn gray" onclick="genChapter(curChapter)">重新生成正文</a>
<a class="ai-btn gray" onclick="closeEditor()">暂不保存并退出</a>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
<script src="/javascript/jquery-1.8.0.min.js" type="text/javascript"></script>
<script src="/layui/layui.all.js" type="text/javascript"></script>
<script src="/javascript/header.js" type="text/javascript"></script>
<script src="/javascript/user.js" type="text/javascript"></script>
<script src="/javascript/common.js" type="text/javascript"></script>
<script language="javascript" type="text/javascript">
// ============================================================
// AI创作助手页面逻辑:进度全部存服务端,刷新/重登后自动恢复
// ============================================================
var planId = null, bookId = null, plan = null;
var chapterPlans = [], paramsObj = {}, planObj = {}, categoryList = [];
var pollTimer = null, curChapter = 0, busyText = '';
var STATUS_TXT = {0:'待创作', 1:'草稿', 2:'已保存', 3:'保存中'};
var STATUS_CLS = {0:'st0', 1:'st1', 2:'st2', 3:'st3'};
function getQuery(name) {
var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
$(function () {
bookId = getQuery('bookId');
$('#stepNav li').click(function () {
switchStep($(this).data('step'));
});
$('#retryBtn').click(function () {
if (window._retryFn) { window._retryFn(); }
});
initPlan();
});
function switchStep(step) {
if (step === 3 && (!plan || plan.stage < 3)) {
layer.msg('请先确认创作方案'); return;
}
$('#stepNav li').removeClass('on');
$('#stepNav li[data-step=' + step + ']').addClass('on');
$('.ai-panel').removeClass('show');
$('#panel' + step).addClass('show');
if (step === 3) { renderChapterList(); }
}
function ajaxPost(url, data, cb) {
$.ajax({
type: 'POST', url: url, data: data, dataType: 'json',
success: function (res) {
if (res.code == 200) { cb(res.data); }
else if (res.code == 1001) {
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else { layer.alert(res.msg); }
},
error: function () { layer.alert('网络异常'); }
});
}
function ajaxGet(url, data, cb) {
$.ajax({
type: 'GET', url: url, data: data, dataType: 'json',
success: function (res) {
if (res.code == 200) { cb(res.data); }
else if (res.code == 1001) {
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else { layer.alert(res.msg); }
},
error: function () { layer.alert('网络异常'); }
});
}
// -------- 初始化与进度恢复 --------
function initPlan() {
var data = {};
if (bookId) { data.bookId = bookId; }
ajaxPost('/author/aiAssistant/initPlan', data, function (p) {
plan = p; planId = p.id;
loadParamsForm();
loadProgress();
});
}
function loadParamsForm() {
paramsObj = plan.creativeParams ? JSON.parse(plan.creativeParams) : {};
if (paramsObj.genre) { $('#genre').val(paramsObj.genre); }
$('#coreIdea').val(paramsObj.coreIdea || '');
$('#protagonist').val(paramsObj.protagonist || '');
$('#conflict').val(paramsObj.conflict || '');
$('#worldview').val(paramsObj.worldview || '');
if (paramsObj.style) { $('#style').val(paramsObj.style); }
$('#theme').val(paramsObj.theme || '');
$('#targetReaders').val(paramsObj.targetReaders || '');
$('#totalChapters').val(paramsObj.totalChapters || 60);
$('#chapterWords').val(paramsObj.chapterWords || 3000);
$('#extra').val(paramsObj.extra || '');
$('#bookName').val(paramsObj.bookName || '');
}
function loadProgress() {
ajaxGet('/author/aiAssistant/progress', {planId: planId}, function (vo) {
plan = vo.plan;
chapterPlans = vo.chapterPlans || [];
renderProgressBar();
// 恢复执行中的任务轮询(刷新/中断后可继续)
var running = (vo.runningTasks || [])[0];
if (running) {
pollTask(running.id, taskDoneHandler(running), false);
} else if ((vo.latestTasks || []).length > 0) {
var latest = vo.latestTasks[0];
if (latest.status == 2) { showError(latest); }
}
if (plan.planContent) {
planObj = JSON.parse(plan.planContent);
fillPlanForm(planObj);
}
if (plan.stage >= 3) { renderChapterList(); }
if (plan.stage >= 2 && !plan.planContent) { switchStep(2); }
});
}
function renderProgressBar() {
var stageTxt = {1: '创作问答', 2: '方案草稿', 3: '章节创作', 4: '完成'}[plan.stage] || '';
var saved = 0, total = paramsObj.totalChapters || 0;
for (var i = 0; i < chapterPlans.length; i++) {
if (chapterPlans[i].status == 2) { saved++; }
}
var bookTxt = plan.bookId ? '已绑定小说ID:' + plan.bookId : '新建小说(确认方案后创建)';
$('#progressBar').html('当前阶段:<b>' + stageTxt + '</b> ' + bookTxt
+ ' 章节进度:<b>' + saved + '</b> / ' + total
+ (plan.outlineVersion ? ' 大纲版本:v' + plan.outlineVersion : ''));
}
function showError(task) {
var name = taskTypeName(task.taskType) + (task.chapterNo > 0 ? '(第' + task.chapterNo + '章)' : '');
$('#errorText').html('最近任务失败:' + name + '——' + (task.errorMsg || '未知错误'));
$('#errorTip').show();
window._retryFn = function () {
$('#errorTip').hide();
startTask(task.taskType, task.chapterNo);
};
}
function taskTypeName(t) {
return {1: '书名建议', 2: '创作方案', 3: '章节正文', 4: '校验修订', 5: '章节摘要', 6: '章节规划增量'}[t] || '任务';
}
// -------- 任务提交与轮询 --------
function startTask(type, chapterNo) {
if (pollTimer) { layer.msg('已有任务执行中,请稍候'); return; }
var data = {planId: planId, taskType: type};
if (chapterNo) { data.chapterNo = chapterNo; }
ajaxPost('/author/aiAssistant/startTask', data, function (taskId) {
pollTask(taskId, taskDoneHandler({taskType: type, chapterNo: chapterNo || 0}), true);
});
}
function pollTask(taskId, onDone, isNew) {
busyText = '任务执行中(' + (isNew ? '生成' : '恢复') + '中),可稍后刷新页面,任务不会丢失…';
$('#runningText').text(busyText);
$('#runningTip').show();
if (pollTimer) { clearInterval(pollTimer); }
pollTimer = setInterval(function () {
ajaxGet('/author/aiAssistant/task/' + taskId, {}, function (task) {
if (task.status == 0) { return; }
clearInterval(pollTimer); pollTimer = null;
$('#runningTip').hide();
if (task.status == 2) {
showError(task);
} else {
onDone(task);
}
});
}, 2500);
}
function taskDoneHandler(task) {
return function (t) {
switch (task.taskType) {
case 1: renderNameChips(JSON.parse(t.result)); break;
case 2: fillPlanForm(JSON.parse(t.result)); switchStep(2);
layer.msg('方案已生成,请编辑后保存确认'); break;
case 6: appendChapters(JSON.parse(t.result).chapters || []); break;
case 3: loadDraftIntoEditor(task.chapterNo, t.result); break;
case 4: var r = JSON.parse(t.result);
loadDraftIntoEditor(task.chapterNo, r.content);
showCheckReport(r); break;
default: break;
}
};
}
// -------- 第一步:参数与书名 --------
var IDEAS = [
{genre: '玄幻', coreIdea: '少年觉醒上古残缺血脉,在被宗门驱逐的绝境中踏上逆天之路', protagonist: '身世成谜的孤儿,性格坚韧重情,能力是吞噬他人法术化为己用,目标是查明身世', conflict: '血脉觉醒会逐步侵蚀神智,至亲之人可能成为第一个牺牲品'},
{genre: '都市', coreIdea: '被裁员的外卖员意外获得能看到他人剩余寿命的眼睛', protagonist: '落魄但乐观的退伍军人,能力是看见寿命但每次使用自身折寿,目标是拯救更多人', conflict: '拯救一个命,就要搭上半条命,而城市里正有一场收割寿命的阴谋'},
{genre: '仙侠', coreIdea: '门派覆灭后,末代弟子带着师门的炼丹炉穿越大荒重振山门', protagonist: '天赋平平但心细如发的药童,能力是过目不忘的丹方记忆,目标是复活师尊', conflict: '丹方最后一味药引,是仇人门主的道心'},
{genre: '科幻', coreIdea: '月球基地维修工发现殖民船名单里根本没有地球人类', protagonist: '较真的机械师,目标是带回真相,性格执拗不善言辞', conflict: '揭开真相意味着基地三万人失去生存配给'},
{genre: '悬疑', coreIdea: '小镇连续失踪案,每个失踪者失踪前都收到了十年前的自己寄来的信', protagonist: '返乡刑警,记忆力超群但患有发作性失忆,目标是揪出幕后寄信人', conflict: '线索一次指向自己,而失忆的十分钟无人能证明去了哪'},
{genre: '言情', coreIdea: '婚礼当天被逃婚的甜品师,与替新郎来道歉的伴郎协议恋爱一百天', protagonist: '独立要强的甜品师,目标是开自己的店,不再相信爱情', conflict: '协议到期那天,心动超出了协议范围'},
{genre: '历史', coreIdea: '抄家罪臣之后化名科举入仕,十年布局只为翻一桩旧案', protagonist: '隐忍谨慎的寒门学子,能力是过人的算学与心算,目标是为父昭雪', conflict: '复查旧案的钥匙,握在当年举荐他的恩师手里'},
{genre: '武侠', coreIdea: '天下第一剑的弟子,发现师父的成名剑谱是从死人手里抢来的', protagonist: '重情重义的年轻剑客,目标是既护师门之名又守心中之剑', conflict: '死者遗孤找上门来,剑下留不留得情面'}
];
function randomIdea() {
var it = IDEAS[Math.floor(Math.random() * IDEAS.length)];
$('#genre').val(it.genre); $('#coreIdea').val(it.coreIdea);
$('#protagonist').val(it.protagonist); $('#conflict').val(it.conflict);
layer.msg('已填入随机灵感,可自由修改');
}
function fillDefaultParams() {
$('#totalChapters').val(60); $('#chapterWords').val(3000);
layer.msg('已使用默认参数:60章 × 3000字/章');
}
function collectParams() {
var p = {
genre: $('#genre').val(), coreIdea: $('#coreIdea').val().trim(),
protagonist: $('#protagonist').val().trim(), conflict: $('#conflict').val().trim(),
worldview: $('#worldview').val().trim(), style: $('#style').val(),
theme: $('#theme').val().trim(), targetReaders: $('#targetReaders').val().trim(),
totalChapters: parseInt($('#totalChapters').val()) || 60,
chapterWords: parseInt($('#chapterWords').val()) || 3000,
extra: $('#extra').val().trim(), bookName: $('#bookName').val().trim()
};
if (!p.genre || !p.coreIdea || !p.protagonist || !p.conflict) {
layer.msg('题材、核心创意、主角设定、核心冲突为必填项'); return null;
}
return p;
}
function saveParams(goNext) {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
layer.msg('参数已保存');
if (goNext) { switchStep(2); }
});
}
function genBookNames() {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
startTask(1, 0);
});
}
function renderNameChips(data) {
var html = '';
var names = data.names || [];
for (var i = 0; i < names.length; i++) {
html += '<a class="ai-chip" title="' + esc(names[i].reason || '') + '" onclick="pickName(this,\'' + esc(names[i].name) + '\')">' + esc(names[i].name) + '</a>';
}
$('#nameChips').html(html || '暂无建议');
}
function pickName(el, name) {
$('#nameChips .ai-chip').removeClass('sel');
$(el).addClass('sel');
$('#bookName').val(name);
$('#pBookName').val(name);
}
function esc(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// -------- 第二步:方案编辑 --------
function genPlan() {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
startTask(2, 0);
});
}
function fillPlanForm(o) {
planObj = o;
$('#pBookName').val(o.bookName || $('#bookName').val() || '');
$('#pSelling').val(o.sellingPoints || '');
$('#pReaders').val(o.targetReaders || '');
$('#pIntro').val(o.intro || '');
$('#pWorld').val(o.worldview || '');
$('#pCharacters').val(JSON.stringify(o.characters || [], null, 1));
$('#pMainOutline').val(JSON.stringify(o.mainOutline || {}, null, 1));
$('#pVolumes').val(JSON.stringify(o.volumes || [], null, 1));
$('#pForeshadows').val(JSON.stringify(o.foreshadows || [], null, 1));
renderPlanChapterTable(o.chapters || []);
updatePlanTip();
}
function outlineToText(c) {
if (c.outline) { return typeof c.outline === 'string' ? c.outline : JSON.stringify(c.outline); }
var parts = [];
if (c.events) { parts.push('事件:' + c.events); }
if (c.conflict) { parts.push('冲突:' + c.conflict); }
if (c.charChange) { parts.push('变化:' + c.charChange); }
if (c.relation) { parts.push('主线:' + c.relation); }
if (c.ending) { parts.push('结尾:' + c.ending); }
return parts.join(';');
}
function renderPlanChapterTable(chapters) {
var html = '';
for (var i = 0; i < chapters.length; i++) {
var c = chapters[i];
html += '<tr data-no="' + c.chapterNo + '">'
+ '<td>' + c.chapterNo + '</td>'
+ '<td><input class="t cp-name" value="' + esc(c.indexName) + '"/></td>'
+ '<td><textarea class="cp-outline">' + esc(outlineToText(c)) + '</textarea></td>'
+ '<td><a style="cursor:pointer;color:#d9534f;" onclick="$(this).closest(\'tr\').remove();">删除</a></td></tr>';
}
$('#chapterPlanTable tbody').html(html);
}
function collectPlanContent() {
var o;
try {
o = {
bookName: $('#pBookName').val().trim(),
sellingPoints: $('#pSelling').val().trim(),
targetReaders: $('#pReaders').val().trim(),
intro: $('#pIntro').val().trim(),
worldview: $('#pWorld').val().trim(),
characters: JSON.parse($('#pCharacters').val() || '[]'),
mainOutline: JSON.parse($('#pMainOutline').val() || '{}'),
volumes: JSON.parse($('#pVolumes').val() || '[]'),
foreshadows: JSON.parse($('#pForeshadows').val() || '[]'),
chapters: []
};
} catch (e) {
layer.msg('结构化设定JSON格式有误,请检查人物/总纲/分卷/伏笔'); return null;
}
$('#chapterPlanTable tbody tr').each(function () {
var no = parseInt($(this).data('no'));
o.chapters.push({chapterNo: no, indexName: $(this).find('.cp-name').val().trim(), outline: $(this).find('.cp-outline').val().trim()});
});
if (!o.intro || o.characters.length === 0) {
layer.msg('简介与人物档案不能为空'); return null;
}
if (o.chapters.length === 0) {
layer.msg('章节规划不能为空'); return null;
}
return o;
}
function savePlanContent() {
var o = collectPlanContent();
if (!o) { return; }
ajaxPost('/author/aiAssistant/savePlanContent', {planId: planId, planContent: JSON.stringify(o)}, function () {
planObj = o;
layer.msg('方案草稿已保存');
loadProgress();
});
}
function genMoreChapters() {
var o = collectPlanContent();
if (!o) { return; }
if (o.chapters.length >= (paramsObj.totalChapters || 60)) {
layer.msg('章节规划数已达预计总章节数'); return;
}
ajaxPost('/author/aiAssistant/savePlanContent', {planId: planId, planContent: JSON.stringify(o)}, function () {
planObj = o;
startTask(6, o.chapters.length + 1);
});
}
function appendChapters(chapters) {
var html = '';
for (var i = 0; i < chapters.length; i++) {
var c = chapters[i];
html += '<tr data-no="' + c.chapterNo + '">'
+ '<td>' + c.chapterNo + '</td>'
+ '<td><input class="t cp-name" value="' + esc(c.indexName) + '"/></td>'
+ '<td><textarea class="cp-outline">' + esc(outlineToText(c)) + '</textarea></td>'
+ '<td><a style="cursor:pointer;color:#d9534f;" onclick="$(this).closest(\'tr\').remove();">删除</a></td></tr>';
}
$('#chapterPlanTable tbody').append(html);
updatePlanTip();
layer.msg('已追加章节规划,请检查后保存/确认方案');
}
function updatePlanTip() {
var n = $('#chapterPlanTable tbody tr').length;
$('#planTip').text('当前规划章节数:' + n + '(预计 ' + (paramsObj.totalChapters || 60) + ' 章,可分批继续生成)');
}
function openConfirmDialog() {
var o = collectPlanContent();
if (!o) { return; }
if (plan.bookId) {
layer.confirm('确认方案将按最新规划重建未保存章节(已保存章节不受影响),是否继续?', {btn: ['确认', '取消']}, function (idx) {
layer.close(idx);
doConfirm(o, {});
});
return;
}
// 新建小说:选择分类与方向
loadCategories(function () {
var html = buildConfirmBookForm(o);
layer.open({
type: 1, title: '确认方案并创建小说', area: ['420px', '330px'], content: html,
btn: ['确认创建', '取消'],
yes: function (idx) {
doConfirm(o, collectConfirmBookForm());
}
});
});
}
function loadCategories(cb) {
if (categoryList.length > 0) { cb(); return; }
ajaxGet('/book/listBookCategory', {}, function (data) {
categoryList = data || []; cb();
});
}
function buildConfirmBookForm(o) {
var wd = '<select id="cWorkDirection" onchange="renderConfirmCats()"><option value="0">男频</option><option value="1">女频</option></select>';
var html = '<div style="padding:15px;">'
+ '<p style="margin-bottom:10px;">书名:<input class="t" id="cBookName" value="' + esc(o.bookName || $('#bookName').val() || '') + '" style="width:220px;"/></p>'
+ '<p style="margin-bottom:10px;">方向:' + wd + '</p>'
+ '<p style="margin-bottom:10px;">分类:<select id="cCatId"></select></p>'
+ '<p class="ai-tip">简介将使用方案中的故事简介;封面沿用现有AI自动生成流程</p></div>';
setTimeout(renderConfirmCats, 50);
return html;
}
function renderConfirmCats() {
var wd = $('#cWorkDirection').val();
var html = '';
for (var i = 0; i < categoryList.length; i++) {
var c = categoryList[i];
if (c.workDirection == wd) {
var sel = (paramsObj.genre && c.name.indexOf(paramsObj.genre) >= 0) ? ' selected' : '';
html += '<option value="' + c.id + '" data-name="' + esc(c.name) + '"' + sel + '>' + esc(c.name) + '</option>';
}
}
$('#cCatId').html(html);
}
function collectConfirmBookForm() {
var bookName = $('#cBookName').val().trim();
if (!bookName) { layer.msg('书名不能为空'); return null; }
var opt = $('#cCatId').find('option:selected');
if (!opt.length) { layer.msg('请选择分类'); return null; }
return {bookName: bookName, workDirection: $('#cWorkDirection').val(), catId: opt.val(), catName: opt.text()};
}
function doConfirm(o, bookForm) {
if (bookForm === null) { return; }
var data = {planId: planId, planContent: JSON.stringify(o)};
if (!plan.bookId && bookForm.bookName) {
$.extend(data, bookForm);
}
layer.confirm('确认后进入逐章创作阶段,是否继续?', {btn: ['继续', '取消']}, function (idx) {
layer.close(idx);
ajaxPost('/author/aiAssistant/confirmPlan', data, function () {
layer.msg('方案已确认');
loadProgress();
switchStep(3);
});
});
}
// -------- 第三步:章节创作 --------
function renderChapterList() {
if (!chapterPlans || chapterPlans.length === 0) {
$('#chapterListTable tbody').html('<tr><td colspan="5">暂无章节规划,请先在第二步确认方案</td></tr>');
return;
}
var html = '';
for (var i = 0; i < chapterPlans.length; i++) {
var c = chapterPlans[i];
var ops = '';
if (c.status != 2) {
ops += '<a class="ai-btn" style="padding:2px 8px;" onclick="genChapter(' + c.chapterNo + ')">生成正文</a>';
ops += '<a class="ai-btn gray" style="padding:2px 8px;" onclick="checkReviseNo(' + c.chapterNo + ')">校验修订</a>';
}
ops += '<a style="color:#3eaf7c;cursor:pointer;" onclick="openEditor(' + c.chapterNo + ')">编辑/查看</a>';
if (c.status == 2 && c.indexId) {
ops += ' <a href="/author/content_update.html?indexId=' + c.indexId + '&bookId=' + plan.bookId + '" target="_blank" style="color:#999;">章节管理</a>';
}
html += '<tr><td>' + c.chapterNo + '</td><td>' + esc(c.indexName || '') + '</td>'
+ '<td class="' + STATUS_CLS[c.status] + '">' + STATUS_TXT[c.status] + '</td>'
+ '<td>' + (c.wordCount || 0) + '</td><td>' + ops + '</td></tr>';
}
$('#chapterListTable tbody').html(html);
renderProgressBar();
}
function genChapter(no) {
if (plan.stage < 3) { layer.msg('请先确认创作方案'); return; }
openEditor(no);
startTask(3, no);
}
function checkReviseNo(no) {
curChapter = no;
openEditor(no, true);
checkRevise();
}
function openEditor(no, skipDraftLoad) {
curChapter = no;
var c = findChapter(no);
if (!c) { return; }
$('#editorTitle').text('第' + no + '章 ' + (c.indexName || ''));
$('#eIndexName').val(c.indexName || '');
$('#eIsVip').prop('checked', false);
$('#editorArea').show();
if (!skipDraftLoad) { loadDraftIntoEditor(no, null); }
if (c.status == 2) {
// 已保存章节:只读展示正式正文
ajaxGet('/author/queryIndexContent/' + c.indexId, {}, function (content) {
$('#eContent').val(content);
$('#eMeta').html('该章节已保存(' + (c.wordCount || 0) + '字),如需修改请再次保存将产生新章节,建议直接使用章节管理更新');
});
}
}
function findChapter(no) {
for (var i = 0; i < chapterPlans.length; i++) {
if (chapterPlans[i].chapterNo == no) { return chapterPlans[i]; }
}
return null;
}
function loadDraftIntoEditor(no, content) {
var c = findChapter(no);
if (content !== null && content !== undefined) {
$('#eContent').val(content);
refreshWordCount(no);
if (c && c.status == 0) { c.status = 1; renderChapterList(); }
return;
}
if (c && c.status >= 1 && c.status != 2) {
// 从最近成功任务恢复草稿(正文或校验修订结果)
ajaxGet('/author/aiAssistant/progress', {planId: planId}, function (vo) {
var tasks = vo.latestTasks || [];
for (var i = 0; i < tasks.length; i++) {
var t = tasks[i];
if (t.chapterNo == no && (t.taskType == 3 || t.taskType == 4) && t.status == 1) {
ajaxGet('/author/aiAssistant/task/' + t.id, {}, function (full) {
var content = full.taskType == 4 ? JSON.parse(full.result).content : full.result;
$('#eContent').val(content);
refreshWordCount(no);
});
return;
}
}
});
}
}
function refreshWordCount(no) {
var content = $('#eContent').val();
if (!content) { $('#eMeta').html(''); return; }
ajaxPost('/author/aiAssistant/programCheck', {content: content, targetWords: paramsObj.chapterWords || 3000}, function (problems) {
var msg = '有效字数以作者后台统计规则为准';
if (problems && problems.length > 0) {
msg = '程序校验提示:' + problems.join(';') + '<br/>(字数达标不代表质量达标,可点击“校验并修订”做AI一致性检查)';
}
$('#eMeta').html(msg);
});
}
function showCheckReport(r) {
var msg = r.passed ? '校验通过(共' + r.rounds + '轮)' : '已修订' + r.rounds + '轮仍未完全通过,请人工处理';
var issues = r.issues || [];
if (issues.length > 0) {
msg += '<br/>问题清单:';
for (var i = 0; i < issues.length; i++) {
msg += '<br/>· [' + esc(issues[i].type || '') + '] ' + esc(issues[i].description || '');
}
}
$('#eMeta').html(msg);
layer.msg(msg.replace(/<br\/>/g, '\n').replace(/·/g, ''));
}
function checkRevise() {
if (!curChapter) { layer.msg('请先选择章节'); return; }
var content = $('#eContent').val();
if (!content) { layer.msg('请先生成正文再校验'); return; }
//先把编辑器当前内容(含作者手工修改)落为服务端草稿,再以该草稿校验修订
ajaxPost('/author/aiAssistant/saveDraft', {planId: planId, chapterNo: curChapter, content: content}, function () {
startTask(4, curChapter);
});
}
function saveChapter() {
var content = $('#eContent').val();
var indexName = $('#eIndexName').val().trim();
if (!indexName || !content) { layer.msg('标题与正文不能为空'); return; }
var c = findChapter(curChapter);
if (c && c.status == 2) {
layer.msg('该章节已保存,请使用章节管理更新,避免重复创建章节'); return;
}
layer.confirm('确认保存为正式章节?保存后不可被任务恢复覆盖', {btn: ['确认保存', '取消']}, function (idx) {
layer.close(idx);
ajaxPost('/author/aiAssistant/saveChapter', {
planId: planId, chapterNo: curChapter, indexName: indexName,
content: content, isVip: $('#eIsVip').is(':checked') ? 1 : 0
}, function () {
layer.msg('已保存,创作记忆摘要将异步更新');
loadProgress();
renderChapterList();
});
});
}
function closeEditor() {
$('#editorArea').hide();
}
</script>
</html>
@@ -28,10 +28,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4" href="/author/index.html">小说管理</a></li> <li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 on" href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income on" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -28,10 +28,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4" href="/author/index.html">小说管理</a></li> <li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 on" href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income on" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -31,10 +31,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!--<li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -101,10 +101,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!--<li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -28,8 +28,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">作品管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul> </ul>
</div> </div>
@@ -46,10 +46,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -222,6 +221,7 @@
" <td class=\"goread\" id='opt" + book.id + "'>" + " <td class=\"goread\" id='opt" + book.id + "'>" +
"<div><a target='_blank' class='redBtn' href='/author/index_list.html?bookId=" + book.id + "'>章节管理 </a></div>" + "<div><a target='_blank' class='redBtn' href='/author/index_list.html?bookId=" + book.id + "'>章节管理 </a></div>" +
"<div><a class='redBtn' href='/author/ai_assistant.html?bookId=" + book.id + "'>AI创作 </a></div>" +
"<div><a target='_blank' href='/author/author_income_detail.html?bookId=" + book.id + "'>薪酬查询 </a></div>" + "<div><a target='_blank' href='/author/author_income_detail.html?bookId=" + book.id + "'>薪酬查询 </a></div>" +
(book.bookStatus == 0 ? ("<div><a href='javascript:finishBook(\"" + book.id + "\")'>设为完结 </a></div>") : "") + (book.bookStatus == 0 ? ("<div><a href='javascript:finishBook(\"" + book.id + "\")'>设为完结 </a></div>") : "") +
"</td> </tr>"); "</td> </tr>");
@@ -42,8 +42,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">作品管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul> </ul>
</div> </div>
@@ -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("已截断"));
}
}
@@ -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<String> 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<String> problems = ChapterTextCheckUtil.check(content, 3000);
assertTrue(problems.stream().anyMatch(p -> p.contains("低于目标")));
}
@Test
void check_shouldPassNormalLength() {
String content = "字".repeat(2500);
List<String> problems = ChapterTextCheckUtil.check(content, 3000);
assertTrue(problems.isEmpty(), "2500字相对目标3000字应在允许范围: " + problems);
}
@Test
void check_shouldFlagOverMaxRatio() {
String content = "字".repeat(5000);
List<String> 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<String> 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);
}
}
@@ -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<String, String> 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<String, String> vars = new HashMap<>();
String rendered = PromptTemplateUtil.render("book_name", vars);
assertTrue(rendered.contains("题材:未提供"));
}
@Test
void render_shouldNotLoopOnSelfReferenceValue() {
//变量值本身包含占位符时不应死循环或二次替换
Map<String, String> 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<String, String> vars = new HashMap<>();
vars.put("genre", "都市");
String rendered = PromptTemplateUtil.render("book_name", vars);
assertTrue(rendered.contains("{\"names\":"));
}
}
@@ -0,0 +1,816 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>AI创作助手-作家管理系统</title>
<link rel="stylesheet" href="/css/base.css?v=1"/>
<link rel="stylesheet" href="/css/user.css"/>
<style type="text/css">
.ai-wrap { padding-bottom: 40px; }
.ai-steps { border-bottom: 1px solid #e5e5e5; margin-bottom: 15px; padding: 0; overflow: hidden; }
.ai-steps li { float: left; list-style: none; padding: 10px 22px; color: #666; cursor: pointer; font-size: 15px; border-bottom: 2px solid transparent; }
.ai-steps li.on { color: #3eaf7c; border-bottom-color: #3eaf7c; font-weight: bold; }
.ai-panel { display: none; }
.ai-panel.show { display: block; }
.ai-form li { list-style: none; margin-bottom: 12px; }
.ai-form label { display: inline-block; width: 110px; vertical-align: top; color: #555; }
.ai-form input.t { width: 320px; height: 30px; line-height: 30px; border: 1px solid #dcdcdc; padding: 0 6px; }
.ai-form select { width: 334px; height: 32px; border: 1px solid #dcdcdc; }
.ai-form textarea { width: 620px; height: 70px; border: 1px solid #dcdcdc; padding: 6px; }
.ai-btn { display: inline-block; padding: 6px 16px; border-radius: 4px; background: #3eaf7c; color: #fff !important; border: 1px solid #3eaf7c; cursor: pointer; font-size: 14px; margin-right: 8px; }
.ai-btn:hover { opacity: .9; }
.ai-btn.gray { background: #fff; color: #3eaf7c !important; }
.ai-btn[disabled] { opacity: .5; cursor: not-allowed; }
.ai-chip { display: inline-block; padding: 4px 12px; margin: 4px 6px 4px 0; border: 1px solid #3eaf7c; border-radius: 16px; color: #3eaf7c; cursor: pointer; background: #fff; }
.ai-chip.sel { background: #3eaf7c; color: #fff !important; }
.ai-tip { color: #999; font-size: 13px; margin: 6px 0; }
.ai-running { display: none; padding: 10px 14px; margin-bottom: 12px; background: #f2fbf6; border: 1px solid #b7e5cd; border-radius: 4px; color: #3eaf7c; }
.ai-error { display: none; padding: 10px 14px; margin-bottom: 12px; background: #fff5f5; border: 1px solid #ffc9c9; border-radius: 4px; color: #d9534f; }
.ai-progress { padding: 8px 14px; margin-bottom: 12px; background: #f7f7f7; border-radius: 4px; color: #666; font-size: 13px; }
table.ai-table { width: 100%; border-collapse: collapse; }
table.ai-table th, table.ai-table td { border: 1px solid #e5e5e5; padding: 6px 8px; font-size: 13px; text-align: left; }
table.ai-table th { background: #f2fbf6; color: #3eaf7c; }
table.ai-table td input.t { width: 96%; height: 26px; border: 1px solid #dcdcdc; padding: 0 4px; }
table.ai-table td textarea { width: 96%; height: 48px; border: 1px solid #dcdcdc; padding: 4px; }
.st0 { color: #999; } .st1 { color: #e6a23c; } .st2 { color: #3eaf7c; } .st3 { color: #4a90d9; }
#editorArea { display: none; }
#editorArea input.t { width: 420px; }
#editorArea textarea { width: 96%; height: 420px; padding: 8px; font-size: 14px; line-height: 1.7; }
.sec-title { font-weight: bold; color: #3eaf7c; margin: 14px 0 6px; border-left: 3px solid #3eaf7c; padding-left: 8px; }
@media screen and (max-width: 720px) {
.ai-form textarea, .ai-form input.t, .ai-form select { width: 96%; }
#editorArea textarea { height: 320px; }
.ai-steps li { padding: 8px 10px; font-size: 13px; }
table.ai-table { display: block; overflow-x: auto; white-space: nowrap; }
}
</style>
</head>
<body>
<div class="header">
<div class="mainNav" id="mainNav">
<div class="box_center cf" style="text-align: center;height: 44px;line-height: 48px;color: #fff;font-size: 16px;">
小说精品屋作家管理
</div>
</div>
</div>
<div class="main box_center cf">
<div class="userBox cf">
<div class="my_l">
<ul class="log_list">
<li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_ai on" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul>
</div>
<div class="my_r ai-wrap">
<ul class="ai-steps cf" id="stepNav">
<li data-step="1" class="on">① 创意问答</li>
<li data-step="2">② 设定与大纲</li>
<li data-step="3">③ 章节创作</li>
</ul>
<div class="ai-progress" id="progressBar">加载中…</div>
<div class="ai-running" id="runningTip"><span id="runningText"></span></div>
<div class="ai-error" id="errorTip"><span id="errorText"></span>
<a class="ai-btn gray" id="retryBtn" style="margin-left:10px;">重试</a>
<a style="cursor:pointer;color:#999;" onclick="$('#errorTip').hide();">忽略</a>
</div>
<!-- 第一步:创意问答 -->
<div class="ai-panel show" id="panel1">
<ul class="ai-form">
<li><label>小说题材<span style="color:red">*</span></label>
<select id="genre">
<option value="">请选择</option>
<option>玄幻</option><option>仙侠</option><option>都市</option>
<option>历史</option><option>科幻</option><option>悬疑</option>
<option>言情</option><option>武侠</option><option>现实</option><option>其他</option>
</select></li>
<li><label>核心创意<span style="color:red">*</span></label>
<textarea id="coreIdea" placeholder="你希望讲一个什么样的故事?一两句话即可"></textarea></li>
<li><label>主角设定<span style="color:red">*</span></label>
<textarea id="protagonist" placeholder="身份、性格、能力、目标等"></textarea></li>
<li><label>核心冲突<span style="color:red">*</span></label>
<textarea id="conflict" placeholder="主角面临的主要矛盾或挑战"></textarea></li>
<li><a class="ai-btn gray" onclick="randomIdea()">随机灵感</a>
<a class="ai-btn gray" onclick="fillDefaultParams()">使用默认参数</a></li>
</ul>
<div class="sec-title">深度设定(可选,可跳过)</div>
<ul class="ai-form">
<li><label>世界观/背景</label><textarea id="worldview" placeholder="选填,留空由AI按题材合理构建"></textarea></li>
<li><label>视角与文风</label>
<select id="style">
<option value="">默认(第三人称,网文节奏)</option>
<option>第三人称,轻松幽默</option><option>第三人称,严肃正剧</option>
<option>第一人称,强代入感</option><option>多视角切换</option>
</select></li>
<li><label>核心主题</label><input class="t" id="theme" placeholder="如:成长与救赎"/></li>
<li><label>目标读者</label><input class="t" id="targetReaders" placeholder="如:18-30岁男频读者"/></li>
<li><label>预计总章节数</label><input class="t" id="totalChapters" value="60"/>
<label style="width:90px;">每章目标字数</label><input class="t" id="chapterWords" value="3000" style="width:100px;"/></li>
<li><label>补充要求</label><textarea id="extra" placeholder="其他想告诉AI的要求,选填"></textarea></li>
</ul>
<div class="sec-title">书名</div>
<div style="margin-bottom:10px;">
<input class="t" id="bookName" placeholder="自行输入,或使用AI建议"/>
<a class="ai-btn gray" onclick="genBookNames()">生成书名建议</a>
<a class="ai-btn gray" onclick="genBookNames()">换一批</a>
</div>
<div id="nameChips" class="ai-tip">点击AI建议书名可直接填入</div>
<div style="margin-top:15px;">
<a class="ai-btn" onclick="saveParams(true)">保存并去生成方案</a>
<a class="ai-btn gray" onclick="saveParams(false)">仅保存参数</a>
</div>
</div>
<!-- 第二步:设定与大纲 -->
<div class="ai-panel" id="panel2">
<div style="margin-bottom:10px;">
<a class="ai-btn" onclick="genPlan()">生成创作方案</a>
<a class="ai-btn gray" onclick="genPlan()">重新生成方案</a>
<span class="ai-tip">生成后可编辑、保存,确认前不会写入任何章节</span>
</div>
<ul class="ai-form">
<li><label>书名</label><input class="t" id="pBookName"/></li>
<li><label>核心卖点</label><input class="t" id="pSelling" style="width:520px;"/></li>
<li><label>目标读者</label><input class="t" id="pReaders"/></li>
<li><label>故事简介</label><textarea id="pIntro"></textarea></li>
<li><label>世界观设定</label><textarea id="pWorld" style="height:100px;"></textarea></li>
</ul>
<div class="sec-title">结构化设定(JSON,可编辑)</div>
<ul class="ai-form">
<li><label>人物档案</label><textarea id="pCharacters" style="height:120px;"></textarea></li>
<li><label>故事总纲</label><textarea id="pMainOutline" style="height:90px;"></textarea></li>
<li><label>分卷大纲</label><textarea id="pVolumes"></textarea></li>
<li><label>伏笔记录</label><textarea id="pForeshadows"></textarea></li>
</ul>
<div class="sec-title">章节规划</div>
<div style="overflow-x:auto;">
<table class="ai-table" id="chapterPlanTable">
<thead><tr><th style="width:50px;">章号</th><th style="width:160px;">标题</th><th>本章规划(事件/冲突/变化/主线/结尾)</th><th style="width:60px;">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="ai-tip" id="planTip"></div>
<div style="margin:10px 0;">
<a class="ai-btn gray" onclick="genMoreChapters()">生成后续章节规划</a>
</div>
<div>
<a class="ai-btn" onclick="savePlanContent()">保存方案草稿</a>
<a class="ai-btn" onclick="openConfirmDialog()">确认方案</a>
</div>
<div class="ai-tip">确认后将按方案拆分章节规划;新建小说会同时创建小说记录(沿用现有发布流程,含自动生成封面)</div>
</div>
<!-- 第三步:章节创作 -->
<div class="ai-panel" id="panel3">
<div style="overflow-x:auto;">
<table class="ai-table" id="chapterListTable">
<thead><tr><th style="width:50px;">章号</th><th>标题</th><th style="width:80px;">状态</th><th style="width:70px;">字数</th><th style="width:230px;">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="ai-tip" id="chapterTip">每次只生成一章,确认保存后才会写入正式章节</div>
<div id="editorArea">
<div class="sec-title" id="editorTitle">章节正文</div>
<div style="margin-bottom:8px;">
<label>标题:</label><input class="t" id="eIndexName"/>
<label style="width:auto;margin-left:10px;"><input type="checkbox" id="eIsVip"/> VIP章节</label>
</div>
<textarea id="eContent" placeholder="生成或手工输入正文…"></textarea>
<div class="ai-tip" id="eMeta"></div>
<div style="margin:10px 0;">
<a class="ai-btn" id="saveBtn" onclick="saveChapter()">确认保存章节</a>
<a class="ai-btn gray" onclick="checkRevise()">校验并修订</a>
<a class="ai-btn gray" onclick="genChapter(curChapter)">重新生成正文</a>
<a class="ai-btn gray" onclick="closeEditor()">暂不保存并退出</a>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
<script src="/javascript/jquery-1.8.0.min.js" type="text/javascript"></script>
<script src="/layui/layui.all.js" type="text/javascript"></script>
<script src="/javascript/header.js" type="text/javascript"></script>
<script src="/javascript/user.js" type="text/javascript"></script>
<script src="/javascript/common.js" type="text/javascript"></script>
<script language="javascript" type="text/javascript">
// ============================================================
// AI创作助手页面逻辑:进度全部存服务端,刷新/重登后自动恢复
// ============================================================
var planId = null, bookId = null, plan = null;
var chapterPlans = [], paramsObj = {}, planObj = {}, categoryList = [];
var pollTimer = null, curChapter = 0, busyText = '';
var STATUS_TXT = {0:'待创作', 1:'草稿', 2:'已保存', 3:'保存中'};
var STATUS_CLS = {0:'st0', 1:'st1', 2:'st2', 3:'st3'};
function getQuery(name) {
var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
$(function () {
bookId = getQuery('bookId');
$('#stepNav li').click(function () {
switchStep($(this).data('step'));
});
$('#retryBtn').click(function () {
if (window._retryFn) { window._retryFn(); }
});
initPlan();
});
function switchStep(step) {
if (step === 3 && (!plan || plan.stage < 3)) {
layer.msg('请先确认创作方案'); return;
}
$('#stepNav li').removeClass('on');
$('#stepNav li[data-step=' + step + ']').addClass('on');
$('.ai-panel').removeClass('show');
$('#panel' + step).addClass('show');
if (step === 3) { renderChapterList(); }
}
function ajaxPost(url, data, cb) {
$.ajax({
type: 'POST', url: url, data: data, dataType: 'json',
success: function (res) {
if (res.code == 200) { cb(res.data); }
else if (res.code == 1001) {
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else { layer.alert(res.msg); }
},
error: function () { layer.alert('网络异常'); }
});
}
function ajaxGet(url, data, cb) {
$.ajax({
type: 'GET', url: url, data: data, dataType: 'json',
success: function (res) {
if (res.code == 200) { cb(res.data); }
else if (res.code == 1001) {
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else { layer.alert(res.msg); }
},
error: function () { layer.alert('网络异常'); }
});
}
// -------- 初始化与进度恢复 --------
function initPlan() {
var data = {};
if (bookId) { data.bookId = bookId; }
ajaxPost('/author/aiAssistant/initPlan', data, function (p) {
plan = p; planId = p.id;
loadParamsForm();
loadProgress();
});
}
function loadParamsForm() {
paramsObj = plan.creativeParams ? JSON.parse(plan.creativeParams) : {};
if (paramsObj.genre) { $('#genre').val(paramsObj.genre); }
$('#coreIdea').val(paramsObj.coreIdea || '');
$('#protagonist').val(paramsObj.protagonist || '');
$('#conflict').val(paramsObj.conflict || '');
$('#worldview').val(paramsObj.worldview || '');
if (paramsObj.style) { $('#style').val(paramsObj.style); }
$('#theme').val(paramsObj.theme || '');
$('#targetReaders').val(paramsObj.targetReaders || '');
$('#totalChapters').val(paramsObj.totalChapters || 60);
$('#chapterWords').val(paramsObj.chapterWords || 3000);
$('#extra').val(paramsObj.extra || '');
$('#bookName').val(paramsObj.bookName || '');
}
function loadProgress() {
ajaxGet('/author/aiAssistant/progress', {planId: planId}, function (vo) {
plan = vo.plan;
chapterPlans = vo.chapterPlans || [];
renderProgressBar();
// 恢复执行中的任务轮询(刷新/中断后可继续)
var running = (vo.runningTasks || [])[0];
if (running) {
pollTask(running.id, taskDoneHandler(running), false);
} else if ((vo.latestTasks || []).length > 0) {
var latest = vo.latestTasks[0];
if (latest.status == 2) { showError(latest); }
}
if (plan.planContent) {
planObj = JSON.parse(plan.planContent);
fillPlanForm(planObj);
}
if (plan.stage >= 3) { renderChapterList(); }
if (plan.stage >= 2 && !plan.planContent) { switchStep(2); }
});
}
function renderProgressBar() {
var stageTxt = {1: '创作问答', 2: '方案草稿', 3: '章节创作', 4: '完成'}[plan.stage] || '';
var saved = 0, total = paramsObj.totalChapters || 0;
for (var i = 0; i < chapterPlans.length; i++) {
if (chapterPlans[i].status == 2) { saved++; }
}
var bookTxt = plan.bookId ? '已绑定小说ID:' + plan.bookId : '新建小说(确认方案后创建)';
$('#progressBar').html('当前阶段:<b>' + stageTxt + '</b> ' + bookTxt
+ ' 章节进度:<b>' + saved + '</b> / ' + total
+ (plan.outlineVersion ? ' 大纲版本:v' + plan.outlineVersion : ''));
}
function showError(task) {
var name = taskTypeName(task.taskType) + (task.chapterNo > 0 ? '(第' + task.chapterNo + '章)' : '');
$('#errorText').html('最近任务失败:' + name + '——' + (task.errorMsg || '未知错误'));
$('#errorTip').show();
window._retryFn = function () {
$('#errorTip').hide();
startTask(task.taskType, task.chapterNo);
};
}
function taskTypeName(t) {
return {1: '书名建议', 2: '创作方案', 3: '章节正文', 4: '校验修订', 5: '章节摘要', 6: '章节规划增量'}[t] || '任务';
}
// -------- 任务提交与轮询 --------
function startTask(type, chapterNo) {
if (pollTimer) { layer.msg('已有任务执行中,请稍候'); return; }
var data = {planId: planId, taskType: type};
if (chapterNo) { data.chapterNo = chapterNo; }
ajaxPost('/author/aiAssistant/startTask', data, function (taskId) {
pollTask(taskId, taskDoneHandler({taskType: type, chapterNo: chapterNo || 0}), true);
});
}
function pollTask(taskId, onDone, isNew) {
busyText = '任务执行中(' + (isNew ? '生成' : '恢复') + '中),可稍后刷新页面,任务不会丢失…';
$('#runningText').text(busyText);
$('#runningTip').show();
if (pollTimer) { clearInterval(pollTimer); }
pollTimer = setInterval(function () {
ajaxGet('/author/aiAssistant/task/' + taskId, {}, function (task) {
if (task.status == 0) { return; }
clearInterval(pollTimer); pollTimer = null;
$('#runningTip').hide();
if (task.status == 2) {
showError(task);
} else {
onDone(task);
}
});
}, 2500);
}
function taskDoneHandler(task) {
return function (t) {
switch (task.taskType) {
case 1: renderNameChips(JSON.parse(t.result)); break;
case 2: fillPlanForm(JSON.parse(t.result)); switchStep(2);
layer.msg('方案已生成,请编辑后保存确认'); break;
case 6: appendChapters(JSON.parse(t.result).chapters || []); break;
case 3: loadDraftIntoEditor(task.chapterNo, t.result); break;
case 4: var r = JSON.parse(t.result);
loadDraftIntoEditor(task.chapterNo, r.content);
showCheckReport(r); break;
default: break;
}
};
}
// -------- 第一步:参数与书名 --------
var IDEAS = [
{genre: '玄幻', coreIdea: '少年觉醒上古残缺血脉,在被宗门驱逐的绝境中踏上逆天之路', protagonist: '身世成谜的孤儿,性格坚韧重情,能力是吞噬他人法术化为己用,目标是查明身世', conflict: '血脉觉醒会逐步侵蚀神智,至亲之人可能成为第一个牺牲品'},
{genre: '都市', coreIdea: '被裁员的外卖员意外获得能看到他人剩余寿命的眼睛', protagonist: '落魄但乐观的退伍军人,能力是看见寿命但每次使用自身折寿,目标是拯救更多人', conflict: '拯救一个命,就要搭上半条命,而城市里正有一场收割寿命的阴谋'},
{genre: '仙侠', coreIdea: '门派覆灭后,末代弟子带着师门的炼丹炉穿越大荒重振山门', protagonist: '天赋平平但心细如发的药童,能力是过目不忘的丹方记忆,目标是复活师尊', conflict: '丹方最后一味药引,是仇人门主的道心'},
{genre: '科幻', coreIdea: '月球基地维修工发现殖民船名单里根本没有地球人类', protagonist: '较真的机械师,目标是带回真相,性格执拗不善言辞', conflict: '揭开真相意味着基地三万人失去生存配给'},
{genre: '悬疑', coreIdea: '小镇连续失踪案,每个失踪者失踪前都收到了十年前的自己寄来的信', protagonist: '返乡刑警,记忆力超群但患有发作性失忆,目标是揪出幕后寄信人', conflict: '线索一次指向自己,而失忆的十分钟无人能证明去了哪'},
{genre: '言情', coreIdea: '婚礼当天被逃婚的甜品师,与替新郎来道歉的伴郎协议恋爱一百天', protagonist: '独立要强的甜品师,目标是开自己的店,不再相信爱情', conflict: '协议到期那天,心动超出了协议范围'},
{genre: '历史', coreIdea: '抄家罪臣之后化名科举入仕,十年布局只为翻一桩旧案', protagonist: '隐忍谨慎的寒门学子,能力是过人的算学与心算,目标是为父昭雪', conflict: '复查旧案的钥匙,握在当年举荐他的恩师手里'},
{genre: '武侠', coreIdea: '天下第一剑的弟子,发现师父的成名剑谱是从死人手里抢来的', protagonist: '重情重义的年轻剑客,目标是既护师门之名又守心中之剑', conflict: '死者遗孤找上门来,剑下留不留得情面'}
];
function randomIdea() {
var it = IDEAS[Math.floor(Math.random() * IDEAS.length)];
$('#genre').val(it.genre); $('#coreIdea').val(it.coreIdea);
$('#protagonist').val(it.protagonist); $('#conflict').val(it.conflict);
layer.msg('已填入随机灵感,可自由修改');
}
function fillDefaultParams() {
$('#totalChapters').val(60); $('#chapterWords').val(3000);
layer.msg('已使用默认参数:60章 × 3000字/章');
}
function collectParams() {
var p = {
genre: $('#genre').val(), coreIdea: $('#coreIdea').val().trim(),
protagonist: $('#protagonist').val().trim(), conflict: $('#conflict').val().trim(),
worldview: $('#worldview').val().trim(), style: $('#style').val(),
theme: $('#theme').val().trim(), targetReaders: $('#targetReaders').val().trim(),
totalChapters: parseInt($('#totalChapters').val()) || 60,
chapterWords: parseInt($('#chapterWords').val()) || 3000,
extra: $('#extra').val().trim(), bookName: $('#bookName').val().trim()
};
if (!p.genre || !p.coreIdea || !p.protagonist || !p.conflict) {
layer.msg('题材、核心创意、主角设定、核心冲突为必填项'); return null;
}
return p;
}
function saveParams(goNext) {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
layer.msg('参数已保存');
if (goNext) { switchStep(2); }
});
}
function genBookNames() {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
startTask(1, 0);
});
}
function renderNameChips(data) {
var html = '';
var names = data.names || [];
for (var i = 0; i < names.length; i++) {
html += '<a class="ai-chip" title="' + esc(names[i].reason || '') + '" onclick="pickName(this,\'' + esc(names[i].name) + '\')">' + esc(names[i].name) + '</a>';
}
$('#nameChips').html(html || '暂无建议');
}
function pickName(el, name) {
$('#nameChips .ai-chip').removeClass('sel');
$(el).addClass('sel');
$('#bookName').val(name);
$('#pBookName').val(name);
}
function esc(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// -------- 第二步:方案编辑 --------
function genPlan() {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
startTask(2, 0);
});
}
function fillPlanForm(o) {
planObj = o;
$('#pBookName').val(o.bookName || $('#bookName').val() || '');
$('#pSelling').val(o.sellingPoints || '');
$('#pReaders').val(o.targetReaders || '');
$('#pIntro').val(o.intro || '');
$('#pWorld').val(o.worldview || '');
$('#pCharacters').val(JSON.stringify(o.characters || [], null, 1));
$('#pMainOutline').val(JSON.stringify(o.mainOutline || {}, null, 1));
$('#pVolumes').val(JSON.stringify(o.volumes || [], null, 1));
$('#pForeshadows').val(JSON.stringify(o.foreshadows || [], null, 1));
renderPlanChapterTable(o.chapters || []);
updatePlanTip();
}
function outlineToText(c) {
if (c.outline) { return typeof c.outline === 'string' ? c.outline : JSON.stringify(c.outline); }
var parts = [];
if (c.events) { parts.push('事件:' + c.events); }
if (c.conflict) { parts.push('冲突:' + c.conflict); }
if (c.charChange) { parts.push('变化:' + c.charChange); }
if (c.relation) { parts.push('主线:' + c.relation); }
if (c.ending) { parts.push('结尾:' + c.ending); }
return parts.join(';');
}
function renderPlanChapterTable(chapters) {
var html = '';
for (var i = 0; i < chapters.length; i++) {
var c = chapters[i];
html += '<tr data-no="' + c.chapterNo + '">'
+ '<td>' + c.chapterNo + '</td>'
+ '<td><input class="t cp-name" value="' + esc(c.indexName) + '"/></td>'
+ '<td><textarea class="cp-outline">' + esc(outlineToText(c)) + '</textarea></td>'
+ '<td><a style="cursor:pointer;color:#d9534f;" onclick="$(this).closest(\'tr\').remove();">删除</a></td></tr>';
}
$('#chapterPlanTable tbody').html(html);
}
function collectPlanContent() {
var o;
try {
o = {
bookName: $('#pBookName').val().trim(),
sellingPoints: $('#pSelling').val().trim(),
targetReaders: $('#pReaders').val().trim(),
intro: $('#pIntro').val().trim(),
worldview: $('#pWorld').val().trim(),
characters: JSON.parse($('#pCharacters').val() || '[]'),
mainOutline: JSON.parse($('#pMainOutline').val() || '{}'),
volumes: JSON.parse($('#pVolumes').val() || '[]'),
foreshadows: JSON.parse($('#pForeshadows').val() || '[]'),
chapters: []
};
} catch (e) {
layer.msg('结构化设定JSON格式有误,请检查人物/总纲/分卷/伏笔'); return null;
}
$('#chapterPlanTable tbody tr').each(function () {
var no = parseInt($(this).data('no'));
o.chapters.push({chapterNo: no, indexName: $(this).find('.cp-name').val().trim(), outline: $(this).find('.cp-outline').val().trim()});
});
if (!o.intro || o.characters.length === 0) {
layer.msg('简介与人物档案不能为空'); return null;
}
if (o.chapters.length === 0) {
layer.msg('章节规划不能为空'); return null;
}
return o;
}
function savePlanContent() {
var o = collectPlanContent();
if (!o) { return; }
ajaxPost('/author/aiAssistant/savePlanContent', {planId: planId, planContent: JSON.stringify(o)}, function () {
planObj = o;
layer.msg('方案草稿已保存');
loadProgress();
});
}
function genMoreChapters() {
var o = collectPlanContent();
if (!o) { return; }
if (o.chapters.length >= (paramsObj.totalChapters || 60)) {
layer.msg('章节规划数已达预计总章节数'); return;
}
ajaxPost('/author/aiAssistant/savePlanContent', {planId: planId, planContent: JSON.stringify(o)}, function () {
planObj = o;
startTask(6, o.chapters.length + 1);
});
}
function appendChapters(chapters) {
var html = '';
for (var i = 0; i < chapters.length; i++) {
var c = chapters[i];
html += '<tr data-no="' + c.chapterNo + '">'
+ '<td>' + c.chapterNo + '</td>'
+ '<td><input class="t cp-name" value="' + esc(c.indexName) + '"/></td>'
+ '<td><textarea class="cp-outline">' + esc(outlineToText(c)) + '</textarea></td>'
+ '<td><a style="cursor:pointer;color:#d9534f;" onclick="$(this).closest(\'tr\').remove();">删除</a></td></tr>';
}
$('#chapterPlanTable tbody').append(html);
updatePlanTip();
layer.msg('已追加章节规划,请检查后保存/确认方案');
}
function updatePlanTip() {
var n = $('#chapterPlanTable tbody tr').length;
$('#planTip').text('当前规划章节数:' + n + '(预计 ' + (paramsObj.totalChapters || 60) + ' 章,可分批继续生成)');
}
function openConfirmDialog() {
var o = collectPlanContent();
if (!o) { return; }
if (plan.bookId) {
layer.confirm('确认方案将按最新规划重建未保存章节(已保存章节不受影响),是否继续?', {btn: ['确认', '取消']}, function (idx) {
layer.close(idx);
doConfirm(o, {});
});
return;
}
// 新建小说:选择分类与方向
loadCategories(function () {
var html = buildConfirmBookForm(o);
layer.open({
type: 1, title: '确认方案并创建小说', area: ['420px', '330px'], content: html,
btn: ['确认创建', '取消'],
yes: function (idx) {
doConfirm(o, collectConfirmBookForm());
}
});
});
}
function loadCategories(cb) {
if (categoryList.length > 0) { cb(); return; }
ajaxGet('/book/listBookCategory', {}, function (data) {
categoryList = data || []; cb();
});
}
function buildConfirmBookForm(o) {
var wd = '<select id="cWorkDirection" onchange="renderConfirmCats()"><option value="0">男频</option><option value="1">女频</option></select>';
var html = '<div style="padding:15px;">'
+ '<p style="margin-bottom:10px;">书名:<input class="t" id="cBookName" value="' + esc(o.bookName || $('#bookName').val() || '') + '" style="width:220px;"/></p>'
+ '<p style="margin-bottom:10px;">方向:' + wd + '</p>'
+ '<p style="margin-bottom:10px;">分类:<select id="cCatId"></select></p>'
+ '<p class="ai-tip">简介将使用方案中的故事简介;封面沿用现有AI自动生成流程</p></div>';
setTimeout(renderConfirmCats, 50);
return html;
}
function renderConfirmCats() {
var wd = $('#cWorkDirection').val();
var html = '';
for (var i = 0; i < categoryList.length; i++) {
var c = categoryList[i];
if (c.workDirection == wd) {
var sel = (paramsObj.genre && c.name.indexOf(paramsObj.genre) >= 0) ? ' selected' : '';
html += '<option value="' + c.id + '" data-name="' + esc(c.name) + '"' + sel + '>' + esc(c.name) + '</option>';
}
}
$('#cCatId').html(html);
}
function collectConfirmBookForm() {
var bookName = $('#cBookName').val().trim();
if (!bookName) { layer.msg('书名不能为空'); return null; }
var opt = $('#cCatId').find('option:selected');
if (!opt.length) { layer.msg('请选择分类'); return null; }
return {bookName: bookName, workDirection: $('#cWorkDirection').val(), catId: opt.val(), catName: opt.text()};
}
function doConfirm(o, bookForm) {
if (bookForm === null) { return; }
var data = {planId: planId, planContent: JSON.stringify(o)};
if (!plan.bookId && bookForm.bookName) {
$.extend(data, bookForm);
}
layer.confirm('确认后进入逐章创作阶段,是否继续?', {btn: ['继续', '取消']}, function (idx) {
layer.close(idx);
ajaxPost('/author/aiAssistant/confirmPlan', data, function () {
layer.msg('方案已确认');
loadProgress();
switchStep(3);
});
});
}
// -------- 第三步:章节创作 --------
function renderChapterList() {
if (!chapterPlans || chapterPlans.length === 0) {
$('#chapterListTable tbody').html('<tr><td colspan="5">暂无章节规划,请先在第二步确认方案</td></tr>');
return;
}
var html = '';
for (var i = 0; i < chapterPlans.length; i++) {
var c = chapterPlans[i];
var ops = '';
if (c.status != 2) {
ops += '<a class="ai-btn" style="padding:2px 8px;" onclick="genChapter(' + c.chapterNo + ')">生成正文</a>';
ops += '<a class="ai-btn gray" style="padding:2px 8px;" onclick="checkReviseNo(' + c.chapterNo + ')">校验修订</a>';
}
ops += '<a style="color:#3eaf7c;cursor:pointer;" onclick="openEditor(' + c.chapterNo + ')">编辑/查看</a>';
if (c.status == 2 && c.indexId) {
ops += ' <a href="/author/content_update.html?indexId=' + c.indexId + '&bookId=' + plan.bookId + '" target="_blank" style="color:#999;">章节管理</a>';
}
html += '<tr><td>' + c.chapterNo + '</td><td>' + esc(c.indexName || '') + '</td>'
+ '<td class="' + STATUS_CLS[c.status] + '">' + STATUS_TXT[c.status] + '</td>'
+ '<td>' + (c.wordCount || 0) + '</td><td>' + ops + '</td></tr>';
}
$('#chapterListTable tbody').html(html);
renderProgressBar();
}
function genChapter(no) {
if (plan.stage < 3) { layer.msg('请先确认创作方案'); return; }
openEditor(no);
startTask(3, no);
}
function checkReviseNo(no) {
curChapter = no;
openEditor(no, true);
checkRevise();
}
function openEditor(no, skipDraftLoad) {
curChapter = no;
var c = findChapter(no);
if (!c) { return; }
$('#editorTitle').text('第' + no + '章 ' + (c.indexName || ''));
$('#eIndexName').val(c.indexName || '');
$('#eIsVip').prop('checked', false);
$('#editorArea').show();
if (!skipDraftLoad) { loadDraftIntoEditor(no, null); }
if (c.status == 2) {
// 已保存章节:只读展示正式正文
ajaxGet('/author/queryIndexContent/' + c.indexId, {}, function (content) {
$('#eContent').val(content);
$('#eMeta').html('该章节已保存(' + (c.wordCount || 0) + '字),如需修改请再次保存将产生新章节,建议直接使用章节管理更新');
});
}
}
function findChapter(no) {
for (var i = 0; i < chapterPlans.length; i++) {
if (chapterPlans[i].chapterNo == no) { return chapterPlans[i]; }
}
return null;
}
function loadDraftIntoEditor(no, content) {
var c = findChapter(no);
if (content !== null && content !== undefined) {
$('#eContent').val(content);
refreshWordCount(no);
if (c && c.status == 0) { c.status = 1; renderChapterList(); }
return;
}
if (c && c.status >= 1 && c.status != 2) {
// 从最近成功任务恢复草稿(正文或校验修订结果)
ajaxGet('/author/aiAssistant/progress', {planId: planId}, function (vo) {
var tasks = vo.latestTasks || [];
for (var i = 0; i < tasks.length; i++) {
var t = tasks[i];
if (t.chapterNo == no && (t.taskType == 3 || t.taskType == 4) && t.status == 1) {
ajaxGet('/author/aiAssistant/task/' + t.id, {}, function (full) {
var content = full.taskType == 4 ? JSON.parse(full.result).content : full.result;
$('#eContent').val(content);
refreshWordCount(no);
});
return;
}
}
});
}
}
function refreshWordCount(no) {
var content = $('#eContent').val();
if (!content) { $('#eMeta').html(''); return; }
ajaxPost('/author/aiAssistant/programCheck', {content: content, targetWords: paramsObj.chapterWords || 3000}, function (problems) {
var msg = '有效字数以作者后台统计规则为准';
if (problems && problems.length > 0) {
msg = '程序校验提示:' + problems.join(';') + '<br/>(字数达标不代表质量达标,可点击“校验并修订”做AI一致性检查)';
}
$('#eMeta').html(msg);
});
}
function showCheckReport(r) {
var msg = r.passed ? '校验通过(共' + r.rounds + '轮)' : '已修订' + r.rounds + '轮仍未完全通过,请人工处理';
var issues = r.issues || [];
if (issues.length > 0) {
msg += '<br/>问题清单:';
for (var i = 0; i < issues.length; i++) {
msg += '<br/>· [' + esc(issues[i].type || '') + '] ' + esc(issues[i].description || '');
}
}
$('#eMeta').html(msg);
layer.msg(msg.replace(/<br\/>/g, '\n').replace(/·/g, ''));
}
function checkRevise() {
if (!curChapter) { layer.msg('请先选择章节'); return; }
var content = $('#eContent').val();
if (!content) { layer.msg('请先生成正文再校验'); return; }
//先把编辑器当前内容(含作者手工修改)落为服务端草稿,再以该草稿校验修订
ajaxPost('/author/aiAssistant/saveDraft', {planId: planId, chapterNo: curChapter, content: content}, function () {
startTask(4, curChapter);
});
}
function saveChapter() {
var content = $('#eContent').val();
var indexName = $('#eIndexName').val().trim();
if (!indexName || !content) { layer.msg('标题与正文不能为空'); return; }
var c = findChapter(curChapter);
if (c && c.status == 2) {
layer.msg('该章节已保存,请使用章节管理更新,避免重复创建章节'); return;
}
layer.confirm('确认保存为正式章节?保存后不可被任务恢复覆盖', {btn: ['确认保存', '取消']}, function (idx) {
layer.close(idx);
ajaxPost('/author/aiAssistant/saveChapter', {
planId: planId, chapterNo: curChapter, indexName: indexName,
content: content, isVip: $('#eIsVip').is(':checked') ? 1 : 0
}, function () {
layer.msg('已保存,创作记忆摘要将异步更新');
loadProgress();
renderChapterList();
});
});
}
function closeEditor() {
$('#editorArea').hide();
}
</script>
</html>
@@ -28,10 +28,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4" href="/author/index.html">小说管理</a></li> <li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 on" href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income on" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -28,10 +28,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4" href="/author/index.html">小说管理</a></li> <li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 on" href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income on" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
+3 -4
View File
@@ -31,10 +31,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!--<li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
+3 -4
View File
@@ -101,10 +101,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!--<li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -28,8 +28,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">作品管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul> </ul>
</div> </div>
+4 -4
View File
@@ -42,10 +42,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -211,6 +210,7 @@
" <td class=\"goread\" id='opt" + book.id + "'>" + " <td class=\"goread\" id='opt" + book.id + "'>" +
"<a target='_blank' class='redBtn' href='/author/index_list.html?bookId=" + book.id + "'>章节管理 </a><br/>" + "<a target='_blank' class='redBtn' href='/author/index_list.html?bookId=" + book.id + "'>章节管理 </a><br/>" +
"<a class='redBtn' href='/author/ai_assistant.html?bookId=" + book.id + "'>AI创作 </a><br/>" +
"<a target='_blank' href='/author/author_income_detail.html?bookId=" + book.id + "'>薪酬查询 </a><br/>" + "<a target='_blank' href='/author/author_income_detail.html?bookId=" + book.id + "'>薪酬查询 </a><br/>" +
"<a target='_blank' href='/book/" + book.id + ".html'>作品信息</a>" + "<a target='_blank' href='/book/" + book.id + ".html'>作品信息</a>" +
"</td> </tr>"); "</td> </tr>");
+3 -2
View File
@@ -42,8 +42,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">作品管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul> </ul>
</div> </div>
+3
View File
@@ -45,6 +45,9 @@
.my_l .link_4 { background-position:32px -314px } .my_l .link_4 { background-position:32px -314px }
.my_l .link_5 { background-position:32px -356px } .my_l .link_5 { background-position:32px -356px }
.my_l .link_6 { background-position:32px -397px } .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_7 { background-position:32px -440px }
.my_l .link_8 { background-position:32px -481px } .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 } .my_r { width:739px; padding:0 30px 30px; float:right; border-left:1px solid #efefef; min-height:470px }
@@ -0,0 +1,816 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>AI创作助手-作家管理系统</title>
<link rel="stylesheet" href="/css/base.css?v=1"/>
<link rel="stylesheet" href="/css/user.css"/>
<style type="text/css">
.ai-wrap { padding-bottom: 40px; }
.ai-steps { border-bottom: 1px solid #e5e5e5; margin-bottom: 15px; padding: 0; overflow: hidden; }
.ai-steps li { float: left; list-style: none; padding: 10px 22px; color: #666; cursor: pointer; font-size: 15px; border-bottom: 2px solid transparent; }
.ai-steps li.on { color: #3eaf7c; border-bottom-color: #3eaf7c; font-weight: bold; }
.ai-panel { display: none; }
.ai-panel.show { display: block; }
.ai-form li { list-style: none; margin-bottom: 12px; }
.ai-form label { display: inline-block; width: 110px; vertical-align: top; color: #555; }
.ai-form input.t { width: 320px; height: 30px; line-height: 30px; border: 1px solid #dcdcdc; padding: 0 6px; }
.ai-form select { width: 334px; height: 32px; border: 1px solid #dcdcdc; }
.ai-form textarea { width: 620px; height: 70px; border: 1px solid #dcdcdc; padding: 6px; }
.ai-btn { display: inline-block; padding: 6px 16px; border-radius: 4px; background: #3eaf7c; color: #fff !important; border: 1px solid #3eaf7c; cursor: pointer; font-size: 14px; margin-right: 8px; }
.ai-btn:hover { opacity: .9; }
.ai-btn.gray { background: #fff; color: #3eaf7c !important; }
.ai-btn[disabled] { opacity: .5; cursor: not-allowed; }
.ai-chip { display: inline-block; padding: 4px 12px; margin: 4px 6px 4px 0; border: 1px solid #3eaf7c; border-radius: 16px; color: #3eaf7c; cursor: pointer; background: #fff; }
.ai-chip.sel { background: #3eaf7c; color: #fff !important; }
.ai-tip { color: #999; font-size: 13px; margin: 6px 0; }
.ai-running { display: none; padding: 10px 14px; margin-bottom: 12px; background: #f2fbf6; border: 1px solid #b7e5cd; border-radius: 4px; color: #3eaf7c; }
.ai-error { display: none; padding: 10px 14px; margin-bottom: 12px; background: #fff5f5; border: 1px solid #ffc9c9; border-radius: 4px; color: #d9534f; }
.ai-progress { padding: 8px 14px; margin-bottom: 12px; background: #f7f7f7; border-radius: 4px; color: #666; font-size: 13px; }
table.ai-table { width: 100%; border-collapse: collapse; }
table.ai-table th, table.ai-table td { border: 1px solid #e5e5e5; padding: 6px 8px; font-size: 13px; text-align: left; }
table.ai-table th { background: #f2fbf6; color: #3eaf7c; }
table.ai-table td input.t { width: 96%; height: 26px; border: 1px solid #dcdcdc; padding: 0 4px; }
table.ai-table td textarea { width: 96%; height: 48px; border: 1px solid #dcdcdc; padding: 4px; }
.st0 { color: #999; } .st1 { color: #e6a23c; } .st2 { color: #3eaf7c; } .st3 { color: #4a90d9; }
#editorArea { display: none; }
#editorArea input.t { width: 420px; }
#editorArea textarea { width: 96%; height: 420px; padding: 8px; font-size: 14px; line-height: 1.7; }
.sec-title { font-weight: bold; color: #3eaf7c; margin: 14px 0 6px; border-left: 3px solid #3eaf7c; padding-left: 8px; }
@media screen and (max-width: 720px) {
.ai-form textarea, .ai-form input.t, .ai-form select { width: 96%; }
#editorArea textarea { height: 320px; }
.ai-steps li { padding: 8px 10px; font-size: 13px; }
table.ai-table { display: block; overflow-x: auto; white-space: nowrap; }
}
</style>
</head>
<body>
<div class="header">
<div class="mainNav" id="mainNav">
<div class="box_center cf" style="text-align: center;height: 44px;line-height: 48px;color: #fff;font-size: 16px;">
小说精品屋作家管理
</div>
</div>
</div>
<div class="main box_center cf">
<div class="userBox cf">
<div class="my_l">
<ul class="log_list">
<li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_ai on" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul>
</div>
<div class="my_r ai-wrap">
<ul class="ai-steps cf" id="stepNav">
<li data-step="1" class="on">① 创意问答</li>
<li data-step="2">② 设定与大纲</li>
<li data-step="3">③ 章节创作</li>
</ul>
<div class="ai-progress" id="progressBar">加载中…</div>
<div class="ai-running" id="runningTip"><span id="runningText"></span></div>
<div class="ai-error" id="errorTip"><span id="errorText"></span>
<a class="ai-btn gray" id="retryBtn" style="margin-left:10px;">重试</a>
<a style="cursor:pointer;color:#999;" onclick="$('#errorTip').hide();">忽略</a>
</div>
<!-- 第一步:创意问答 -->
<div class="ai-panel show" id="panel1">
<ul class="ai-form">
<li><label>小说题材<span style="color:red">*</span></label>
<select id="genre">
<option value="">请选择</option>
<option>玄幻</option><option>仙侠</option><option>都市</option>
<option>历史</option><option>科幻</option><option>悬疑</option>
<option>言情</option><option>武侠</option><option>现实</option><option>其他</option>
</select></li>
<li><label>核心创意<span style="color:red">*</span></label>
<textarea id="coreIdea" placeholder="你希望讲一个什么样的故事?一两句话即可"></textarea></li>
<li><label>主角设定<span style="color:red">*</span></label>
<textarea id="protagonist" placeholder="身份、性格、能力、目标等"></textarea></li>
<li><label>核心冲突<span style="color:red">*</span></label>
<textarea id="conflict" placeholder="主角面临的主要矛盾或挑战"></textarea></li>
<li><a class="ai-btn gray" onclick="randomIdea()">随机灵感</a>
<a class="ai-btn gray" onclick="fillDefaultParams()">使用默认参数</a></li>
</ul>
<div class="sec-title">深度设定(可选,可跳过)</div>
<ul class="ai-form">
<li><label>世界观/背景</label><textarea id="worldview" placeholder="选填,留空由AI按题材合理构建"></textarea></li>
<li><label>视角与文风</label>
<select id="style">
<option value="">默认(第三人称,网文节奏)</option>
<option>第三人称,轻松幽默</option><option>第三人称,严肃正剧</option>
<option>第一人称,强代入感</option><option>多视角切换</option>
</select></li>
<li><label>核心主题</label><input class="t" id="theme" placeholder="如:成长与救赎"/></li>
<li><label>目标读者</label><input class="t" id="targetReaders" placeholder="如:18-30岁男频读者"/></li>
<li><label>预计总章节数</label><input class="t" id="totalChapters" value="60"/>
<label style="width:90px;">每章目标字数</label><input class="t" id="chapterWords" value="3000" style="width:100px;"/></li>
<li><label>补充要求</label><textarea id="extra" placeholder="其他想告诉AI的要求,选填"></textarea></li>
</ul>
<div class="sec-title">书名</div>
<div style="margin-bottom:10px;">
<input class="t" id="bookName" placeholder="自行输入,或使用AI建议"/>
<a class="ai-btn gray" onclick="genBookNames()">生成书名建议</a>
<a class="ai-btn gray" onclick="genBookNames()">换一批</a>
</div>
<div id="nameChips" class="ai-tip">点击AI建议书名可直接填入</div>
<div style="margin-top:15px;">
<a class="ai-btn" onclick="saveParams(true)">保存并去生成方案</a>
<a class="ai-btn gray" onclick="saveParams(false)">仅保存参数</a>
</div>
</div>
<!-- 第二步:设定与大纲 -->
<div class="ai-panel" id="panel2">
<div style="margin-bottom:10px;">
<a class="ai-btn" onclick="genPlan()">生成创作方案</a>
<a class="ai-btn gray" onclick="genPlan()">重新生成方案</a>
<span class="ai-tip">生成后可编辑、保存,确认前不会写入任何章节</span>
</div>
<ul class="ai-form">
<li><label>书名</label><input class="t" id="pBookName"/></li>
<li><label>核心卖点</label><input class="t" id="pSelling" style="width:520px;"/></li>
<li><label>目标读者</label><input class="t" id="pReaders"/></li>
<li><label>故事简介</label><textarea id="pIntro"></textarea></li>
<li><label>世界观设定</label><textarea id="pWorld" style="height:100px;"></textarea></li>
</ul>
<div class="sec-title">结构化设定(JSON,可编辑)</div>
<ul class="ai-form">
<li><label>人物档案</label><textarea id="pCharacters" style="height:120px;"></textarea></li>
<li><label>故事总纲</label><textarea id="pMainOutline" style="height:90px;"></textarea></li>
<li><label>分卷大纲</label><textarea id="pVolumes"></textarea></li>
<li><label>伏笔记录</label><textarea id="pForeshadows"></textarea></li>
</ul>
<div class="sec-title">章节规划</div>
<div style="overflow-x:auto;">
<table class="ai-table" id="chapterPlanTable">
<thead><tr><th style="width:50px;">章号</th><th style="width:160px;">标题</th><th>本章规划(事件/冲突/变化/主线/结尾)</th><th style="width:60px;">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="ai-tip" id="planTip"></div>
<div style="margin:10px 0;">
<a class="ai-btn gray" onclick="genMoreChapters()">生成后续章节规划</a>
</div>
<div>
<a class="ai-btn" onclick="savePlanContent()">保存方案草稿</a>
<a class="ai-btn" onclick="openConfirmDialog()">确认方案</a>
</div>
<div class="ai-tip">确认后将按方案拆分章节规划;新建小说会同时创建小说记录(沿用现有发布流程,含自动生成封面)</div>
</div>
<!-- 第三步:章节创作 -->
<div class="ai-panel" id="panel3">
<div style="overflow-x:auto;">
<table class="ai-table" id="chapterListTable">
<thead><tr><th style="width:50px;">章号</th><th>标题</th><th style="width:80px;">状态</th><th style="width:70px;">字数</th><th style="width:230px;">操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="ai-tip" id="chapterTip">每次只生成一章,确认保存后才会写入正式章节</div>
<div id="editorArea">
<div class="sec-title" id="editorTitle">章节正文</div>
<div style="margin-bottom:8px;">
<label>标题:</label><input class="t" id="eIndexName"/>
<label style="width:auto;margin-left:10px;"><input type="checkbox" id="eIsVip"/> VIP章节</label>
</div>
<textarea id="eContent" placeholder="生成或手工输入正文…"></textarea>
<div class="ai-tip" id="eMeta"></div>
<div style="margin:10px 0;">
<a class="ai-btn" id="saveBtn" onclick="saveChapter()">确认保存章节</a>
<a class="ai-btn gray" onclick="checkRevise()">校验并修订</a>
<a class="ai-btn gray" onclick="genChapter(curChapter)">重新生成正文</a>
<a class="ai-btn gray" onclick="closeEditor()">暂不保存并退出</a>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
<script src="/javascript/jquery-1.8.0.min.js" type="text/javascript"></script>
<script src="/layui/layui.all.js" type="text/javascript"></script>
<script src="/javascript/header.js" type="text/javascript"></script>
<script src="/javascript/user.js" type="text/javascript"></script>
<script src="/javascript/common.js" type="text/javascript"></script>
<script language="javascript" type="text/javascript">
// ============================================================
// AI创作助手页面逻辑:进度全部存服务端,刷新/重登后自动恢复
// ============================================================
var planId = null, bookId = null, plan = null;
var chapterPlans = [], paramsObj = {}, planObj = {}, categoryList = [];
var pollTimer = null, curChapter = 0, busyText = '';
var STATUS_TXT = {0:'待创作', 1:'草稿', 2:'已保存', 3:'保存中'};
var STATUS_CLS = {0:'st0', 1:'st1', 2:'st2', 3:'st3'};
function getQuery(name) {
var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
$(function () {
bookId = getQuery('bookId');
$('#stepNav li').click(function () {
switchStep($(this).data('step'));
});
$('#retryBtn').click(function () {
if (window._retryFn) { window._retryFn(); }
});
initPlan();
});
function switchStep(step) {
if (step === 3 && (!plan || plan.stage < 3)) {
layer.msg('请先确认创作方案'); return;
}
$('#stepNav li').removeClass('on');
$('#stepNav li[data-step=' + step + ']').addClass('on');
$('.ai-panel').removeClass('show');
$('#panel' + step).addClass('show');
if (step === 3) { renderChapterList(); }
}
function ajaxPost(url, data, cb) {
$.ajax({
type: 'POST', url: url, data: data, dataType: 'json',
success: function (res) {
if (res.code == 200) { cb(res.data); }
else if (res.code == 1001) {
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else { layer.alert(res.msg); }
},
error: function () { layer.alert('网络异常'); }
});
}
function ajaxGet(url, data, cb) {
$.ajax({
type: 'GET', url: url, data: data, dataType: 'json',
success: function (res) {
if (res.code == 200) { cb(res.data); }
else if (res.code == 1001) {
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else { layer.alert(res.msg); }
},
error: function () { layer.alert('网络异常'); }
});
}
// -------- 初始化与进度恢复 --------
function initPlan() {
var data = {};
if (bookId) { data.bookId = bookId; }
ajaxPost('/author/aiAssistant/initPlan', data, function (p) {
plan = p; planId = p.id;
loadParamsForm();
loadProgress();
});
}
function loadParamsForm() {
paramsObj = plan.creativeParams ? JSON.parse(plan.creativeParams) : {};
if (paramsObj.genre) { $('#genre').val(paramsObj.genre); }
$('#coreIdea').val(paramsObj.coreIdea || '');
$('#protagonist').val(paramsObj.protagonist || '');
$('#conflict').val(paramsObj.conflict || '');
$('#worldview').val(paramsObj.worldview || '');
if (paramsObj.style) { $('#style').val(paramsObj.style); }
$('#theme').val(paramsObj.theme || '');
$('#targetReaders').val(paramsObj.targetReaders || '');
$('#totalChapters').val(paramsObj.totalChapters || 60);
$('#chapterWords').val(paramsObj.chapterWords || 3000);
$('#extra').val(paramsObj.extra || '');
$('#bookName').val(paramsObj.bookName || '');
}
function loadProgress() {
ajaxGet('/author/aiAssistant/progress', {planId: planId}, function (vo) {
plan = vo.plan;
chapterPlans = vo.chapterPlans || [];
renderProgressBar();
// 恢复执行中的任务轮询(刷新/中断后可继续)
var running = (vo.runningTasks || [])[0];
if (running) {
pollTask(running.id, taskDoneHandler(running), false);
} else if ((vo.latestTasks || []).length > 0) {
var latest = vo.latestTasks[0];
if (latest.status == 2) { showError(latest); }
}
if (plan.planContent) {
planObj = JSON.parse(plan.planContent);
fillPlanForm(planObj);
}
if (plan.stage >= 3) { renderChapterList(); }
if (plan.stage >= 2 && !plan.planContent) { switchStep(2); }
});
}
function renderProgressBar() {
var stageTxt = {1: '创作问答', 2: '方案草稿', 3: '章节创作', 4: '完成'}[plan.stage] || '';
var saved = 0, total = paramsObj.totalChapters || 0;
for (var i = 0; i < chapterPlans.length; i++) {
if (chapterPlans[i].status == 2) { saved++; }
}
var bookTxt = plan.bookId ? '已绑定小说ID:' + plan.bookId : '新建小说(确认方案后创建)';
$('#progressBar').html('当前阶段:<b>' + stageTxt + '</b> ' + bookTxt
+ ' 章节进度:<b>' + saved + '</b> / ' + total
+ (plan.outlineVersion ? ' 大纲版本:v' + plan.outlineVersion : ''));
}
function showError(task) {
var name = taskTypeName(task.taskType) + (task.chapterNo > 0 ? '(第' + task.chapterNo + '章)' : '');
$('#errorText').html('最近任务失败:' + name + '——' + (task.errorMsg || '未知错误'));
$('#errorTip').show();
window._retryFn = function () {
$('#errorTip').hide();
startTask(task.taskType, task.chapterNo);
};
}
function taskTypeName(t) {
return {1: '书名建议', 2: '创作方案', 3: '章节正文', 4: '校验修订', 5: '章节摘要', 6: '章节规划增量'}[t] || '任务';
}
// -------- 任务提交与轮询 --------
function startTask(type, chapterNo) {
if (pollTimer) { layer.msg('已有任务执行中,请稍候'); return; }
var data = {planId: planId, taskType: type};
if (chapterNo) { data.chapterNo = chapterNo; }
ajaxPost('/author/aiAssistant/startTask', data, function (taskId) {
pollTask(taskId, taskDoneHandler({taskType: type, chapterNo: chapterNo || 0}), true);
});
}
function pollTask(taskId, onDone, isNew) {
busyText = '任务执行中(' + (isNew ? '生成' : '恢复') + '中),可稍后刷新页面,任务不会丢失…';
$('#runningText').text(busyText);
$('#runningTip').show();
if (pollTimer) { clearInterval(pollTimer); }
pollTimer = setInterval(function () {
ajaxGet('/author/aiAssistant/task/' + taskId, {}, function (task) {
if (task.status == 0) { return; }
clearInterval(pollTimer); pollTimer = null;
$('#runningTip').hide();
if (task.status == 2) {
showError(task);
} else {
onDone(task);
}
});
}, 2500);
}
function taskDoneHandler(task) {
return function (t) {
switch (task.taskType) {
case 1: renderNameChips(JSON.parse(t.result)); break;
case 2: fillPlanForm(JSON.parse(t.result)); switchStep(2);
layer.msg('方案已生成,请编辑后保存确认'); break;
case 6: appendChapters(JSON.parse(t.result).chapters || []); break;
case 3: loadDraftIntoEditor(task.chapterNo, t.result); break;
case 4: var r = JSON.parse(t.result);
loadDraftIntoEditor(task.chapterNo, r.content);
showCheckReport(r); break;
default: break;
}
};
}
// -------- 第一步:参数与书名 --------
var IDEAS = [
{genre: '玄幻', coreIdea: '少年觉醒上古残缺血脉,在被宗门驱逐的绝境中踏上逆天之路', protagonist: '身世成谜的孤儿,性格坚韧重情,能力是吞噬他人法术化为己用,目标是查明身世', conflict: '血脉觉醒会逐步侵蚀神智,至亲之人可能成为第一个牺牲品'},
{genre: '都市', coreIdea: '被裁员的外卖员意外获得能看到他人剩余寿命的眼睛', protagonist: '落魄但乐观的退伍军人,能力是看见寿命但每次使用自身折寿,目标是拯救更多人', conflict: '拯救一个命,就要搭上半条命,而城市里正有一场收割寿命的阴谋'},
{genre: '仙侠', coreIdea: '门派覆灭后,末代弟子带着师门的炼丹炉穿越大荒重振山门', protagonist: '天赋平平但心细如发的药童,能力是过目不忘的丹方记忆,目标是复活师尊', conflict: '丹方最后一味药引,是仇人门主的道心'},
{genre: '科幻', coreIdea: '月球基地维修工发现殖民船名单里根本没有地球人类', protagonist: '较真的机械师,目标是带回真相,性格执拗不善言辞', conflict: '揭开真相意味着基地三万人失去生存配给'},
{genre: '悬疑', coreIdea: '小镇连续失踪案,每个失踪者失踪前都收到了十年前的自己寄来的信', protagonist: '返乡刑警,记忆力超群但患有发作性失忆,目标是揪出幕后寄信人', conflict: '线索一次指向自己,而失忆的十分钟无人能证明去了哪'},
{genre: '言情', coreIdea: '婚礼当天被逃婚的甜品师,与替新郎来道歉的伴郎协议恋爱一百天', protagonist: '独立要强的甜品师,目标是开自己的店,不再相信爱情', conflict: '协议到期那天,心动超出了协议范围'},
{genre: '历史', coreIdea: '抄家罪臣之后化名科举入仕,十年布局只为翻一桩旧案', protagonist: '隐忍谨慎的寒门学子,能力是过人的算学与心算,目标是为父昭雪', conflict: '复查旧案的钥匙,握在当年举荐他的恩师手里'},
{genre: '武侠', coreIdea: '天下第一剑的弟子,发现师父的成名剑谱是从死人手里抢来的', protagonist: '重情重义的年轻剑客,目标是既护师门之名又守心中之剑', conflict: '死者遗孤找上门来,剑下留不留得情面'}
];
function randomIdea() {
var it = IDEAS[Math.floor(Math.random() * IDEAS.length)];
$('#genre').val(it.genre); $('#coreIdea').val(it.coreIdea);
$('#protagonist').val(it.protagonist); $('#conflict').val(it.conflict);
layer.msg('已填入随机灵感,可自由修改');
}
function fillDefaultParams() {
$('#totalChapters').val(60); $('#chapterWords').val(3000);
layer.msg('已使用默认参数:60章 × 3000字/章');
}
function collectParams() {
var p = {
genre: $('#genre').val(), coreIdea: $('#coreIdea').val().trim(),
protagonist: $('#protagonist').val().trim(), conflict: $('#conflict').val().trim(),
worldview: $('#worldview').val().trim(), style: $('#style').val(),
theme: $('#theme').val().trim(), targetReaders: $('#targetReaders').val().trim(),
totalChapters: parseInt($('#totalChapters').val()) || 60,
chapterWords: parseInt($('#chapterWords').val()) || 3000,
extra: $('#extra').val().trim(), bookName: $('#bookName').val().trim()
};
if (!p.genre || !p.coreIdea || !p.protagonist || !p.conflict) {
layer.msg('题材、核心创意、主角设定、核心冲突为必填项'); return null;
}
return p;
}
function saveParams(goNext) {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
layer.msg('参数已保存');
if (goNext) { switchStep(2); }
});
}
function genBookNames() {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
startTask(1, 0);
});
}
function renderNameChips(data) {
var html = '';
var names = data.names || [];
for (var i = 0; i < names.length; i++) {
html += '<a class="ai-chip" title="' + esc(names[i].reason || '') + '" onclick="pickName(this,\'' + esc(names[i].name) + '\')">' + esc(names[i].name) + '</a>';
}
$('#nameChips').html(html || '暂无建议');
}
function pickName(el, name) {
$('#nameChips .ai-chip').removeClass('sel');
$(el).addClass('sel');
$('#bookName').val(name);
$('#pBookName').val(name);
}
function esc(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// -------- 第二步:方案编辑 --------
function genPlan() {
var p = collectParams();
if (!p) { return; }
ajaxPost('/author/aiAssistant/saveParams', {planId: planId, creativeParams: JSON.stringify(p)}, function () {
paramsObj = p;
startTask(2, 0);
});
}
function fillPlanForm(o) {
planObj = o;
$('#pBookName').val(o.bookName || $('#bookName').val() || '');
$('#pSelling').val(o.sellingPoints || '');
$('#pReaders').val(o.targetReaders || '');
$('#pIntro').val(o.intro || '');
$('#pWorld').val(o.worldview || '');
$('#pCharacters').val(JSON.stringify(o.characters || [], null, 1));
$('#pMainOutline').val(JSON.stringify(o.mainOutline || {}, null, 1));
$('#pVolumes').val(JSON.stringify(o.volumes || [], null, 1));
$('#pForeshadows').val(JSON.stringify(o.foreshadows || [], null, 1));
renderPlanChapterTable(o.chapters || []);
updatePlanTip();
}
function outlineToText(c) {
if (c.outline) { return typeof c.outline === 'string' ? c.outline : JSON.stringify(c.outline); }
var parts = [];
if (c.events) { parts.push('事件:' + c.events); }
if (c.conflict) { parts.push('冲突:' + c.conflict); }
if (c.charChange) { parts.push('变化:' + c.charChange); }
if (c.relation) { parts.push('主线:' + c.relation); }
if (c.ending) { parts.push('结尾:' + c.ending); }
return parts.join(';');
}
function renderPlanChapterTable(chapters) {
var html = '';
for (var i = 0; i < chapters.length; i++) {
var c = chapters[i];
html += '<tr data-no="' + c.chapterNo + '">'
+ '<td>' + c.chapterNo + '</td>'
+ '<td><input class="t cp-name" value="' + esc(c.indexName) + '"/></td>'
+ '<td><textarea class="cp-outline">' + esc(outlineToText(c)) + '</textarea></td>'
+ '<td><a style="cursor:pointer;color:#d9534f;" onclick="$(this).closest(\'tr\').remove();">删除</a></td></tr>';
}
$('#chapterPlanTable tbody').html(html);
}
function collectPlanContent() {
var o;
try {
o = {
bookName: $('#pBookName').val().trim(),
sellingPoints: $('#pSelling').val().trim(),
targetReaders: $('#pReaders').val().trim(),
intro: $('#pIntro').val().trim(),
worldview: $('#pWorld').val().trim(),
characters: JSON.parse($('#pCharacters').val() || '[]'),
mainOutline: JSON.parse($('#pMainOutline').val() || '{}'),
volumes: JSON.parse($('#pVolumes').val() || '[]'),
foreshadows: JSON.parse($('#pForeshadows').val() || '[]'),
chapters: []
};
} catch (e) {
layer.msg('结构化设定JSON格式有误,请检查人物/总纲/分卷/伏笔'); return null;
}
$('#chapterPlanTable tbody tr').each(function () {
var no = parseInt($(this).data('no'));
o.chapters.push({chapterNo: no, indexName: $(this).find('.cp-name').val().trim(), outline: $(this).find('.cp-outline').val().trim()});
});
if (!o.intro || o.characters.length === 0) {
layer.msg('简介与人物档案不能为空'); return null;
}
if (o.chapters.length === 0) {
layer.msg('章节规划不能为空'); return null;
}
return o;
}
function savePlanContent() {
var o = collectPlanContent();
if (!o) { return; }
ajaxPost('/author/aiAssistant/savePlanContent', {planId: planId, planContent: JSON.stringify(o)}, function () {
planObj = o;
layer.msg('方案草稿已保存');
loadProgress();
});
}
function genMoreChapters() {
var o = collectPlanContent();
if (!o) { return; }
if (o.chapters.length >= (paramsObj.totalChapters || 60)) {
layer.msg('章节规划数已达预计总章节数'); return;
}
ajaxPost('/author/aiAssistant/savePlanContent', {planId: planId, planContent: JSON.stringify(o)}, function () {
planObj = o;
startTask(6, o.chapters.length + 1);
});
}
function appendChapters(chapters) {
var html = '';
for (var i = 0; i < chapters.length; i++) {
var c = chapters[i];
html += '<tr data-no="' + c.chapterNo + '">'
+ '<td>' + c.chapterNo + '</td>'
+ '<td><input class="t cp-name" value="' + esc(c.indexName) + '"/></td>'
+ '<td><textarea class="cp-outline">' + esc(outlineToText(c)) + '</textarea></td>'
+ '<td><a style="cursor:pointer;color:#d9534f;" onclick="$(this).closest(\'tr\').remove();">删除</a></td></tr>';
}
$('#chapterPlanTable tbody').append(html);
updatePlanTip();
layer.msg('已追加章节规划,请检查后保存/确认方案');
}
function updatePlanTip() {
var n = $('#chapterPlanTable tbody tr').length;
$('#planTip').text('当前规划章节数:' + n + '(预计 ' + (paramsObj.totalChapters || 60) + ' 章,可分批继续生成)');
}
function openConfirmDialog() {
var o = collectPlanContent();
if (!o) { return; }
if (plan.bookId) {
layer.confirm('确认方案将按最新规划重建未保存章节(已保存章节不受影响),是否继续?', {btn: ['确认', '取消']}, function (idx) {
layer.close(idx);
doConfirm(o, {});
});
return;
}
// 新建小说:选择分类与方向
loadCategories(function () {
var html = buildConfirmBookForm(o);
layer.open({
type: 1, title: '确认方案并创建小说', area: ['420px', '330px'], content: html,
btn: ['确认创建', '取消'],
yes: function (idx) {
doConfirm(o, collectConfirmBookForm());
}
});
});
}
function loadCategories(cb) {
if (categoryList.length > 0) { cb(); return; }
ajaxGet('/book/listBookCategory', {}, function (data) {
categoryList = data || []; cb();
});
}
function buildConfirmBookForm(o) {
var wd = '<select id="cWorkDirection" onchange="renderConfirmCats()"><option value="0">男频</option><option value="1">女频</option></select>';
var html = '<div style="padding:15px;">'
+ '<p style="margin-bottom:10px;">书名:<input class="t" id="cBookName" value="' + esc(o.bookName || $('#bookName').val() || '') + '" style="width:220px;"/></p>'
+ '<p style="margin-bottom:10px;">方向:' + wd + '</p>'
+ '<p style="margin-bottom:10px;">分类:<select id="cCatId"></select></p>'
+ '<p class="ai-tip">简介将使用方案中的故事简介;封面沿用现有AI自动生成流程</p></div>';
setTimeout(renderConfirmCats, 50);
return html;
}
function renderConfirmCats() {
var wd = $('#cWorkDirection').val();
var html = '';
for (var i = 0; i < categoryList.length; i++) {
var c = categoryList[i];
if (c.workDirection == wd) {
var sel = (paramsObj.genre && c.name.indexOf(paramsObj.genre) >= 0) ? ' selected' : '';
html += '<option value="' + c.id + '" data-name="' + esc(c.name) + '"' + sel + '>' + esc(c.name) + '</option>';
}
}
$('#cCatId').html(html);
}
function collectConfirmBookForm() {
var bookName = $('#cBookName').val().trim();
if (!bookName) { layer.msg('书名不能为空'); return null; }
var opt = $('#cCatId').find('option:selected');
if (!opt.length) { layer.msg('请选择分类'); return null; }
return {bookName: bookName, workDirection: $('#cWorkDirection').val(), catId: opt.val(), catName: opt.text()};
}
function doConfirm(o, bookForm) {
if (bookForm === null) { return; }
var data = {planId: planId, planContent: JSON.stringify(o)};
if (!plan.bookId && bookForm.bookName) {
$.extend(data, bookForm);
}
layer.confirm('确认后进入逐章创作阶段,是否继续?', {btn: ['继续', '取消']}, function (idx) {
layer.close(idx);
ajaxPost('/author/aiAssistant/confirmPlan', data, function () {
layer.msg('方案已确认');
loadProgress();
switchStep(3);
});
});
}
// -------- 第三步:章节创作 --------
function renderChapterList() {
if (!chapterPlans || chapterPlans.length === 0) {
$('#chapterListTable tbody').html('<tr><td colspan="5">暂无章节规划,请先在第二步确认方案</td></tr>');
return;
}
var html = '';
for (var i = 0; i < chapterPlans.length; i++) {
var c = chapterPlans[i];
var ops = '';
if (c.status != 2) {
ops += '<a class="ai-btn" style="padding:2px 8px;" onclick="genChapter(' + c.chapterNo + ')">生成正文</a>';
ops += '<a class="ai-btn gray" style="padding:2px 8px;" onclick="checkReviseNo(' + c.chapterNo + ')">校验修订</a>';
}
ops += '<a style="color:#3eaf7c;cursor:pointer;" onclick="openEditor(' + c.chapterNo + ')">编辑/查看</a>';
if (c.status == 2 && c.indexId) {
ops += ' <a href="/author/content_update.html?indexId=' + c.indexId + '&bookId=' + plan.bookId + '" target="_blank" style="color:#999;">章节管理</a>';
}
html += '<tr><td>' + c.chapterNo + '</td><td>' + esc(c.indexName || '') + '</td>'
+ '<td class="' + STATUS_CLS[c.status] + '">' + STATUS_TXT[c.status] + '</td>'
+ '<td>' + (c.wordCount || 0) + '</td><td>' + ops + '</td></tr>';
}
$('#chapterListTable tbody').html(html);
renderProgressBar();
}
function genChapter(no) {
if (plan.stage < 3) { layer.msg('请先确认创作方案'); return; }
openEditor(no);
startTask(3, no);
}
function checkReviseNo(no) {
curChapter = no;
openEditor(no, true);
checkRevise();
}
function openEditor(no, skipDraftLoad) {
curChapter = no;
var c = findChapter(no);
if (!c) { return; }
$('#editorTitle').text('第' + no + '章 ' + (c.indexName || ''));
$('#eIndexName').val(c.indexName || '');
$('#eIsVip').prop('checked', false);
$('#editorArea').show();
if (!skipDraftLoad) { loadDraftIntoEditor(no, null); }
if (c.status == 2) {
// 已保存章节:只读展示正式正文
ajaxGet('/author/queryIndexContent/' + c.indexId, {}, function (content) {
$('#eContent').val(content);
$('#eMeta').html('该章节已保存(' + (c.wordCount || 0) + '字),如需修改请再次保存将产生新章节,建议直接使用章节管理更新');
});
}
}
function findChapter(no) {
for (var i = 0; i < chapterPlans.length; i++) {
if (chapterPlans[i].chapterNo == no) { return chapterPlans[i]; }
}
return null;
}
function loadDraftIntoEditor(no, content) {
var c = findChapter(no);
if (content !== null && content !== undefined) {
$('#eContent').val(content);
refreshWordCount(no);
if (c && c.status == 0) { c.status = 1; renderChapterList(); }
return;
}
if (c && c.status >= 1 && c.status != 2) {
// 从最近成功任务恢复草稿(正文或校验修订结果)
ajaxGet('/author/aiAssistant/progress', {planId: planId}, function (vo) {
var tasks = vo.latestTasks || [];
for (var i = 0; i < tasks.length; i++) {
var t = tasks[i];
if (t.chapterNo == no && (t.taskType == 3 || t.taskType == 4) && t.status == 1) {
ajaxGet('/author/aiAssistant/task/' + t.id, {}, function (full) {
var content = full.taskType == 4 ? JSON.parse(full.result).content : full.result;
$('#eContent').val(content);
refreshWordCount(no);
});
return;
}
}
});
}
}
function refreshWordCount(no) {
var content = $('#eContent').val();
if (!content) { $('#eMeta').html(''); return; }
ajaxPost('/author/aiAssistant/programCheck', {content: content, targetWords: paramsObj.chapterWords || 3000}, function (problems) {
var msg = '有效字数以作者后台统计规则为准';
if (problems && problems.length > 0) {
msg = '程序校验提示:' + problems.join(';') + '<br/>(字数达标不代表质量达标,可点击“校验并修订”做AI一致性检查)';
}
$('#eMeta').html(msg);
});
}
function showCheckReport(r) {
var msg = r.passed ? '校验通过(共' + r.rounds + '轮)' : '已修订' + r.rounds + '轮仍未完全通过,请人工处理';
var issues = r.issues || [];
if (issues.length > 0) {
msg += '<br/>问题清单:';
for (var i = 0; i < issues.length; i++) {
msg += '<br/>· [' + esc(issues[i].type || '') + '] ' + esc(issues[i].description || '');
}
}
$('#eMeta').html(msg);
layer.msg(msg.replace(/<br\/>/g, '\n').replace(/·/g, ''));
}
function checkRevise() {
if (!curChapter) { layer.msg('请先选择章节'); return; }
var content = $('#eContent').val();
if (!content) { layer.msg('请先生成正文再校验'); return; }
//先把编辑器当前内容(含作者手工修改)落为服务端草稿,再以该草稿校验修订
ajaxPost('/author/aiAssistant/saveDraft', {planId: planId, chapterNo: curChapter, content: content}, function () {
startTask(4, curChapter);
});
}
function saveChapter() {
var content = $('#eContent').val();
var indexName = $('#eIndexName').val().trim();
if (!indexName || !content) { layer.msg('标题与正文不能为空'); return; }
var c = findChapter(curChapter);
if (c && c.status == 2) {
layer.msg('该章节已保存,请使用章节管理更新,避免重复创建章节'); return;
}
layer.confirm('确认保存为正式章节?保存后不可被任务恢复覆盖', {btn: ['确认保存', '取消']}, function (idx) {
layer.close(idx);
ajaxPost('/author/aiAssistant/saveChapter', {
planId: planId, chapterNo: curChapter, indexName: indexName,
content: content, isVip: $('#eIsVip').is(':checked') ? 1 : 0
}, function () {
layer.msg('已保存,创作记忆摘要将异步更新');
loadProgress();
renderChapterList();
});
});
}
function closeEditor() {
$('#editorArea').hide();
}
</script>
</html>
@@ -27,10 +27,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4" href="/author/index.html">小说管理</a></li> <li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 on" href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income on" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -27,10 +27,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4" href="/author/index.html">小说管理</a></li> <li><a class="link_book" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 on" href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income on" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
+3 -4
View File
@@ -31,10 +31,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!--<li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -28,10 +28,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!--<li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -28,8 +28,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">作品管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul> </ul>
</div> </div>
+4 -4
View File
@@ -42,10 +42,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">小说管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<!-- <li><a class="link_1 " href="/user/userinfo.html">批量小说爬取</a></li> <li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
<li><a class="link_4 " href="/user/favorites.html">单本小说爬取</a></li>-->
</ul> </ul>
</div> </div>
@@ -193,6 +192,7 @@
" <td class=\"goread\" id='opt" + book.id + "'>" + " <td class=\"goread\" id='opt" + book.id + "'>" +
"<a target='_blank' class='redBtn' href='/author/index_list.html?bookId=" + book.id + "'>章节管理 </a><br/>" + "<a target='_blank' class='redBtn' href='/author/index_list.html?bookId=" + book.id + "'>章节管理 </a><br/>" +
"<a class='redBtn' href='/author/ai_assistant.html?bookId=" + book.id + "'>AI创作 </a><br/>" +
"<a target='_blank' href='/author/author_income_detail.html?bookId=" + book.id + "'>薪酬查询 </a><br/>" + "<a target='_blank' href='/author/author_income_detail.html?bookId=" + book.id + "'>薪酬查询 </a><br/>" +
"<a target='_blank' href='/book/" + book.id + ".html'>作品信息</a>" + "<a target='_blank' href='/book/" + book.id + ".html'>作品信息</a>" +
"</td> </tr>"); "</td> </tr>");
+3 -2
View File
@@ -42,8 +42,9 @@
<div class="my_l"> <div class="my_l">
<ul class="log_list"> <ul class="log_list">
<li><a class="link_4 on" href="/author/index.html">作品管理</a></li> <li><a class="link_book on" href="/author/index.html">小说管理</a></li>
<li><a class="link_2 " href="/author/author_income_detail.html">稿费收入</a></li> <li><a class="link_ai" href="/author/ai_assistant.html">AI创作助手</a></li>
<li><a class="link_income" href="/author/author_income_detail.html">稿费收入</a></li>
</ul> </ul>
</div> </div>
+3
View File
@@ -45,6 +45,9 @@
.my_l .link_4 { background-position:32px -314px } .my_l .link_4 { background-position:32px -314px }
.my_l .link_5 { background-position:32px -356px } .my_l .link_5 { background-position:32px -356px }
.my_l .link_6 { background-position:32px -397px } .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_7 { background-position:32px -440px }
.my_l .link_8 { background-position:32px -481px } .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 } .my_r { width:739px; padding:0 30px 30px; float:right; border-left:1px solid #efefef; min-height:470px }