feat(chat): 新增聊天润色与向量搜索接口
- ChatController 提供 /chat/talk SSE 流式对话,融合 LLM 输出与 Qdrant 向量检索 - 新增 ChatReq、ChatStreamMessage 等 DTO 与 Service 骨架 - 调整向量维度与集合名称,开放跨域并补充错误码
This commit is contained in:
2
pom.xml
2
pom.xml
@@ -59,7 +59,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.qdrant</groupId>
|
<groupId>io.qdrant</groupId>
|
||||||
<artifactId>client</artifactId>
|
<artifactId>client</artifactId>
|
||||||
<version>1.15.0</version>
|
<version>1.16.1</version>
|
||||||
<exclusions>
|
<exclusions>
|
||||||
<exclusion>
|
<exclusion>
|
||||||
<artifactId>guava</artifactId>
|
<artifactId>guava</artifactId>
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ public enum ErrorCode {
|
|||||||
USER_CHARACTER_DEL_ERROR(50007, "删除用户人设失败"),
|
USER_CHARACTER_DEL_ERROR(50007, "删除用户人设失败"),
|
||||||
VERIFY_CODE_ERROR(50008, "验证码错误"),
|
VERIFY_CODE_ERROR(50008, "验证码错误"),
|
||||||
REPEATEDLY_ADDING_CHARACTER(50009, "重复添加键盘人设"),
|
REPEATEDLY_ADDING_CHARACTER(50009, "重复添加键盘人设"),
|
||||||
MAIL_SEND_BUSY(50010,"邮件发送频繁,1分钟后再试" );
|
MAIL_SEND_BUSY(50010,"邮件发送频繁,1分钟后再试" ),
|
||||||
|
PASSWORD_CAN_NOT_NULL(50011, "密码不能为空" ),
|
||||||
|
USER_HAS_EXISTED(50012, "用户已存在" );
|
||||||
/**
|
/**
|
||||||
* 状态码
|
* 状态码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class LLMConfig {
|
|||||||
MetadataMode.EMBED,
|
MetadataMode.EMBED,
|
||||||
OpenAiEmbeddingOptions.builder()
|
OpenAiEmbeddingOptions.builder()
|
||||||
.model("qwen/qwen3-embedding-8b")
|
.model("qwen/qwen3-embedding-8b")
|
||||||
.dimensions(2048)
|
.dimensions(1536)
|
||||||
.user("user-6")
|
.user("user-6")
|
||||||
.build(),
|
.build(),
|
||||||
RetryUtils.DEFAULT_RETRY_TEMPLATE);
|
RetryUtils.DEFAULT_RETRY_TEMPLATE);
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/character/listByTagWithNotLogin",
|
"/character/listByTagWithNotLogin",
|
||||||
"/character/detailWithNotLogin",
|
"/character/detailWithNotLogin",
|
||||||
"/character/addUserCharacter",
|
"/character/addUserCharacter",
|
||||||
"/api/apple/validate-receipt"
|
"/api/apple/validate-receipt",
|
||||||
|
"/character/list",
|
||||||
|
"/user/resetPassWord",
|
||||||
|
"/chat/talk"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
104
src/main/java/com/yolo/keyborad/controller/ChatController.java
Normal file
104
src/main/java/com/yolo/keyborad/controller/ChatController.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package com.yolo.keyborad.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
||||||
|
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardCharacter;
|
||||||
|
import com.yolo.keyborad.service.KeyboardCharacterService;
|
||||||
|
import com.yolo.keyborad.service.impl.QdrantVectorService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
|
import org.springframework.ai.openai.OpenAiEmbeddingModel;
|
||||||
|
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||||
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/8 15:05
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/chat")
|
||||||
|
@Slf4j
|
||||||
|
@CrossOrigin
|
||||||
|
@Tag(name = "聊天", description = "聊天接口")
|
||||||
|
public class ChatController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ChatClient client;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OpenAiEmbeddingModel embeddingModel;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private QdrantVectorService qdrantVectorService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardCharacterService keyboardCharacterService;
|
||||||
|
|
||||||
|
|
||||||
|
@PostMapping("/talk")
|
||||||
|
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
|
||||||
|
@Parameter(name = "userInput",required = true,description = "测试聊天接口",example = "talk to something")
|
||||||
|
public Flux<ServerSentEvent<ChatStreamMessage>> testTalk(@RequestBody ChatReq chatReq){
|
||||||
|
|
||||||
|
KeyboardCharacter character = keyboardCharacterService.getById(chatReq.getCharacterId());
|
||||||
|
|
||||||
|
// 1. LLM 流式输出
|
||||||
|
Flux<ChatStreamMessage> llmFlux = client
|
||||||
|
.prompt(character.getPrompt() +
|
||||||
|
"\nUser message: %s".formatted(chatReq.getMessage()))
|
||||||
|
.system("""
|
||||||
|
Format rules:
|
||||||
|
- Return EXACTLY 3 replies.
|
||||||
|
- Use "<SPLIT>" as the separator.
|
||||||
|
- reply1<SPLIT>reply2<SPLIT>reply3
|
||||||
|
""")
|
||||||
|
.user(chatReq.getMessage())
|
||||||
|
.options(OpenAiChatOptions.builder()
|
||||||
|
.user(StpUtil.getLoginIdAsString())
|
||||||
|
.build())
|
||||||
|
.stream()
|
||||||
|
.content()
|
||||||
|
.map(chunk -> new ChatStreamMessage("llm_chunk", chunk));
|
||||||
|
|
||||||
|
// 2. 向量搜索Flux(一次性发送搜索结果)
|
||||||
|
Flux<ChatStreamMessage> searchFlux = Mono
|
||||||
|
.fromCallable(() -> qdrantVectorService.searchText(chatReq.getMessage()))
|
||||||
|
.subscribeOn(Schedulers.boundedElastic()) // 避免阻塞 event-loop
|
||||||
|
.map(list -> new ChatStreamMessage("search_result", list))
|
||||||
|
.flux();
|
||||||
|
// 3. 结束标记
|
||||||
|
Flux<ChatStreamMessage> doneFlux =
|
||||||
|
Flux.just(new ChatStreamMessage("done", null));
|
||||||
|
|
||||||
|
// 4. 合并所有Flux
|
||||||
|
Flux<ChatStreamMessage> merged =
|
||||||
|
Flux.merge(llmFlux, searchFlux)
|
||||||
|
.concatWith(doneFlux);
|
||||||
|
|
||||||
|
// 5. SSE 包装
|
||||||
|
return merged.map(msg ->
|
||||||
|
ServerSentEvent.builder(msg)
|
||||||
|
.event(msg.getType())
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@PostMapping("/save_embed")
|
||||||
|
@Operation(summary = "保存润色后的句子", description = "保存润色后的句子")
|
||||||
|
@Parameter(name = "userInput",required = true,description = "测试聊天接口",example = "talk to something")
|
||||||
|
public Flux<String> testTalkWithVector(@RequestBody ChatReq chatReq) {
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,8 +62,6 @@ public class DemoController {
|
|||||||
@Operation(summary = "测试聊天接口", description = "测试接口")
|
@Operation(summary = "测试聊天接口", description = "测试接口")
|
||||||
@Parameter(name = "userInput",required = true,description = "测试聊天接口",example = "talk to something")
|
@Parameter(name = "userInput",required = true,description = "测试聊天接口",example = "talk to something")
|
||||||
public Flux<String> testTalk(@DefaultValue("you are so cute!") String userInput){
|
public Flux<String> testTalk(@DefaultValue("you are so cute!") String userInput){
|
||||||
String delimiter = "/t";
|
|
||||||
|
|
||||||
return client
|
return client
|
||||||
.prompt("""
|
.prompt("""
|
||||||
You're a 25-year-old guy—witty and laid-back, always replying in English.
|
You're a 25-year-old guy—witty and laid-back, always replying in English.
|
||||||
@@ -83,7 +81,7 @@ public class DemoController {
|
|||||||
""")
|
""")
|
||||||
.user(userInput)
|
.user(userInput)
|
||||||
.options(OpenAiChatOptions.builder()
|
.options(OpenAiChatOptions.builder()
|
||||||
.user(StpUtil.getLoginIdAsString()) // ✅ 这里每次请求都会重新取当前登录用户
|
.user(StpUtil.getLoginIdAsString())// ✅ 这里每次请求都会重新取当前登录用户
|
||||||
.build())
|
.build())
|
||||||
.stream()
|
.stream()
|
||||||
.content();
|
.content();
|
||||||
@@ -99,12 +97,6 @@ public class DemoController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Operation(summary = "IOS内购凭证校验", description = "IOS内购凭证校验")
|
|
||||||
public BaseResponse<String> iosPay(@RequestBody IosPayVerifyReq req) {
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/testSaveEmbed")
|
@PostMapping("/testSaveEmbed")
|
||||||
@Operation(summary = "测试存储向量接口", description = "测试存储向量接口")
|
@Operation(summary = "测试存储向量接口", description = "测试存储向量接口")
|
||||||
@Parameter(name = "userInput",required = true,description = "测试存储向量接口")
|
@Parameter(name = "userInput",required = true,description = "测试存储向量接口")
|
||||||
|
|||||||
20
src/main/java/com/yolo/keyborad/model/dto/chat/ChatReq.java
Normal file
20
src/main/java/com/yolo/keyborad/model/dto/chat/ChatReq.java
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.yolo.keyborad.model.dto.chat;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/8 15:12
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ChatReq {
|
||||||
|
|
||||||
|
@Schema(description="键盘人设 id")
|
||||||
|
private Long characterId;
|
||||||
|
|
||||||
|
@Schema(description="用户输入")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.yolo.keyborad.model.dto.chat;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ChatStreamMessage {
|
||||||
|
private String type; // "llm_chunk" / "search_result" / "done"
|
||||||
|
private Object data;
|
||||||
|
}
|
||||||
8
src/main/java/com/yolo/keyborad/service/ChatService.java
Normal file
8
src/main/java/com/yolo/keyborad/service/ChatService.java
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/8 15:16
|
||||||
|
*/
|
||||||
|
public interface ChatService {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.service.ChatService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/8 15:17
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ChatServiceImpl implements ChatService {
|
||||||
|
}
|
||||||
@@ -106,9 +106,10 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
|
|||||||
KeyboardUserCharacter keyboardUserCharacter = BeanUtil.copyProperties(addDTO, KeyboardUserCharacter.class);
|
KeyboardUserCharacter keyboardUserCharacter = BeanUtil.copyProperties(addDTO, KeyboardUserCharacter.class);
|
||||||
long userId = StpUtil.getLoginIdAsLong();
|
long userId = StpUtil.getLoginIdAsLong();
|
||||||
keyboardUserCharacter.setUserId(userId);
|
keyboardUserCharacter.setUserId(userId);
|
||||||
KeyboardUserCharacter alreadyExistsCharacter = keyboardUserCharacterMapper.selectOne(new LambdaQueryWrapper<KeyboardUserCharacter>()
|
KeyboardUserCharacter alreadyExistsCharacter = keyboardUserCharacterMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<KeyboardUserCharacter>()
|
||||||
.eq(KeyboardUserCharacter::getCharacterId, addDTO.getCharacterId())
|
.eq(KeyboardUserCharacter::getCharacterId, addDTO.getCharacterId())
|
||||||
.eq(KeyboardUserCharacter::getDeleted, false));
|
.eq(KeyboardUserCharacter::getUserId, userId));
|
||||||
if (alreadyExistsCharacter != null){
|
if (alreadyExistsCharacter != null){
|
||||||
throw new BusinessException(ErrorCode.REPEATEDLY_ADDING_CHARACTER);
|
throw new BusinessException(ErrorCode.REPEATEDLY_ADDING_CHARACTER);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class QdrantVectorService {
|
|||||||
@Resource
|
@Resource
|
||||||
private QdrantClient qdrantClient;
|
private QdrantClient qdrantClient;
|
||||||
|
|
||||||
private static final String COLLECTION_NAME = "test_document";
|
private static final String COLLECTION_NAME = "chat_resources";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private EmbeddingModel embeddingModel;
|
private EmbeddingModel embeddingModel;
|
||||||
|
|||||||
@@ -105,9 +105,20 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public Boolean userRegister(UserRegisterDTO userRegisterDTO) {
|
public Boolean userRegister(UserRegisterDTO userRegisterDTO) {
|
||||||
|
KeyboardUser userMail = keyboardUserMapper.selectOne(new LambdaQueryWrapper<KeyboardUser>()
|
||||||
|
.eq(KeyboardUser::getEmail, userRegisterDTO.getMailAddress()));
|
||||||
|
|
||||||
|
if (userMail != null) {
|
||||||
|
throw new BusinessException(ErrorCode.USER_HAS_EXISTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRegisterDTO.getPassword() == null) {
|
||||||
|
throw new BusinessException(ErrorCode.PASSWORD_CAN_NOT_NULL);
|
||||||
|
}
|
||||||
if (!userRegisterDTO.getPassword().equals(userRegisterDTO.getPasswordConfirm())) {
|
if (!userRegisterDTO.getPassword().equals(userRegisterDTO.getPasswordConfirm())) {
|
||||||
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
|
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyboardUser keyboardUser = new KeyboardUser();
|
KeyboardUser keyboardUser = new KeyboardUser();
|
||||||
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
|
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
|
||||||
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
|
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
|
||||||
@@ -115,11 +126,12 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
|
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
|
||||||
keyboardUser.setGender(userRegisterDTO.getGender());
|
keyboardUser.setGender(userRegisterDTO.getGender());
|
||||||
log.info(keyboardUser.toString());
|
log.info(keyboardUser.toString());
|
||||||
String s = redisUtil.get("user" + userRegisterDTO.getMailAddress());
|
String s = redisUtil.get("user:" + userRegisterDTO.getMailAddress());
|
||||||
if (!s.equals(userRegisterDTO.getVerifyCode())) {
|
if (!s.equals(userRegisterDTO.getVerifyCode())) {
|
||||||
throw new BusinessException(ErrorCode.VERIFY_CODE_ERROR);
|
throw new BusinessException(ErrorCode.VERIFY_CODE_ERROR);
|
||||||
}
|
}
|
||||||
redisUtil.delete("user" + userRegisterDTO.getMailAddress());
|
keyboardUser.setEmailVerified(true);
|
||||||
|
redisUtil.delete("user:" + userRegisterDTO.getMailAddress());
|
||||||
return keyboardUserMapper.insert(keyboardUser) > 0;
|
return keyboardUserMapper.insert(keyboardUser) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +183,7 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
if (keyboardUser == null) {
|
if (keyboardUser == null) {
|
||||||
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (resetPassWordDTO.getPassword().equals(resetPassWordDTO.getConfirmPassword())) {
|
if (!resetPassWordDTO.getPassword().equals(resetPassWordDTO.getConfirmPassword())) {
|
||||||
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
|
throw new BusinessException(ErrorCode.CONFIRM_PASSWORD_NOT_MATCH);
|
||||||
}
|
}
|
||||||
keyboardUser.setPassword(passwordEncoder.encode(resetPassWordDTO.getPassword()));
|
keyboardUser.setPassword(passwordEncoder.encode(resetPassWordDTO.getPassword()));
|
||||||
|
|||||||
Reference in New Issue
Block a user