聊天
This commit is contained in:
@@ -9,7 +9,10 @@
|
|||||||
"Bash(cd:*)",
|
"Bash(cd:*)",
|
||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(chmod:*)",
|
||||||
|
"Bash(docker build:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
1
.idea/compiler.xml
generated
1
.idea/compiler.xml
generated
@@ -2,6 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<annotationProcessing>
|
<annotationProcessing>
|
||||||
|
<profile default="true" name="Default" enabled="true" />
|
||||||
<profile name="Maven default annotation processors profile" enabled="true">
|
<profile name="Maven default annotation processors profile" enabled="true">
|
||||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||||
|
|||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Use official OpenJDK 17 runtime as base image
|
||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy Maven wrapper and pom.xml first for better layer caching
|
||||||
|
COPY .mvn/ .mvn/
|
||||||
|
COPY mvnw pom.xml ./
|
||||||
|
|
||||||
|
# Download dependencies (this layer will be cached unless pom.xml changes)
|
||||||
|
RUN ./mvnw dependency:go-offline -B
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN ./mvnw clean package -DskipTests
|
||||||
|
|
||||||
|
# Expose port 8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run the jar file
|
||||||
|
CMD ["java", "-jar", "target/chat-0.0.1-SNAPSHOT.jar"]
|
||||||
69
deploy.sh
Executable file
69
deploy.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Deploy script for backserver to 101.200.154.78
|
||||||
|
# Usage: ./deploy.sh
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SERVER_HOST="101.200.154.78"
|
||||||
|
SERVER_USER="root"
|
||||||
|
DEPLOY_PATH="/opt/backserver"
|
||||||
|
CONTAINER_NAME="backserver-app"
|
||||||
|
|
||||||
|
echo "Starting deployment to $SERVER_HOST..."
|
||||||
|
|
||||||
|
# Build the Docker image locally
|
||||||
|
echo "Building Docker image..."
|
||||||
|
docker build -t backserver:latest .
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Docker build failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save the image to a tar file
|
||||||
|
echo "Saving Docker image to tar file..."
|
||||||
|
docker save backserver:latest > backserver.tar
|
||||||
|
|
||||||
|
# Transfer files to server
|
||||||
|
echo "Transferring files to server..."
|
||||||
|
scp backserver.tar docker-compose.yml $SERVER_USER@$SERVER_HOST:$DEPLOY_PATH/
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "File transfer failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute deployment commands on server
|
||||||
|
echo "Executing deployment on server..."
|
||||||
|
ssh $SERVER_USER@$SERVER_HOST << EOF
|
||||||
|
cd $DEPLOY_PATH
|
||||||
|
|
||||||
|
# Stop existing container
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Load new image
|
||||||
|
docker load < backserver.tar
|
||||||
|
|
||||||
|
# Start new container
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
rm backserver.tar
|
||||||
|
|
||||||
|
# Show status
|
||||||
|
docker-compose ps
|
||||||
|
docker logs $CONTAINER_NAME --tail 20
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Deployment completed successfully!"
|
||||||
|
echo "Application should be available at http://$SERVER_HOST:8080"
|
||||||
|
else
|
||||||
|
echo "Deployment failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up local tar file
|
||||||
|
rm backserver.tar
|
||||||
|
|
||||||
|
echo "Deployment process finished."
|
||||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: backserver-app
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- SPRING_PROFILES_ACTIVE=prod
|
||||||
|
- SPRING_DATASOURCE_URL=jdbc:mysql://101.200.154.78:3306/yunda_qa?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
|
||||||
|
- SPRING_DATASOURCE_USERNAME=root
|
||||||
|
- SPRING_DATASOURCE_PASSWORD=mysql_Jt3yzh
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge
|
||||||
129
docs/chat.md
Normal file
129
docs/chat.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
好的,以下是 简化版的智能问答接口设计,聚焦两个核心问答模式:
|
||||||
|
• ✅ 普通聊天(chat)
|
||||||
|
• ✅ 深度研究(research)
|
||||||
|
|
||||||
|
省略了 history_limit、model、parameters、metadata 等高级控制参数,仅保留核心功能字段。
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
✅ 请求接口设计
|
||||||
|
|
||||||
|
POST /api/qa/ask
|
||||||
|
|
||||||
|
📥 请求体(JSON)
|
||||||
|
|
||||||
|
{
|
||||||
|
"mode": "chat", // chat | research,问答模式
|
||||||
|
"conversation_id": "12345", // 可选,会话ID(用于上下文关联)
|
||||||
|
"message": "什么是RAG?" // 用户输入的问题
|
||||||
|
}
|
||||||
|
|
||||||
|
📌 字段说明
|
||||||
|
|
||||||
|
字段名 类型 必填 说明
|
||||||
|
mode string 是 chat 普通聊天,research 深度研究
|
||||||
|
conversation_id string 否 可选;为空则创建新会话
|
||||||
|
message string 是 用户发送的问题内容
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
✅ 响应数据设计
|
||||||
|
|
||||||
|
📤 响应体(JSON)
|
||||||
|
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"conversation_id": "12345",
|
||||||
|
"message_id": "msg_001",
|
||||||
|
"answer": "RAG 是一种结合检索与生成的问答系统架构...",
|
||||||
|
"references": [], // chat 模式为空;research 模式可能返回引用资料
|
||||||
|
"report_id": null, // 仅在 research 模式中,若已触发报告生成
|
||||||
|
"tokens_used": 198,
|
||||||
|
"time_ms": 732
|
||||||
|
}
|
||||||
|
|
||||||
|
📌 字段说明
|
||||||
|
|
||||||
|
字段名 类型 说明
|
||||||
|
success boolean 是否成功
|
||||||
|
conversation_id string 当前所属会话 ID
|
||||||
|
message_id string 当前返回消息的唯一标识
|
||||||
|
answer string 模型生成的回答内容
|
||||||
|
references array 若为 research 模式,可能包含引用资料;否则为空
|
||||||
|
report_id string | null 若生成报告,返回报告ID;否则为 null
|
||||||
|
tokens_used number 生成过程使用的 token 数量(可用于统计/计费)
|
||||||
|
time_ms number 模型生成耗时(毫秒)
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
✅ 示例
|
||||||
|
|
||||||
|
📌 普通聊天模式
|
||||||
|
|
||||||
|
请求
|
||||||
|
|
||||||
|
{
|
||||||
|
"mode": "chat",
|
||||||
|
"message": "你好,帮我解释一下 LLM 是什么?"
|
||||||
|
}
|
||||||
|
|
||||||
|
响应
|
||||||
|
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"conversation_id": "abc123",
|
||||||
|
"message_id": "msg_1001",
|
||||||
|
"answer": "LLM(Large Language Model)是指大型语言模型...",
|
||||||
|
"references": [],
|
||||||
|
"report_id": null,
|
||||||
|
"tokens_used": 154,
|
||||||
|
"time_ms": 645
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
📌 深度研究模式
|
||||||
|
|
||||||
|
请求
|
||||||
|
|
||||||
|
{
|
||||||
|
"mode": "research",
|
||||||
|
"conversation_id": "r20240718_001",
|
||||||
|
"message": "请详细说明 RAG 在金融领域的应用。"
|
||||||
|
}
|
||||||
|
|
||||||
|
响应
|
||||||
|
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"conversation_id": "r20240718_001",
|
||||||
|
"message_id": "msg_2009",
|
||||||
|
"answer": "在金融领域,RAG 可用于增强问答系统...",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"title": "RAG 应用于金融智能问答",
|
||||||
|
"url": "https://example.com/rag-finance",
|
||||||
|
"quote": "金融领域中的RAG系统能显著提升精确率..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"report_id": "report_84928",
|
||||||
|
"tokens_used": 341,
|
||||||
|
"time_ms": 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
✅ 结构总结(精简版)
|
||||||
|
|
||||||
|
模式 关键差异
|
||||||
|
chat 只返回回答,无引用、无报告
|
||||||
|
research 返回引用资料、可返回 report_id
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
如需下一步生成 Swagger/OpenAPI 接口文档、接口校验 Schema、数据库如何存储这些内容,欢迎继续提问。
|
||||||
13
pom.xml
13
pom.xml
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.0.4</version>
|
<version>3.2.4</version>
|
||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
@@ -108,6 +108,17 @@
|
|||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
<version>2.5.0</version>
|
<version>2.5.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
|
||||||
|
<version>1.0.0-M6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ package com.yundage.chat.config;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.AuthenticationProvider;
|
import org.springframework.security.authentication.AuthenticationProvider;
|
||||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||||
|
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
|
||||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
@@ -22,6 +25,8 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
|||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -67,31 +72,64 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
|
// 1. CORS 和 CSRF 配置
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
|
||||||
|
// 2. 路由权限配置
|
||||||
.authorizeHttpRequests(authz -> authz
|
.authorizeHttpRequests(authz -> authz
|
||||||
// 认证相关接口
|
// 允许所有 OPTIONS 请求(CORS 预检)
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
|
|
||||||
|
// 登录 / 注册
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
// 公开API
|
|
||||||
.requestMatchers("/api/public/**").permitAll()
|
// Swagger 文档
|
||||||
|
.requestMatchers(
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/swagger-ui.html",
|
||||||
|
"/api-docs/**",
|
||||||
|
"/v3/api-docs/**",
|
||||||
|
"/swagger-resources/**",
|
||||||
|
"/webjars/**"
|
||||||
|
).permitAll()
|
||||||
|
|
||||||
// 健康检查
|
// 健康检查
|
||||||
.requestMatchers("/health").permitAll()
|
.requestMatchers("/health").permitAll()
|
||||||
// Swagger API文档
|
|
||||||
.requestMatchers("/swagger-ui/**").permitAll()
|
// 公开接口
|
||||||
.requestMatchers("/swagger-ui.html").permitAll()
|
.requestMatchers("/api/public/**").permitAll()
|
||||||
.requestMatchers("/api-docs/**").permitAll()
|
|
||||||
.requestMatchers("/v3/api-docs/**").permitAll()
|
// 聊天接口(允许流式访问)
|
||||||
.requestMatchers("/swagger-resources/**").permitAll()
|
.requestMatchers(HttpMethod.POST, "/qa/ask/stream").permitAll()
|
||||||
.requestMatchers("/webjars/**").permitAll()
|
|
||||||
// 其他所有请求需要认证
|
// 聊天相关接口需认证
|
||||||
|
.requestMatchers("/qa/**").authenticated()
|
||||||
|
|
||||||
|
// 所有其他请求需认证
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 3. 无状态会话配置(适用于 JWT)
|
||||||
.sessionManagement(session -> session
|
.sessionManagement(session -> session
|
||||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 4. 认证提供者与 JWT 过滤器
|
||||||
.authenticationProvider(authenticationProvider())
|
.authenticationProvider(authenticationProvider())
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
// 设置安全上下文策略(适用于多线程环境)
|
||||||
|
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ExecutorService securityContextExecutorService() {
|
||||||
|
// 创建一个固定大小的线程池,并使用DelegatingSecurityContextExecutorService包装
|
||||||
|
// 这样线程池中的线程会自动继承安全上下文
|
||||||
|
ExecutorService executorService = Executors.newFixedThreadPool(10);
|
||||||
|
return new DelegatingSecurityContextExecutorService(executorService);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.yundage.chat.controller;
|
||||||
|
|
||||||
|
import com.yundage.chat.common.ApiResponse;
|
||||||
|
import com.yundage.chat.dto.ChatRequest;
|
||||||
|
import com.yundage.chat.dto.ChatResponse;
|
||||||
|
import com.yundage.chat.entity.Conversation;
|
||||||
|
import com.yundage.chat.entity.Message;
|
||||||
|
import com.yundage.chat.entity.User;
|
||||||
|
import com.yundage.chat.service.ChatService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/qa")
|
||||||
|
@Tag(name = "智能问答", description = "聊天和深度研究接口")
|
||||||
|
public class ChatController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ChatService chatService;
|
||||||
|
|
||||||
|
@PostMapping("/ask")
|
||||||
|
@Operation(summary = "智能问答", description = "支持普通聊天和深度研究两种模式")
|
||||||
|
public ResponseEntity<ChatResponse> ask(
|
||||||
|
@Valid @RequestBody ChatRequest request,
|
||||||
|
org.springframework.security.core.Authentication authentication) {
|
||||||
|
|
||||||
|
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
|
||||||
|
try {
|
||||||
|
ChatResponse response = chatService.processChat(request, user);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 错误处理
|
||||||
|
ChatResponse errorResponse = new ChatResponse();
|
||||||
|
errorResponse.setSuccess(false);
|
||||||
|
errorResponse.setAnswer("抱歉,处理您的请求时出现了错误:" + e.getMessage());
|
||||||
|
return ResponseEntity.ok(errorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
@Operation(summary = "智能问答流式输出", description = "支持流式输出的智能问答,内容格式为Markdown")
|
||||||
|
public SseEmitter askStream(
|
||||||
|
@Valid @RequestBody ChatRequest request,
|
||||||
|
org.springframework.security.core.Authentication authentication) {
|
||||||
|
|
||||||
|
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
|
||||||
|
return chatService.processChatStream(request, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/conversations")
|
||||||
|
@Operation(summary = "获取用户会话列表", description = "获取当前用户的所有活跃会话")
|
||||||
|
public ResponseEntity<ApiResponse<List<Conversation>>> getConversations(
|
||||||
|
org.springframework.security.core.Authentication authentication) {
|
||||||
|
|
||||||
|
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Conversation> conversations = chatService.getUserConversations(user.getId());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(conversations));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("获取会话列表失败:" + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/conversations/{conversationId}/messages")
|
||||||
|
@Operation(summary = "获取会话消息历史", description = "获取指定会话的所有消息")
|
||||||
|
public ResponseEntity<ApiResponse<List<Message>>> getConversationMessages(
|
||||||
|
@PathVariable Long conversationId,
|
||||||
|
org.springframework.security.core.Authentication authentication) {
|
||||||
|
|
||||||
|
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Message> messages = chatService.getConversationMessages(conversationId, user.getId());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(messages));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("获取消息历史失败:" + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/main/java/com/yundage/chat/dto/ChatRequest.java
Normal file
42
src/main/java/com/yundage/chat/dto/ChatRequest.java
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package com.yundage.chat.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
public class ChatRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "mode不能为空")
|
||||||
|
@Pattern(regexp = "^(chat|research)$", message = "mode只能是chat或research")
|
||||||
|
private String mode;
|
||||||
|
|
||||||
|
private String conversationId;
|
||||||
|
|
||||||
|
@NotBlank(message = "message不能为空")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
public ChatRequest() {}
|
||||||
|
|
||||||
|
public String getMode() {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMode(String mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getConversationId() {
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConversationId(String conversationId) {
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/main/java/com/yundage/chat/dto/ChatResponse.java
Normal file
121
src/main/java/com/yundage/chat/dto/ChatResponse.java
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package com.yundage.chat.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ChatResponse {
|
||||||
|
|
||||||
|
private boolean success;
|
||||||
|
private String conversationId;
|
||||||
|
private String messageId;
|
||||||
|
private String answer;
|
||||||
|
private List<ReferenceItem> references;
|
||||||
|
private String reportId;
|
||||||
|
private Integer tokensUsed;
|
||||||
|
private Long timeMs;
|
||||||
|
|
||||||
|
public ChatResponse() {
|
||||||
|
this.success = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSuccess(boolean success) {
|
||||||
|
this.success = success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getConversationId() {
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConversationId(String conversationId) {
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessageId() {
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessageId(String messageId) {
|
||||||
|
this.messageId = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAnswer() {
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAnswer(String answer) {
|
||||||
|
this.answer = answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ReferenceItem> getReferences() {
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReferences(List<ReferenceItem> references) {
|
||||||
|
this.references = references;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReportId() {
|
||||||
|
return reportId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReportId(String reportId) {
|
||||||
|
this.reportId = reportId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTokensUsed() {
|
||||||
|
return tokensUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokensUsed(Integer tokensUsed) {
|
||||||
|
this.tokensUsed = tokensUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTimeMs() {
|
||||||
|
return timeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeMs(Long timeMs) {
|
||||||
|
this.timeMs = timeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ReferenceItem {
|
||||||
|
private String title;
|
||||||
|
private String url;
|
||||||
|
private String quote;
|
||||||
|
|
||||||
|
public ReferenceItem() {}
|
||||||
|
|
||||||
|
public ReferenceItem(String title, String url, String quote) {
|
||||||
|
this.title = title;
|
||||||
|
this.url = url;
|
||||||
|
this.quote = quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUrl(String url) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getQuote() {
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setQuote(String quote) {
|
||||||
|
this.quote = quote;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/main/java/com/yundage/chat/dto/StreamResponse.java
Normal file
133
src/main/java/com/yundage/chat/dto/StreamResponse.java
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package com.yundage.chat.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class StreamResponse {
|
||||||
|
|
||||||
|
private String type;
|
||||||
|
private String conversationId;
|
||||||
|
private String messageId;
|
||||||
|
private String content;
|
||||||
|
private List<ChatResponse.ReferenceItem> references;
|
||||||
|
private String reportId;
|
||||||
|
private Integer tokensUsed;
|
||||||
|
private Long timeMs;
|
||||||
|
private boolean finished;
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
public StreamResponse() {}
|
||||||
|
|
||||||
|
public static StreamResponse content(String content) {
|
||||||
|
StreamResponse response = new StreamResponse();
|
||||||
|
response.setType("content");
|
||||||
|
response.setContent(content);
|
||||||
|
response.setFinished(false);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StreamResponse finished(String conversationId, String messageId,
|
||||||
|
List<ChatResponse.ReferenceItem> references,
|
||||||
|
String reportId, Integer tokensUsed, Long timeMs) {
|
||||||
|
StreamResponse response = new StreamResponse();
|
||||||
|
response.setType("finished");
|
||||||
|
response.setConversationId(conversationId);
|
||||||
|
response.setMessageId(messageId);
|
||||||
|
response.setReferences(references);
|
||||||
|
response.setReportId(reportId);
|
||||||
|
response.setTokensUsed(tokensUsed);
|
||||||
|
response.setTimeMs(timeMs);
|
||||||
|
response.setFinished(true);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StreamResponse error(String error) {
|
||||||
|
StreamResponse response = new StreamResponse();
|
||||||
|
response.setType("error");
|
||||||
|
response.setError(error);
|
||||||
|
response.setFinished(true);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getConversationId() {
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConversationId(String conversationId) {
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessageId() {
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessageId(String messageId) {
|
||||||
|
this.messageId = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChatResponse.ReferenceItem> getReferences() {
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReferences(List<ChatResponse.ReferenceItem> references) {
|
||||||
|
this.references = references;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReportId() {
|
||||||
|
return reportId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReportId(String reportId) {
|
||||||
|
this.reportId = reportId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTokensUsed() {
|
||||||
|
return tokensUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokensUsed(Integer tokensUsed) {
|
||||||
|
this.tokensUsed = tokensUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTimeMs() {
|
||||||
|
return timeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeMs(Long timeMs) {
|
||||||
|
this.timeMs = timeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFinished() {
|
||||||
|
return finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFinished(boolean finished) {
|
||||||
|
this.finished = finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getError() {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setError(String error) {
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main/java/com/yundage/chat/entity/Conversation.java
Normal file
104
src/main/java/com/yundage/chat/entity/Conversation.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package com.yundage.chat.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Id;
|
||||||
|
import com.mybatisflex.annotation.KeyType;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Table("conversations")
|
||||||
|
public class Conversation {
|
||||||
|
|
||||||
|
@Id(keyType = KeyType.Auto)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column("model_version")
|
||||||
|
private String modelVersion;
|
||||||
|
|
||||||
|
@Column("chat_mode")
|
||||||
|
private String chatMode;
|
||||||
|
|
||||||
|
@Column("is_active")
|
||||||
|
private Integer isActive;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column("updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Conversation() {
|
||||||
|
this.isActive = 1;
|
||||||
|
this.chatMode = "chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(Long userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getModelVersion() {
|
||||||
|
return modelVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModelVersion(String modelVersion) {
|
||||||
|
this.modelVersion = modelVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getChatMode() {
|
||||||
|
return chatMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatMode(String chatMode) {
|
||||||
|
this.chatMode = chatMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Integer isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/main/java/com/yundage/chat/entity/Message.java
Normal file
99
src/main/java/com/yundage/chat/entity/Message.java
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package com.yundage.chat.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Id;
|
||||||
|
import com.mybatisflex.annotation.KeyType;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Table("messages")
|
||||||
|
public class Message {
|
||||||
|
|
||||||
|
@Id(keyType = KeyType.Auto)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("conversation_id")
|
||||||
|
private Long conversationId;
|
||||||
|
|
||||||
|
@Column("sequence_no")
|
||||||
|
private Integer sequenceNo;
|
||||||
|
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
private Integer tokens;
|
||||||
|
|
||||||
|
@Column("latency_ms")
|
||||||
|
private Integer latencyMs;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public Message() {}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getConversationId() {
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConversationId(Long conversationId) {
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSequenceNo() {
|
||||||
|
return sequenceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSequenceNo(Integer sequenceNo) {
|
||||||
|
this.sequenceNo = sequenceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTokens() {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokens(Integer tokens) {
|
||||||
|
this.tokens = tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLatencyMs() {
|
||||||
|
return latencyMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLatencyMs(Integer latencyMs) {
|
||||||
|
this.latencyMs = latencyMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.yundage.chat.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import com.yundage.chat.entity.Conversation;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ConversationMapper extends BaseMapper<Conversation> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM conversations WHERE user_id = #{userId} AND is_active = 1 ORDER BY updated_at DESC")
|
||||||
|
List<Conversation> findActiveByUserId(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM conversations WHERE id = #{id} AND user_id = #{userId}")
|
||||||
|
Conversation findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId);
|
||||||
|
}
|
||||||
19
src/main/java/com/yundage/chat/mapper/MessageMapper.java
Normal file
19
src/main/java/com/yundage/chat/mapper/MessageMapper.java
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package com.yundage.chat.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import com.yundage.chat.entity.Message;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface MessageMapper extends BaseMapper<Message> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM messages WHERE conversation_id = #{conversationId} ORDER BY sequence_no ASC")
|
||||||
|
List<Message> findByConversationId(@Param("conversationId") Long conversationId);
|
||||||
|
|
||||||
|
@Select("SELECT COALESCE(MAX(sequence_no), 0) FROM messages WHERE conversation_id = #{conversationId}")
|
||||||
|
Integer getMaxSequenceNo(@Param("conversationId") Long conversationId);
|
||||||
|
}
|
||||||
277
src/main/java/com/yundage/chat/service/ChatService.java
Normal file
277
src/main/java/com/yundage/chat/service/ChatService.java
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package com.yundage.chat.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yundage.chat.dto.ChatRequest;
|
||||||
|
import com.yundage.chat.dto.ChatResponse;
|
||||||
|
import com.yundage.chat.dto.StreamResponse;
|
||||||
|
import com.yundage.chat.entity.Conversation;
|
||||||
|
import com.yundage.chat.entity.Message;
|
||||||
|
import com.yundage.chat.entity.User;
|
||||||
|
import com.yundage.chat.mapper.ConversationMapper;
|
||||||
|
import com.yundage.chat.mapper.MessageMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.concurrent.DelegatingSecurityContextRunnable;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class ChatService {
|
||||||
|
|
||||||
|
private ConversationMapper conversationMapper;
|
||||||
|
|
||||||
|
|
||||||
|
private MessageMapper messageMapper;
|
||||||
|
|
||||||
|
|
||||||
|
/** ChatClient 由 Spring AI Starter 自动装配;可注入 Builder 再 build(),也可直接注入 ChatClient */
|
||||||
|
private final ChatClient chatClient;
|
||||||
|
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||||
|
// 👇 手写构造器完成所有 final 字段的注入
|
||||||
|
@Autowired
|
||||||
|
public ChatService(ChatClient.Builder builder,
|
||||||
|
ConversationMapper conversationMapper,
|
||||||
|
MessageMapper messageMapper) {
|
||||||
|
this.chatClient = builder.build();;
|
||||||
|
this.conversationMapper = conversationMapper;
|
||||||
|
this.messageMapper = messageMapper;
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public SseEmitter processChatStream(ChatRequest request, User user) {
|
||||||
|
// 0 表示不超时;如需限制自行调整
|
||||||
|
SseEmitter emitter = new SseEmitter(0L);
|
||||||
|
|
||||||
|
try {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// 1️⃣ 取得会话并保存用户消息
|
||||||
|
Conversation convo = getOrCreateConversation(request, user);
|
||||||
|
saveUserMessage(convo.getId(), request.getMessage());
|
||||||
|
|
||||||
|
// 2️⃣ 在独立线程里完成流处理,避免阻塞 servlet 线程
|
||||||
|
SecurityContext ctx = SecurityContextHolder.getContext();
|
||||||
|
executor.submit(new DelegatingSecurityContextRunnable(() -> {
|
||||||
|
|
||||||
|
StringBuilder full = new StringBuilder();
|
||||||
|
|
||||||
|
// 3️⃣ 使用 Spring AI 流式接口;`content()` 把 Flux<ChatResponse> 映射成 Flux<String>
|
||||||
|
Flux<String> flux = chatClient
|
||||||
|
.prompt() // fluent API
|
||||||
|
.user(request.getMessage()) // 用户消息
|
||||||
|
.stream() // 进入流模式 [oai_citation:0‡Home](https://docs.spring.io/spring-ai/reference/api/chatclient.html)
|
||||||
|
.content(); // 只取内容字符串 [oai_citation:1‡danvega.dev](https://www.danvega.dev/blog/spring-ai-streaming-chatbot)
|
||||||
|
|
||||||
|
flux.doOnNext(chunk -> { // 每个 token/块回调
|
||||||
|
try {
|
||||||
|
full.append(chunk); // 累积完整内容
|
||||||
|
StreamResponse resp = StreamResponse.content(chunk);
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("message")
|
||||||
|
.data(mapper.writeValueAsString(resp)));
|
||||||
|
} catch (Exception io) {
|
||||||
|
emitter.completeWithError(io);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.doOnComplete(() -> { // 4️⃣ 模型输出结束
|
||||||
|
try {
|
||||||
|
Message assistantMsg = saveAssistantMessage(convo.getId(), full.toString());
|
||||||
|
convo.setUpdatedAt(LocalDateTime.now());
|
||||||
|
conversationMapper.update(convo);
|
||||||
|
|
||||||
|
List<ChatResponse.ReferenceItem> refs =
|
||||||
|
"research".equals(request.getMode()) ? generateMockReferences() : List.of();
|
||||||
|
|
||||||
|
StreamResponse finish = StreamResponse.finished(
|
||||||
|
convo.getId().toString(),
|
||||||
|
assistantMsg.getId().toString(),
|
||||||
|
refs,
|
||||||
|
null,
|
||||||
|
estimateTokens(request.getMessage(), full.toString()),
|
||||||
|
System.currentTimeMillis() - start
|
||||||
|
);
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("finished")
|
||||||
|
.data(mapper.writeValueAsString(finish)));
|
||||||
|
emitter.complete();
|
||||||
|
} catch (Exception e) {
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.doOnError(err -> { // 5️⃣ 流异常
|
||||||
|
try {
|
||||||
|
StreamResponse error = StreamResponse.error("AI 处理失败:" + err.getMessage());
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("error")
|
||||||
|
.data(mapper.writeValueAsString(error)));
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
emitter.completeWithError(err);
|
||||||
|
})
|
||||||
|
.subscribe(); // 最终触发流
|
||||||
|
}, ctx));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
emitter.completeWithError(ex);
|
||||||
|
}
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ChatResponse processChat(ChatRequest request, User user) {
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// 获取或创建会话
|
||||||
|
Conversation conversation = getOrCreateConversation(request, user);
|
||||||
|
|
||||||
|
// 保存用户消息
|
||||||
|
Message userMessage = saveUserMessage(conversation.getId(), request.getMessage());
|
||||||
|
|
||||||
|
// 模拟AI回复生成 - 这里可以接入实际的AI服务
|
||||||
|
String aiResponse = generateAIResponse(request);
|
||||||
|
|
||||||
|
// 保存AI回复消息
|
||||||
|
Message assistantMessage = saveAssistantMessage(conversation.getId(), aiResponse);
|
||||||
|
|
||||||
|
// 更新会话的更新时间
|
||||||
|
conversation.setUpdatedAt(LocalDateTime.now());
|
||||||
|
conversationMapper.update(conversation);
|
||||||
|
|
||||||
|
// 构建响应
|
||||||
|
ChatResponse response = new ChatResponse();
|
||||||
|
response.setConversationId(conversation.getId().toString());
|
||||||
|
response.setMessageId(assistantMessage.getId().toString());
|
||||||
|
response.setAnswer(aiResponse);
|
||||||
|
response.setTimeMs(System.currentTimeMillis() - startTime);
|
||||||
|
|
||||||
|
// 根据模式设置不同的响应内容
|
||||||
|
if ("research".equals(request.getMode())) {
|
||||||
|
// 研究模式可能包含引用资料
|
||||||
|
response.setReferences(generateMockReferences());
|
||||||
|
response.setReportId(null); // 可以在后续版本中实现报告生成功能
|
||||||
|
} else {
|
||||||
|
// 普通聊天模式
|
||||||
|
response.setReferences(new ArrayList<>());
|
||||||
|
response.setReportId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟token统计
|
||||||
|
response.setTokensUsed(estimateTokens(request.getMessage(), aiResponse));
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String[] generateStreamingAIResponse(ChatRequest request) {
|
||||||
|
// 目前先用单段文本模拟,后续可改为 chatClient.stream() 真正流式
|
||||||
|
String aiReply = "这是AI的回复内容,模拟流式输出。";
|
||||||
|
return new String[]{aiReply};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Conversation getOrCreateConversation(ChatRequest request, User user) {
|
||||||
|
if (request.getConversationId() != null) {
|
||||||
|
// 尝试获取现有会话
|
||||||
|
try {
|
||||||
|
Long conversationId = Long.parseLong(request.getConversationId());
|
||||||
|
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, user.getId());
|
||||||
|
if (conversation != null) {
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// 忽略无效的会话ID,创建新会话
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新会话
|
||||||
|
Conversation conversation = new Conversation();
|
||||||
|
conversation.setUserId(user.getId());
|
||||||
|
conversation.setChatMode(request.getMode());
|
||||||
|
conversation.setTitle(generateConversationTitle(request.getMessage()));
|
||||||
|
conversation.setCreatedAt(LocalDateTime.now());
|
||||||
|
conversation.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
conversationMapper.insert(conversation);
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Message saveUserMessage(Long conversationId, String content) {
|
||||||
|
Integer nextSequenceNo = messageMapper.getMaxSequenceNo(conversationId) + 1;
|
||||||
|
|
||||||
|
Message message = new Message();
|
||||||
|
message.setConversationId(conversationId);
|
||||||
|
message.setSequenceNo(nextSequenceNo);
|
||||||
|
message.setRole("user");
|
||||||
|
message.setContent(content);
|
||||||
|
message.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
messageMapper.insert(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Message saveAssistantMessage(Long conversationId, String content) {
|
||||||
|
Integer nextSequenceNo = messageMapper.getMaxSequenceNo(conversationId) + 1;
|
||||||
|
|
||||||
|
Message message = new Message();
|
||||||
|
message.setConversationId(conversationId);
|
||||||
|
message.setSequenceNo(nextSequenceNo);
|
||||||
|
message.setRole("assistant");
|
||||||
|
message.setContent(content);
|
||||||
|
message.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
messageMapper.insert(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateAIResponse(ChatRequest request) {
|
||||||
|
// 使用 Spring AI ChatClient 生成回复
|
||||||
|
|
||||||
|
return "这是AI的回复内容,模拟生成。";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateConversationTitle(String message) {
|
||||||
|
// 简单的标题生成逻辑,取前20个字符
|
||||||
|
if (message.length() <= 20) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return message.substring(0, 20) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ChatResponse.ReferenceItem> generateMockReferences() {
|
||||||
|
// 模拟的引用资料,实际项目中从真实的知识库获取
|
||||||
|
List<ChatResponse.ReferenceItem> references = new ArrayList<>();
|
||||||
|
references.add(new ChatResponse.ReferenceItem(
|
||||||
|
"相关研究文档",
|
||||||
|
"https://example.com/research",
|
||||||
|
"这是一段引用的文本内容..."
|
||||||
|
));
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer estimateTokens(String userMessage, String aiResponse) {
|
||||||
|
// 简单的token估算,实际项目中需要使用真实的tokenizer
|
||||||
|
return (userMessage.length() + aiResponse.length()) / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Conversation> getUserConversations(Long userId) {
|
||||||
|
return conversationMapper.findActiveByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Message> getConversationMessages(Long conversationId, Long userId) {
|
||||||
|
// 验证会话是否属于用户
|
||||||
|
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, userId);
|
||||||
|
if (conversation == null) {
|
||||||
|
throw new RuntimeException("会话不存在或无权限访问");
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageMapper.findByConversationId(conversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,15 @@ spring:
|
|||||||
auth: true
|
auth: true
|
||||||
starttls:
|
starttls:
|
||||||
enable: true
|
enable: true
|
||||||
|
ai:
|
||||||
|
openai:
|
||||||
|
api-key: sk-5gAxnpuwJ9KIsea51cD05e21F1Ac4b6a99C8A97278C1F837
|
||||||
|
base-url: https://www.jzhengda-api.com
|
||||||
|
chat:
|
||||||
|
options:
|
||||||
|
model: gpt-4o-mini
|
||||||
|
security:
|
||||||
|
debug: true
|
||||||
# MyBatis-Flex configuration
|
# MyBatis-Flex configuration
|
||||||
mybatis-flex:
|
mybatis-flex:
|
||||||
# Enable SQL logging
|
# Enable SQL logging
|
||||||
@@ -57,6 +65,7 @@ springdoc:
|
|||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.yundage.chat: debug
|
com.yundage.chat: debug
|
||||||
com.mybatisflex: debug
|
com.mybatisflex: info
|
||||||
|
org.springframework.security: DEBUG
|
||||||
# org.springframework: DEBUG
|
# org.springframework: DEBUG
|
||||||
#debug: true
|
#debug: true
|
||||||
Reference in New Issue
Block a user