diff --git a/novel-common/src/main/java/com/java2nb/novel/core/advice/CustomResponseBodyAdvice.java b/novel-common/src/main/java/com/java2nb/novel/core/advice/CustomResponseBodyAdvice.java index 1d70937..fa96131 100644 --- a/novel-common/src/main/java/com/java2nb/novel/core/advice/CustomResponseBodyAdvice.java +++ b/novel-common/src/main/java/com/java2nb/novel/core/advice/CustomResponseBodyAdvice.java @@ -12,9 +12,13 @@ import org.springframework.http.server.ServerHttpResponse; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; +import java.util.Objects; + /** - * 在对 RestController 返回对象 json 格式化时,将所有 Long 类型转为 String 类型返回,避免前端数据精度丢失的问题 - * 取代 spring.jackson.generator.write-numbers-as-strings=true 配置,避免影响全局的 ObjectMapper 配置 + * 在对 RestController 返回对象 json 序列化时,将所有 Long 类型转为 String 类型返回,避免前端数据精度丢失的问题 + * 取代 spring.jackson.generator.write-numbers-as-strings=true 配置,避免影响全局的 ObjectMapper + * + * @author xiongxiaoyang * */ @RestControllerAdvice public class CustomResponseBodyAdvice implements ResponseBodyAdvice { @@ -38,7 +42,11 @@ public class CustomResponseBodyAdvice implements ResponseBodyAdvice { @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 使用自定义的 ObjectMapper 序列化响应体 - return customObjectMapper.valueToTree(body); + if(Objects.nonNull(body)) { + return customObjectMapper.valueToTree(body); + }else{ + return null; + } } } diff --git a/novel-front/src/main/java/com/java2nb/novel/controller/AuthorController.java b/novel-front/src/main/java/com/java2nb/novel/controller/AuthorController.java index 41ba628..54e24aa 100644 --- a/novel-front/src/main/java/com/java2nb/novel/controller/AuthorController.java +++ b/novel-front/src/main/java/com/java2nb/novel/controller/AuthorController.java @@ -17,7 +17,14 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; import java.util.Date; @@ -28,7 +35,7 @@ import java.util.Date; @RestController @Slf4j @RequiredArgsConstructor -public class AuthorController extends BaseController{ +public class AuthorController extends BaseController { private final AuthorService authorService; @@ -36,62 +43,64 @@ public class AuthorController extends BaseController{ private final ChatClient chatClient; + private final OpenAiChatModel chatModel; + /** * 校验笔名是否存在 - * */ + */ @GetMapping("checkPenName") - public RestResult checkPenName(String penName){ + public RestResult checkPenName(String penName) { return RestResult.ok(authorService.checkPenName(penName)); } /** * 作家发布小说分页列表查询 - * */ + */ @GetMapping("listBookByPage") - public RestResult> listBookByPage(@RequestParam(value = "curr", defaultValue = "1") int page, @RequestParam(value = "limit", defaultValue = "10") int pageSize , HttpServletRequest request){ + public RestResult> listBookByPage(@RequestParam(value = "curr", defaultValue = "1") int page, + @RequestParam(value = "limit", defaultValue = "10") int pageSize, HttpServletRequest request) { - return RestResult.ok(bookService.listBookPageByUserId(getUserDetails(request).getId(),page,pageSize)); + return RestResult.ok(bookService.listBookPageByUserId(getUserDetails(request).getId(), page, pageSize)); } /** * 发布小说 - * */ + */ @PostMapping("addBook") - public RestResult addBook(@RequestParam("bookDesc") String bookDesc, Book book, HttpServletRequest request){ + public RestResult addBook(@RequestParam("bookDesc") String bookDesc, Book book, HttpServletRequest request) { Author author = checkAuthor(request); //bookDesc不能使用book对象来接收,否则会自动去掉前面的空格 book.setBookDesc(bookDesc - .replaceAll("\\n","
") - .replaceAll("\\s"," ")); + .replaceAll("\\n", "
") + .replaceAll("\\s", " ")); //发布小说 - bookService.addBook(book,author.getId(),author.getPenName()); + bookService.addBook(book, author.getId(), author.getPenName()); return RestResult.ok(); } /** * 更新小说状态,上架或下架 - * */ + */ @PostMapping("updateBookStatus") - public RestResult updateBookStatus(Long bookId,Byte status,HttpServletRequest request){ + public RestResult updateBookStatus(Long bookId, Byte status, HttpServletRequest request) { Author author = checkAuthor(request); //更新小说状态,上架或下架 - bookService.updateBookStatus(bookId,status,author.getId()); + bookService.updateBookStatus(bookId, status, author.getId()); return RestResult.ok(); } - /** * 删除章节 */ @DeleteMapping("deleteIndex/{indexId}") - public RestResult deleteIndex(@PathVariable("indexId") Long indexId, HttpServletRequest request) { + public RestResult deleteIndex(@PathVariable("indexId") Long indexId, HttpServletRequest request) { Author author = checkAuthor(request); @@ -105,7 +114,7 @@ public class AuthorController extends BaseController{ * 更新章节名 */ @PostMapping("updateIndexName") - public RestResult updateIndexName(Long indexId, String indexName, HttpServletRequest request) { + public RestResult updateIndexName(Long indexId, String indexName, HttpServletRequest request) { Author author = checkAuthor(request); @@ -116,19 +125,18 @@ public class AuthorController extends BaseController{ } - - /** * 发布章节内容 */ @PostMapping("addBookContent") - public RestResult addBookContent(Long bookId, String indexName, String content,Byte isVip, HttpServletRequest request) { + public RestResult addBookContent(Long bookId, String indexName, String content, Byte isVip, + HttpServletRequest request) { Author author = checkAuthor(request); content = content.replaceAll("\\n", "
") - .replaceAll("\\s", " "); + .replaceAll("\\s", " "); //发布章节内容 - bookService.addBookContent(bookId, indexName, content,isVip, author.getId()); + bookService.addBookContent(bookId, indexName, content, isVip, author.getId()); return RestResult.ok(); } @@ -137,14 +145,14 @@ public class AuthorController extends BaseController{ * 查询章节内容 */ @GetMapping("queryIndexContent/{indexId}") - public RestResult queryIndexContent(@PathVariable("indexId") Long indexId, HttpServletRequest request) { + public RestResult queryIndexContent(@PathVariable("indexId") Long indexId, HttpServletRequest request) { Author author = checkAuthor(request); String content = bookService.queryIndexContent(indexId, author.getId()); content = content.replaceAll("
", "\n") - .replaceAll(" ", " "); + .replaceAll(" ", " "); return RestResult.ok(content); } @@ -153,11 +161,12 @@ public class AuthorController extends BaseController{ * 更新章节内容 */ @PostMapping("updateBookContent") - public RestResult updateBookContent(Long indexId, String indexName, String content, HttpServletRequest request) { + public RestResult updateBookContent(Long indexId, String indexName, String content, + HttpServletRequest request) { Author author = checkAuthor(request); content = content.replaceAll("\\n", "
") - .replaceAll("\\s", " "); + .replaceAll("\\s", " "); //更新章节内容 bookService.updateBookContent(indexId, indexName, content, author.getId()); @@ -168,38 +177,44 @@ public class AuthorController extends BaseController{ * 修改小说封面 */ @PostMapping("updateBookPic") - public RestResult updateBookPic(@RequestParam("bookId") Long bookId,@RequestParam("bookPic") String bookPic,HttpServletRequest request) { + public RestResult updateBookPic(@RequestParam("bookId") Long bookId, @RequestParam("bookPic") String bookPic, + HttpServletRequest request) { Author author = checkAuthor(request); - bookService.updateBookPic(bookId,bookPic, author.getId()); + bookService.updateBookPic(bookId, bookPic, author.getId()); return RestResult.ok(); } /** * 作家日收入统计数据分页列表查询 - * */ + */ @GetMapping("listIncomeDailyByPage") - public RestResult> listIncomeDailyByPage(@RequestParam(value = "curr", defaultValue = "1") int page, - @RequestParam(value = "limit", defaultValue = "10") int pageSize , - @RequestParam(value = "bookId", defaultValue = "0") Long bookId, - @RequestParam(value = "startTime",defaultValue = "2020-05-01") Date startTime, - @RequestParam(value = "endTime",defaultValue = "2030-01-01") Date endTime, - HttpServletRequest request){ + public RestResult> listIncomeDailyByPage( + @RequestParam(value = "curr", defaultValue = "1") int page, + @RequestParam(value = "limit", defaultValue = "10") int pageSize, + @RequestParam(value = "bookId", defaultValue = "0") Long bookId, + @RequestParam(value = "startTime", defaultValue = "2020-05-01") Date startTime, + @RequestParam(value = "endTime", defaultValue = "2030-01-01") Date endTime, + HttpServletRequest request) { - return RestResult.ok(authorService.listIncomeDailyByPage(page,pageSize,getUserDetails(request).getId(),bookId,startTime,endTime)); + return RestResult.ok( + authorService.listIncomeDailyByPage(page, pageSize, getUserDetails(request).getId(), bookId, startTime, + endTime)); } /** * 作家月收入统计数据分页列表查询 - * */ + */ @GetMapping("listIncomeMonthByPage") - public RestResult> listIncomeMonthByPage(@RequestParam(value = "curr", defaultValue = "1") int page, - @RequestParam(value = "limit", defaultValue = "10") int pageSize , - @RequestParam(value = "bookId", defaultValue = "0") Long bookId, - HttpServletRequest request){ + public RestResult> listIncomeMonthByPage( + @RequestParam(value = "curr", defaultValue = "1") int page, + @RequestParam(value = "limit", defaultValue = "10") int pageSize, + @RequestParam(value = "bookId", defaultValue = "0") Long bookId, + HttpServletRequest request) { - return RestResult.ok(authorService.listIncomeMonthByPage(page,pageSize,getUserDetails(request).getId(),bookId)); + return RestResult.ok( + authorService.listIncomeMonthByPage(page, pageSize, getUserDetails(request).getId(), bookId)); } private Author checkAuthor(HttpServletRequest request) { @@ -218,7 +233,6 @@ public class AuthorController extends BaseController{ throw new BusinessException(ResponseStatus.AUTHOR_STATUS_FORBIDDEN); } - return author; @@ -229,11 +243,11 @@ public class AuthorController extends BaseController{ */ @PostMapping("ai/expand") public RestResult expandText(@RequestParam("text") String text, @RequestParam("ratio") Double ratio) { - String prompt = "请将以下文本扩写为原长度的" + ratio/100 + "倍:" + text; + String prompt = "请将以下文本扩写为原长度的" + ratio / 100 + "倍:" + text; return RestResult.ok(chatClient.prompt() - .user(prompt) - .call() - .content()); + .user(prompt) + .call() + .content()); } /** @@ -241,11 +255,11 @@ public class AuthorController extends BaseController{ */ @PostMapping("ai/condense") public RestResult condenseText(@RequestParam("text") String text, @RequestParam("ratio") Integer ratio) { - String prompt = "请将以下文本缩写为原长度的" + 100/ratio + "分之一:" + text; + String prompt = "请将以下文本缩写为原长度的" + 100 / ratio + "分之一:" + text; return RestResult.ok(chatClient.prompt() - .user(prompt) - .call() - .content()); + .user(prompt) + .call() + .content()); } /** @@ -255,9 +269,9 @@ public class AuthorController extends BaseController{ public RestResult continueText(@RequestParam("text") String text, @RequestParam("length") Integer length) { String prompt = "请续写以下文本,续写长度约为" + length + "字:" + text; return RestResult.ok(chatClient.prompt() - .user(prompt) - .call() - .content()); + .user(prompt) + .call() + .content()); } /** @@ -267,12 +281,57 @@ public class AuthorController extends BaseController{ public RestResult polishText(@RequestParam("text") String text) { String prompt = "请润色优化以下文本,保持原意:" + text; return RestResult.ok(chatClient.prompt() - .user(prompt) - .call() - .content()); + .user(prompt) + .call() + .content()); } + /** + * AI扩写 + */ + @GetMapping(value = "ai/stream/expand", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux streamExpandText(@RequestParam("text") String text, @RequestParam("ratio") Double ratio) { + String prompt = "请将以下文本扩写为原长度的" + ratio / 100 + "倍:" + text; + return chatClient.prompt() + .user(prompt) + .stream() + .content(); + } + /** + * AI缩写 + */ + @GetMapping(value = "ai/stream/condense", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux streamCondenseText(@RequestParam("text") String text, @RequestParam("ratio") Integer ratio) { + String prompt = "请将以下文本缩写为原长度的" + 100 / ratio + "分之一:" + text; + return chatClient.prompt() + .user(prompt) + .stream() + .content(); + } + /** + * AI续写 + */ + @GetMapping(value = "ai/stream/continue", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux streamContinueText(@RequestParam("text") String text, @RequestParam("length") Integer length) { + String prompt = "请续写以下文本,续写长度约为" + length + "字:" + text; + return chatClient.prompt() + .user(prompt) + .stream() + .content(); + } + + /** + * AI润色 + */ + @GetMapping(value = "/ai/stream/polish", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux streamPolishText(@RequestParam("text") String text) { + String prompt = "请润色优化以下文本,保持原意:" + text; + return chatClient.prompt() + .user(prompt) + .stream() + .content(); + } } diff --git a/novel-front/src/main/java/com/java2nb/novel/core/config/AiConfig.java b/novel-front/src/main/java/com/java2nb/novel/core/config/AiConfig.java index 6cd0740..775066d 100644 --- a/novel-front/src/main/java/com/java2nb/novel/core/config/AiConfig.java +++ b/novel-front/src/main/java/com/java2nb/novel/core/config/AiConfig.java @@ -18,16 +18,7 @@ import org.springframework.web.client.RestClient; public class AiConfig { /** - * 目的:配置自定义的 RestClientBuilder 对象 - *

- * 原因:Spring AI 框架的 ChatClient 内部通过 RestClient(Spring Framework 6 和 Spring Boot 3 中引入) 发起 HTTP REST 请求与远程的大模型服务进行通信, - * 如果项目中没有配置自定义的 RestClientBuilder 对象, 那么在 RestClient 的自动配置类 org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration - * 中配置的 RestClientBuilder 对象会使用 Spring 容器中提供的 HttpMessageConverters, 由于本项目中配置了 spring.jackson.generator.write-numbers-as-strings - * = true, 所以 Spring 容器中的 HttpMessageConverters 在 RestClient 发起 HTTP REST 请求转换 Java 对象为 JSON 字符串时会自动将 Number 类型的 - * Java 对象属性转换为字符串而导致请求参数错误 - *

- * 示例:"temperature": 0.7 =》"temperature": "0.7" - * {"code":20015,"message":"The parameter is invalid. Please check again.","data":null} + * 配置自定义的 RestClientBuilder 对象 */ @Bean public RestClient.Builder restClientBuilder() { diff --git a/novel-front/src/main/resources/templates/author/content_add.html b/novel-front/src/main/resources/templates/author/content_add.html index 7e4c0c8..2754cad 100644 --- a/novel-front/src/main/resources/templates/author/content_add.html +++ b/novel-front/src/main/resources/templates/author/content_add.html @@ -15,7 +15,7 @@ padding: 20px; background: #fff; border-radius: 8px; - box-shadow: 0 2px 12px 0 rgba(0,0,0,.1); + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1); } /* 文本域样式 */ @@ -124,10 +124,10 @@

  • @@ -268,7 +268,7 @@ }, speed); } - $('.ai-toolbar .ai-link').click(function(e){ + $('.ai-toolbar .ai-link').click(function (e) { e.preventDefault(); // 阻止默认链接行为 const type = $(this).data('type'); const textarea = $('#bookContent'); @@ -284,27 +284,27 @@ // 参数配置 let params = {text: selectedText}; - if(type === 'expand' || type === 'condense'){ + if (type === 'stream/expand' || type === 'stream/condense') { layer.prompt({ title: '请输入比例', value: 2, btn: ['确定', '取消'], - btn2: function(){ + btn2: function () { layer.close(loading); }, - cancel: function(){ + cancel: function () { layer.close(loading); } - }, function(value, index){ - if(isNaN(Number(value)) || isNaN(parseFloat(value))){ + }, function (value, index) { + if (isNaN(Number(value)) || isNaN(parseFloat(value))) { layer.msg('请输入正确的比例'); return; } - if(type === 'expand' && value <= 1){ + if (type === 'stream/expand' && value <= 1) { layer.msg('请输入正确的比例'); return; } - if(type === 'condense' && (value <=0 || value >= 1)){ + if (type === 'stream/condense' && (value <= 0 || value >= 1)) { layer.msg('请输入正确的比例'); return; } @@ -313,19 +313,19 @@ sendRequest(type, params, loading, textarea); }); return; - }else if(type === 'continue'){ + } else if (type === 'stream/continue') { layer.prompt({ title: '请输入续写长度(字数)', value: 200, btn: ['确定', '取消'], - btn2: function(){ + btn2: function () { layer.close(loading); }, - cancel: function(){ + cancel: function () { layer.close(loading); } - }, function(value, index){ - if(!Number.isInteger(Number(value)) || value <= 0){ + }, function (value, index) { + if (!Number.isInteger(Number(value)) || value <= 0) { layer.msg('请输入正确的长度'); return; } @@ -339,26 +339,28 @@ sendRequest(type, params, loading, textarea); }); - function sendRequest(type, params, loading, textarea){ - $.ajax({ - url: '/author/ai/' + type, - type: 'POST', - data: params, - success: function(res){ - layer.close(loading); - // 将生成的内容追加到文本末尾 - if(res.code == '200'){ - const newText = "\n\n【AI生成内容】" + res.data; // 添加换行符分隔 - typeWriter(textarea, newText); // 使用打字机效果 - }else{ - layer.msg('AI内容生成失败,请稍后重试'); - } - }, - error: function(){ - layer.msg('请求失败,请稍后重试'); - layer.close(loading); - } - }); + function sendRequest(type, params, loading, textarea) { + const url = `/author/ai/${type}?text=${encodeURIComponent(params.text)}&ratio=${params.ratio}&length=${params.length}`; + const eventSource = new EventSource(url); + + // 监听消息事件 + eventSource.onmessage = function (event) { + layer.close(loading); + const data = event.data; + console.log('Received data:', data); + + textarea.val(textarea.val() + data); + // 滚动到底部 + textarea.scrollTop(textarea[0].scrollHeight); + }; + + // 监听错误事件 + eventSource.onerror = function (error) { + layer.close(loading); + console.error('EventSource failed:', error); + eventSource.close(); // 关闭连接 + }; + } diff --git a/templates/green/html/author/content_add.html b/templates/green/html/author/content_add.html index 7e4c0c8..2754cad 100644 --- a/templates/green/html/author/content_add.html +++ b/templates/green/html/author/content_add.html @@ -15,7 +15,7 @@ padding: 20px; background: #fff; border-radius: 8px; - box-shadow: 0 2px 12px 0 rgba(0,0,0,.1); + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1); } /* 文本域样式 */ @@ -124,10 +124,10 @@
  • @@ -268,7 +268,7 @@ }, speed); } - $('.ai-toolbar .ai-link').click(function(e){ + $('.ai-toolbar .ai-link').click(function (e) { e.preventDefault(); // 阻止默认链接行为 const type = $(this).data('type'); const textarea = $('#bookContent'); @@ -284,27 +284,27 @@ // 参数配置 let params = {text: selectedText}; - if(type === 'expand' || type === 'condense'){ + if (type === 'stream/expand' || type === 'stream/condense') { layer.prompt({ title: '请输入比例', value: 2, btn: ['确定', '取消'], - btn2: function(){ + btn2: function () { layer.close(loading); }, - cancel: function(){ + cancel: function () { layer.close(loading); } - }, function(value, index){ - if(isNaN(Number(value)) || isNaN(parseFloat(value))){ + }, function (value, index) { + if (isNaN(Number(value)) || isNaN(parseFloat(value))) { layer.msg('请输入正确的比例'); return; } - if(type === 'expand' && value <= 1){ + if (type === 'stream/expand' && value <= 1) { layer.msg('请输入正确的比例'); return; } - if(type === 'condense' && (value <=0 || value >= 1)){ + if (type === 'stream/condense' && (value <= 0 || value >= 1)) { layer.msg('请输入正确的比例'); return; } @@ -313,19 +313,19 @@ sendRequest(type, params, loading, textarea); }); return; - }else if(type === 'continue'){ + } else if (type === 'stream/continue') { layer.prompt({ title: '请输入续写长度(字数)', value: 200, btn: ['确定', '取消'], - btn2: function(){ + btn2: function () { layer.close(loading); }, - cancel: function(){ + cancel: function () { layer.close(loading); } - }, function(value, index){ - if(!Number.isInteger(Number(value)) || value <= 0){ + }, function (value, index) { + if (!Number.isInteger(Number(value)) || value <= 0) { layer.msg('请输入正确的长度'); return; } @@ -339,26 +339,28 @@ sendRequest(type, params, loading, textarea); }); - function sendRequest(type, params, loading, textarea){ - $.ajax({ - url: '/author/ai/' + type, - type: 'POST', - data: params, - success: function(res){ - layer.close(loading); - // 将生成的内容追加到文本末尾 - if(res.code == '200'){ - const newText = "\n\n【AI生成内容】" + res.data; // 添加换行符分隔 - typeWriter(textarea, newText); // 使用打字机效果 - }else{ - layer.msg('AI内容生成失败,请稍后重试'); - } - }, - error: function(){ - layer.msg('请求失败,请稍后重试'); - layer.close(loading); - } - }); + function sendRequest(type, params, loading, textarea) { + const url = `/author/ai/${type}?text=${encodeURIComponent(params.text)}&ratio=${params.ratio}&length=${params.length}`; + const eventSource = new EventSource(url); + + // 监听消息事件 + eventSource.onmessage = function (event) { + layer.close(loading); + const data = event.data; + console.log('Received data:', data); + + textarea.val(textarea.val() + data); + // 滚动到底部 + textarea.scrollTop(textarea[0].scrollHeight); + }; + + // 监听错误事件 + eventSource.onerror = function (error) { + layer.close(loading); + console.error('EventSource failed:', error); + eventSource.close(); // 关闭连接 + }; + }