tmp
This commit is contained in:
11
week9/src/main/java/com/learn/Week9Application.java
Normal file
11
week9/src/main/java/com/learn/Week9Application.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.learn;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Week9Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Week9Application.class, args);
|
||||
}
|
||||
}
|
||||
80
week9/src/main/java/com/learn/config/AIConfig.java
Normal file
80
week9/src/main/java/com/learn/config/AIConfig.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.learn.config;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
|
||||
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Spring AI 配置类(手动装配,便于理解各组件的关系)
|
||||
*
|
||||
* 架构:
|
||||
* ChatModel (OpenAiChatModel) ← 底层 HTTP 通信,由 application.yml 自动配置
|
||||
* ↓ 包装
|
||||
* ChatClient ← 高级 Fluent API,我们手动创建
|
||||
* ↓ 拦截
|
||||
* MessageChatMemoryAdvisor ← 自动注入对话历史(多轮对话)
|
||||
* ↓ 写入 / 读取
|
||||
* ChatMemory (MessageWindowChatMemory) ← 滑动窗口记忆(默认保留最近 20 条)
|
||||
* ↓ 存储
|
||||
* ChatMemoryRepository (InMemoryChatMemoryRepository) ← ConcurrentHashMap 存储
|
||||
*
|
||||
* Spring AI 1.0.6 的架构变化:
|
||||
* - 旧版 InMemoryChatMemory 被拆分为两个角色:
|
||||
* ChatMemory(记忆策略)+ ChatMemoryRepository(存储实现)
|
||||
* - MessageChatMemoryAdvisor 构造函数私有化,必须用 Builder 创建
|
||||
* - 会话 ID 常量从 AbstractChatMemoryAdvisor 迁移到 ChatMemory 接口
|
||||
*/
|
||||
@Configuration
|
||||
public class AIConfig {
|
||||
|
||||
/**
|
||||
* 对话记忆存储(内存实现,重启丢失)。
|
||||
*
|
||||
* InMemoryChatMemoryRepository = ConcurrentHashMap 包装
|
||||
* MessageWindowChatMemory = 滑动窗口策略(只保留最近 N 条消息)
|
||||
*
|
||||
* 生产环境可替换为 JdbcChatMemoryRepository 实现持久化。
|
||||
*/
|
||||
@Bean
|
||||
public ChatMemory chatMemory() {
|
||||
var repository = new InMemoryChatMemoryRepository();
|
||||
return MessageWindowChatMemory.builder()
|
||||
.chatMemoryRepository(repository)
|
||||
.maxMessages(20) // 每个会话最多保留 20 条历史消息
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatClient —— Spring AI 的推荐入口
|
||||
*
|
||||
* 构建步骤:
|
||||
* 1. ChatClient.builder(chatModel) —— 绑定底层的 ChatModel 实现
|
||||
* 2. .defaultSystem(...) —— 设置系统级角色 Prompt
|
||||
* 3. .defaultAdvisors(...) —— 注册 Advisor 拦截器链(如记忆管理)
|
||||
* 4. .build() —— 创建不可变实例
|
||||
*
|
||||
* 关键点说明:
|
||||
* - ChatClient 是线程安全的,整个应用共用一个单例
|
||||
* - .defaultSystem() 对所有通过此 ChatClient 的请求生效
|
||||
* - MessageChatMemoryAdvisor 会在每次请求前自动注入历史消息
|
||||
* - 单次请求可通过 .system() 覆盖默认 System Prompt
|
||||
*/
|
||||
@Bean
|
||||
public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
|
||||
return ChatClient.builder(chatModel)
|
||||
.defaultSystem("""
|
||||
你是一个友好的 AI 助手,名字叫"小智"。
|
||||
请用中文回答用户的问题。
|
||||
如果你不知道答案,诚实地告诉用户,不要编造信息。
|
||||
""")
|
||||
.defaultAdvisors(
|
||||
MessageChatMemoryAdvisor.builder(chatMemory).build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
93
week9/src/main/java/com/learn/controller/ChatController.java
Normal file
93
week9/src/main/java/com/learn/controller/ChatController.java
Normal file
@@ -0,0 +1,93 @@
|
||||
package com.learn.controller;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import com.learn.dto.ChatRequest;
|
||||
import com.learn.service.ChatService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chat")
|
||||
public class ChatController {
|
||||
|
||||
private final ChatService chatService;
|
||||
|
||||
public ChatController(ChatService chatService) {
|
||||
this.chatService = chatService;
|
||||
}
|
||||
|
||||
// ==================== Day 5: 同步对话 ====================
|
||||
|
||||
/**
|
||||
* 同步聊天 —— 发送完整消息,等待完整回复。
|
||||
*
|
||||
* 请求: POST /api/chat
|
||||
* Body: {"message": "你好", "conversationId": "会话ID(可选)"}
|
||||
* 响应: {"code": 200, "data": {"reply": "你好!有什么可以帮你的?", "conversationId": "xxx"}}
|
||||
*/
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, String>> chat(@Valid @RequestBody ChatRequest request) {
|
||||
String conversationId = request.getConversationId() != null
|
||||
? request.getConversationId() : "default";
|
||||
String reply = chatService.chat(request.getMessage(), conversationId);
|
||||
return ApiResponse.success(Map.of(
|
||||
"reply", reply,
|
||||
"conversationId", conversationId
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Day 6: 流式对话 (SSE) ====================
|
||||
|
||||
/**
|
||||
* 流式聊天 —— 逐 token 返回,打字机效果。
|
||||
*
|
||||
* 请求: POST /api/chat/stream
|
||||
* Body: 同 /api/chat
|
||||
* 响应: text/event-stream
|
||||
* data: 你
|
||||
* data: 好
|
||||
* data: !
|
||||
* event: done
|
||||
* data: [DONE]
|
||||
*/
|
||||
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public Flux<ServerSentEvent<String>> chatStream(@Valid @RequestBody ChatRequest request) {
|
||||
return chatService.chatStream(request.getMessage(), request.getConversationId())
|
||||
.map(token -> ServerSentEvent.<String>builder()
|
||||
.data(token)
|
||||
.build())
|
||||
.concatWith(Mono.just(ServerSentEvent.<String>builder()
|
||||
.event("done")
|
||||
.data("[DONE]")
|
||||
.build()));
|
||||
}
|
||||
|
||||
// ==================== Day 7: 历史管理 ====================
|
||||
|
||||
/**
|
||||
* 查询某个会话的历史消息。
|
||||
*
|
||||
* GET /api/chat/history?conversationId=xxx
|
||||
*/
|
||||
@GetMapping("/history")
|
||||
public ApiResponse<?> getHistory(@RequestParam(defaultValue = "default") String conversationId) {
|
||||
return ApiResponse.success(chatService.getHistory(conversationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某个会话的历史。
|
||||
*
|
||||
* DELETE /api/chat/history?conversationId=xxx
|
||||
*/
|
||||
@DeleteMapping("/history")
|
||||
public ApiResponse<?> clearHistory(@RequestParam(defaultValue = "default") String conversationId) {
|
||||
chatService.clearHistory(conversationId);
|
||||
return ApiResponse.success("已清除会话: " + conversationId);
|
||||
}
|
||||
}
|
||||
61
week9/src/main/java/com/learn/dto/ApiResponse.java
Normal file
61
week9/src/main/java/com/learn/dto/ApiResponse.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
private Map<String, Object> extra;
|
||||
|
||||
public ApiResponse() {}
|
||||
|
||||
public ApiResponse(int code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(200, "success", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return new ApiResponse<>(200, message, data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> created(T data) {
|
||||
return new ApiResponse<>(201, "created", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String message) {
|
||||
return new ApiResponse<>(code, message, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> notFound(String message) {
|
||||
return new ApiResponse<>(404, message, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> badRequest(String message) {
|
||||
return new ApiResponse<>(400, message, null);
|
||||
}
|
||||
|
||||
public ApiResponse<T> put(String key, Object value) {
|
||||
if (this.extra == null) {
|
||||
this.extra = new HashMap<>();
|
||||
}
|
||||
this.extra.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getCode() { return code; }
|
||||
public void setCode(int code) { this.code = code; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public T getData() { return data; }
|
||||
public void setData(T data) { this.data = data; }
|
||||
public Map<String, Object> getExtra() { return extra; }
|
||||
public void setExtra(Map<String, Object> extra) { this.extra = extra; }
|
||||
}
|
||||
18
week9/src/main/java/com/learn/dto/ChatHistoryVo.java
Normal file
18
week9/src/main/java/com/learn/dto/ChatHistoryVo.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class ChatHistoryVo {
|
||||
|
||||
private String conversationId;
|
||||
private int messageCount;
|
||||
private List<Map<String, Object>> messages;
|
||||
|
||||
public String getConversationId() { return conversationId; }
|
||||
public void setConversationId(String conversationId) { this.conversationId = conversationId; }
|
||||
public int getMessageCount() { return messageCount; }
|
||||
public void setMessageCount(int messageCount) { this.messageCount = messageCount; }
|
||||
public List<Map<String, Object>> getMessages() { return messages; }
|
||||
public void setMessages(List<Map<String, Object>> messages) { this.messages = messages; }
|
||||
}
|
||||
16
week9/src/main/java/com/learn/dto/ChatRequest.java
Normal file
16
week9/src/main/java/com/learn/dto/ChatRequest.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class ChatRequest {
|
||||
|
||||
@NotBlank(message = "消息不能为空")
|
||||
private String message;
|
||||
|
||||
private String conversationId;
|
||||
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public String getConversationId() { return conversationId; }
|
||||
public void setConversationId(String conversationId) { this.conversationId = conversationId; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.learn.exception;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
var errors = new StringBuilder();
|
||||
for (var error : ex.getBindingResult().getFieldErrors()) {
|
||||
if (!errors.isEmpty()) errors.append("; ");
|
||||
errors.append(error.getField()).append(": ").append(error.getDefaultMessage());
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest("参数校验失败: " + errors));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleAll(Exception ex) {
|
||||
log.error("Unexpected error", ex);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
118
week9/src/main/java/com/learn/service/ChatService.java
Normal file
118
week9/src/main/java/com/learn/service/ChatService.java
Normal file
@@ -0,0 +1,118 @@
|
||||
package com.learn.service;
|
||||
|
||||
import com.learn.dto.ChatHistoryVo;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
|
||||
|
||||
@Service
|
||||
public class ChatService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
|
||||
|
||||
private final ChatClient chatClient;
|
||||
private final ChatMemory chatMemory;
|
||||
|
||||
public ChatService(ChatClient chatClient, ChatMemory chatMemory) {
|
||||
this.chatClient = chatClient;
|
||||
this.chatMemory = chatMemory;
|
||||
}
|
||||
|
||||
// ==================== Day 5: 同步对话 ====================
|
||||
|
||||
/**
|
||||
* 一问一答,等待完整回复后返回。
|
||||
*/
|
||||
public String chat(String message) {
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.call()
|
||||
.content();
|
||||
}
|
||||
|
||||
// ==================== Day 6: 流式对话 ====================
|
||||
|
||||
/**
|
||||
* 流式对话,逐 token 返回,实现"打字机效果"。
|
||||
*
|
||||
* @param message 用户消息
|
||||
* @param conversationId 会话 ID(默认 "default")
|
||||
* @return Flux<String> 逐 token 流
|
||||
*/
|
||||
public Flux<String> chatStream(String message, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
log.debug("Streaming chat for conversation: {}", convId);
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.advisors(a -> a.param(CONVERSATION_ID, convId))
|
||||
.stream()
|
||||
.content();
|
||||
}
|
||||
|
||||
// ==================== Day 7: 多轮对话 ====================
|
||||
|
||||
/**
|
||||
* 带上下文的多轮对话(非流式版)。
|
||||
*/
|
||||
public String chat(String message, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
log.debug("Chat with memory, conversation: {}", convId);
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.advisors(a -> a.param(CONVERSATION_ID, convId))
|
||||
.call()
|
||||
.content();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某个会话的历史消息。
|
||||
*/
|
||||
public ChatHistoryVo getHistory(String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
var messages = chatMemory.get(convId);
|
||||
var list = new ArrayList<Map<String, Object>>();
|
||||
for (var msg : messages) {
|
||||
var item = new HashMap<String, Object>();
|
||||
item.put("role", msg.getMessageType().name().toLowerCase());
|
||||
item.put("content", msg.getText());
|
||||
list.add(item);
|
||||
}
|
||||
ChatHistoryVo vo = new ChatHistoryVo();
|
||||
vo.setConversationId(convId);
|
||||
vo.setMessageCount(list.size());
|
||||
vo.setMessages(list);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某个会话的所有历史。
|
||||
*/
|
||||
public void clearHistory(String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
chatMemory.clear(convId);
|
||||
log.debug("Cleared history for conversation: {}", convId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有会话 ID 列表。
|
||||
*/
|
||||
public List<String> listConversations() {
|
||||
// InMemoryChatMemory 没有直接列出所有 key 的方法
|
||||
// 这里用一个简化方案:客户端自行管理会话 ID
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private String defaultIfNull(String id) {
|
||||
return (id == null || id.isBlank()) ? "default" : id;
|
||||
}
|
||||
}
|
||||
38
week9/src/main/resources/application.yml
Normal file
38
week9/src/main/resources/application.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
spring:
|
||||
application:
|
||||
name: week9-ai-chat
|
||||
|
||||
# =============================================================
|
||||
# AI Provider Configuration (Spring AI)
|
||||
# =============================================================
|
||||
# 更换模型只需改两个地方:
|
||||
# 1. base-url → 服务商的 API 地址
|
||||
# 2. model → 模型名称
|
||||
#
|
||||
# 国内推荐 DeepSeek (注册送免费额度):
|
||||
# 注册地址: https://platform.deepseek.com
|
||||
# API Key 获取: 登录后 → API Keys → 创建
|
||||
#
|
||||
# 备选: 阿里云百炼 Qwen (通义千问)
|
||||
# 注册地址: https://bailian.console.aliyun.com
|
||||
#
|
||||
# 本地运行 (无需网络, 无需 API Key):
|
||||
# 安装 Ollama: https://ollama.com
|
||||
# 拉取模型: ollama pull qwen2.5:7b
|
||||
# =============================================================
|
||||
ai:
|
||||
openai:
|
||||
api-key: ${AI_API_KEY:sk-fe4d2f26eda04a659b9ff3408ce5024b}
|
||||
base-url: ${AI_BASE_URL:https://api.deepseek.com}
|
||||
chat:
|
||||
options:
|
||||
model: deepseek-chat
|
||||
temperature: 0.7
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.learn: DEBUG
|
||||
org.springframework.ai.chat.client.advisor: DEBUG
|
||||
70
week9/src/main/resources/static/chat.html
Normal file
70
week9/src/main/resources/static/chat.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 聊天机器人 - Week 9</title>
|
||||
<link rel="stylesheet" href="/css/chat.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<!-- 侧边栏(Day 7) -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>对话列表</h2>
|
||||
</div>
|
||||
<button class="btn-new-chat" onclick="newConversation()">+ 新建对话</button>
|
||||
<div class="conversation-list" id="conversation-list">
|
||||
<div class="conv-item active" data-id="default" onclick="switchConversation('default')">
|
||||
<span class="conv-title">默认对话</span>
|
||||
<button class="conv-delete" onclick="event.stopPropagation(); deleteConversation('default')" title="删除">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<select id="model-select" onchange="onModelChange()">
|
||||
<option value="deepseek-chat">DeepSeek-Chat</option>
|
||||
<option value="gpt-4o-mini">GPT-4o-mini</option>
|
||||
<option value="qwen-turbo">Qwen-Turbo</option>
|
||||
</select>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主聊天区 -->
|
||||
<main class="chat-area">
|
||||
<header class="chat-header">
|
||||
<button class="btn-toggle-sidebar" onclick="toggleSidebar()" title="切换侧边栏">☰</button>
|
||||
<h1>AI 聊天机器人</h1>
|
||||
<span class="subtitle">Week 9 — Spring AI</span>
|
||||
</header>
|
||||
|
||||
<div class="messages" id="messages">
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">🤖</div>
|
||||
<h3>你好!我是小智</h3>
|
||||
<p>我是你的 AI 助手,有什么可以帮你的?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="input-toolbar">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="stream-mode" checked>
|
||||
<span>流式输出</span>
|
||||
</label>
|
||||
<button class="btn-clear" onclick="clearCurrentHistory()">清除历史</button>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<textarea id="user-input"
|
||||
placeholder="输入消息,按 Enter 发送,Shift+Enter 换行..."
|
||||
rows="1"
|
||||
onkeydown="handleKeyDown(event)"></textarea>
|
||||
<button class="btn-send" id="send-btn" onclick="sendMessage()">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
277
week9/src/main/resources/static/css/chat.css
Normal file
277
week9/src/main/resources/static/css/chat.css
Normal file
@@ -0,0 +1,277 @@
|
||||
/* ============================================================
|
||||
Week 9 Chat UI — AI 聊天机器人样式
|
||||
============================================================ */
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #f0f2f5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(-260px);
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
height: 100%;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 20px 16px 12px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.sidebar-header h2 { font-size: 16px; color: #f1f5f9; }
|
||||
|
||||
.btn-new-chat {
|
||||
margin: 12px 16px;
|
||||
padding: 10px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-new-chat:hover { background: #2563eb; }
|
||||
|
||||
.conversation-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
margin: 2px 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.conv-item:hover { background: #334155; }
|
||||
.conv-item.active { background: #3b82f6; color: #fff; }
|
||||
.conv-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conv-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.conv-item:hover .conv-delete { opacity: 0.6; }
|
||||
.conv-delete:hover { opacity: 1 !important; }
|
||||
|
||||
.sidebar-footer { padding: 12px 16px; border-top: 1px solid #334155; }
|
||||
.sidebar-footer select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- Chat Area ---- */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.chat-header h1 { font-size: 18px; color: #1e293b; }
|
||||
.chat-header .subtitle { font-size: 13px; color: #94a3b8; }
|
||||
.btn-toggle-sidebar {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #64748b;
|
||||
}
|
||||
.btn-toggle-sidebar:hover { background: #e2e8f0; }
|
||||
|
||||
/* ---- Messages ---- */
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.welcome-msg {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.welcome-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.welcome-msg h3 { font-size: 20px; color: #475569; margin-bottom: 6px; }
|
||||
.welcome-msg p { font-size: 14px; }
|
||||
|
||||
/* ---- Message Bubbles ---- */
|
||||
.msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 80%;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg .avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg.assistant .avatar { background: #e0e7ff; }
|
||||
.msg.user .avatar { background: #dbeafe; }
|
||||
|
||||
.msg .bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.assistant .bubble {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.msg.user .bubble {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* Streaming cursor */
|
||||
.msg.streaming .bubble::after {
|
||||
content: '▊';
|
||||
animation: blink 1s infinite;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.msg.streaming.user .bubble::after { color: #fff; }
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ---- Input Area ---- */
|
||||
.input-area {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.input-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.btn-clear:hover { color: #ef4444; }
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
#user-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
max-height: 120px;
|
||||
}
|
||||
#user-input:focus { border-color: #3b82f6; }
|
||||
|
||||
.btn-send {
|
||||
padding: 10px 20px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:hover { background: #2563eb; }
|
||||
.btn-send:disabled {
|
||||
background: #93c5fd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 640px) {
|
||||
.sidebar { display: none; }
|
||||
.msg { max-width: 90%; }
|
||||
}
|
||||
272
week9/src/main/resources/static/js/chat.js
Normal file
272
week9/src/main/resources/static/js/chat.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Week 9 Chat JS — AI 聊天机器人前端逻辑
|
||||
*
|
||||
* 涵盖 Day 5 (同步), Day 6 (SSE 流式), Day 7 (多轮对话管理)
|
||||
*/
|
||||
|
||||
// ---- 全局状态 ----
|
||||
let currentConversationId = 'default';
|
||||
const conversations = new Map(); // id -> { title, lastTime }
|
||||
conversations.set('default', { title: '默认对话', lastTime: Date.now() });
|
||||
|
||||
// ---- DOM 引用 ----
|
||||
const messagesEl = document.getElementById('messages');
|
||||
const inputEl = document.getElementById('user-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const streamToggle = document.getElementById('stream-mode');
|
||||
const convListEl = document.getElementById('conversation-list');
|
||||
|
||||
// ---- 工具函数 ----
|
||||
function generateId() {
|
||||
return 'conv_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ---- UI 渲染 ----
|
||||
function removeWelcome() {
|
||||
const welcome = messagesEl.querySelector('.welcome-msg');
|
||||
if (welcome) welcome.remove();
|
||||
}
|
||||
|
||||
function createMsgElement(role) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + role;
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
avatar.textContent = role === 'user' ? '👤' : '🤖';
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'bubble';
|
||||
div.appendChild(avatar);
|
||||
div.appendChild(bubble);
|
||||
messagesEl.appendChild(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ---- Day 5: 同步发送 ----
|
||||
async function sendSyncMessage(message) {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, conversationId: currentConversationId })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code !== 200) {
|
||||
throw new Error(result.message || '请求失败');
|
||||
}
|
||||
return result.data.reply;
|
||||
}
|
||||
|
||||
// ---- Day 6: SSE 流式发送 ----
|
||||
async function sendStreamMessage(message) {
|
||||
const response = await fetch('/api/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, conversationId: currentConversationId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.message || '流式请求失败');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (data === '[DONE]') return;
|
||||
yield data;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 主发送流程 ----
|
||||
async function sendMessage() {
|
||||
const message = inputEl.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
// 禁用输入
|
||||
inputEl.value = '';
|
||||
inputEl.style.height = 'auto';
|
||||
sendBtn.disabled = true;
|
||||
inputEl.disabled = true;
|
||||
|
||||
removeWelcome();
|
||||
|
||||
// 渲染用户消息
|
||||
const userMsgEl = createMsgElement('user');
|
||||
userMsgEl.querySelector('.bubble').textContent = message;
|
||||
scrollToBottom();
|
||||
|
||||
// 渲染 AI 消息占位
|
||||
const aiMsgEl = createMsgElement('assistant');
|
||||
const aiBubble = aiMsgEl.querySelector('.bubble');
|
||||
|
||||
try {
|
||||
if (streamToggle.checked) {
|
||||
// ---- 流式模式 ----
|
||||
aiMsgEl.classList.add('streaming');
|
||||
aiBubble.textContent = '';
|
||||
const stream = await sendStreamMessage(message);
|
||||
|
||||
for await (const token of stream) {
|
||||
aiBubble.textContent += token;
|
||||
scrollToBottom();
|
||||
}
|
||||
aiMsgEl.classList.remove('streaming');
|
||||
|
||||
} else {
|
||||
// ---- 同步模式 ----
|
||||
aiBubble.textContent = '思考中...';
|
||||
const reply = await sendSyncMessage(message);
|
||||
aiBubble.textContent = reply;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// 更新会话信息
|
||||
const conv = conversations.get(currentConversationId);
|
||||
if (conv) {
|
||||
conv.lastTime = Date.now();
|
||||
if (conv.title === '默认对话' || conv.title === '新对话') {
|
||||
conv.title = message.substring(0, 20) + (message.length > 20 ? '...' : '');
|
||||
}
|
||||
}
|
||||
renderConversationList();
|
||||
|
||||
} catch (error) {
|
||||
aiMsgEl.classList.remove('streaming');
|
||||
aiBubble.textContent = '错误: ' + error.message;
|
||||
aiBubble.style.color = '#ef4444';
|
||||
console.error('Chat error:', error);
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
inputEl.disabled = false;
|
||||
inputEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 键盘处理 ----
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
inputEl.addEventListener('input', () => {
|
||||
inputEl.style.height = 'auto';
|
||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
// ---- Day 7: 会话管理 ----
|
||||
function switchConversation(id) {
|
||||
currentConversationId = id;
|
||||
messagesEl.innerHTML = `
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">🤖</div>
|
||||
<h3>会话已切换</h3>
|
||||
<p>当前会话: ${escapeHtml(conversations.get(id)?.title || id)}</p>
|
||||
</div>`;
|
||||
renderConversationList();
|
||||
}
|
||||
|
||||
function newConversation() {
|
||||
const id = generateId();
|
||||
conversations.set(id, { title: '新对话', lastTime: Date.now() });
|
||||
currentConversationId = id;
|
||||
messagesEl.innerHTML = `
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">💬</div>
|
||||
<h3>新对话已创建</h3>
|
||||
<p>开始和 AI 聊天吧!</p>
|
||||
</div>`;
|
||||
renderConversationList();
|
||||
}
|
||||
|
||||
async function deleteConversation(id) {
|
||||
if (id === 'default') return;
|
||||
conversations.delete(id);
|
||||
try {
|
||||
await fetch(`/api/chat/history?conversationId=${id}`, { method: 'DELETE' });
|
||||
} catch (e) { /* ignore */ }
|
||||
if (currentConversationId === id) {
|
||||
currentConversationId = 'default';
|
||||
switchConversation('default');
|
||||
}
|
||||
renderConversationList();
|
||||
}
|
||||
|
||||
async function clearCurrentHistory() {
|
||||
if (!confirm('确定清除当前会话的所有历史消息?')) return;
|
||||
try {
|
||||
await fetch(`/api/chat/history?conversationId=${currentConversationId}`, { method: 'DELETE' });
|
||||
} catch (e) { /* ignore */ }
|
||||
messagesEl.innerHTML = `
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">🧹</div>
|
||||
<h3>历史已清除</h3>
|
||||
<p>当前会话的记忆已清空,开始新的对话吧!</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function onModelChange() {
|
||||
alert('模型切换需要在 application.yml 中修改 spring.ai.openai.chat.options.model 并重启应用。\n此处仅作演示。');
|
||||
}
|
||||
|
||||
function renderConversationList() {
|
||||
convListEl.innerHTML = '';
|
||||
const sorted = [...conversations.entries()]
|
||||
.sort((a, b) => b[1].lastTime - a[1].lastTime);
|
||||
for (const [id, conv] of sorted) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'conv-item' + (id === currentConversationId ? ' active' : '');
|
||||
item.setAttribute('data-id', id);
|
||||
item.onclick = () => switchConversation(id);
|
||||
item.innerHTML = `
|
||||
<span class="conv-title">${escapeHtml(conv.title)}</span>
|
||||
${id !== 'default' ? `<button class="conv-delete" title="删除">×</button>` : ''}`;
|
||||
|
||||
const delBtn = item.querySelector('.conv-delete');
|
||||
if (delBtn) {
|
||||
delBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deleteConversation(id);
|
||||
};
|
||||
}
|
||||
convListEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// ---- 初始化 ----
|
||||
renderConversationList();
|
||||
inputEl.focus();
|
||||
Reference in New Issue
Block a user