mirror of
https://github.com/201206030/novel-plus.git
synced 2025-04-26 09:20:50 +00:00
feat: 集成 Spring AI 实现基础的 AI 写作功能
This commit is contained in:
parent
d77ce5b446
commit
467290b908
@ -55,6 +55,12 @@
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- AI -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
@ -12,9 +12,11 @@ import com.java2nb.novel.entity.AuthorIncomeDetail;
|
||||
import com.java2nb.novel.entity.Book;
|
||||
import com.java2nb.novel.service.AuthorService;
|
||||
import com.java2nb.novel.service.BookService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Date;
|
||||
@ -32,6 +34,8 @@ public class AuthorController extends BaseController{
|
||||
|
||||
private final BookService bookService;
|
||||
|
||||
private final ChatClient chatClient;
|
||||
|
||||
/**
|
||||
* 校验笔名是否存在
|
||||
* */
|
||||
@ -220,6 +224,54 @@ public class AuthorController extends BaseController{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* AI扩写
|
||||
*/
|
||||
@PostMapping("ai/expand")
|
||||
public RestResult<String> expandText(@RequestParam("text") String text, @RequestParam("ratio") Double ratio) {
|
||||
String prompt = "请将以下文本扩写为原长度的" + ratio/100 + "倍:" + text;
|
||||
return RestResult.ok(chatClient.prompt()
|
||||
.user(prompt)
|
||||
.call()
|
||||
.content());
|
||||
}
|
||||
|
||||
/**
|
||||
* AI缩写
|
||||
*/
|
||||
@PostMapping("ai/condense")
|
||||
public RestResult<String> condenseText(@RequestParam("text") String text, @RequestParam("ratio") Integer ratio) {
|
||||
String prompt = "请将以下文本缩写为原长度的" + 100/ratio + "分之一:" + text;
|
||||
return RestResult.ok(chatClient.prompt()
|
||||
.user(prompt)
|
||||
.call()
|
||||
.content());
|
||||
}
|
||||
|
||||
/**
|
||||
* AI续写
|
||||
*/
|
||||
@PostMapping("ai/continue")
|
||||
public RestResult<String> continueText(@RequestParam("text") String text, @RequestParam("length") Integer length) {
|
||||
String prompt = "请续写以下文本,续写长度约为" + length + "字:" + text;
|
||||
return RestResult.ok(chatClient.prompt()
|
||||
.user(prompt)
|
||||
.call()
|
||||
.content());
|
||||
}
|
||||
|
||||
/**
|
||||
* AI润色
|
||||
*/
|
||||
@PostMapping("ai/polish")
|
||||
public RestResult<String> polishText(@RequestParam("text") String text) {
|
||||
String prompt = "请润色优化以下文本,保持原意:" + text;
|
||||
return RestResult.ok(chatClient.prompt()
|
||||
.user(prompt)
|
||||
.call()
|
||||
.content());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,47 @@
|
||||
package com.java2nb.novel.core.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Ai 相关配置
|
||||
*
|
||||
* @author xiongxiaoyang
|
||||
* @date 2025/2/19
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class AiConfig {
|
||||
|
||||
/**
|
||||
* 目的:配置自定义的 RestClientBuilder 对象
|
||||
* <p>
|
||||
* 原因: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 对象属性转换为字符串而导致请求参数错误
|
||||
* <p>
|
||||
* 示例:"temperature": 0.7 =》"temperature": "0.7"
|
||||
* {"code":20015,"message":"The parameter is invalid. Please check again.","data":null}
|
||||
*/
|
||||
@Bean
|
||||
public RestClient.Builder restClientBuilder() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
// 连接超时时间
|
||||
factory.setConnectTimeout(5000);
|
||||
// 读取超时时间
|
||||
factory.setReadTimeout(60000);
|
||||
return RestClient.builder().requestFactory(factory);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ChatClient chatClient(ChatClient.Builder chatClientBuilder) {
|
||||
return chatClientBuilder.build();
|
||||
}
|
||||
|
||||
}
|
@ -46,7 +46,15 @@ book:
|
||||
value: 5
|
||||
|
||||
|
||||
|
||||
--- #--------------------- Spring AI 配置----------------------
|
||||
spring:
|
||||
ai:
|
||||
openai:
|
||||
api-key: sk-nnhjmxuljagcuubbovjztbhkiawqaabzziazeurppinxtgva
|
||||
base-url: https://api.siliconflow.cn
|
||||
chat:
|
||||
options:
|
||||
model: deepseek-ai/DeepSeek-R1-Distill-Llama-8B
|
||||
|
||||
|
||||
|
||||
|
@ -8,6 +8,79 @@
|
||||
<title>作家管理系统-小说精品屋</title>
|
||||
<link rel="stylesheet" href="/css/base.css?v=1"/>
|
||||
<link rel="stylesheet" href="/css/user.css"/>
|
||||
<style>
|
||||
/* 编辑器容器样式 */
|
||||
.editor-container {
|
||||
margin: 10px 0px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
/* 文本域样式 */
|
||||
#bookContent {
|
||||
width: 93%;
|
||||
height: 400px;
|
||||
padding: 15px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* 工具栏样式 */
|
||||
.ai-toolbar {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 10px; /* 按钮间距 */
|
||||
}
|
||||
|
||||
/* 自定义链接按钮样式 */
|
||||
.ai-link {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: linear-gradient(135deg, #6a11cb, #2575fc);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 链接按钮悬停效果 */
|
||||
.ai-link:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 链接按钮点击效果 */
|
||||
.ai-link:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 不同按钮的颜色 */
|
||||
.ai-link.expand {
|
||||
background: linear-gradient(135deg, #ff9a9e, #fad0c4);
|
||||
}
|
||||
|
||||
.ai-link.condense {
|
||||
background: linear-gradient(135deg, #a18cd1, #fbc2eb);
|
||||
}
|
||||
|
||||
.ai-link.continue {
|
||||
background: linear-gradient(135deg, #f6d365, #fda085);
|
||||
}
|
||||
|
||||
.ai-link.polish {
|
||||
background: linear-gradient(135deg, #ff6f61, #ffcc00);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</head>
|
||||
<body class="">
|
||||
@ -46,17 +119,28 @@
|
||||
<ul class="log_list">
|
||||
<li><span id="LabErr"></span></li>
|
||||
<b>章节名:</b>
|
||||
<li><input type="text" id="bookIndex" name="bookIndex" class="s_input" ></li>
|
||||
<b>章节内容:</b><li id="contentLi">
|
||||
<textarea name="bookContent" rows="30" cols="80" id="bookContent"
|
||||
class="textarea"></textarea></li><br/>
|
||||
<li><input type="text" id="bookIndex" name="bookIndex" class="s_input"></li>
|
||||
<b>章节内容:</b>
|
||||
<li id="contentLi" style="width: 500px">
|
||||
<div class="editor-container">
|
||||
<div class="ai-toolbar">
|
||||
<a class="ai-link expand" data-type="expand">AI扩写</a>
|
||||
<a class="ai-link condense" data-type="condense">AI缩写</a>
|
||||
<a class="ai-link continue" data-type="continue">AI续写</a>
|
||||
<a class="ai-link polish" data-type="polish">AI润色</a>
|
||||
</div>
|
||||
<textarea id="bookContent" name="bookContent"
|
||||
placeholder="请输入文本内容..."></textarea>
|
||||
</div>
|
||||
|
||||
<b>是否收费:</b>
|
||||
<li><input type="radio" name="isVip" value="0" checked >免费
|
||||
<input type="radio" name="isVip" value="1" >收费</li>
|
||||
<li><input type="radio" name="isVip" value="0" checked>免费
|
||||
<input type="radio" name="isVip" value="1">收费
|
||||
</li>
|
||||
|
||||
|
||||
<li style="margin-top: 10px"><input type="button" onclick="addBookContent()" name="btnRegister" value="提交"
|
||||
<li style="margin-top: 10px"><input type="button" onclick="addBookContent()"
|
||||
name="btnRegister" value="提交"
|
||||
id="btnRegister" class="btn_red">
|
||||
|
||||
|
||||
@ -113,9 +197,10 @@
|
||||
|
||||
|
||||
var lock = false;
|
||||
|
||||
function addBookContent() {
|
||||
|
||||
if(lock){
|
||||
if (lock) {
|
||||
return;
|
||||
}
|
||||
lock = true;
|
||||
@ -125,14 +210,14 @@
|
||||
|
||||
|
||||
var indexName = $("#bookIndex").val();
|
||||
if(!indexName){
|
||||
if (!indexName) {
|
||||
$("#LabErr").html("章节名不能为空!");
|
||||
lock = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var content = $("#bookContent").val();
|
||||
if(!content){
|
||||
if (!content) {
|
||||
$("#LabErr").html("章节内容不能为空!");
|
||||
lock = false;
|
||||
return;
|
||||
@ -142,17 +227,15 @@
|
||||
var isVip = $("input:checked[name=isVip]").val();
|
||||
|
||||
|
||||
|
||||
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/author/addBookContent",
|
||||
data: {'bookId':bookId,'indexName':indexName,'content':content,'isVip':isVip},
|
||||
data: {'bookId': bookId, 'indexName': indexName, 'content': content, 'isVip': isVip},
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
if (data.code == 200) {
|
||||
|
||||
window.location.href = '/author/index_list.html?bookId='+bookId;
|
||||
window.location.href = '/author/index_list.html?bookId=' + bookId;
|
||||
|
||||
|
||||
} else {
|
||||
@ -169,5 +252,110 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
// 打字机效果函数
|
||||
function typeWriter(textarea, text, speed = 50) {
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (i < text.length) {
|
||||
textarea.val(textarea.val() + text.charAt(i));
|
||||
i++;
|
||||
// 滚动到底部
|
||||
textarea.scrollTop(textarea[0].scrollHeight);
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, speed);
|
||||
}
|
||||
|
||||
$('.ai-toolbar .ai-link').click(function(e){
|
||||
e.preventDefault(); // 阻止默认链接行为
|
||||
const type = $(this).data('type');
|
||||
const textarea = $('#bookContent');
|
||||
const selectedText = textarea.val().substring(textarea[0].selectionStart, textarea[0].selectionEnd);
|
||||
|
||||
// 检查是否选中文本
|
||||
if (!selectedText) {
|
||||
layer.msg('请先选中要处理的文本');
|
||||
return;
|
||||
}
|
||||
|
||||
const loading = layer.load(1, {shade: 0.3});
|
||||
|
||||
// 参数配置
|
||||
let params = {text: selectedText};
|
||||
if(type === 'expand' || type === 'condense'){
|
||||
layer.prompt({
|
||||
title: '请输入比例',
|
||||
value: 2,
|
||||
btn: ['确定', '取消'],
|
||||
btn2: function(){
|
||||
layer.close(loading);
|
||||
},
|
||||
cancel: function(){
|
||||
layer.close(loading);
|
||||
}
|
||||
}, function(value, index){
|
||||
if(isNaN(Number(value)) || isNaN(parseFloat(value))){
|
||||
layer.msg('请输入正确的比例');
|
||||
return;
|
||||
}
|
||||
if(type === 'expand' && value <= 1){
|
||||
layer.msg('请输入正确的比例');
|
||||
return;
|
||||
}
|
||||
if(type === 'condense' && (value <=0 || value >= 1)){
|
||||
layer.msg('请输入正确的比例');
|
||||
return;
|
||||
}
|
||||
params.ratio = parseFloat(value) * 100;
|
||||
layer.close(index);
|
||||
sendRequest(type, params, loading, textarea);
|
||||
});
|
||||
return;
|
||||
}else if(type === 'continue'){
|
||||
layer.prompt({
|
||||
title: '请输入续写长度(字数)',
|
||||
value: 200,
|
||||
btn: ['确定', '取消'],
|
||||
btn2: function(){
|
||||
layer.close(loading);
|
||||
},
|
||||
cancel: function(){
|
||||
layer.close(loading);
|
||||
}
|
||||
}, function(value, index){
|
||||
if(!Number.isInteger(Number(value)) || value <= 0){
|
||||
layer.msg('请输入正确的长度');
|
||||
return;
|
||||
}
|
||||
params.length = parseInt(value);
|
||||
layer.close(index);
|
||||
sendRequest(type, params, loading, textarea);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
// 将生成的内容追加到文本末尾
|
||||
const newText = "\n\n" + res.data; // 添加换行符分隔
|
||||
typeWriter(textarea, newText); // 使用打字机效果
|
||||
},
|
||||
error: function(){
|
||||
layer.msg('请求失败,请稍后重试');
|
||||
layer.close(loading);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
</html>
|
||||
|
7
pom.xml
7
pom.xml
@ -53,6 +53,13 @@
|
||||
<artifactId>novel-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>1.0.0-M6</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user