feat(author): 新增AI创作助手
This commit is contained in:
1 parent
2c92b10684
commit
15716638f5
62 files changed
+5310
-80
No files matched your search
@@ -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创作」继续补充设定与续写。
|
||||||
|
|
||||||
|
[](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 模型。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|||||||
@@ -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 '小说封面';
|
||||||
|
|
||||||
@@ -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(" ", " ").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", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储格式还原为纯文本(供模型阅读与摘要)
|
||||||
|
*/
|
||||||
|
private String unescapeContent(String content) {
|
||||||
|
return content == null ? "" : content.replace("<br>", "\n").replace(" ", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- 第二步:方案编辑 --------
|
||||||
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- 第二步:方案编辑 --------
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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>");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- 第二步:方案编辑 --------
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in new issue
Block a user