tmp
This commit is contained in:
11
week10/src/main/java/com/learn/Week10Application.java
Normal file
11
week10/src/main/java/com/learn/Week10Application.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.learn;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Week10Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Week10Application.class, args);
|
||||
}
|
||||
}
|
||||
129
week10/src/main/java/com/learn/config/AIConfig.java
Normal file
129
week10/src/main/java/com/learn/config/AIConfig.java
Normal file
@@ -0,0 +1,129 @@
|
||||
package com.learn.config;
|
||||
|
||||
import com.learn.tool.CalculatorTool;
|
||||
import com.learn.tool.SearchTool;
|
||||
import com.learn.tool.WeatherTool;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.ai.chat.memory.ChatMemoryRepository;
|
||||
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.ai.openai.OpenAiEmbeddingModel;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.ai.vectorstore.SimpleVectorStore;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
|
||||
/**
|
||||
* Week 10 核心配置 —— Agent 能力装配
|
||||
*
|
||||
* 新增(对比 Week 9):
|
||||
* 1. EmbeddingModel(手动创建,用 Ollama 本地) → RAG
|
||||
* 2. VectorStore(SimpleVectorStore,内存) → RAG
|
||||
* 3. ChatMemory 持久化(RedisChatMemoryRepository) → 记忆不丢失
|
||||
* 4. ChatClient 注册 Tools + QuestionAnswerAdvisor → Agent
|
||||
*/
|
||||
@Configuration
|
||||
public class AIConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AIConfig.class);
|
||||
|
||||
// ==================== Embedding(用于 RAG) ====================
|
||||
|
||||
/**
|
||||
* EmbeddingModel —— 把文本转成向量。
|
||||
*
|
||||
* 这里手动创建 OpenAiEmbeddingModel,指向 Ollama 本地服务,
|
||||
* 因为 Chat 用的是 DeepSeek(不支持 Embedding),两者 base-url 不同。
|
||||
*
|
||||
* 前置条件:ollama pull nomic-embed-text
|
||||
*/
|
||||
@Bean
|
||||
@Lazy
|
||||
public EmbeddingModel embeddingModel(
|
||||
@Value("${spring.ai.openai.embedding.base-url:http://localhost:11434}") String baseUrl,
|
||||
@Value("${spring.ai.openai.embedding.api-key:ollama}") String apiKey) {
|
||||
|
||||
var api = OpenAiApi.builder()
|
||||
.baseUrl(baseUrl.endsWith("/v1") ? baseUrl : baseUrl + "/v1")
|
||||
.apiKey(apiKey)
|
||||
.build();
|
||||
log.info("EmbeddingModel created with base-url: {}", baseUrl);
|
||||
return new OpenAiEmbeddingModel(api);
|
||||
}
|
||||
|
||||
// ==================== VectorStore(用于 RAG) ====================
|
||||
|
||||
/**
|
||||
* SimpleVectorStore —— 纯内存向量存储,免外部服务。
|
||||
* 生产环境可替换为 RedisVectorStore / PgVectorStore 等。
|
||||
*/
|
||||
@Bean
|
||||
@Lazy
|
||||
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
|
||||
return SimpleVectorStore.builder(embeddingModel).build();
|
||||
}
|
||||
|
||||
// ==================== ChatMemory(持久化) ====================
|
||||
|
||||
/**
|
||||
* ChatMemory —— 对话记忆,Redis 持久化(Day 5)。
|
||||
*
|
||||
* 架构: MessageWindowChatMemory (滑动窗口, 最多 30 条)
|
||||
* ↓ 存储
|
||||
* ChatMemoryRepository → RedisChatMemoryRepository (自定义实现)
|
||||
*
|
||||
* 切换方案:把注入的 ChatMemoryRepository 换成 InMemoryChatMemoryRepository
|
||||
* 就回到 Week 9 的内存模式,其他代码完全不用改。
|
||||
*/
|
||||
@Bean
|
||||
public ChatMemory chatMemory(ChatMemoryRepository repository) {
|
||||
return MessageWindowChatMemory.builder()
|
||||
.chatMemoryRepository(repository)
|
||||
.maxMessages(30)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ==================== ChatClient(Agent 入口) ====================
|
||||
|
||||
/**
|
||||
* ChatClient —— Week 10 增强版:
|
||||
* - 复用 Week 9 的 defaultSystem + MessageChatMemoryAdvisor
|
||||
* - 新增 defaultTools:注册所有 @Tool 方法
|
||||
* - 新增 QuestionAnswerAdvisor:RAG 检索增强
|
||||
*
|
||||
* 注意:Tool 对象通过 Spring 注入(用 @Component 标注),
|
||||
* .defaultTools() 接收所有带 @Tool 注解的 Bean。
|
||||
*/
|
||||
@Bean
|
||||
public ChatClient chatClient(
|
||||
ChatModel chatModel,
|
||||
ChatMemory chatMemory,
|
||||
@Lazy VectorStore vectorStore,
|
||||
WeatherTool weatherTool,
|
||||
CalculatorTool calculatorTool,
|
||||
SearchTool searchTool) {
|
||||
|
||||
return ChatClient.builder(chatModel)
|
||||
.defaultSystem("""
|
||||
你是一个 AI Agent 助手,名字叫"小智 Agent"。
|
||||
你可以调用工具来获取信息或执行计算。
|
||||
当用户询问天气、计算数学、或搜索信息时,请使用对应的工具。
|
||||
回答时请用中文,基于工具返回的结果来组织自然流畅的回复。
|
||||
""")
|
||||
.defaultTools(weatherTool, calculatorTool, searchTool)
|
||||
.defaultAdvisors(
|
||||
MessageChatMemoryAdvisor.builder(chatMemory).build(),
|
||||
new QuestionAnswerAdvisor(vectorStore)
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
31
week10/src/main/java/com/learn/config/RedisConfig.java
Normal file
31
week10/src/main/java/com/learn/config/RedisConfig.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.learn.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Redis 相关配置。
|
||||
*
|
||||
* ChatMemory 的 Message 接口有多个实现(UserMessage、AssistantMessage 等),
|
||||
* Jackson 反序列化时需要类型信息才能确定具体类型。
|
||||
* 这里配置一个启用默认类型推导的 ObjectMapper。
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
/**
|
||||
* 专用于 ChatMemory Message 序列化/反序列化的 ObjectMapper。
|
||||
* 启用 NON_FINAL 类型推导,让 Jackson 在多态反序列化时自动写入/读取类型信息。
|
||||
*/
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
var mapper = new ObjectMapper();
|
||||
mapper.activateDefaultTyping(
|
||||
BasicPolymorphicTypeValidator.builder().build(),
|
||||
ObjectMapper.DefaultTyping.NON_FINAL
|
||||
);
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
166
week10/src/main/java/com/learn/controller/ChatController.java
Normal file
166
week10/src/main/java/com/learn/controller/ChatController.java
Normal file
@@ -0,0 +1,166 @@
|
||||
package com.learn.controller;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import com.learn.dto.ChatRequest;
|
||||
import com.learn.dto.RAGQueryRequest;
|
||||
import com.learn.service.AgentService;
|
||||
import com.learn.service.ChatService;
|
||||
import com.learn.service.PromptTemplateService;
|
||||
import com.learn.service.RAGService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.ai.document.Document;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class ChatController {
|
||||
|
||||
private final ChatService chatService;
|
||||
private final AgentService agentService;
|
||||
private final RAGService ragService;
|
||||
private final PromptTemplateService promptService;
|
||||
|
||||
public ChatController(ChatService chatService, AgentService agentService,
|
||||
RAGService ragService, PromptTemplateService promptService) {
|
||||
this.chatService = chatService;
|
||||
this.agentService = agentService;
|
||||
this.ragService = ragService;
|
||||
this.promptService = promptService;
|
||||
}
|
||||
|
||||
// ==================== Week 9 复用: 基础对话 ====================
|
||||
|
||||
@PostMapping("/api/chat")
|
||||
public ApiResponse<Map<String, String>> chat(@Valid @RequestBody ChatRequest request) {
|
||||
String convId = request.getConversationId() != null ? request.getConversationId() : "default";
|
||||
String reply = chatService.chat(request.getMessage(), convId);
|
||||
return ApiResponse.success(Map.of("reply", reply, "conversationId", convId));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/api/chat/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()));
|
||||
}
|
||||
|
||||
@GetMapping("/api/chat/history")
|
||||
public ApiResponse<?> getHistory(@RequestParam(defaultValue = "default") String conversationId) {
|
||||
return ApiResponse.success(chatService.getHistory(conversationId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/chat/history")
|
||||
public ApiResponse<?> clearHistory(@RequestParam(defaultValue = "default") String conversationId) {
|
||||
chatService.clearHistory(conversationId);
|
||||
return ApiResponse.success("已清除会话: " + conversationId);
|
||||
}
|
||||
|
||||
// ==================== Day 3-4: Agent 任务(Function Calling) ====================
|
||||
|
||||
/**
|
||||
* 同步 Agent 执行 —— 模型自动选择并调用工具。
|
||||
*
|
||||
* POST /api/agent
|
||||
* Body: {"message": "北京天气如何?", "conversationId": "会话ID(可选)"}
|
||||
*/
|
||||
@PostMapping("/api/agent")
|
||||
public ApiResponse<Map<String, Object>> agent(@Valid @RequestBody ChatRequest request) {
|
||||
var result = agentService.execute(request.getMessage(), request.getConversationId());
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式 Agent 执行。
|
||||
*
|
||||
* POST /api/agent/stream
|
||||
*/
|
||||
@PostMapping(value = "/api/agent/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public Flux<ServerSentEvent<String>> agentStream(@Valid @RequestBody ChatRequest request) {
|
||||
return agentService.executeStream(request.getMessage(), request.getConversationId())
|
||||
.map(token -> ServerSentEvent.<String>builder().data(token).build())
|
||||
.concatWith(Mono.just(ServerSentEvent.<String>builder()
|
||||
.event("done").data("[DONE]").build()));
|
||||
}
|
||||
|
||||
// ==================== Day 6: RAG 检索增强 ====================
|
||||
|
||||
/**
|
||||
* RAG 检索(只检索不生成,看检索效果)。
|
||||
*
|
||||
* POST /api/rag/search
|
||||
* Body: {"question": "什么是 Spring AI?", "topK": 3}
|
||||
*/
|
||||
@PostMapping("/api/rag/search")
|
||||
public ApiResponse<List<Map<String, Object>>> ragSearch(@Valid @RequestBody RAGQueryRequest request) {
|
||||
return ApiResponse.success(ragService.search(request.getQuestion(), request.getTopK()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 向知识库添加文档。
|
||||
*
|
||||
* POST /api/rag/load
|
||||
* Body: [{"text": "文档内容", "metadata": {"source": "spring-doc"}}]
|
||||
*/
|
||||
@PostMapping("/api/rag/load")
|
||||
public ApiResponse<Map<String, Object>> ragLoad(@RequestBody List<Map<String, Object>> documents) {
|
||||
var docs = documents.stream()
|
||||
.map(m -> {
|
||||
String text = (String) m.get("text");
|
||||
@SuppressWarnings("unchecked")
|
||||
var metadata = (Map<String, Object>) m.getOrDefault("metadata", Map.of());
|
||||
return new Document(text, new java.util.HashMap<>(metadata));
|
||||
})
|
||||
.toList();
|
||||
int count = ragService.addDocuments(docs);
|
||||
return ApiResponse.created(Map.of("added", count));
|
||||
}
|
||||
|
||||
// ==================== Day 7: Prompt 模板管理 ====================
|
||||
|
||||
/**
|
||||
* 获取所有模板名称和预览。
|
||||
*
|
||||
* GET /api/prompts
|
||||
*/
|
||||
@GetMapping("/api/prompts")
|
||||
public ApiResponse<List<Map<String, String>>> listPrompts() {
|
||||
return ApiResponse.success(promptService.list());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定模板的原始内容。
|
||||
*
|
||||
* GET /api/prompts/{name}
|
||||
*/
|
||||
@GetMapping("/api/prompts/{name}")
|
||||
public ApiResponse<Map<String, String>> getPrompt(@PathVariable String name) {
|
||||
String content = promptService.getTemplate(name);
|
||||
return ApiResponse.success(Map.of("name", name, "content", content));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Prompt 模板进行对话。
|
||||
*
|
||||
* POST /api/chat/with-template
|
||||
* Body: {"message": "用户消息", "templateName": "java-expert", "params": {"key": "value"}, "conversationId": "xxx"}
|
||||
*/
|
||||
@PostMapping("/api/chat/with-template")
|
||||
public ApiResponse<Map<String, String>> chatWithTemplate(@RequestBody Map<String, Object> body) {
|
||||
String message = (String) body.get("message");
|
||||
String templateName = (String) body.get("templateName");
|
||||
String conversationId = (String) body.getOrDefault("conversationId", "default");
|
||||
@SuppressWarnings("unchecked")
|
||||
var params = (Map<String, String>) body.getOrDefault("params", Map.of());
|
||||
|
||||
String templateContent = promptService.apply(templateName, params);
|
||||
String reply = chatService.chatWithTemplate(templateContent, message, conversationId);
|
||||
return ApiResponse.success(Map.of("reply", reply, "conversationId", conversationId));
|
||||
}
|
||||
}
|
||||
59
week10/src/main/java/com/learn/dto/ApiResponse.java
Normal file
59
week10/src/main/java/com/learn/dto/ApiResponse.java
Normal file
@@ -0,0 +1,59 @@
|
||||
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
week10/src/main/java/com/learn/dto/ChatHistoryVo.java
Normal file
18
week10/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; }
|
||||
}
|
||||
15
week10/src/main/java/com/learn/dto/ChatRequest.java
Normal file
15
week10/src/main/java/com/learn/dto/ChatRequest.java
Normal file
@@ -0,0 +1,15 @@
|
||||
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; }
|
||||
}
|
||||
16
week10/src/main/java/com/learn/dto/RAGQueryRequest.java
Normal file
16
week10/src/main/java/com/learn/dto/RAGQueryRequest.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class RAGQueryRequest {
|
||||
|
||||
@NotBlank(message = "问题不能为空")
|
||||
private String question;
|
||||
|
||||
private int topK = 3;
|
||||
|
||||
public String getQuestion() { return question; }
|
||||
public void setQuestion(String question) { this.question = question; }
|
||||
public int getTopK() { return topK; }
|
||||
public void setTopK(int topK) { this.topK = topK; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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(org.springframework.ai.retry.NonTransientAiException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleAIException(org.springframework.ai.retry.NonTransientAiException ex) {
|
||||
log.error("AI API error", ex);
|
||||
return ResponseEntity.status(502)
|
||||
.body(ApiResponse.error(502, "AI 服务错误: " + 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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.learn.memory;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.memory.ChatMemoryRepository;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Redis 实现的 ChatMemoryRepository。
|
||||
*
|
||||
* 实现 ChatMemoryRepository 接口(4 个方法),让 MessageWindowChatMemory
|
||||
* 可以把对话历史持久化到 Redis,应用重启后记忆不丢失。
|
||||
*
|
||||
* 存储结构:
|
||||
* chat:memory:<conversationId> → Redis List,每个元素是 Message 的 JSON
|
||||
* chat:memory:ids → Redis Set,存放所有 conversationId
|
||||
*/
|
||||
@Repository
|
||||
public class RedisChatMemoryRepository implements ChatMemoryRepository {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RedisChatMemoryRepository.class);
|
||||
private static final String KEY_PREFIX = "chat:memory:";
|
||||
private static final String IDS_KEY = "chat:memory:ids";
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RedisChatMemoryRepository(StringRedisTemplate redis, ObjectMapper objectMapper) {
|
||||
this.redis = redis;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> findConversationIds() {
|
||||
var ids = redis.opsForSet().members(IDS_KEY);
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
return ids.stream().sorted().toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Message> findByConversationId(String conversationId) {
|
||||
String key = KEY_PREFIX + conversationId;
|
||||
var rawList = redis.opsForList().range(key, 0, -1);
|
||||
if (rawList == null || rawList.isEmpty()) return List.of();
|
||||
|
||||
var messages = new ArrayList<Message>();
|
||||
for (var raw : rawList) {
|
||||
try {
|
||||
messages.add(objectMapper.readValue(raw, Message.class));
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to deserialize message: {}", raw, e);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAll(String conversationId, List<Message> messages) {
|
||||
String key = KEY_PREFIX + conversationId;
|
||||
redis.delete(key);
|
||||
|
||||
for (var msg : messages) {
|
||||
try {
|
||||
redis.opsForList().rightPush(key, objectMapper.writeValueAsString(msg));
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to serialize message", e);
|
||||
}
|
||||
}
|
||||
redis.opsForSet().add(IDS_KEY, conversationId);
|
||||
log.debug("Saved {} messages to Redis for conversation: {}", messages.size(), conversationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByConversationId(String conversationId) {
|
||||
redis.delete(KEY_PREFIX + conversationId);
|
||||
redis.opsForSet().remove(IDS_KEY, conversationId);
|
||||
log.debug("Deleted Redis memory for conversation: {}", conversationId);
|
||||
}
|
||||
}
|
||||
74
week10/src/main/java/com/learn/rag/KnowledgeDataLoader.java
Normal file
74
week10/src/main/java/com/learn/rag/KnowledgeDataLoader.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package com.learn.rag;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 启动时向 VectorStore 加载示例知识库文档。
|
||||
*
|
||||
* 依赖 Ollama Embedding 服务,完全懒加载 —— 只在首次 RAG 请求时触发。
|
||||
* 如果 Ollama 不可用,会打印警告但不会阻止应用启动。
|
||||
*/
|
||||
@Component
|
||||
@Lazy
|
||||
public class KnowledgeDataLoader {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(KnowledgeDataLoader.class);
|
||||
|
||||
private final VectorStore vectorStore;
|
||||
|
||||
public KnowledgeDataLoader(@Lazy VectorStore vectorStore) {
|
||||
this.vectorStore = vectorStore;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void load() {
|
||||
try {
|
||||
var docs = List.of(
|
||||
new Document("""
|
||||
Spring AI 是一个面向 AI 工程的 Spring 框架,目标是让 Java 开发者能够轻松集成 AI 能力。
|
||||
它提供了 ChatClient、VectorStore、Function Calling 等核心组件。
|
||||
Spring AI 支持 OpenAI、DeepSeek、Ollama 等多种模型提供商。
|
||||
""", Map.of("source", "spring-ai-overview", "topic", "spring-ai")),
|
||||
|
||||
new Document("""
|
||||
RAG(Retrieval-Augmented Generation,检索增强生成)是一种结合信息检索和文本生成的技术。
|
||||
它先从知识库中检索与用户问题相关的文档片段,然后将这些片段作为上下文注入 Prompt,
|
||||
让大模型基于这些外部知识生成更准确的回答,有效缓解模型幻觉问题。
|
||||
""", Map.of("source", "rag-concept", "topic", "rag")),
|
||||
|
||||
new Document("""
|
||||
Function Calling(函数调用)是让大模型调用外部工具的能力。
|
||||
在 Spring AI 中,使用 @Tool 注解标记一个 Java 方法,框架会自动生成工具定义
|
||||
并发送给模型。模型判断需要调用工具时,框架执行对应方法并将结果返回给模型。
|
||||
""", Map.of("source", "function-calling", "topic", "function-calling")),
|
||||
|
||||
new Document("""
|
||||
VectorStore(向量存储)是 RAG 的核心组件,负责存储和检索文档向量。
|
||||
Spring AI 提供多种实现:SimpleVectorStore(内存)、PgVectorStore(PostgreSQL)、
|
||||
RedisVectorStore(Redis)、MilvusVectorStore、QdrantVectorStore 等。
|
||||
SimpleVectorStore 适合学习和开发,生产环境应选择支持持久化的方案。
|
||||
""", Map.of("source", "vector-store", "topic", "rag")),
|
||||
|
||||
new Document("""
|
||||
ChatMemory 用于存储对话历史,实现多轮对话的记忆能力。
|
||||
Spring AI 提供 MessageWindowChatMemory(滑动窗口)和 MessageTokenChatMemory(Token 截断)两种策略。
|
||||
通过 ChatMemoryRepository 接口可以将对话历史持久化到 Redis、JDBC 等存储中。
|
||||
""", Map.of("source", "chat-memory", "topic", "spring-ai"))
|
||||
);
|
||||
|
||||
vectorStore.add(docs);
|
||||
log.info("Loaded {} knowledge documents into VectorStore", docs.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("RAG knowledge base unavailable: {}. Agent features still work.", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
70
week10/src/main/java/com/learn/service/AgentService.java
Normal file
70
week10/src/main/java/com/learn/service/AgentService.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.learn.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
|
||||
|
||||
/**
|
||||
* Agent 编排服务 —— 管理多工具协作的执行。
|
||||
*
|
||||
* 和 ChatService 的区别:
|
||||
* - ChatService: 纯聊天(Week 9 能力)
|
||||
* - AgentService: Agent 模式,ChatClient 通过 .tools() 注册的工具自动调用
|
||||
*
|
||||
* 实际上,因为 AIConfig 中已经在 ChatClient 上注册了 defaultTools,
|
||||
* 所以即使用 ChatService 聊天,模型也会在需要时自动调用工具。
|
||||
* AgentService 是更明确的"Agent 任务"入口,未来可以扩展多步推理等高级特性。
|
||||
*/
|
||||
@Service
|
||||
public class AgentService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentService.class);
|
||||
|
||||
private final ChatClient chatClient;
|
||||
|
||||
public AgentService(ChatClient chatClient) {
|
||||
this.chatClient = chatClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 Agent 执行。
|
||||
* 模型会自动判断是否需要调用工具,以及调用哪个工具。
|
||||
*/
|
||||
public Map<String, Object> execute(String task, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
log.info("Agent task [{}]: {}", convId, task);
|
||||
|
||||
String reply = chatClient.prompt()
|
||||
.user(task)
|
||||
.advisors(a -> a.param(CONVERSATION_ID, convId))
|
||||
.call()
|
||||
.content();
|
||||
|
||||
return Map.of("reply", reply, "conversationId", convId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式 Agent 执行。
|
||||
*/
|
||||
public Flux<String> executeStream(String task, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
log.info("Agent stream task [{}]: {}", convId, task);
|
||||
|
||||
return chatClient.prompt()
|
||||
.user(task)
|
||||
.advisors(a -> a.param(CONVERSATION_ID, convId))
|
||||
.stream()
|
||||
.content();
|
||||
}
|
||||
|
||||
private String defaultIfNull(String id) {
|
||||
return (id == null || id.isBlank()) ? "default" : id;
|
||||
}
|
||||
}
|
||||
85
week10/src/main/java/com/learn/service/ChatService.java
Normal file
85
week10/src/main/java/com/learn/service/ChatService.java
Normal file
@@ -0,0 +1,85 @@
|
||||
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.*;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 同步对话(带记忆) */
|
||||
public String chat(String message, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.advisors(a -> a.param(CONVERSATION_ID, convId))
|
||||
.call()
|
||||
.content();
|
||||
}
|
||||
|
||||
/** 流式对话(带记忆) */
|
||||
public Flux<String> chatStream(String message, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.advisors(a -> a.param(CONVERSATION_ID, convId))
|
||||
.stream()
|
||||
.content();
|
||||
}
|
||||
|
||||
/** 使用 Prompt 模板的对话 */
|
||||
public String chatWithTemplate(String templateContent, String message, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
String fullPrompt = templateContent + "\n\n---\n用户消息: " + message;
|
||||
return chatClient.prompt()
|
||||
.user(fullPrompt)
|
||||
.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) {
|
||||
chatMemory.clear(defaultIfNull(conversationId));
|
||||
}
|
||||
|
||||
private String defaultIfNull(String id) {
|
||||
return (id == null || id.isBlank()) ? "default" : id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.learn.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Prompt 模板管理服务(Day 7)。
|
||||
*
|
||||
* 从 classpath:/prompts/ 加载 .txt 模板文件。
|
||||
* 支持占位符替换: {{key}} → value
|
||||
*/
|
||||
@Service
|
||||
public class PromptTemplateService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PromptTemplateService.class);
|
||||
|
||||
private final Map<String, String> templates = new LinkedHashMap<>();
|
||||
|
||||
@PostConstruct
|
||||
public void loadTemplates() throws IOException {
|
||||
var resolver = new PathMatchingResourcePatternResolver();
|
||||
var resources = resolver.getResources("classpath:/prompts/*.txt");
|
||||
|
||||
for (var res : resources) {
|
||||
String filename = res.getFilename();
|
||||
if (filename == null) continue;
|
||||
String name = filename.replace(".txt", "");
|
||||
String content = res.getContentAsString(StandardCharsets.UTF_8);
|
||||
templates.put(name, content);
|
||||
log.info("Loaded prompt template: {} ({} chars)", name, content.length());
|
||||
}
|
||||
log.info("Loaded {} prompt templates", templates.size());
|
||||
}
|
||||
|
||||
/** 获取所有模板名称 */
|
||||
public List<Map<String, String>> list() {
|
||||
return templates.entrySet().stream()
|
||||
.map(e -> Map.of("name", e.getKey(), "preview",
|
||||
e.getValue().substring(0, Math.min(80, e.getValue().length())) + "..."))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 获取模板原始内容 */
|
||||
public String getTemplate(String name) {
|
||||
String content = templates.get(name);
|
||||
if (content == null) {
|
||||
throw new IllegalArgumentException("模板不存在: " + name + "。可用: " + templates.keySet());
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** 应用模板:替换 {{key}} 占位符 */
|
||||
public String apply(String name, Map<String, String> params) {
|
||||
String content = getTemplate(name);
|
||||
for (var entry : params.entrySet()) {
|
||||
content = content.replace("{{" + entry.getKey() + "}}", entry.getValue());
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
63
week10/src/main/java/com/learn/service/RAGService.java
Normal file
63
week10/src/main/java/com/learn/service/RAGService.java
Normal file
@@ -0,0 +1,63 @@
|
||||
package com.learn.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.vectorstore.SearchRequest;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RAG 服务 —— 检索增强生成。
|
||||
*
|
||||
* 流程: 用户问题 → VectorStore 相似度检索 → 拼接 Context → ChatClient 生成回答
|
||||
*
|
||||
* 由于 AIConfig 中 ChatClient 已注册 QuestionAnswerAdvisor,
|
||||
* 实际上通过 ChatService 聊天时已自带 RAG 能力。
|
||||
* 本服务提供显式的 RAG 查询接口,让学习过程更清晰。
|
||||
*/
|
||||
@Service
|
||||
public class RAGService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RAGService.class);
|
||||
|
||||
private final VectorStore vectorStore;
|
||||
|
||||
public RAGService(VectorStore vectorStore) {
|
||||
this.vectorStore = vectorStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索相关文档(不做生成,纯粹看检索效果)。
|
||||
*/
|
||||
public List<Map<String, Object>> search(String question, int topK) {
|
||||
var results = vectorStore.similaritySearch(
|
||||
SearchRequest.builder()
|
||||
.query(question)
|
||||
.topK(topK)
|
||||
.similarityThreshold(0.3)
|
||||
.build()
|
||||
);
|
||||
log.info("RAG search for '{}': found {} docs", question, results.size());
|
||||
|
||||
return results.stream()
|
||||
.map(doc -> Map.<String, Object>of(
|
||||
"content", doc.getText(),
|
||||
"score", String.format("%.4f", doc.getScore()),
|
||||
"metadata", doc.getMetadata()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 向知识库添加文档。
|
||||
*/
|
||||
public int addDocuments(List<Document> documents) {
|
||||
vectorStore.add(documents);
|
||||
log.info("Added {} documents to vector store", documents.size());
|
||||
return documents.size();
|
||||
}
|
||||
}
|
||||
29
week10/src/main/java/com/learn/tool/CalculatorTool.java
Normal file
29
week10/src/main/java/com/learn/tool/CalculatorTool.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.learn.tool;
|
||||
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 计算器工具 —— Day 4 多工具协作示例。
|
||||
* 支持基本四则运算,使用 JavaScript 引擎安全求值。
|
||||
*/
|
||||
@Component
|
||||
public class CalculatorTool {
|
||||
|
||||
@Tool(description = "执行数学计算表达式。支持加减乘除和括号。例如: '2+3*4' 返回 14")
|
||||
public String calculate(
|
||||
@ToolParam(description = "数学表达式,如 '123*456' 或 '(100+200)/3'") String expression) {
|
||||
|
||||
try {
|
||||
var engine = new javax.script.ScriptEngineManager().getEngineByName("js");
|
||||
if (engine == null) {
|
||||
throw new RuntimeException("JavaScript 引擎不可用");
|
||||
}
|
||||
var result = engine.eval(expression);
|
||||
return expression + " = " + result;
|
||||
} catch (Exception e) {
|
||||
return "计算失败: " + e.getMessage() + "。请确认表达式格式是否正确,例如 '123*456'。";
|
||||
}
|
||||
}
|
||||
}
|
||||
34
week10/src/main/java/com/learn/tool/SearchTool.java
Normal file
34
week10/src/main/java/com/learn/tool/SearchTool.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.learn.tool;
|
||||
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 搜索工具 —— Day 4 多工具协作示例。
|
||||
* 模拟搜索引擎,返回预置的"搜索结果"。
|
||||
* 实际项目中可对接 SearXNG、Bing Search API 等。
|
||||
*/
|
||||
@Component
|
||||
public class SearchTool {
|
||||
|
||||
@Tool(description = "搜索互联网信息,返回相关结果摘要。用于查找事实、新闻、技术文档等")
|
||||
public String search(
|
||||
@ToolParam(description = "搜索关键词") String query) {
|
||||
|
||||
return switch (query.toLowerCase()) {
|
||||
case "spring ai" ->
|
||||
"Spring AI 是 Spring 生态的 AI 集成框架。最新稳定版 1.0.6 (2026.04)。" +
|
||||
"核心功能: ChatClient、Function Calling (@Tool)、RAG、Advisor 拦截器链。";
|
||||
case "java 21" ->
|
||||
"Java 21 是 Oracle 发布的 LTS 版本 (2023.09)。" +
|
||||
"新特性: 虚拟线程 (Virtual Threads)、Record Patterns、Switch 模式匹配。";
|
||||
case "deepseek" ->
|
||||
"DeepSeek 由深度求索公司开发。最新模型 DeepSeek-V3 在代码和数学能力上表现突出。" +
|
||||
"API 兼容 OpenAI 格式,base-url: https://api.deepseek.com。";
|
||||
default ->
|
||||
"关于'" + query + "',未找到精确匹配。这是一条模拟搜索结果。" +
|
||||
"实际部署时请接入真实搜索引擎 API。";
|
||||
};
|
||||
}
|
||||
}
|
||||
40
week10/src/main/java/com/learn/tool/WeatherTool.java
Normal file
40
week10/src/main/java/com/learn/tool/WeatherTool.java
Normal file
@@ -0,0 +1,40 @@
|
||||
package com.learn.tool;
|
||||
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 天气查询工具 —— Day 3 的第一个 Tool 示例。
|
||||
* 模拟天气 API,实际项目中可以对接和风天气、OpenWeatherMap 等真实 API。
|
||||
*/
|
||||
@Component
|
||||
public class WeatherTool {
|
||||
|
||||
private static final Map<String, String> CITY_MAP = Map.of(
|
||||
"北京", "晴, 22°C, 湿度 45%, 北风 3级",
|
||||
"上海", "多云, 25°C, 湿度 65%, 东南风 2级",
|
||||
"广州", "雷阵雨, 28°C, 湿度 80%, 南风 4级",
|
||||
"深圳", "阴转多云, 27°C, 湿度 72%, 东风 3级",
|
||||
"杭州", "小雨, 20°C, 湿度 78%, 东北风 2级",
|
||||
"成都", "阴, 18°C, 湿度 70%, 无持续风向",
|
||||
"武汉", "晴转多云, 24°C, 湿度 55%, 南风 3级",
|
||||
"东京", "晴, 18°C, 湿度 50%, 西风 2级",
|
||||
"纽约", "阴, 15°C, 湿度 60%, 西北风 5级",
|
||||
"伦敦", "小雨, 12°C, 湿度 75%, 西南风 4级"
|
||||
);
|
||||
|
||||
@Tool(description = "获取指定城市的当前天气信息,包括温度、湿度、风向等")
|
||||
public String getWeather(
|
||||
@ToolParam(description = "城市名称,如'北京'、'上海'、'东京'") String city) {
|
||||
|
||||
for (var entry : CITY_MAP.entrySet()) {
|
||||
if (entry.getKey().contains(city) || city.contains(entry.getKey())) {
|
||||
return entry.getKey() + "天气: " + entry.getValue();
|
||||
}
|
||||
}
|
||||
return "未找到城市'" + city + "'的天气信息。支持的城市: " + CITY_MAP.keySet();
|
||||
}
|
||||
}
|
||||
51
week10/src/main/resources/application.yml
Normal file
51
week10/src/main/resources/application.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
spring:
|
||||
application:
|
||||
name: week10-ai-agent
|
||||
|
||||
# =============================================================
|
||||
# AI 模型配置(Chat + Embedding 分离)
|
||||
# =============================================================
|
||||
# Chat 模型: DeepSeek(或其他 OpenAI 兼容服务)
|
||||
# Embedding 模型: Ollama 本地(或其他 OpenAI 兼容服务)
|
||||
# =============================================================
|
||||
ai:
|
||||
openai:
|
||||
# ---- Chat 模型(DeepSeek) ----
|
||||
api-key: ${AI_API_KEY:sk-fe4d2f26eda04a659b9ff3408ce5024b}
|
||||
base-url: ${AI_BASE_URL:https://api.deepseek.com}
|
||||
chat:
|
||||
options:
|
||||
model: deepseek-chat
|
||||
temperature: 0.3 # Agent/工具调用建议用较低温度,减少幻觉
|
||||
|
||||
# ---- Embedding 模型(Ollama 本地) ----
|
||||
# 方案 B(默认): Ollama 本地 → 免费、离线
|
||||
# 前置: ollama pull nomic-embed-text
|
||||
#
|
||||
# 方案 A(备选): 阿里云百炼 → 云端、稳定
|
||||
# embedding:
|
||||
# api-key: ${EMBEDDING_API_KEY:sk-your-bailian-key}
|
||||
# base-url: https://dashscope.aliyuncs.com/compatible-mode
|
||||
# options:
|
||||
# model: text-embedding-v2
|
||||
embedding:
|
||||
api-key: ollama
|
||||
base-url: ${EMBEDDING_BASE_URL:http://localhost:11434}
|
||||
options:
|
||||
model: nomic-embed-text
|
||||
|
||||
# Redis(Week 5 的 Redis 配置,用于 ChatMemory 持久化)
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password: ${REDIS_PASSWORD:1365957941@Wfj}
|
||||
timeout: 3000ms
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.learn: DEBUG
|
||||
org.springframework.ai.chat.client.advisor: DEBUG
|
||||
22
week10/src/main/resources/prompts/code-reviewer.txt
Normal file
22
week10/src/main/resources/prompts/code-reviewer.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
你是一位严格的代码审查专家,擅长发现代码中的潜在问题。
|
||||
|
||||
请从以下维度审查用户提供的代码:
|
||||
|
||||
1. **正确性**: 逻辑是否有误?边界条件是否处理?
|
||||
2. **安全性**: 是否存在 OWASP Top 10 漏洞?SQL 注入、XSS、敏感信息泄露等
|
||||
3. **性能**: 是否有 N+1 查询、内存泄漏、不必要的对象创建?
|
||||
4. **可维护性**: 命名是否清晰?职责是否单一?耦合是否过高?
|
||||
5. **规范**: 是否符合 Java/Spring 编码规范?
|
||||
|
||||
审查报告格式:
|
||||
|
||||
### 严重问题(必须修复)
|
||||
- [ ] 问题描述 + 修复建议
|
||||
|
||||
### 改进建议(推荐优化)
|
||||
- [ ] 问题描述 + 优化方案
|
||||
|
||||
### 亮点(做得好的地方)
|
||||
- [ ] ...
|
||||
|
||||
请直接对用户后续发送的代码进行审查。
|
||||
14
week10/src/main/resources/prompts/java-expert.txt
Normal file
14
week10/src/main/resources/prompts/java-expert.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
你是一位资深 Java 技术专家,拥有 15 年以上的企业级开发经验。
|
||||
|
||||
请遵循以下原则回答用户的问题:
|
||||
|
||||
1. **代码示例**: 使用 Java 21+ 语法,优先使用虚拟线程、Records、Sealed Classes 等新特性
|
||||
2. **最佳实践**: 强调 SOLID 原则、设计模式、Clean Code
|
||||
3. **性能意识**: 关注内存管理、GC 调优、并发优化
|
||||
4. **Spring 生态**: 优先推荐 Spring Boot 3.x 的最新实践
|
||||
5. **安全**: 对涉及安全的代码要特别标注注意事项
|
||||
|
||||
回答时请:
|
||||
- 先用 1-2 句话总结核心观点
|
||||
- 再用代码示例说明
|
||||
- 最后补充注意事项和替代方案
|
||||
10
week10/src/main/resources/prompts/translator.txt
Normal file
10
week10/src/main/resources/prompts/translator.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
你是一位专业的中英文翻译专家。
|
||||
|
||||
翻译规则:
|
||||
|
||||
1. **中文→英文**: 使用地道的英语表达,避免中式英语
|
||||
2. **英文→中文**: 使用流畅的自然中文,避免翻译腔
|
||||
3. **技术术语**: 保持专业术语的准确性
|
||||
4. **格式保留**: 保持原文的空行、列表、代码块等格式
|
||||
|
||||
请直接输出翻译结果,无需额外解释。
|
||||
94
week10/src/main/resources/static/agent.html
Normal file
94
week10/src/main/resources/static/agent.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小智 Agent — Week 10 AI Agent 控制台</title>
|
||||
<link rel="stylesheet" href="/css/agent.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>小智 Agent</h2>
|
||||
<span class="badge">Week 10</span>
|
||||
</div>
|
||||
|
||||
<!-- 会话管理 -->
|
||||
<div class="sidebar-section">
|
||||
<h3>会话</h3>
|
||||
<button class="btn btn-sm btn-secondary" onclick="newConversation()">+ 新会话</button>
|
||||
<ul class="conv-list" id="convList">
|
||||
<li class="conv-item active" data-id="default" onclick="switchConv('default')">默认会话</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Prompt 模板 -->
|
||||
<div class="sidebar-section">
|
||||
<h3>Prompt 模板</h3>
|
||||
<select id="templateSelect" onchange="selectTemplate(this.value)">
|
||||
<option value="">— 不使用模板 —</option>
|
||||
</select>
|
||||
<div id="templatePreview" class="template-preview hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- 工具状态 -->
|
||||
<div class="sidebar-section">
|
||||
<h3>可用工具</h3>
|
||||
<div class="tool-list" id="toolList">
|
||||
<div class="tool-item"><span class="tool-icon">🌤️</span> WeatherTool</div>
|
||||
<div class="tool-item"><span class="tool-icon">🔢</span> CalculatorTool</div>
|
||||
<div class="tool-item"><span class="tool-icon">🔍</span> SearchTool</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置 -->
|
||||
<div class="sidebar-section">
|
||||
<h3>模式</h3>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="streamToggle" checked>
|
||||
<span>流式输出 (SSE)</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="agentToggle" checked>
|
||||
<span>Agent 模式</span>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<main class="main">
|
||||
<div class="chat-container">
|
||||
<div class="chat-header">
|
||||
<h1 id="chatTitle">Agent 控制台</h1>
|
||||
<span class="conv-id" id="convIdLabel">会话: default</span>
|
||||
</div>
|
||||
|
||||
<div class="messages" id="messages">
|
||||
<div class="welcome">
|
||||
<h2>欢迎使用 AI Agent 控制台</h2>
|
||||
<p>我可以调用工具(天气、计算、搜索)来回答你的问题。</p>
|
||||
<div class="sample-prompts">
|
||||
<h4>试试这些:</h4>
|
||||
<button onclick="sendSample('北京今天天气如何?')">🌤️ 查天气</button>
|
||||
<button onclick="sendSample('帮我算一下 123 * 456 等于多少')">🔢 计算</button>
|
||||
<button onclick="sendSample('搜索一下 Spring AI 的最新信息')">🔍 搜索</button>
|
||||
<button onclick="sendSample('什么是 Function Calling?')">📚 RAG 知识库</button>
|
||||
<button onclick="sendSample('北京天气如何?顺便帮我算一下 999 * 888')">🤖 多工具协作</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea id="userInput" placeholder="输入你的消息..." rows="2"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
|
||||
<button class="btn btn-primary" onclick="sendMessage()" id="sendBtn">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/agent.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
307
week10/src/main/resources/static/css/agent.css
Normal file
307
week10/src/main/resources/static/css/agent.css
Normal file
@@ -0,0 +1,307 @@
|
||||
/* ==================== Reset & Base ==================== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--sidebar-bg: #1a1d27;
|
||||
--card-bg: #1e2130;
|
||||
--border: #2a2d3a;
|
||||
--text: #e1e4ea;
|
||||
--text-muted: #8b8fa3;
|
||||
--accent: #6c5ce7;
|
||||
--accent-hover: #7d6ff0;
|
||||
--success: #00cec9;
|
||||
--warning: #fdcb6e;
|
||||
--danger: #e17055;
|
||||
--user-bg: #2d3043;
|
||||
--bot-bg: #1a1d27;
|
||||
--tool-bg: #1a2332;
|
||||
--source-bg: #1a2e1a;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== Layout ==================== */
|
||||
.app { display: flex; height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
.main { flex: 1; overflow: hidden; }
|
||||
|
||||
/* ==================== Sidebar ==================== */
|
||||
.sidebar-header { display: flex; align-items: center; gap: 8px; }
|
||||
.sidebar-header h2 { font-size: 18px; color: var(--accent); }
|
||||
.badge {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sidebar-section h3 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ==================== Buttons ==================== */
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #fff; width: 100%; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-secondary { background: var(--card-bg); color: var(--text); width: 100%; border: 1px solid var(--border); }
|
||||
.btn-secondary:hover { border-color: var(--accent); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* ==================== Conversation List ==================== */
|
||||
.conv-list { list-style: none; margin-top: 8px; }
|
||||
.conv-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s;
|
||||
margin-bottom: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.conv-item:hover { background: var(--card-bg); color: var(--text); }
|
||||
.conv-item.active { background: var(--accent); color: #fff; }
|
||||
|
||||
/* ==================== Template Select ==================== */
|
||||
#templateSelect {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.template-preview {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: var(--card-bg);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.hidden { display: none; }
|
||||
|
||||
/* ==================== Tool List ==================== */
|
||||
.tool-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--card-bg);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tool-icon { font-size: 16px; }
|
||||
|
||||
/* ==================== Toggles ==================== */
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.toggle input { accent-color: var(--accent); }
|
||||
|
||||
/* ==================== Chat Container ==================== */
|
||||
.chat-container { display: flex; flex-direction: column; height: 100%; }
|
||||
.chat-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.chat-header h1 { font-size: 18px; }
|
||||
.conv-id { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* ==================== Messages ==================== */
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.welcome h2 { color: var(--text); margin-bottom: 8px; }
|
||||
|
||||
.sample-prompts { margin-top: 20px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
||||
.sample-prompts button {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.sample-prompts button:hover { border-color: var(--accent); background: var(--user-bg); }
|
||||
|
||||
/* ==================== Message Bubbles ==================== */
|
||||
.message { display: flex; flex-direction: column; max-width: 85%; animation: fadeIn 0.3s ease; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.message.user { align-self: flex-end; }
|
||||
.message.user .msg-content { background: var(--user-bg); border-radius: 16px 16px 4px 16px; }
|
||||
|
||||
.message.bot { align-self: flex-start; }
|
||||
.message.bot .msg-content { background: var(--bot-bg); border-radius: 16px 16px 16px 4px; border: 1px solid var(--border); }
|
||||
|
||||
.msg-role {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.msg-content { padding: 12px 16px; font-size: 14px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
/* ==================== Tool Call Card ==================== */
|
||||
.tool-call {
|
||||
margin-top: 8px;
|
||||
background: var(--tool-bg);
|
||||
border: 1px solid #2a3a5c;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tool-call-header {
|
||||
padding: 8px 12px;
|
||||
background: rgba(108, 92, 231, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
.tool-call-body { padding: 8px 12px; font-size: 12px; color: var(--text-muted); }
|
||||
.tool-call-body pre {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ==================== RAG Sources Card ==================== */
|
||||
.rag-sources {
|
||||
margin-top: 8px;
|
||||
background: var(--source-bg);
|
||||
border: 1px solid #2a4a2a;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
.rag-sources-header {
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 206, 201, 0.1);
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.rag-source-item {
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid rgba(0, 206, 201, 0.15);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.rag-source-item .score { color: var(--success); margin-left: 8px; font-size: 11px; }
|
||||
|
||||
/* ==================== Typing Indicator ==================== */
|
||||
.typing { align-self: flex-start; }
|
||||
.typing .dots { display: flex; gap: 4px; padding: 8px 12px; }
|
||||
.typing .dots span {
|
||||
width: 8px; height: 8px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite both;
|
||||
}
|
||||
.typing .dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing .dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ==================== Input Area ==================== */
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.input-area textarea {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input-area textarea:focus { border-color: var(--accent); }
|
||||
|
||||
/* ==================== Scrollbar ==================== */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.message { max-width: 95%; }
|
||||
}
|
||||
231
week10/src/main/resources/static/js/agent.js
Normal file
231
week10/src/main/resources/static/js/agent.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// ==================== State ====================
|
||||
const state = {
|
||||
conversationId: 'default',
|
||||
convList: ['default'],
|
||||
templateName: '',
|
||||
isStreaming: true,
|
||||
isAgentMode: true,
|
||||
isTyping: false
|
||||
};
|
||||
|
||||
// ==================== Init ====================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadPrompts();
|
||||
document.getElementById('streamToggle').addEventListener('change', e => state.isStreaming = e.target.checked);
|
||||
document.getElementById('agentToggle').addEventListener('change', e => {
|
||||
state.isAgentMode = e.target.checked;
|
||||
document.getElementById('chatTitle').textContent = e.target.checked ? 'Agent 控制台' : '聊天';
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Conversation ====================
|
||||
function newConversation() {
|
||||
const id = 'conv_' + Date.now();
|
||||
state.convList.push(id);
|
||||
state.conversationId = id;
|
||||
renderConvList();
|
||||
document.getElementById('messages').innerHTML = '';
|
||||
document.getElementById('convIdLabel').textContent = '会话: ' + id;
|
||||
}
|
||||
|
||||
function switchConv(id) {
|
||||
state.conversationId = id;
|
||||
renderConvList();
|
||||
document.getElementById('convIdLabel').textContent = '会话: ' + id;
|
||||
document.getElementById('messages').innerHTML = '';
|
||||
loadHistory(id);
|
||||
}
|
||||
|
||||
function renderConvList() {
|
||||
const list = document.getElementById('convList');
|
||||
list.innerHTML = state.convList.map(id =>
|
||||
`<li class="conv-item${id === state.conversationId ? ' active' : ''}"
|
||||
data-id="${id}" onclick="switchConv('${id}')">${id === 'default' ? '默认会话' : id}</li>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
async function loadHistory(convId) {
|
||||
try {
|
||||
const res = await fetch(`/api/chat/history?conversationId=${convId}`);
|
||||
const data = await res.json();
|
||||
if (data.code === 200 && data.data?.messages) {
|
||||
data.data.messages.forEach(msg => addMessage(msg.role, msg.content));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ==================== Templates ====================
|
||||
async function loadPrompts() {
|
||||
try {
|
||||
const res = await fetch('/api/prompts');
|
||||
const data = await res.json();
|
||||
if (data.code === 200) {
|
||||
const select = document.getElementById('templateSelect');
|
||||
data.data.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.name;
|
||||
opt.textContent = t.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('Failed to load prompts:', e); }
|
||||
}
|
||||
|
||||
async function selectTemplate(name) {
|
||||
state.templateName = name;
|
||||
const preview = document.getElementById('templatePreview');
|
||||
if (!name) { preview.classList.add('hidden'); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/prompts/${name}`);
|
||||
const data = await res.json();
|
||||
if (data.code === 200) {
|
||||
preview.textContent = data.data.content.substring(0, 200) + '...';
|
||||
preview.classList.remove('hidden');
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ==================== Send Message ====================
|
||||
function sendSample(text) {
|
||||
document.getElementById('userInput').value = text;
|
||||
sendMessage();
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('userInput');
|
||||
const message = input.value.trim();
|
||||
if (!message || state.isTyping) return;
|
||||
|
||||
input.value = '';
|
||||
addMessage('user', message);
|
||||
showTyping();
|
||||
|
||||
const isAgent = state.isAgentMode;
|
||||
const useTemplate = !!state.templateName;
|
||||
const isStream = state.isStreaming;
|
||||
|
||||
state.isTyping = true;
|
||||
document.getElementById('sendBtn').disabled = true;
|
||||
|
||||
try {
|
||||
let endpoint, body;
|
||||
if (useTemplate) {
|
||||
endpoint = '/api/chat/with-template';
|
||||
body = JSON.stringify({
|
||||
message, templateName: state.templateName,
|
||||
conversationId: state.conversationId, params: {}
|
||||
});
|
||||
isStream = false; // template mode uses sync
|
||||
} else if (isAgent) {
|
||||
endpoint = isStream ? '/api/agent/stream' : '/api/agent';
|
||||
body = JSON.stringify({ message, conversationId: state.conversationId });
|
||||
} else {
|
||||
endpoint = isStream ? '/api/chat/stream' : '/api/chat';
|
||||
body = JSON.stringify({ message, conversationId: state.conversationId });
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
await handleStream(endpoint, body);
|
||||
} else {
|
||||
await handleSync(endpoint, body);
|
||||
}
|
||||
} catch (e) {
|
||||
addMessage('bot', '错误: ' + e.message);
|
||||
} finally {
|
||||
hideTyping();
|
||||
state.isTyping = false;
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSync(endpoint, body) {
|
||||
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
|
||||
const data = await res.json();
|
||||
if (data.code === 200) {
|
||||
const reply = data.data?.reply ?? JSON.stringify(data.data);
|
||||
addMessage('bot', reply);
|
||||
} else {
|
||||
addMessage('bot', '错误: ' + (data.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStream(endpoint, body) {
|
||||
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let botMsgEl = addMessage('bot', '');
|
||||
let buffer = '';
|
||||
|
||||
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) {
|
||||
if (line.startsWith('data:')) {
|
||||
const token = line.slice(5).trim();
|
||||
if (token === '[DONE]') continue;
|
||||
botMsgEl.querySelector('.msg-content').textContent += token;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UI Helpers ====================
|
||||
function addMessage(role, content) {
|
||||
const messages = document.getElementById('messages');
|
||||
// Remove welcome if present
|
||||
const welcome = messages.querySelector('.welcome');
|
||||
if (welcome) welcome.remove();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${role}`;
|
||||
|
||||
const roleLabel = document.createElement('div');
|
||||
roleLabel.className = 'msg-role';
|
||||
roleLabel.textContent = role === 'user' ? '你' : '小智 Agent';
|
||||
div.appendChild(roleLabel);
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'msg-content';
|
||||
contentDiv.textContent = content;
|
||||
div.appendChild(contentDiv);
|
||||
|
||||
messages.appendChild(div);
|
||||
scrollToBottom();
|
||||
return div;
|
||||
}
|
||||
|
||||
function showTyping() {
|
||||
const messages = document.getElementById('messages');
|
||||
const welcome = messages.querySelector('.welcome');
|
||||
if (welcome) welcome.remove();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message bot typing';
|
||||
div.id = 'typingIndicator';
|
||||
div.innerHTML = '<div class="dots"><span></span><span></span><span></span></div>';
|
||||
messages.appendChild(div);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function hideTyping() {
|
||||
const el = document.getElementById('typingIndicator');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const messages = document.getElementById('messages');
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
// ==================== Tool Call / RAG Render Helpers ====================
|
||||
// These can be used to render structured tool-call and RAG-source data
|
||||
// when the API response includes metadata. Current simple mode appends text only.
|
||||
// Enhanced mode: if backend returns structured JSON with toolCalls / sources,
|
||||
// parse and render with .tool-call and .rag-sources CSS classes.
|
||||
Reference in New Issue
Block a user