├── src └── main │ ├── java │ └── com │ │ └── melon │ │ ├── OpenManusApplication.java │ │ ├── controller │ │ ├── ViewController.java │ │ └── AIController.java │ │ ├── config │ │ ├── properties │ │ │ ├── OllamaEmbeddingProperties.java │ │ │ ├── OllamaProperties.java │ │ │ └── ZhipuProperties.java │ │ ├── LangChain4jEmbeddingConfig.java │ │ ├── FastJson2JsonRedisSerializer.java │ │ ├── JacksonConfig.java │ │ ├── LangChain4jChatConfig.java │ │ ├── LangChain4jStreamConfig.java │ │ └── RedisCache.java │ │ ├── service │ │ ├── StreamingChatService.java │ │ ├── FileStorageService.java │ │ ├── impl │ │ │ └── StreamingChatServiceImpl.java │ │ └── RagService.java │ │ ├── entity │ │ └── UploadFile.java │ │ ├── prompt │ │ └── Langchain4jPrompt.java │ │ └── tool │ │ ├── FetchTool.java │ │ ├── GitHubTool.java │ │ └── FilesystemTool.java │ └── resources │ ├── application.yml │ ├── static │ └── css │ │ └── chat.css │ └── templates │ └── chat.html ├── README.md └── pom.xml /src/main/java/com/melon/OpenManusApplication.java: -------------------------------------------------------------------------------- 1 | package com.melon; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class OpenManusApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(OpenManusApplication.class, args); 11 | } 12 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/controller/ViewController.java: -------------------------------------------------------------------------------- 1 | package com.melon.controller; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | 6 | /** 7 | * 统一视图控制器,处理所有页面的请求 8 | */ 9 | @Controller 10 | public class ViewController { 11 | 12 | /** 13 | * 显示主页 14 | * 15 | * @return 视图名称 16 | */ 17 | @GetMapping("/") 18 | public String showHomePage() { 19 | return "chat"; 20 | } 21 | 22 | /** 23 | * 显示AI聊天页面 24 | * 25 | * @return 视图名称 26 | */ 27 | @GetMapping("/chat") 28 | public String showAIChatPage() { 29 | return "chat"; 30 | } 31 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/config/properties/OllamaEmbeddingProperties.java: -------------------------------------------------------------------------------- 1 | package com.melon.config.properties; 2 | 3 | import lombok.Data; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | import java.time.Duration; 8 | 9 | /** 10 | * @author: melon 11 | * @description: TODO 12 | * @date: 2025/3/18 10:57 13 | */ 14 | @Data 15 | @Configuration 16 | public class OllamaEmbeddingProperties { 17 | 18 | @Value("${langchain4j.embedding.ollama.base-url}") 19 | private String ollamaBaseUrl; 20 | 21 | @Value("${langchain4j.embedding.ollama.model-name}") 22 | private String ollamaModelName; 23 | 24 | @Value("${langchain4j.embedding.ollama.timeout}") 25 | private Duration ollamaTimeout; 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/melon/service/StreamingChatService.java: -------------------------------------------------------------------------------- 1 | package com.melon.service; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.stereotype.Service; 5 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 6 | 7 | import java.util.List; 8 | 9 | /** 10 | * @author: melon 11 | * @description: TODO 12 | * @date: 2025/3/18 13:41 13 | */ 14 | public interface StreamingChatService { 15 | 16 | public SseEmitter processGitHubRequest(String input); 17 | 18 | SseEmitter processFetchRequest(String input); 19 | 20 | SseEmitter processFilesystemRequestStream(String input); 21 | 22 | SseEmitter processOtherRequest(String input); 23 | 24 | SseEmitter processRagRequest(String input, List fileIds); 25 | 26 | void removeFileFromRag(String fileId); 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/melon/config/properties/OllamaProperties.java: -------------------------------------------------------------------------------- 1 | package com.melon.config.properties; 2 | 3 | import lombok.Data; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | import java.time.Duration; 8 | 9 | /** 10 | * @author: melon 11 | * @description: TODO 12 | * @date: 2025/3/18 10:57 13 | */ 14 | @Data 15 | @Configuration 16 | public class OllamaProperties { 17 | 18 | @Value("${langchain4j.chat.ollama.base-url}") 19 | private String ollamaBaseUrl; 20 | 21 | @Value("${langchain4j.chat.ollama.model-name}") 22 | private String ollamaModelName; 23 | 24 | @Value("${langchain4j.chat.ollama.timeout}") 25 | private Duration ollamaTimeout; 26 | 27 | @Value("${langchain4j.chat.ollama.temperature}") 28 | private Double ollamaTemperature; 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/melon/config/properties/ZhipuProperties.java: -------------------------------------------------------------------------------- 1 | package com.melon.config.properties; 2 | 3 | import lombok.Data; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | /** 8 | * @author: melon 9 | * @description: TODO 10 | * @date: 2025/3/18 10:57 11 | */ 12 | @Data 13 | @Configuration 14 | public class ZhipuProperties { 15 | @Value("${langchain4j.chat.openai.api-key}") 16 | private String apiKey; 17 | 18 | @Value("${langchain4j.chat.openai.model-name}") 19 | private String modelName; 20 | 21 | @Value("${langchain4j.chat.openai.temperature}") 22 | private Double temperature; 23 | 24 | @Value("${langchain4j.chat.openai.max-tokens}") 25 | private Integer maxTokens; 26 | 27 | @Value("${langchain4j.chat.openai.base-url}") 28 | private String baseUrl; 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/melon/entity/UploadFile.java: -------------------------------------------------------------------------------- 1 | package com.melon.entity; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.time.LocalDateTime; 9 | 10 | /** 11 | * 上传文件实体类 12 | */ 13 | @Data 14 | @Builder 15 | @NoArgsConstructor 16 | @AllArgsConstructor 17 | public class UploadFile { 18 | /** 19 | * 文件ID 20 | */ 21 | private String id; 22 | 23 | /** 24 | * 原始文件名 25 | */ 26 | private String originalFileName; 27 | 28 | /** 29 | * 保存的文件名 30 | */ 31 | private String storedFileName; 32 | 33 | /** 34 | * 文件路径 35 | */ 36 | private String filePath; 37 | 38 | /** 39 | * 文件大小(字节) 40 | */ 41 | private long size; 42 | 43 | /** 44 | * 文件类型 45 | */ 46 | private String fileType; 47 | 48 | /** 49 | * 上传时间 50 | */ 51 | private LocalDateTime uploadTime; 52 | 53 | /** 54 | * 是否已经加载到RAG系统 55 | */ 56 | private boolean loadedToRag; 57 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/config/LangChain4jEmbeddingConfig.java: -------------------------------------------------------------------------------- 1 | package com.melon.config; 2 | 3 | import com.melon.config.properties.OllamaEmbeddingProperties; 4 | import dev.langchain4j.model.embedding.EmbeddingModel; 5 | import dev.langchain4j.model.ollama.OllamaEmbeddingModel; 6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.context.annotation.Import; 10 | import org.springframework.context.annotation.Primary; 11 | 12 | /** 13 | * LangChain4j配置类 14 | * @author melon 15 | */ 16 | @Configuration 17 | @Import(value = {OllamaEmbeddingProperties.class}) 18 | public class LangChain4jEmbeddingConfig { 19 | /** 20 | * 配置Ollama Embedding模型 21 | * 22 | * @return ChatLanguageModel 23 | */ 24 | @Bean 25 | @Primary 26 | @ConditionalOnProperty(prefix = "langchain4j.embedding",name = "enable", havingValue = "ollama") 27 | public EmbeddingModel embeddingLanguageModel(OllamaEmbeddingProperties ollamaProperties) { 28 | return OllamaEmbeddingModel.builder() 29 | .baseUrl(ollamaProperties.getOllamaBaseUrl()) 30 | .modelName(ollamaProperties.getOllamaModelName()) 31 | .timeout(ollamaProperties.getOllamaTimeout()) 32 | .build(); 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/config/FastJson2JsonRedisSerializer.java: -------------------------------------------------------------------------------- 1 | package com.melon.config; 2 | 3 | import com.alibaba.fastjson2.JSON; 4 | import com.alibaba.fastjson2.JSONReader; 5 | import com.alibaba.fastjson2.JSONWriter; 6 | import org.springframework.data.redis.serializer.RedisSerializer; 7 | import org.springframework.data.redis.serializer.SerializationException; 8 | 9 | import java.nio.charset.Charset; 10 | import java.nio.charset.StandardCharsets; 11 | 12 | /** 13 | * Redis使用FastJson序列化 14 | * 15 | * @author melon 16 | */ 17 | public class FastJson2JsonRedisSerializer implements RedisSerializer 18 | { 19 | public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; 20 | 21 | private final Class clazz; 22 | 23 | public FastJson2JsonRedisSerializer(Class clazz) 24 | { 25 | super(); 26 | this.clazz = clazz; 27 | } 28 | 29 | @Override 30 | public byte[] serialize(T t) throws SerializationException 31 | { 32 | if (t == null) 33 | { 34 | return new byte[0]; 35 | } 36 | return JSON.toJSONString(t, JSONWriter.Feature.WriteClassName).getBytes(DEFAULT_CHARSET); 37 | } 38 | 39 | @Override 40 | public T deserialize(byte[] bytes) throws SerializationException 41 | { 42 | if (bytes == null || bytes.length <= 0) 43 | { 44 | return null; 45 | } 46 | String str = new String(bytes, DEFAULT_CHARSET); 47 | 48 | return JSON.parseObject(str, clazz, JSONReader.Feature.SupportAutoType); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/melon/prompt/Langchain4jPrompt.java: -------------------------------------------------------------------------------- 1 | package com.melon.prompt; 2 | 3 | /** 4 | * @author: melon 5 | * @description: TODO 6 | * @date: 2025/3/18 09:26 7 | */ 8 | public class Langchain4jPrompt { 9 | // 大模型进行更复杂的分类 10 | public static final String QUESTION_CATEGORY = 11 | """ 12 | 请分析以下用户输入,并确定应该使用哪种工具处理。只返回工具名称,不要有其他内容。 13 | 14 | 可选的工具有: 15 | 1. github - 用于处理GitHub相关查询,如仓库信息、提交历史等 16 | 2. fetch - 用于获取网页内容并回答问题 17 | 3. filesystem - 用于处理文件系统操作,如列出目录、读写文件等 18 | 4. other - 其他类型 19 | 20 | 用户输入:%s 21 | 22 | 工具名称: 23 | """; 24 | 25 | // 大模型分析用户意图 26 | public static final String USER_INTENT = 27 | """ 28 | 请分析以下用户输入,并确定用户想要执行的文件系统操作。 29 | 30 | 可选的操作有: 31 | 1. list - 列出目录内容 32 | 2. read - 读取文件内容 33 | 3. write - 写入文件内容 34 | 4. delete - 删除文件或目录 35 | 5. search - 搜索文件 36 | 6. info - 获取文件系统信息 37 | 38 | 用户输入:%s 39 | 40 | 请返回操作类型和相关参数,格式为:操作类型|参数1|参数2... 41 | """; 42 | 43 | // mcp返回整理 44 | public static final String MCP_RESULT = 45 | """ 46 | 基于以下网页内容回答问题: 47 | 48 | %s 49 | 50 | 问题:%s 51 | 52 | """; 53 | 54 | // RAG文档查询问答 55 | public static final String RAG_QUERY = 56 | """ 57 | 你是一个专业的文档问答助手。请基于提供的文档内容,准确回答用户的问题。 58 | 如果文档内容中没有足够的信息来回答问题,请明确指出,不要编造信息。 59 | 在回答时,引用相关的文档段落,并解释你的推理过程。 60 | 文档内容: %s 61 | 62 | 问题:%s 63 | 64 | """; 65 | } 66 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | 4 | spring: 5 | application: 6 | name: LangChain4j-Springboot 7 | thymeleaf: 8 | cache: false 9 | prefix: classpath:/templates/ 10 | suffix: .html 11 | mode: HTML 12 | encoding: UTF-8 13 | data: 14 | redis: 15 | host: localhost 16 | port: 6379 17 | password: 18 | database: 0 19 | # 日志配置 20 | logging: 21 | level: 22 | root: INFO 23 | com.openmanus: DEBUG 24 | dev.langchain4j: DEBUG 25 | org.springframework.web: INFO 26 | 27 | # MCP配置 28 | mcp: 29 | # GitHub工具配置 30 | github: 31 | token: your-token 32 | 33 | # 网页获取工具配置 34 | fetch: 35 | timeout: 120 36 | debug: true 37 | 38 | # 文件系统工具配置 39 | filesystem: 40 | # 允许访问的根目录,多个目录用逗号分隔 41 | allowed-roots: /Users/melon/melon/filesystem 42 | # 是否允许写入操作 43 | allow-write: true 44 | # 是否允许删除操作 45 | allow-delete: false 46 | 47 | # 文件上传配置 48 | file: 49 | upload: 50 | # 上传目录 51 | dir: /Users/melon/melon/filesystem/uploads 52 | # 最大文件大小 (10MB) 53 | max-size: 10485760 54 | # 允许的文件类型 55 | allowed-types: pdf,txt,doc,docx 56 | 57 | # LangChain4j配置 58 | langchain4j: 59 | chat: 60 | enable: ollama 61 | # Ollama配置(默认使用) 62 | ollama: 63 | base-url: http://localhost:11434 64 | model-name: qwen2.5:7b 65 | temperature: 0.0 66 | timeout: 60s 67 | # 智普AI配置(保留但不生效) 68 | openai: 69 | api-key: OPENAI_API_KEY 70 | model-name: glm-4 71 | temperature: 0.0 72 | max-tokens: 4096 73 | base-url: https://open.bigmodel.cn/api/paas/v4 74 | embedding: 75 | enable: ollama 76 | # Ollama配置(默认使用) 77 | ollama: 78 | base-url: http://localhost:11434 79 | model-name: quentinz/bge-large-zh-v1.5:latest 80 | timeout: 60s 81 | -------------------------------------------------------------------------------- /src/main/java/com/melon/config/JacksonConfig.java: -------------------------------------------------------------------------------- 1 | package com.melon.config; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.fasterxml.jackson.databind.SerializationFeature; 5 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.context.annotation.Primary; 9 | import org.springframework.data.redis.connection.RedisConnectionFactory; 10 | import org.springframework.data.redis.core.RedisTemplate; 11 | import org.springframework.data.redis.serializer.StringRedisSerializer; 12 | 13 | @Configuration 14 | public class JacksonConfig { 15 | 16 | @Bean 17 | @Primary 18 | public ObjectMapper objectMapper() { 19 | ObjectMapper objectMapper = new ObjectMapper(); 20 | objectMapper.registerModule(new JavaTimeModule()); 21 | objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); 22 | return objectMapper; 23 | } 24 | 25 | @Bean 26 | @SuppressWarnings(value = {"unchecked", "rawtypes"}) 27 | public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { 28 | RedisTemplate template = new RedisTemplate<>(); 29 | template.setConnectionFactory(connectionFactory); 30 | 31 | FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class); 32 | 33 | // 使用StringRedisSerializer来序列化和反序列化redis的key值 34 | template.setKeySerializer(new StringRedisSerializer()); 35 | template.setValueSerializer(serializer); 36 | 37 | // Hash的key也采用StringRedisSerializer的序列化方式 38 | template.setHashKeySerializer(new StringRedisSerializer()); 39 | template.setHashValueSerializer(serializer); 40 | 41 | template.afterPropertiesSet(); 42 | return template; 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/config/LangChain4jChatConfig.java: -------------------------------------------------------------------------------- 1 | package com.melon.config; 2 | 3 | import com.melon.config.properties.OllamaProperties; 4 | import com.melon.config.properties.ZhipuProperties; 5 | import dev.langchain4j.model.chat.ChatLanguageModel; 6 | import dev.langchain4j.model.ollama.OllamaChatModel; 7 | import dev.langchain4j.model.openai.OpenAiChatModel; 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.context.annotation.Import; 12 | import org.springframework.context.annotation.Primary; 13 | 14 | import java.time.Duration; 15 | 16 | /** 17 | * LangChain4j配置类 18 | * @author melon 19 | */ 20 | @Configuration 21 | @Import(value = {OllamaProperties.class, ZhipuProperties.class}) 22 | public class LangChain4jChatConfig { 23 | /** 24 | * 配置Ollama聊天模型 25 | * 26 | * @return ChatLanguageModel 27 | */ 28 | @Bean 29 | @Primary 30 | @ConditionalOnProperty(prefix = "langchain4j.chat",name = "enable", havingValue = "ollama") 31 | public ChatLanguageModel chatLanguageModelOllama(OllamaProperties ollamaProperties) { 32 | return OllamaChatModel.builder() 33 | .baseUrl(ollamaProperties.getOllamaBaseUrl()) 34 | .modelName(ollamaProperties.getOllamaModelName()) 35 | .timeout(ollamaProperties.getOllamaTimeout()) 36 | .temperature(ollamaProperties.getOllamaTemperature()) 37 | .build(); 38 | } 39 | 40 | @Bean 41 | @Primary 42 | @ConditionalOnProperty(prefix = "langchain4j.chat",name = "enable", havingValue = "zhipu") 43 | public ChatLanguageModel chatLanguageModelZhipu(ZhipuProperties zhipuProperties) { 44 | // 创建OpenAI兼容的模型 45 | return OpenAiChatModel.builder() 46 | .apiKey(zhipuProperties.getApiKey()) 47 | .modelName(zhipuProperties.getModelName()) 48 | .temperature(zhipuProperties.getTemperature()) 49 | .maxTokens(zhipuProperties.getMaxTokens()) 50 | .baseUrl(zhipuProperties.getBaseUrl()) 51 | .timeout(Duration.ofSeconds(60)) 52 | .logRequests(true) 53 | .logResponses(true) 54 | .build(); 55 | } 56 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/config/LangChain4jStreamConfig.java: -------------------------------------------------------------------------------- 1 | package com.melon.config; 2 | 3 | import com.melon.config.properties.OllamaProperties; 4 | import com.melon.config.properties.ZhipuProperties; 5 | import dev.langchain4j.model.chat.StreamingChatLanguageModel; 6 | import dev.langchain4j.model.ollama.OllamaStreamingChatModel; 7 | import dev.langchain4j.model.openai.OpenAiStreamingChatModel; 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.context.annotation.Import; 12 | import org.springframework.context.annotation.Primary; 13 | 14 | import java.time.Duration; 15 | 16 | /** 17 | * LangChain4j配置类 18 | * @author melon 19 | */ 20 | @Configuration 21 | @Import(value = {OllamaProperties.class, ZhipuProperties.class}) 22 | public class LangChain4jStreamConfig { 23 | /** 24 | * 配置Ollama聊天模型 25 | * 26 | * @return ChatLanguageModel 27 | */ 28 | 29 | @Bean 30 | @Primary 31 | @ConditionalOnProperty(prefix = "langchain4j.chat",name = "enable", havingValue = "ollama") 32 | public StreamingChatLanguageModel streamLanguageModelOllama(OllamaProperties ollamaProperties) { 33 | return OllamaStreamingChatModel.builder() 34 | .baseUrl(ollamaProperties.getOllamaBaseUrl()) 35 | .temperature(ollamaProperties.getOllamaTemperature()) 36 | .logRequests(true) 37 | .logResponses(true) 38 | .modelName(ollamaProperties.getOllamaModelName()) 39 | .build(); 40 | } 41 | 42 | 43 | @Bean 44 | @Primary 45 | @ConditionalOnProperty(prefix = "langchain4j.chat",name = "enable", havingValue = "zhipu") 46 | public StreamingChatLanguageModel streamLanguageModelZhipu(ZhipuProperties zhipuProperties) { 47 | // 创建OpenAI兼容的模型 48 | return OpenAiStreamingChatModel.builder() 49 | .apiKey(zhipuProperties.getApiKey()) 50 | .modelName(zhipuProperties.getModelName()) 51 | .temperature(zhipuProperties.getTemperature()) 52 | .maxTokens(zhipuProperties.getMaxTokens()) 53 | .baseUrl(zhipuProperties.getBaseUrl()) 54 | .timeout(Duration.ofSeconds(60)) 55 | .logRequests(true) 56 | .logResponses(true) 57 | .build(); 58 | } 59 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LangChain4j与Spring Boot集成示例 2 | 3 | 这个项目展示了如何将LangChain4j与Spring Boot集成,创建一个功能丰富的AI应用程序,包括多种工具的集成和自动工具选择功能。 4 | 5 | ## 功能特点 6 | 7 | - **多种AI工具集成**: 8 | - GitHub工具:查询GitHub仓库信息、提交历史等 9 | - 网页获取工具:获取网页内容并基于内容回答问题 10 | - Ollama集成:使用本地Ollama模型进行对话 11 | 12 | - **智能工具选择**: 13 | - 自动分析用户问题,选择最合适的工具 14 | - 支持混合模式,结合多种工具的优势 15 | 16 | - **用户友好界面**: 17 | - 简洁直观的Web界面 18 | - 示例问题快速上手 19 | - 响应式设计,适配各种设备 20 | 21 | ## 技术栈 22 | 23 | - **后端**: 24 | - Spring Boot 3.x 25 | - LangChain4j 26 | - Ollama集成 27 | - MCP (Model Context Protocol) 工具 28 | 29 | - **前端**: 30 | - Thymeleaf模板引擎 31 | - 现代CSS和JavaScript 32 | - 响应式设计 33 | 34 | ## 快速开始 35 | 36 | ### 前提条件 37 | 38 | - JDK 17+ 39 | - Maven 3.6+ 40 | - Node.js和npm(用于MCP工具) 41 | - Ollama(可选,用于本地模型) 42 | 43 | ### 环境变量设置 44 | 45 | 在运行应用前,需要设置以下环境变量: 46 | 47 | ```bash 48 | # GitHub工具所需 49 | export GITHUB_TOKEN=your_github_personal_access_token 50 | 51 | # Ollama配置(可选) 52 | export OLLAMA_BASE_URL=http://localhost:11434 53 | ``` 54 | 55 | ### 安装MCP工具 56 | 57 | ```bash 58 | # 安装uvx工具 59 | npm install -g @modelcontextprotocol/uvx 60 | 61 | # 安装GitHub MCP服务器 62 | uvx install @modelcontextprotocol/server-github 63 | 64 | # 安装Fetch MCP服务器 65 | uvx install @modelcontextprotocol/server-fetch 66 | ``` 67 | 68 | ### 构建和运行 69 | 70 | ```bash 71 | # 克隆仓库 72 | git clone https://github.com/melon1010/LangChain4j-Springboot.git 73 | cd LangChain4j-Springboot 74 | 75 | # 构建项目 76 | mvn clean package 77 | 78 | # 运行应用 79 | java -jar target/langchain4j-springboot-0.0.1-SNAPSHOT.jar 80 | ``` 81 | 82 | 应用将在 http://localhost:8080 启动。 83 | 84 | ## 使用指南 85 | 86 | ### 主页 87 | 88 | 访问 http://localhost:8080 进入主页,可以选择不同的工具或直接使用AI智能工具。 89 | 90 | ### GitHub工具 91 | 92 | - 访问 http://localhost:8080/github 93 | - 输入关于GitHub仓库的问题,如"langchain4j项目最近的三个提交是什么?" 94 | - 系统将使用GitHub API获取信息并回答问题 95 | 96 | ### 网页获取工具 97 | 98 | - 访问 http://localhost:8080/fetch 99 | - 输入URL和问题,如URL为"https://spring.io/blog",问题为"Spring最新的特性是什么?" 100 | - 系统将获取网页内容并基于内容回答问题 101 | 102 | ### AI智能工具 103 | 104 | - 访问 http://localhost:8080/ai-tools 105 | - 输入问题,系统将自动选择合适的工具 106 | - 可以选择性地提供URL以获取特定网页的内容 107 | - 系统会显示使用了哪种工具来回答问题 108 | 109 | ## 配置 110 | 111 | 主要配置文件位于 `src/main/resources/application.yml`: 112 | 113 | ```yaml 114 | langchain4j: 115 | ollama: 116 | chat-model: 117 | base-url: http://localhost:11434 118 | model-name: llama3 119 | timeout: 60s 120 | 121 | mcp: 122 | github: 123 | token: ${GITHUB_TOKEN} 124 | timeout: 120 125 | debug: true 126 | fetch: 127 | timeout: 120 128 | debug: true 129 | ``` 130 | 131 | ## 故障排除 132 | 133 | ### GitHub工具问题 134 | 135 | - 确保GitHub令牌有效且具有足够的权限 136 | - 检查网络连接是否正常 137 | - 启用调试模式获取更详细的日志 138 | 139 | ### 网页获取工具问题 140 | 141 | - 确保已正确安装uvx和MCP服务器 142 | - 检查URL是否可访问 143 | - 某些网站可能有反爬虫措施,可能无法获取内容 144 | 145 | ### Ollama问题 146 | 147 | - 确保Ollama服务正在运行 148 | - 检查配置的模型是否已在Ollama中安装 149 | - 调整超时设置以适应较大的请求 150 | 151 | ## 贡献 152 | 153 | 欢迎贡献代码、报告问题或提出改进建议!请遵循以下步骤: 154 | 155 | 1. Fork仓库 156 | 2. 创建特性分支 (`git checkout -b feature/amazing-feature`) 157 | 3. 提交更改 (`git commit -m 'Add some amazing feature'`) 158 | 4. 推送到分支 (`git push origin feature/amazing-feature`) 159 | 5. 创建Pull Request 160 | 161 | ## 许可证 162 | 163 | 本项目采用MIT许可证 - 详情请参阅 [LICENSE](LICENSE) 文件。 164 | 165 | ## 致谢 166 | 167 | - [LangChain4j](https://github.com/langchain4j/langchain4j) - Java的LangChain实现 168 | - [Spring Boot](https://spring.io/projects/spring-boot) - 简化Spring应用开发的框架 169 | - [Ollama](https://ollama.ai/) - 本地运行大型语言模型的工具 170 | - [Model Context Protocol](https://github.com/modelcontextprotocol) - 连接AI模型与外部工具的协议 171 | -------------------------------------------------------------------------------- /src/main/java/com/melon/tool/FetchTool.java: -------------------------------------------------------------------------------- 1 | package com.melon.tool; 2 | 3 | import dev.langchain4j.agent.tool.Tool; 4 | import dev.langchain4j.mcp.McpToolProvider; 5 | import dev.langchain4j.mcp.client.McpClient; 6 | import dev.langchain4j.model.chat.ChatLanguageModel; 7 | import dev.langchain4j.service.AiServices; 8 | import dev.langchain4j.service.UserMessage; 9 | import dev.langchain4j.mcp.client.transport.McpTransport; 10 | import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport; 11 | import dev.langchain4j.mcp.client.DefaultMcpClient; 12 | import lombok.extern.slf4j.Slf4j; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.beans.factory.annotation.Value; 15 | import org.springframework.stereotype.Component; 16 | 17 | import java.util.HashMap; 18 | import java.util.List; 19 | import java.util.Map; 20 | 21 | import static com.melon.prompt.Langchain4jPrompt.MCP_RESULT; 22 | 23 | /** 24 | * 网页内容获取工具 25 | * 使用uvx命令运行mcp-server-fetch 26 | */ 27 | @Component 28 | @Slf4j 29 | public class FetchTool { 30 | 31 | private final ChatLanguageModel chatLanguageModel; 32 | 33 | @Value("${mcp.fetch.timeout:120}") 34 | private int timeout; 35 | 36 | @Value("${mcp.fetch.debug:false}") 37 | private boolean debug; 38 | 39 | @Autowired 40 | public FetchTool(ChatLanguageModel chatLanguageModel) { 41 | this.chatLanguageModel = chatLanguageModel; 42 | log.info("FetchTool初始化完成,timeout={}秒,debug={}", timeout, debug); 43 | } 44 | 45 | /** 46 | * 获取网页内容并回答问题 47 | * @param question 用户问题 48 | * @return 回答 49 | */ 50 | @Tool("获取网页内容并回答问题") 51 | public String fetchAndAnswer(String question) { 52 | log.info("收到获取网页内容请求: {}", question); 53 | 54 | try { 55 | // 创建MCP传输 56 | Map env = new HashMap<>(); 57 | env.put("PYTHONIOENCODING", "utf-8"); 58 | 59 | // 使用更详细的命令配置 60 | McpTransport transport = new StdioMcpTransport.Builder() 61 | .command(List.of("uvx", "mcp-server-fetch")) 62 | .environment(env) 63 | .logEvents(true) 64 | .build(); 65 | 66 | // 创建MCP客户端 67 | McpClient mcpClient = new DefaultMcpClient.Builder() 68 | .transport(transport) 69 | .build(); 70 | 71 | // 创建工具提供者 72 | McpToolProvider toolProvider = McpToolProvider.builder() 73 | .mcpClients(List.of(mcpClient)) 74 | .build(); 75 | 76 | // 定义Bot接口 77 | FetchTool.FetchBot bot = AiServices.builder(FetchTool.FetchBot.class) 78 | .chatLanguageModel(chatLanguageModel) 79 | .toolProvider(toolProvider) 80 | .build(); 81 | try { 82 | // 调用Bot回答问题 83 | String response = bot.chat(question); 84 | log.info("获取到GitHub问题回答: {}", response); 85 | // 使用AI模型回答问题 86 | String answer = chatLanguageModel.chat(String.format(MCP_RESULT,response, question)); 87 | 88 | log.info("AI回答: {}", answer); 89 | return answer; 90 | } catch (Exception e) { 91 | log.error("调用Bot回答问题时发生错误", e); 92 | return "向GitHub提问失败: " + e.getMessage() + "\n\n请确保您的问题与GitHub相关,并且GitHub令牌有足够的权限。"; 93 | } 94 | } catch (Exception e) { 95 | log.error("获取网页内容时发生错误", e); 96 | return "抱歉,获取网页内容时发生错误: " + e.getMessage(); 97 | } 98 | } 99 | 100 | /** 101 | * Fetch Bot接口 102 | */ 103 | interface FetchBot { 104 | String chat(@UserMessage String message); 105 | } 106 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/tool/GitHubTool.java: -------------------------------------------------------------------------------- 1 | package com.melon.tool; 2 | 3 | import dev.langchain4j.agent.tool.Tool; 4 | import dev.langchain4j.mcp.McpToolProvider; 5 | import dev.langchain4j.mcp.client.McpClient; 6 | import dev.langchain4j.model.chat.ChatLanguageModel; 7 | import dev.langchain4j.service.AiServices; 8 | import dev.langchain4j.service.UserMessage; 9 | import dev.langchain4j.mcp.client.transport.McpTransport; 10 | import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport; 11 | import dev.langchain4j.mcp.client.DefaultMcpClient; 12 | import lombok.extern.slf4j.Slf4j; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.beans.factory.annotation.Value; 15 | import org.springframework.stereotype.Component; 16 | 17 | import java.util.HashMap; 18 | import java.util.List; 19 | import java.util.Map; 20 | 21 | import static com.melon.prompt.Langchain4jPrompt.MCP_RESULT; 22 | 23 | /** 24 | * GitHub工具类,用于与GitHub交互 25 | */ 26 | @Slf4j 27 | @Component 28 | public class GitHubTool { 29 | 30 | private final ChatLanguageModel chatLanguageModel; 31 | 32 | @Value("${mcp.github.token}") 33 | private String githubToken; 34 | 35 | @Autowired 36 | public GitHubTool(ChatLanguageModel chatLanguageModel) { 37 | this.chatLanguageModel = chatLanguageModel; 38 | } 39 | 40 | /** 41 | * 向GitHub提出任意问题 42 | * 43 | * @param question 用户的问题 44 | * @return 回答 45 | */ 46 | @Tool("向GitHub提出任意问题") 47 | public String askGitHubQuestion(String question) { 48 | log.info("向GitHub提出问题: {}", question); 49 | 50 | try { 51 | 52 | // 创建MCP传输 53 | Map env = new HashMap<>(); 54 | env.put("GITHUB_PERSONAL_ACCESS_TOKEN", githubToken); 55 | 56 | log.info("正在创建MCP传输,使用npx运行@modelcontextprotocol/server-github"); 57 | 58 | // 使用更详细的命令配置 59 | McpTransport transport = new StdioMcpTransport.Builder() 60 | .command(List.of("npx", "-y", "@modelcontextprotocol/server-github@latest")) 61 | .environment(env) 62 | .logEvents(true) 63 | .build(); 64 | 65 | // 创建MCP客户端 66 | McpClient mcpClient = new DefaultMcpClient.Builder() 67 | .transport(transport) 68 | .build(); 69 | 70 | // 创建工具提供者 71 | McpToolProvider toolProvider = McpToolProvider.builder() 72 | .mcpClients(List.of(mcpClient)) 73 | .build(); 74 | 75 | // 定义Bot接口 76 | GitHubBot bot = AiServices.builder(GitHubBot.class) 77 | .chatLanguageModel(chatLanguageModel) 78 | .toolProvider(toolProvider) 79 | .build(); 80 | try { 81 | // 调用Bot回答问题 82 | String response = bot.chat(question); 83 | log.info("获取到GitHub问题回答: {}", response); 84 | return response; 85 | } catch (Exception e) { 86 | log.error("调用Bot回答问题时发生错误", e); 87 | return "向GitHub提问失败: " + e.getMessage() + "\n\n请确保您的问题与GitHub相关,并且GitHub令牌有足够的权限。"; 88 | } finally { 89 | log.info("关闭MCP客户端"); 90 | mcpClient.close(); 91 | } 92 | } catch (Exception e) { 93 | log.error("向GitHub提问失败", e); 94 | return "向GitHub提问失败: " + e.getMessage() + "\n\n可能的原因:\n1. Node.js或npm未正确安装\n2. GitHub令牌无效或权限不足\n3. 网络连接问题\n4. @modelcontextprotocol/server-github包安装失败"; 95 | } 96 | } 97 | 98 | /** 99 | * 向GitHub提出任意问题(别名方法,与askGitHubQuestion功能相同) 100 | * 101 | * @param question 用户的问题 102 | * @return 回答 103 | */ 104 | public String askGitHub(String question) { 105 | return askGitHubQuestion(question); 106 | } 107 | 108 | /** 109 | * GitHub Bot接口 110 | */ 111 | interface GitHubBot { 112 | String chat(@UserMessage String message); 113 | } 114 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/controller/AIController.java: -------------------------------------------------------------------------------- 1 | package com.melon.controller; 2 | 3 | import com.melon.entity.UploadFile; 4 | import com.melon.service.FileStorageService; 5 | import com.melon.service.StreamingChatService; 6 | import dev.langchain4j.model.chat.ChatLanguageModel; 7 | import lombok.AllArgsConstructor; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.http.MediaType; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.web.bind.annotation.*; 12 | import org.springframework.web.multipart.MultipartFile; 13 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 14 | 15 | import java.util.HashMap; 16 | import java.util.List; 17 | import java.util.Map; 18 | 19 | import static com.melon.prompt.Langchain4jPrompt.*; 20 | 21 | /** 22 | * AI工具统一控制器 23 | * 处理所有AI工具的请求 24 | * @author melon 25 | */ 26 | @AllArgsConstructor 27 | @RestController 28 | @RequestMapping("/api/ai") 29 | @Slf4j 30 | public class AIController { 31 | private final ChatLanguageModel chatLanguageModel; 32 | private final FileStorageService fileStorageService; 33 | private final StreamingChatService streamingChatService; 34 | 35 | /** 36 | * 统一处理AI工具请求(流式响应) 37 | * @param request 请求参数 38 | * @return 流式响应结果 39 | */ 40 | @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) 41 | public SseEmitter askStream(@RequestBody Map request) { 42 | String input = (String) request.get("input"); 43 | @SuppressWarnings("unchecked") 44 | List fileIds = (List) request.get("fileIds"); 45 | 46 | log.info("收到AI工具流式请求: input={}, fileIds={}", input, fileIds); 47 | 48 | // 如果有上传的文件,优先使用RAG 49 | if (fileIds != null && !fileIds.isEmpty()) { 50 | return streamingChatService.processRagRequest(input, fileIds); 51 | } 52 | 53 | // 使用AI模型回答问题 54 | String answer = classifyToolType(input); 55 | switch (answer.toLowerCase()) { 56 | case "github": 57 | return streamingChatService.processGitHubRequest(input); 58 | case "fetch": 59 | return streamingChatService.processFetchRequest(input); 60 | case "filesystem": 61 | return streamingChatService.processFilesystemRequestStream(input); 62 | default: 63 | // 默认使用Ollama模型直接回答 64 | return streamingChatService.processOtherRequest(input); 65 | } 66 | } 67 | 68 | /** 69 | * 处理文件上传请求 70 | * @param files 上传的文件列表 71 | * @return 上传结果 72 | */ 73 | @PostMapping("/upload") 74 | public ResponseEntity> uploadFiles(@RequestParam("files") List files) { 75 | log.info("收到文件上传请求,文件数: {}", files.size()); 76 | 77 | if (files.isEmpty()) { 78 | return ResponseEntity.badRequest().build(); 79 | } 80 | 81 | try { 82 | List uploadedFiles = fileStorageService.storeAll(files); 83 | return ResponseEntity.ok(uploadedFiles); 84 | } catch (Exception e) { 85 | log.error("文件上传失败", e); 86 | return ResponseEntity.badRequest().build(); 87 | } 88 | } 89 | 90 | /** 91 | * 删除上传的文件 92 | * @param fileId 文件ID 93 | * @return 删除结果 94 | */ 95 | @DeleteMapping("/upload/{fileId}") 96 | public ResponseEntity> deleteFile(@PathVariable String fileId) { 97 | log.info("收到文件删除请求,文件ID: {}", fileId); 98 | 99 | Map response = new HashMap<>(); 100 | try { 101 | // 从RAG系统移除文件 102 | streamingChatService.removeFileFromRag(fileId); 103 | 104 | // 删除文件 105 | boolean deleted = fileStorageService.delete(fileId); 106 | 107 | if (deleted) { 108 | response.put("success", true); 109 | response.put("message", "文件删除成功"); 110 | return ResponseEntity.ok(response); 111 | } else { 112 | response.put("success", false); 113 | response.put("message", "文件不存在或删除失败"); 114 | return ResponseEntity.ok(response); 115 | } 116 | } catch (Exception e) { 117 | log.error("文件删除失败", e); 118 | response.put("success", false); 119 | response.put("message", "文件删除失败: " + e.getMessage()); 120 | return ResponseEntity.badRequest().body(response); 121 | } 122 | } 123 | 124 | /** 125 | * 分类工具类型 126 | * 127 | * @param input 用户输入 128 | * @return 工具类型 129 | */ 130 | private String classifyToolType(String input) { 131 | // 使用大模型进行更复杂的分类 132 | try { 133 | String prompt = String.format(QUESTION_CATEGORY,input); 134 | 135 | String classification = chatLanguageModel.chat(prompt).trim().toLowerCase(); 136 | 137 | if (classification.contains("github")) { 138 | return "github"; 139 | } else if (classification.contains("fetch")) { 140 | return "fetch"; 141 | } else if (classification.contains("filesystem")) { 142 | return "filesystem"; 143 | } 144 | return "other"; 145 | } catch (Exception e) { 146 | log.error("使用大模型分类工具类型时出错", e); 147 | return "other"; 148 | } 149 | } 150 | } -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.2.3 9 | 10 | 11 | com.openmanus 12 | openmanus-springboot 13 | 0.0.1-SNAPSHOT 14 | OpenManus-SpringBoot 15 | OpenManus implementation with SpringBoot and LangChain4j 16 | 17 | 18 | 17 19 | 1.0.0-beta1 20 | 17 21 | 17 22 | 23 | 24 | 25 | 26 | 27 | org.springframework.boot 28 | spring-boot-starter-web 29 | 30 | 31 | org.springframework.boot 32 | spring-boot-starter-thymeleaf 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-webflux 37 | 38 | 39 | 40 | 41 | dev.langchain4j 42 | langchain4j 43 | ${langchain4j.version} 44 | 45 | 46 | dev.langchain4j 47 | langchain4j-mcp 48 | ${langchain4j.version} 49 | 50 | 51 | dev.langchain4j 52 | langchain4j-open-ai 53 | ${langchain4j.version} 54 | 55 | 56 | dev.langchain4j 57 | langchain4j-core 58 | ${langchain4j.version} 59 | 60 | 61 | dev.langchain4j 62 | langchain4j-easy-rag 63 | ${langchain4j.version} 64 | 65 | 66 | 67 | dev.langchain4j 68 | langchain4j-web-search-engine-google-custom 69 | ${langchain4j.version} 70 | 71 | 72 | 73 | 74 | org.projectlombok 75 | lombok 76 | true 77 | 78 | 79 | org.jsoup 80 | jsoup 81 | 1.18.1 82 | 83 | 84 | 85 | org.springframework.boot 86 | spring-boot-starter-test 87 | test 88 | 89 | 90 | 91 | dev.langchain4j 92 | langchain4j-ollama 93 | ${langchain4j.version} 94 | 95 | 96 | 97 | 98 | dev.langchain4j 99 | langchain4j-document-parser-apache-pdfbox 100 | ${langchain4j.version} 101 | 102 | 103 | dev.langchain4j 104 | langchain4j-document-parser-apache-poi 105 | ${langchain4j.version} 106 | 107 | 108 | 109 | org.springframework.boot 110 | spring-boot-starter-data-redis 111 | 112 | 113 | 114 | com.alibaba.fastjson2 115 | fastjson2 116 | 2.0.53 117 | 118 | 119 | 120 | 121 | 122 | 123 | org.springframework.boot 124 | spring-boot-maven-plugin 125 | 126 | 127 | 128 | org.projectlombok 129 | lombok 130 | 131 | 132 | 133 | 134 | 135 | org.apache.maven.plugins 136 | maven-compiler-plugin 137 | 3.11.0 138 | 139 | 17 140 | 17 141 | 142 | -parameters 143 | 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /src/main/java/com/melon/service/FileStorageService.java: -------------------------------------------------------------------------------- 1 | package com.melon.service; 2 | 3 | import com.melon.config.RedisCache; 4 | import com.melon.entity.UploadFile; 5 | import jakarta.annotation.PostConstruct; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.web.multipart.MultipartFile; 11 | 12 | import java.io.IOException; 13 | import java.nio.file.Files; 14 | import java.nio.file.Path; 15 | import java.nio.file.Paths; 16 | import java.time.LocalDateTime; 17 | import java.util.ArrayList; 18 | import java.util.List; 19 | import java.util.Map; 20 | import java.util.UUID; 21 | import java.util.concurrent.ConcurrentHashMap; 22 | import java.util.concurrent.TimeUnit; 23 | 24 | /** 25 | * 文件存储服务类 26 | * 负责处理文件的上传和存储 27 | */ 28 | @Service 29 | @Slf4j 30 | public class FileStorageService { 31 | 32 | @Value("${file.upload.dir}") 33 | private String uploadDir; 34 | 35 | @Value("${file.upload.max-size}") // 默认10MB 36 | private long maxFileSize; 37 | 38 | @Autowired 39 | private RedisCache redisCache; 40 | 41 | /** 42 | * 初始化存储目录 43 | */ 44 | @PostConstruct 45 | public void init() { 46 | try { 47 | Path path = Paths.get(uploadDir); 48 | if (!Files.exists(path)) { 49 | Files.createDirectories(path); 50 | log.info("创建文件上传目录: {}", path.toAbsolutePath()); 51 | } 52 | } catch (IOException e) { 53 | log.error("无法创建文件上传目录: {}", uploadDir, e); 54 | throw new RuntimeException("无法创建文件上传目录", e); 55 | } 56 | } 57 | 58 | /** 59 | * 存储单个文件 60 | * 61 | * @param file 上传的文件 62 | * @return 存储的文件信息 63 | */ 64 | public UploadFile store(MultipartFile file) { 65 | // 检查文件大小 66 | if (file.getSize() > maxFileSize) { 67 | throw new RuntimeException("文件太大,最大允许大小为: " + (maxFileSize / 1024 / 1024) + "MB"); 68 | } 69 | 70 | try { 71 | // 生成唯一文件名 72 | String fileId = UUID.randomUUID().toString(); 73 | String originalFileName = file.getOriginalFilename(); 74 | String fileExtension = getFileExtension(originalFileName); 75 | String storedFileName = fileId + (fileExtension != null ? "." + fileExtension : ""); 76 | 77 | // 保存文件 78 | Path targetPath = Paths.get(uploadDir, storedFileName); 79 | Files.copy(file.getInputStream(), targetPath); 80 | 81 | // 创建文件记录 82 | UploadFile uploadFile = UploadFile.builder() 83 | .id(fileId) 84 | .originalFileName(originalFileName) 85 | .storedFileName(storedFileName) 86 | .filePath(targetPath.toString()) 87 | .size(file.getSize()) 88 | .fileType(file.getContentType()) 89 | .uploadTime(LocalDateTime.now()) 90 | .loadedToRag(false) 91 | .build(); 92 | 93 | // 保存到内存中 94 | redisCache.setCacheObject(fileId, uploadFile,5, TimeUnit.MINUTES); 95 | 96 | log.info("成功存储文件: {}, 路径: {}", originalFileName, targetPath); 97 | return uploadFile; 98 | } catch (IOException e) { 99 | log.error("存储文件失败: {}", file.getOriginalFilename(), e); 100 | throw new RuntimeException("存储文件失败: " + e.getMessage(), e); 101 | } 102 | } 103 | 104 | /** 105 | * 存储多个文件 106 | * 107 | * @param files 上传的文件列表 108 | * @return 存储的文件信息列表 109 | */ 110 | public List storeAll(List files) { 111 | List result = new ArrayList<>(); 112 | for (MultipartFile file : files) { 113 | if (!file.isEmpty()) { 114 | result.add(store(file)); 115 | } 116 | } 117 | return result; 118 | } 119 | 120 | /** 121 | * 根据ID获取文件信息 122 | * 123 | * @param fileId 文件ID 124 | * @return 文件信息 125 | */ 126 | public UploadFile getById(String fileId) { 127 | UploadFile file = redisCache.getCacheObject(fileId); 128 | if (file == null) { 129 | throw new RuntimeException("文件不存在,ID: " + fileId); 130 | } 131 | return file; 132 | } 133 | 134 | /** 135 | * 根据ID列表获取多个文件信息 136 | * 137 | * @param fileIds 文件ID列表 138 | * @return 文件信息列表 139 | */ 140 | public List getByIds(List fileIds) { 141 | List result = new ArrayList<>(); 142 | if (fileIds != null) { 143 | for (String fileId : fileIds) { 144 | UploadFile file = redisCache.getCacheObject(fileId); 145 | if (file != null) { 146 | result.add(file); 147 | } 148 | } 149 | } 150 | return result; 151 | } 152 | 153 | /** 154 | * 删除文件 155 | * 156 | * @param fileId 文件ID 157 | * @return 是否删除成功 158 | */ 159 | public boolean delete(String fileId) { 160 | UploadFile file = redisCache.getCacheObject(fileId); 161 | if (file != null) { 162 | try { 163 | Path filePath = Paths.get(file.getFilePath()); 164 | if (Files.exists(filePath)) { 165 | Files.delete(filePath); 166 | } 167 | redisCache.deleteObject(fileId); 168 | log.info("删除文件: {}", file.getOriginalFileName()); 169 | return true; 170 | } catch (IOException e) { 171 | log.error("删除文件失败: {}", file.getOriginalFileName(), e); 172 | return false; 173 | } 174 | } 175 | return false; 176 | } 177 | 178 | /** 179 | * 获取文件扩展名 180 | * 181 | * @param fileName 文件名 182 | * @return 扩展名 183 | */ 184 | private String getFileExtension(String fileName) { 185 | if (fileName == null || !fileName.contains(".")) { 186 | return null; 187 | } 188 | return fileName.substring(fileName.lastIndexOf(".") + 1); 189 | } 190 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/config/RedisCache.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2022-2030 SUNSEA 3 | */ 4 | package com.melon.config; 5 | 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.data.redis.core.BoundSetOperations; 8 | import org.springframework.data.redis.core.HashOperations; 9 | import org.springframework.data.redis.core.RedisTemplate; 10 | import org.springframework.data.redis.core.ValueOperations; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.util.*; 14 | import java.util.concurrent.TimeUnit; 15 | 16 | /** 17 | * spring redis 工具类 18 | * 19 | * @version master (springboot) Fri Aug 13 15:15:35 2021 +0800 版本 20 | **/ 21 | @SuppressWarnings(value = {"unchecked", "rawtypes"}) 22 | @Component 23 | public class RedisCache { 24 | @Autowired 25 | public RedisTemplate redisTemplate; 26 | 27 | /** 28 | * 缓存基本的对象,Integer、String、实体类等 29 | * 30 | * @param key 缓存的键值 31 | * @param value 缓存的值 32 | */ 33 | public void setCacheObject(final String key, final T value) { 34 | redisTemplate.opsForValue().set(key, value); 35 | } 36 | 37 | /** 38 | * 缓存基本的对象,Integer、String、实体类等 39 | * 40 | * @param key 缓存的键值 41 | * @param value 缓存的值 42 | * @param timeout 时间 43 | * @param timeUnit 时间颗粒度 44 | */ 45 | public void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) { 46 | redisTemplate.opsForValue().set(key, value, timeout, timeUnit); 47 | } 48 | 49 | /** 50 | * 设置有效时间 51 | * 52 | * @param key Redis键 53 | * @param timeout 超时时间 54 | * @return true=设置成功;false=设置失败 55 | */ 56 | public boolean expire(final String key, final long timeout) { 57 | return expire(key, timeout, TimeUnit.SECONDS); 58 | } 59 | 60 | /** 61 | * 设置有效时间 62 | * 63 | * @param key Redis键 64 | * @param timeout 超时时间 65 | * @param unit 时间单位 66 | * @return true=设置成功;false=设置失败 67 | */ 68 | public boolean expire(final String key, final long timeout, final TimeUnit unit) { 69 | return redisTemplate.expire(key, timeout, unit); 70 | } 71 | 72 | /** 73 | * 获得缓存的基本对象。 74 | * 75 | * @param key 缓存键值 76 | * @return 缓存键值对应的数据 77 | */ 78 | public T getCacheObject(final String key) { 79 | ValueOperations operation = redisTemplate.opsForValue(); 80 | return operation.get(key); 81 | } 82 | 83 | /** 84 | * 删除单个对象 85 | * 86 | * @param key 87 | */ 88 | public boolean deleteObject(final String key) { 89 | return redisTemplate.delete(key); 90 | } 91 | 92 | /** 93 | * 删除集合对象 94 | * 95 | * @param collection 多个对象 96 | * @return 97 | */ 98 | public long deleteObject(final Collection collection) { 99 | return redisTemplate.delete(collection); 100 | } 101 | 102 | /** 103 | * 缓存List数据 104 | * 105 | * @param key 缓存的键值 106 | * @param dataList 待缓存的List数据 107 | * @return 缓存的对象 108 | */ 109 | public long setCacheList(final String key, final List dataList) { 110 | Long count = redisTemplate.opsForList().rightPushAll(key, dataList); 111 | return count == null ? 0 : count; 112 | } 113 | 114 | /** 115 | * 获得缓存的list对象 116 | * 117 | * @param key 缓存的键值 118 | * @return 缓存键值对应的数据 119 | */ 120 | public List getCacheList(final String key) { 121 | return redisTemplate.opsForList().range(key, 0, -1); 122 | } 123 | 124 | /** 125 | * 缓存Set 126 | * 127 | * @param key 缓存键值 128 | * @param dataSet 缓存的数据 129 | * @return 缓存数据的对象 130 | */ 131 | public BoundSetOperations setCacheSet(final String key, final Set dataSet) { 132 | BoundSetOperations setOperation = redisTemplate.boundSetOps(key); 133 | Iterator it = dataSet.iterator(); 134 | while (it.hasNext()) { 135 | setOperation.add(it.next()); 136 | } 137 | return setOperation; 138 | } 139 | 140 | /** 141 | * 获得缓存的set 142 | * 143 | * @param key 144 | * @return 145 | */ 146 | public Set getCacheSet(final String key) { 147 | return redisTemplate.opsForSet().members(key); 148 | } 149 | 150 | /** 151 | * 缓存Map 152 | * 153 | * @param key 154 | * @param dataMap 155 | */ 156 | public void setCacheMap(final String key, final Map dataMap) { 157 | if (dataMap != null) { 158 | redisTemplate.opsForHash().putAll(key, dataMap); 159 | } 160 | } 161 | 162 | /** 163 | * 获得缓存的Map 164 | * 165 | * @param key 166 | * @return 167 | */ 168 | public Map getCacheMap(final String key) { 169 | return redisTemplate.opsForHash().entries(key); 170 | } 171 | 172 | /** 173 | * 往Hash中存入数据 174 | * 175 | * @param key Redis键 176 | * @param hKey Hash键 177 | * @param value 值 178 | */ 179 | public void setCacheMapValue(final String key, final String hKey, final T value) { 180 | redisTemplate.opsForHash().put(key, hKey, value); 181 | } 182 | 183 | /** 184 | * 获取Hash中的数据 185 | * 186 | * @param key Redis键 187 | * @param hKey Hash键 188 | * @return Hash中的对象 189 | */ 190 | public T getCacheMapValue(final String key, final String hKey) { 191 | HashOperations opsForHash = redisTemplate.opsForHash(); 192 | return opsForHash.get(key, hKey); 193 | } 194 | 195 | /** 196 | * 获取多个Hash中的数据 197 | * 198 | * @param key Redis键 199 | * @param hKeys Hash键集合 200 | * @return Hash对象集合 201 | */ 202 | public List getMultiCacheMapValue(final String key, final Collection hKeys) { 203 | return redisTemplate.opsForHash().multiGet(key, hKeys); 204 | } 205 | 206 | /** 207 | * 获得缓存的基本对象列表 208 | * 209 | * @param pattern 字符串前缀 210 | * @return 对象列表 211 | */ 212 | public Collection keys(final String pattern) { 213 | return redisTemplate.keys(pattern); 214 | } 215 | 216 | /** 217 | * 从redis中获取key对应的过期时间; 218 | * 如果该值有过期时间,就返回相应的过期时间; 219 | * 如果该值没有设置过期时间,就返回-1; 220 | * 如果没有该值,就返回-2; 221 | * 222 | * @param key 223 | * @return 224 | */ 225 | public Long getExpire(final String key) { 226 | return redisTemplate.opsForValue().getOperations().getExpire(key); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/main/java/com/melon/service/impl/StreamingChatServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.melon.service.impl; 2 | 3 | import com.melon.service.RagService; 4 | import com.melon.service.StreamingChatService; 5 | import com.melon.tool.FetchTool; 6 | import com.melon.tool.FilesystemTool; 7 | import com.melon.tool.GitHubTool; 8 | import dev.langchain4j.model.chat.ChatLanguageModel; 9 | import dev.langchain4j.model.chat.StreamingChatLanguageModel; 10 | import dev.langchain4j.model.chat.response.ChatResponse; 11 | import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; 12 | import lombok.AllArgsConstructor; 13 | import lombok.extern.slf4j.Slf4j; 14 | import org.springframework.stereotype.Service; 15 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 16 | 17 | import java.util.List; 18 | import java.util.concurrent.CompletableFuture; 19 | 20 | import static com.melon.prompt.Langchain4jPrompt.MCP_RESULT; 21 | import static com.melon.prompt.Langchain4jPrompt.RAG_QUERY; 22 | 23 | /** 24 | * @author: melon 25 | * @description: TODO 26 | * @date: 2025/3/18 13:45 27 | */ 28 | @AllArgsConstructor 29 | @Slf4j 30 | @Service 31 | public class StreamingChatServiceImpl implements StreamingChatService { 32 | private final GitHubTool gitHubTool; 33 | private final FetchTool fetchTool; 34 | private final FilesystemTool filesystemTool; 35 | private final StreamingChatLanguageModel streamingChatLanguageModel; 36 | private final RagService ragService; 37 | 38 | @Override 39 | public SseEmitter processGitHubRequest(String input) { 40 | String result = gitHubTool.askGitHub(input); 41 | // -1 表示不超时 42 | SseEmitter emitter = new SseEmitter(-1L); 43 | CompletableFuture futureResponse = new CompletableFuture<>(); 44 | streamingChatLanguageModel.chat(String.format(MCP_RESULT, input, result), new StreamingChatResponseHandler() { 45 | @Override 46 | public void onPartialResponse(String partialResponse) { 47 | try { 48 | emitter.send(partialResponse); 49 | } catch (Exception e) { 50 | emitter.completeWithError(e); 51 | } 52 | } 53 | 54 | @Override 55 | public void onCompleteResponse(ChatResponse completeResponse) { 56 | futureResponse.complete(completeResponse); 57 | emitter.complete(); 58 | } 59 | 60 | @Override 61 | public void onError(Throwable error) { 62 | futureResponse.completeExceptionally(error); 63 | emitter.completeWithError(error); 64 | } 65 | }); 66 | return emitter; 67 | } 68 | 69 | @Override 70 | public SseEmitter processFetchRequest(String input) { 71 | String result = fetchTool.fetchAndAnswer(input); 72 | // -1 表示不超时 73 | SseEmitter emitter = new SseEmitter(-1L); 74 | CompletableFuture futureResponse = new CompletableFuture<>(); 75 | streamingChatLanguageModel.chat(String.format(MCP_RESULT, input, result), new StreamingChatResponseHandler() { 76 | @Override 77 | public void onPartialResponse(String partialResponse) { 78 | try { 79 | emitter.send(partialResponse); 80 | } catch (Exception e) { 81 | emitter.completeWithError(e); 82 | } 83 | } 84 | 85 | @Override 86 | public void onCompleteResponse(ChatResponse completeResponse) { 87 | futureResponse.complete(completeResponse); 88 | emitter.complete(); 89 | } 90 | 91 | @Override 92 | public void onError(Throwable error) { 93 | futureResponse.completeExceptionally(error); 94 | emitter.completeWithError(error); 95 | } 96 | }); 97 | return emitter; 98 | } 99 | 100 | @Override 101 | public SseEmitter processFilesystemRequestStream(String input) { 102 | return null; 103 | } 104 | 105 | @Override 106 | public SseEmitter processOtherRequest(String input) { 107 | // -1 表示不超时 108 | SseEmitter emitter = new SseEmitter(-1L); 109 | CompletableFuture futureResponse = new CompletableFuture<>(); 110 | streamingChatLanguageModel.chat(input, new StreamingChatResponseHandler() { 111 | @Override 112 | public void onPartialResponse(String partialResponse) { 113 | try { 114 | emitter.send(partialResponse); 115 | } catch (Exception e) { 116 | emitter.completeWithError(e); 117 | } 118 | } 119 | 120 | @Override 121 | public void onCompleteResponse(ChatResponse completeResponse) { 122 | futureResponse.complete(completeResponse); 123 | emitter.complete(); 124 | } 125 | 126 | @Override 127 | public void onError(Throwable error) { 128 | futureResponse.completeExceptionally(error); 129 | emitter.completeWithError(error); 130 | } 131 | }); 132 | return emitter; 133 | } 134 | 135 | @Override 136 | public SseEmitter processRagRequest(String input, List fileIds) { 137 | String result = ragService.answerWithRag(input, fileIds); 138 | // -1 表示不超时 139 | SseEmitter emitter = new SseEmitter(-1L); 140 | CompletableFuture futureResponse = new CompletableFuture<>(); 141 | streamingChatLanguageModel.chat(String.format(RAG_QUERY, result, input), new StreamingChatResponseHandler() { 142 | @Override 143 | public void onPartialResponse(String partialResponse) { 144 | try { 145 | emitter.send(partialResponse); 146 | } catch (Exception e) { 147 | emitter.completeWithError(e); 148 | } 149 | } 150 | 151 | @Override 152 | public void onCompleteResponse(ChatResponse completeResponse) { 153 | futureResponse.complete(completeResponse); 154 | emitter.complete(); 155 | } 156 | 157 | @Override 158 | public void onError(Throwable error) { 159 | futureResponse.completeExceptionally(error); 160 | emitter.completeWithError(error); 161 | } 162 | }); 163 | return emitter; 164 | } 165 | 166 | @Override 167 | public void removeFileFromRag(String fileId) { 168 | ragService.removeFileFromRag(fileId); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/main/java/com/melon/service/RagService.java: -------------------------------------------------------------------------------- 1 | package com.melon.service; 2 | 3 | import com.melon.entity.UploadFile; 4 | import dev.langchain4j.data.document.Document; 5 | import dev.langchain4j.data.document.DocumentParser; 6 | import dev.langchain4j.data.document.DocumentSplitter; 7 | import dev.langchain4j.data.document.parser.TextDocumentParser; 8 | import dev.langchain4j.data.document.parser.apache.pdfbox.ApachePdfBoxDocumentParser; 9 | import dev.langchain4j.data.document.splitter.DocumentSplitters; 10 | import dev.langchain4j.data.embedding.Embedding; 11 | import dev.langchain4j.data.segment.TextSegment; 12 | import dev.langchain4j.memory.chat.MessageWindowChatMemory; 13 | import dev.langchain4j.model.chat.ChatLanguageModel; 14 | import dev.langchain4j.model.embedding.EmbeddingModel; 15 | import dev.langchain4j.rag.content.retriever.ContentRetriever; 16 | import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; 17 | import dev.langchain4j.service.AiServices; 18 | import dev.langchain4j.store.embedding.EmbeddingStore; 19 | import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; 20 | import lombok.extern.slf4j.Slf4j; 21 | import org.springframework.beans.factory.annotation.Autowired; 22 | import org.springframework.stereotype.Service; 23 | 24 | import java.io.IOException; 25 | import java.nio.file.Files; 26 | import java.nio.file.Paths; 27 | import java.util.HashMap; 28 | import java.util.List; 29 | import java.util.Map; 30 | 31 | /** 32 | * RAG服务类 33 | * 负责文档解析和知识库问答 34 | */ 35 | @Service 36 | @Slf4j 37 | public class RagService { 38 | 39 | private final ChatLanguageModel chatLanguageModel; 40 | private final FileStorageService fileStorageService; 41 | 42 | // 内存中存储文档的嵌入 43 | private final EmbeddingStore embeddingStore = new InMemoryEmbeddingStore<>(); 44 | 45 | // 嵌入模型 46 | private final EmbeddingModel embeddingModel; 47 | 48 | // 文档解析器映射表 49 | private final Map documentParsers = new HashMap<>(); 50 | 51 | // 文档分割器 52 | private final DocumentSplitter documentSplitter; 53 | 54 | // 内容检索器 55 | private final ContentRetriever contentRetriever; 56 | 57 | // 记录文件ID到文档片段的映射 58 | private final Map> fileSegmentsMap = new HashMap<>(); 59 | 60 | @Autowired 61 | public RagService(ChatLanguageModel chatLanguageModel, FileStorageService fileStorageService, EmbeddingModel embeddingModel) { 62 | this.chatLanguageModel = chatLanguageModel; 63 | this.fileStorageService = fileStorageService; 64 | 65 | // 初始化嵌入模型 66 | this.embeddingModel = embeddingModel; 67 | 68 | // 初始化文档解析器 69 | documentParsers.put("pdf", new ApachePdfBoxDocumentParser()); 70 | documentParsers.put("txt", new TextDocumentParser()); 71 | 72 | // 初始化文档分割器 73 | this.documentSplitter = DocumentSplitters.recursive(500, 100); 74 | 75 | // 初始化内容检索器 76 | this.contentRetriever = EmbeddingStoreContentRetriever.builder() 77 | .embeddingStore(embeddingStore) 78 | .embeddingModel(embeddingModel) 79 | .maxResults(5) 80 | .minScore(0.6) 81 | .build(); 82 | 83 | log.info("RAG服务初始化完成"); 84 | } 85 | 86 | /** 87 | * 加载文件到RAG系统 88 | * 89 | * @param file 上传的文件 90 | * @return 加载结果描述 91 | */ 92 | public String loadFileToRag(UploadFile file) { 93 | try { 94 | log.info("开始加载文件到RAG系统: {}", file.getOriginalFileName()); 95 | 96 | String fileExtension = getFileExtension(file.getOriginalFileName()).toLowerCase(); 97 | DocumentParser parser = documentParsers.get(fileExtension); 98 | 99 | if (parser == null) { 100 | return "不支持的文件类型: " + fileExtension + ",支持的类型有: " + String.join(", ", documentParsers.keySet()); 101 | } 102 | 103 | // 解析文档 104 | Document document = parser.parse(Files.newInputStream(Paths.get(file.getFilePath()))); 105 | 106 | // 分割文档 107 | List segments = documentSplitter.split(document); 108 | log.info("文件 {} 被分割为 {} 个片段", file.getOriginalFileName(), segments.size()); 109 | 110 | // 为片段创建嵌入并存储 111 | List embeddings = embeddingModel.embedAll(segments).content(); 112 | embeddingStore.addAll(embeddings, segments); 113 | 114 | // 记录文件ID到分段的映射 115 | fileSegmentsMap.put(file.getId(), segments); 116 | 117 | // 更新文件状态 118 | file.setLoadedToRag(true); 119 | 120 | log.info("文件已成功加载到RAG系统: {}", file.getOriginalFileName()); 121 | return "文件已成功加载到RAG系统,共 " + segments.size() + " 个文本片段"; 122 | } catch (IOException e) { 123 | log.error("加载文件到RAG系统失败: {}", file.getOriginalFileName(), e); 124 | return "加载文件失败: " + e.getMessage(); 125 | } 126 | } 127 | 128 | /** 129 | * 从RAG系统中移除文件 130 | * 131 | * @param fileId 文件ID 132 | */ 133 | public void removeFileFromRag(String fileId) { 134 | List segments = fileSegmentsMap.get(fileId); 135 | if (segments != null) { 136 | // 从嵌入存储中移除所有相关分段 137 | for (TextSegment segment : segments) { 138 | embeddingStore.remove(segment.text()); 139 | } 140 | fileSegmentsMap.remove(fileId); 141 | log.info("已从RAG系统中移除文件: {}", fileId); 142 | } 143 | } 144 | 145 | /** 146 | * 基于RAG回答问题 147 | * 148 | * @param question 问题 149 | * @param fileIds 要查询的文件ID列表 150 | * @return 回答 151 | */ 152 | public String answerWithRag(String question, List fileIds) { 153 | try { 154 | log.info("基于RAG回答问题: {}, 文件数量: {}", question, fileIds.size()); 155 | 156 | // 先加载未加载过的文件 157 | List files = fileStorageService.getByIds(fileIds); 158 | for (UploadFile file : files) { 159 | if (!file.isLoadedToRag()) { 160 | loadFileToRag(file); 161 | } 162 | } 163 | 164 | // 创建问答助手 165 | RagChatService chatService = AiServices.builder(RagChatService.class) 166 | .chatLanguageModel(chatLanguageModel) 167 | .contentRetriever(contentRetriever) 168 | .chatMemory(MessageWindowChatMemory.withMaxMessages(10)) 169 | .build(); 170 | 171 | // 获取回答 172 | String answer = chatService.chat(question); 173 | log.info("RAG回答: {}", answer); 174 | return answer; 175 | } catch (Exception e) { 176 | log.error("RAG回答问题失败", e); 177 | return "回答问题失败: " + e.getMessage(); 178 | } 179 | } 180 | 181 | /** 182 | * RAG聊天服务接口 183 | */ 184 | interface RagChatService { 185 | String chat(String message); 186 | } 187 | 188 | /** 189 | * 获取文件扩展名 190 | * 191 | * @param fileName 文件名 192 | * @return 扩展名 193 | */ 194 | private String getFileExtension(String fileName) { 195 | if (fileName == null || !fileName.contains(".")) { 196 | return ""; 197 | } 198 | return fileName.substring(fileName.lastIndexOf(".") + 1); 199 | } 200 | } -------------------------------------------------------------------------------- /src/main/java/com/melon/tool/FilesystemTool.java: -------------------------------------------------------------------------------- 1 | package com.melon.tool; 2 | 3 | import dev.langchain4j.agent.tool.Tool; 4 | import jakarta.annotation.PostConstruct; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.io.File; 10 | import java.io.IOException; 11 | import java.nio.file.Files; 12 | import java.nio.file.Path; 13 | import java.nio.file.Paths; 14 | import java.util.ArrayList; 15 | import java.util.Arrays; 16 | import java.util.List; 17 | import java.util.stream.Collectors; 18 | import java.util.stream.Stream; 19 | 20 | /** 21 | * 文件系统工具 22 | * 提供文件系统操作功能 23 | */ 24 | @Component 25 | @Slf4j 26 | public class FilesystemTool { 27 | 28 | @Value("${mcp.filesystem.allowed-roots}") 29 | private String allowedRootsString; 30 | 31 | @Value("${mcp.filesystem.allow-write:false}") 32 | private boolean allowWrite; 33 | 34 | @Value("${mcp.filesystem.allow-delete:false}") 35 | private boolean allowDelete; 36 | 37 | private List allowedRoots; 38 | 39 | /** 40 | * 初始化允许访问的根目录列表 41 | */ 42 | @PostConstruct 43 | public void init() { 44 | allowedRoots = Arrays.stream(allowedRootsString.split(",")) 45 | .map(String::trim) 46 | .collect(Collectors.toList()); 47 | log.info("文件系统工具初始化完成,允许访问的根目录: {}, 允许写入: {}, 允许删除: {}", 48 | allowedRoots, allowWrite, allowDelete); 49 | } 50 | 51 | /** 52 | * 列出目录内容 53 | * 54 | * @param dirPath 目录路径 55 | * @return 目录内容列表 56 | */ 57 | @Tool("列出指定目录下的文件和子目录") 58 | public String listDirectory(String dirPath) { 59 | log.info("列出目录内容: {}", dirPath); 60 | 61 | try { 62 | Path path = validateAndGetPath(dirPath); 63 | if (!Files.isDirectory(path)) { 64 | return "错误: 指定路径不是目录: " + dirPath; 65 | } 66 | 67 | StringBuilder result = new StringBuilder(); 68 | result.append("目录: ").append(path.toAbsolutePath()).append("\n\n"); 69 | 70 | try (Stream paths = Files.list(path)) { 71 | List files = new ArrayList<>(); 72 | List directories = new ArrayList<>(); 73 | 74 | paths.forEach(p -> { 75 | String name = p.getFileName().toString(); 76 | if (Files.isDirectory(p)) { 77 | directories.add(name + "/"); 78 | } else { 79 | try { 80 | long size = Files.size(p); 81 | String sizeStr = formatFileSize(size); 82 | files.add(name + " (" + sizeStr + ")"); 83 | } catch (IOException e) { 84 | files.add(name + " (无法获取大小)"); 85 | } 86 | } 87 | }); 88 | 89 | if (!directories.isEmpty()) { 90 | result.append("目录:\n"); 91 | directories.stream().sorted().forEach(d -> result.append("- ").append(d).append("\n")); 92 | result.append("\n"); 93 | } 94 | 95 | if (!files.isEmpty()) { 96 | result.append("文件:\n"); 97 | files.stream().sorted().forEach(f -> result.append("- ").append(f).append("\n")); 98 | } 99 | 100 | if (directories.isEmpty() && files.isEmpty()) { 101 | result.append("目录为空"); 102 | } 103 | } 104 | 105 | return result.toString(); 106 | } catch (Exception e) { 107 | log.error("列出目录内容时出错", e); 108 | return "错误: " + e.getMessage(); 109 | } 110 | } 111 | 112 | /** 113 | * 读取文件内容 114 | * 115 | * @param filePath 文件路径 116 | * @return 文件内容 117 | */ 118 | @Tool("读取指定文件的内容") 119 | public String readFile(String filePath) { 120 | log.info("读取文件内容: {}", filePath); 121 | 122 | try { 123 | Path path = validateAndGetPath(filePath); 124 | if (!Files.isRegularFile(path)) { 125 | return "错误: 指定路径不是文件: " + filePath; 126 | } 127 | 128 | long fileSize = Files.size(path); 129 | if (fileSize > 1024 * 1024) { // 限制文件大小为1MB 130 | return "错误: 文件过大,无法读取。文件大小: " + formatFileSize(fileSize); 131 | } 132 | 133 | String content = Files.readString(path); 134 | return "文件: " + path.toAbsolutePath() + "\n\n" + content; 135 | } catch (Exception e) { 136 | log.error("读取文件内容时出错", e); 137 | return "错误: " + e.getMessage(); 138 | } 139 | } 140 | 141 | /** 142 | * 写入文件内容 143 | * 144 | * @param filePath 文件路径 145 | * @param content 文件内容 146 | * @return 操作结果 147 | */ 148 | @Tool("写入内容到指定文件") 149 | public String writeFile(String filePath, String content) { 150 | log.info("写入文件内容: {}", filePath); 151 | 152 | if (!allowWrite) { 153 | return "错误: 写入操作已禁用"; 154 | } 155 | 156 | try { 157 | Path path = validateAndGetPath(filePath); 158 | 159 | // 确保父目录存在 160 | Path parent = path.getParent(); 161 | if (parent != null && !Files.exists(parent)) { 162 | Files.createDirectories(parent); 163 | } 164 | 165 | Files.writeString(path, content); 166 | return "成功: 内容已写入文件 " + path.toAbsolutePath(); 167 | } catch (Exception e) { 168 | log.error("写入文件内容时出错", e); 169 | return "错误: " + e.getMessage(); 170 | } 171 | } 172 | 173 | /** 174 | * 删除文件或目录 175 | * 176 | * @param path 文件或目录路径 177 | * @return 操作结果 178 | */ 179 | @Tool("删除指定的文件或目录") 180 | public String delete(String path) { 181 | log.info("删除文件或目录: {}", path); 182 | 183 | if (!allowDelete) { 184 | return "错误: 删除操作已禁用"; 185 | } 186 | 187 | try { 188 | Path filePath = validateAndGetPath(path); 189 | if (!Files.exists(filePath)) { 190 | return "错误: 指定路径不存在: " + path; 191 | } 192 | 193 | if (Files.isDirectory(filePath)) { 194 | try (Stream pathStream = Files.walk(filePath)) { 195 | pathStream.sorted((p1, p2) -> -p1.compareTo(p2)) // 逆序,先删除子文件 196 | .forEach(p -> { 197 | try { 198 | Files.delete(p); 199 | } catch (IOException e) { 200 | log.error("删除路径时出错: {}", p, e); 201 | } 202 | }); 203 | } 204 | return "成功: 目录已删除 " + filePath.toAbsolutePath(); 205 | } else { 206 | Files.delete(filePath); 207 | return "成功: 文件已删除 " + filePath.toAbsolutePath(); 208 | } 209 | } catch (Exception e) { 210 | log.error("删除文件或目录时出错", e); 211 | return "错误: " + e.getMessage(); 212 | } 213 | } 214 | 215 | /** 216 | * 搜索文件 217 | * 218 | * @param dirPath 目录路径 219 | * @param keyword 关键字 220 | * @return 搜索结果 221 | */ 222 | @Tool("在指定目录中搜索包含关键字的文件") 223 | public String searchFiles(String dirPath, String keyword) { 224 | log.info("搜索文件: 目录={}, 关键字={}", dirPath, keyword); 225 | 226 | if (keyword == null || keyword.trim().isEmpty()) { 227 | return "错误: 搜索关键字不能为空"; 228 | } 229 | 230 | try { 231 | Path path = validateAndGetPath(dirPath); 232 | if (!Files.isDirectory(path)) { 233 | return "错误: 指定路径不是目录: " + dirPath; 234 | } 235 | 236 | final String finalKeyword = keyword.toLowerCase(); 237 | List results = new ArrayList<>(); 238 | 239 | try (Stream pathStream = Files.walk(path, 5)) { // 限制搜索深度为5 240 | pathStream 241 | .filter(Files::isRegularFile) 242 | .filter(p -> { 243 | String fileName = p.getFileName().toString().toLowerCase(); 244 | return fileName.contains(finalKeyword); 245 | }) 246 | .limit(50) // 限制结果数量 247 | .forEach(p -> results.add(path.relativize(p).toString())); 248 | } 249 | 250 | if (results.isEmpty()) { 251 | return "在目录 " + path.toAbsolutePath() + " 中未找到包含关键字 '" + keyword + "' 的文件"; 252 | } 253 | 254 | StringBuilder result = new StringBuilder(); 255 | result.append("在目录 ").append(path.toAbsolutePath()) 256 | .append(" 中找到 ").append(results.size()).append(" 个包含关键字 '") 257 | .append(keyword).append("' 的文件:\n\n"); 258 | 259 | results.stream().sorted().forEach(f -> result.append("- ").append(f).append("\n")); 260 | 261 | return result.toString(); 262 | } catch (Exception e) { 263 | log.error("搜索文件时出错", e); 264 | return "错误: " + e.getMessage(); 265 | } 266 | } 267 | 268 | /** 269 | * 获取文件系统信息 270 | * 271 | * @return 文件系统信息 272 | */ 273 | @Tool("获取文件系统信息,包括总空间、可用空间等") 274 | public String getFilesystemInfo() { 275 | log.info("获取文件系统信息"); 276 | 277 | try { 278 | StringBuilder result = new StringBuilder(); 279 | result.append("文件系统信息:\n\n"); 280 | 281 | for (String root : allowedRoots) { 282 | Path path = Paths.get(root); 283 | if (Files.exists(path)) { 284 | File file = path.toFile(); 285 | long totalSpace = file.getTotalSpace(); 286 | long freeSpace = file.getFreeSpace(); 287 | long usableSpace = file.getUsableSpace(); 288 | 289 | result.append("路径: ").append(path.toAbsolutePath()).append("\n"); 290 | result.append("- 总空间: ").append(formatFileSize(totalSpace)).append("\n"); 291 | result.append("- 可用空间: ").append(formatFileSize(usableSpace)).append("\n"); 292 | result.append("- 已用空间: ").append(formatFileSize(totalSpace - freeSpace)).append("\n"); 293 | result.append("- 使用率: ").append(String.format("%.2f%%", (double) (totalSpace - freeSpace) / totalSpace * 100)).append("\n\n"); 294 | } 295 | } 296 | 297 | return result.toString(); 298 | } catch (Exception e) { 299 | log.error("获取文件系统信息时出错", e); 300 | return "错误: " + e.getMessage(); 301 | } 302 | } 303 | 304 | /** 305 | * 验证并获取路径 306 | * 307 | * @param pathStr 路径字符串 308 | * @return 验证后的路径 309 | * @throws IOException 如果路径无效或不在允许的根目录下 310 | */ 311 | private Path validateAndGetPath(String pathStr) throws IOException { 312 | if (pathStr == null || pathStr.trim().isEmpty()) { 313 | throw new IOException("路径不能为空"); 314 | } 315 | 316 | Path path = Paths.get(pathStr); 317 | 318 | // 如果是相对路径,则使用第一个允许的根目录作为基础 319 | if (!path.isAbsolute()) { 320 | path = Paths.get(allowedRoots.get(0), pathStr); 321 | } 322 | 323 | // 规范化路径 324 | path = path.normalize().toAbsolutePath(); 325 | 326 | // 检查路径是否在允许的根目录下 327 | boolean isAllowed = false; 328 | for (String root : allowedRoots) { 329 | Path rootPath = Paths.get(root).normalize().toAbsolutePath(); 330 | if (path.startsWith(rootPath)) { 331 | isAllowed = true; 332 | break; 333 | } 334 | } 335 | 336 | if (!isAllowed) { 337 | throw new IOException("访问被拒绝: 路径不在允许的根目录下: " + path); 338 | } 339 | 340 | return path; 341 | } 342 | 343 | /** 344 | * 格式化文件大小 345 | * 346 | * @param size 文件大小(字节) 347 | * @return 格式化后的文件大小 348 | */ 349 | private String formatFileSize(long size) { 350 | if (size < 1024) { 351 | return size + " B"; 352 | } else if (size < 1024 * 1024) { 353 | return String.format("%.2f KB", size / 1024.0); 354 | } else if (size < 1024 * 1024 * 1024) { 355 | return String.format("%.2f MB", size / (1024.0 * 1024)); 356 | } else { 357 | return String.format("%.2f GB", size / (1024.0 * 1024 * 1024)); 358 | } 359 | } 360 | } -------------------------------------------------------------------------------- /src/main/resources/static/css/chat.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #5468ff; 3 | --primary-light: #6e7fff; 4 | --primary-dark: #4050e0; 5 | --secondary-color: #8ba3ff; 6 | --text-color: #333; 7 | --light-text: #f8f9fa; 8 | --bg-color: #f9fafe; 9 | --card-bg: #ffffff; 10 | --border-radius: 12px; 11 | --shadow: 0 10px 30px rgba(0, 0, 0, 0.05); 12 | --transition: all 0.3s ease; 13 | --message-human-bg: #ebf3ff; 14 | --message-ai-bg: #ffffff; 15 | --message-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); 16 | } 17 | 18 | * { 19 | margin: 0; 20 | padding: 0; 21 | box-sizing: border-box; 22 | } 23 | 24 | body { 25 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 26 | line-height: 1.6; 27 | color: var(--text-color); 28 | background-color: var(--bg-color); 29 | min-height: 100vh; 30 | display: flex; 31 | flex-direction: column; 32 | } 33 | 34 | .container { 35 | max-width: 1200px; 36 | margin: 0 auto; 37 | padding: 0 1.5rem; 38 | flex: 1; 39 | width: 100%; 40 | display: flex; 41 | flex-direction: column; 42 | } 43 | 44 | .chat-container { 45 | background-color: var(--card-bg); 46 | border-radius: var(--border-radius); 47 | box-shadow: var(--shadow); 48 | overflow: hidden; 49 | flex: 1; 50 | display: flex; 51 | flex-direction: column; 52 | margin-bottom: 2rem; 53 | position: relative; 54 | } 55 | 56 | .chat-header { 57 | padding: 1.5rem; 58 | background: linear-gradient(to right, var(--primary-dark), var(--primary-color)); 59 | color: var(--light-text); 60 | display: flex; 61 | flex-direction: column; 62 | align-items: center; 63 | } 64 | 65 | .chat-header h2 { 66 | font-size: 1.8rem; 67 | margin: 0 0 0.5rem 0; 68 | font-weight: 600; 69 | } 70 | 71 | .chat-header p { 72 | font-size: 1rem; 73 | opacity: 0.9; 74 | margin: 0; 75 | } 76 | 77 | .chat-messages { 78 | flex: 1; 79 | padding: 1.5rem; 80 | overflow-y: auto; 81 | max-height: calc(100vh - 300px); 82 | min-height: 300px; 83 | display: flex; 84 | flex-direction: column; 85 | gap: 1.5rem; 86 | background-color: var(--bg-color); 87 | } 88 | 89 | .message { 90 | display: flex; 91 | margin-bottom: 1rem; 92 | animation: fadeIn 0.3s ease; 93 | } 94 | 95 | @keyframes fadeIn { 96 | from { opacity: 0; transform: translateY(8px); } 97 | to { opacity: 1; transform: translateY(0); } 98 | } 99 | 100 | .message.ai { 101 | justify-content: flex-start; 102 | } 103 | 104 | .message.human { 105 | justify-content: flex-end; 106 | } 107 | 108 | .message-content { 109 | padding: 1.25rem; 110 | border-radius: var(--border-radius); 111 | max-width: 80%; 112 | box-shadow: var(--message-shadow); 113 | position: relative; 114 | transition: transform 0.2s ease; 115 | } 116 | 117 | .message-content:hover { 118 | transform: translateY(-2px); 119 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); 120 | } 121 | 122 | .message.ai .message-content { 123 | background-color: var(--message-ai-bg); 124 | border-top-left-radius: 0; 125 | margin-right: 2rem; 126 | } 127 | 128 | .message.human .message-content { 129 | background-color: var(--message-human-bg); 130 | border-top-right-radius: 0; 131 | margin-left: 2rem; 132 | } 133 | 134 | .tool-badge { 135 | position: absolute; 136 | top: -10px; 137 | left: 10px; 138 | padding: 0.25rem 0.75rem; 139 | border-radius: 20px; 140 | font-size: 0.75rem; 141 | font-weight: bold; 142 | background: var(--primary-color); 143 | color: white; 144 | box-shadow: 0 2px 4px rgba(84, 104, 255, 0.3); 145 | } 146 | 147 | .chat-input-container { 148 | padding: 1.5rem; 149 | border-top: 1px solid rgba(0, 0, 0, 0.05); 150 | background-color: white; 151 | position: relative; 152 | } 153 | 154 | .chat-form { 155 | display: flex; 156 | flex-direction: column; 157 | position: relative; 158 | } 159 | 160 | .input-wrapper { 161 | width: 100%; 162 | position: relative; 163 | display: flex; 164 | align-items: center; 165 | border: 1px solid rgba(84, 104, 255, 0.2); 166 | border-radius: var(--border-radius); 167 | background-color: white; 168 | padding: 0.5rem; 169 | box-shadow: 0 2px 6px rgba(84, 104, 255, 0.05); 170 | transition: all 0.2s ease; 171 | } 172 | 173 | .input-wrapper:focus-within { 174 | border-color: var(--primary-color); 175 | box-shadow: 0 0 0 3px rgba(84, 104, 255, 0.15); 176 | } 177 | 178 | .chat-input { 179 | flex: 1; 180 | padding: 0.75rem; 181 | padding-right: 50px; /* 为发送按钮留出空间 */ 182 | border: none; 183 | border-radius: var(--border-radius); 184 | font-size: 1rem; 185 | outline: none; 186 | resize: none; 187 | max-height: 150px; 188 | min-height: 60px; 189 | font-family: inherit; 190 | background: transparent; 191 | line-height: 1.5; 192 | } 193 | 194 | .send-button { 195 | position: absolute; 196 | right: 10px; 197 | bottom: 10px; 198 | width: 40px; 199 | height: 40px; 200 | min-width: 40px; 201 | border-radius: 50%; 202 | padding: 0; 203 | display: flex; 204 | align-items: center; 205 | justify-content: center; 206 | background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)); 207 | color: white; 208 | border: none; 209 | cursor: pointer; 210 | transition: all 0.2s ease; 211 | box-shadow: 0 2px 6px rgba(84, 104, 255, 0.2); 212 | z-index: 2; 213 | } 214 | 215 | .send-button:hover { 216 | transform: translateY(-2px); 217 | box-shadow: 0 4px 8px rgba(84, 104, 255, 0.3); 218 | } 219 | 220 | .send-button:disabled { 221 | background: #999; 222 | opacity: 0.8; 223 | cursor: pointer; 224 | transform: none; 225 | box-shadow: none; 226 | transition: opacity 0.3s ease; 227 | } 228 | 229 | .send-button:disabled:hover { 230 | opacity: 1; 231 | } 232 | 233 | .upload-button { 234 | position: absolute; 235 | left: 10px; 236 | bottom: 10px; 237 | width: 40px; 238 | height: 40px; 239 | min-width: 40px; 240 | display: flex; 241 | align-items: center; 242 | justify-content: center; 243 | border-radius: 50%; 244 | cursor: pointer; 245 | transition: all 0.2s ease; 246 | background: rgba(84, 104, 255, 0.05); 247 | color: var(--primary-color); 248 | border: 1px solid rgba(84, 104, 255, 0.2); 249 | z-index: 2; /* 确保上传按钮在最上层 */ 250 | } 251 | 252 | .upload-button svg { 253 | width: 20px; 254 | height: 20px; 255 | } 256 | 257 | .upload-button:hover { 258 | background-color: rgba(84, 104, 255, 0.1); 259 | color: var(--primary-dark); 260 | transform: translateY(-2px); 261 | box-shadow: 0 2px 8px rgba(84, 104, 255, 0.2); 262 | } 263 | 264 | .upload-button:disabled { 265 | color: #ccc; 266 | border-color: #eee; 267 | background-color: #f5f5f5; 268 | cursor: not-allowed; 269 | transform: none; 270 | box-shadow: none; 271 | } 272 | 273 | /* 取消按钮样式 */ 274 | .cancel-button { 275 | position: absolute; 276 | bottom: 20px; 277 | right: 20px; 278 | background-color: #ff4d4f; 279 | color: white; 280 | border: none; 281 | padding: 8px 16px; 282 | border-radius: 30px; 283 | cursor: pointer; 284 | display: none; 285 | align-items: center; 286 | gap: 8px; 287 | font-size: 14px; 288 | font-weight: 500; 289 | box-shadow: 0 2px 8px rgba(255, 77, 79, 0.3); 290 | transition: all 0.2s ease; 291 | z-index: 10; 292 | } 293 | 294 | .cancel-button:hover { 295 | background-color: #ff7875; 296 | transform: translateY(-2px); 297 | box-shadow: 0 4px 12px rgba(255, 77, 79, 0.4); 298 | } 299 | 300 | .cancel-button svg { 301 | width: 16px; 302 | height: 16px; 303 | } 304 | 305 | /* 取消通知样式 */ 306 | .cancel-notice { 307 | display: inline-block; 308 | margin-top: 8px; 309 | padding: 4px 8px; 310 | background-color: #fff2f0; 311 | border: 1px solid #ffccc7; 312 | border-radius: 4px; 313 | color: #ff4d4f; 314 | font-size: 12px; 315 | font-weight: 500; 316 | } 317 | 318 | /* 文件标签容器 */ 319 | .file-tags-container { 320 | position: absolute; 321 | top: -25px; /* 将文件标签移到输入框上方 */ 322 | left: 15px; 323 | right: 15px; 324 | display: flex; 325 | flex-wrap: wrap; 326 | gap: 6px; 327 | z-index: 2; 328 | max-height: 2rem; 329 | overflow-y: hidden; 330 | transition: max-height 0.3s ease; 331 | } 332 | 333 | .file-tags-container.expanded { 334 | max-height: 5rem; 335 | overflow-y: auto; 336 | background-color: rgba(255, 255, 255, 0.9); 337 | border-radius: 8px; 338 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); 339 | padding: 4px; 340 | top: -60px; /* 展开时向上移动更多 */ 341 | } 342 | 343 | /* 文件标签样式 */ 344 | .file-tag { 345 | display: inline-flex; 346 | align-items: center; 347 | background-color: var(--message-human-bg); 348 | border-radius: 20px; 349 | padding: 4px 10px; 350 | font-size: 0.75rem; 351 | max-width: 150px; 352 | cursor: default; 353 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 354 | transition: all 0.2s ease; 355 | border: 1px solid rgba(84, 104, 255, 0.2); 356 | } 357 | 358 | .file-tag:hover { 359 | background-color: #d4e3ff; 360 | } 361 | 362 | .file-tag-name { 363 | white-space: nowrap; 364 | overflow: hidden; 365 | text-overflow: ellipsis; 366 | color: var(--primary-dark); 367 | } 368 | 369 | .file-tag-remove { 370 | margin-left: 5px; 371 | cursor: pointer; 372 | font-size: 14px; 373 | color: #ff4d4f; 374 | border: none; 375 | background: none; 376 | padding: 0; 377 | width: 16px; 378 | height: 16px; 379 | line-height: 14px; 380 | text-align: center; 381 | border-radius: 50%; 382 | transition: all 0.2s ease; 383 | } 384 | 385 | .file-tag-remove:hover { 386 | background-color: rgba(255, 77, 79, 0.2); 387 | transform: scale(1.1); 388 | } 389 | 390 | /* 文件计数器 - 当文件较多时显示 */ 391 | .file-count { 392 | display: inline-flex; 393 | align-items: center; 394 | justify-content: center; 395 | background-color: var(--primary-light); 396 | color: white; 397 | border-radius: 20px; 398 | padding: 3px 10px; 399 | font-size: 0.75rem; 400 | cursor: pointer; 401 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 402 | } 403 | 404 | .examples-container { 405 | margin-bottom: 2rem; 406 | } 407 | 408 | .examples-title { 409 | font-size: 1.2rem; 410 | font-weight: 600; 411 | margin-bottom: 1rem; 412 | color: var(--primary-dark); 413 | } 414 | 415 | .examples-grid { 416 | display: grid; 417 | grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); 418 | gap: 1rem; 419 | } 420 | 421 | .example-card { 422 | background-color: white; 423 | padding: 1rem; 424 | border-radius: var(--border-radius); 425 | box-shadow: var(--shadow); 426 | cursor: pointer; 427 | transition: var(--transition); 428 | border-left: 3px solid var(--primary-color); 429 | } 430 | 431 | .example-card:hover { 432 | transform: translateY(-5px); 433 | box-shadow: 0 15px 30px rgba(0, 0, 0, 0.1); 434 | } 435 | 436 | .example-card h3 { 437 | font-size: 1rem; 438 | margin-bottom: 0.5rem; 439 | color: var(--primary-dark); 440 | } 441 | 442 | .example-card p { 443 | font-size: 0.9rem; 444 | color: #666; 445 | } 446 | 447 | .loading-container { 448 | display: flex; 449 | align-items: center; 450 | justify-content: center; 451 | padding: 2rem; 452 | } 453 | 454 | .loading-spinner { 455 | width: 50px; 456 | height: 50px; 457 | border: 5px solid rgba(84, 104, 255, 0.1); 458 | border-radius: 50%; 459 | border-top-color: var(--primary-color); 460 | animation: spin 1s ease-in-out infinite; 461 | } 462 | 463 | @keyframes spin { 464 | to { 465 | transform: rotate(360deg); 466 | } 467 | } 468 | 469 | .typing-indicator { 470 | display: flex; 471 | align-items: center; 472 | padding: 0.5rem 1rem; 473 | background-color: var(--message-ai-bg); 474 | border-radius: 20px; 475 | margin-bottom: 1rem; 476 | width: fit-content; 477 | box-shadow: var(--message-shadow); 478 | } 479 | 480 | .typing-dot { 481 | width: 8px; 482 | height: 8px; 483 | background-color: var(--primary-color); 484 | border-radius: 50%; 485 | margin: 0 2px; 486 | animation: typing-dot 1.4s infinite ease-in-out; 487 | } 488 | 489 | .typing-dot:nth-child(1) { 490 | animation-delay: 0s; 491 | } 492 | 493 | .typing-dot:nth-child(2) { 494 | animation-delay: 0.2s; 495 | } 496 | 497 | .typing-dot:nth-child(3) { 498 | animation-delay: 0.4s; 499 | } 500 | 501 | @keyframes typing-dot { 502 | 0%, 60%, 100% { 503 | transform: translateY(0); 504 | } 505 | 30% { 506 | transform: translateY(-5px); 507 | } 508 | } 509 | 510 | footer { 511 | padding: 1.5rem 0; 512 | text-align: center; 513 | margin-top: auto; 514 | background-color: white; 515 | border-top: 1px solid rgba(0, 0, 0, 0.05); 516 | } 517 | 518 | footer p { 519 | color: #666; 520 | font-size: 0.9rem; 521 | } 522 | 523 | footer a { 524 | color: var(--primary-color); 525 | text-decoration: none; 526 | font-weight: 500; 527 | } 528 | 529 | footer a:hover { 530 | text-decoration: underline; 531 | } 532 | 533 | /* 文件上传相关样式 */ 534 | .upload-files-container { 535 | position: fixed; 536 | right: 30px; 537 | bottom: 100px; 538 | width: 320px; 539 | max-height: 400px; 540 | background-color: white; 541 | border-radius: var(--border-radius); 542 | box-shadow: 0 5px 20px rgba(0, 0, 0, 0.15); 543 | z-index: 1000; 544 | display: none; 545 | overflow: hidden; 546 | flex-direction: column; 547 | border: 1px solid rgba(84, 104, 255, 0.1); 548 | } 549 | 550 | .upload-files-header { 551 | padding: 12px 16px; 552 | background: linear-gradient(to right, var(--primary-color), var(--secondary-color)); 553 | color: white; 554 | display: flex; 555 | justify-content: space-between; 556 | align-items: center; 557 | } 558 | 559 | .upload-files-header h3 { 560 | margin: 0; 561 | font-size: 16px; 562 | font-weight: 600; 563 | } 564 | 565 | .close-files-btn { 566 | background: none; 567 | border: none; 568 | color: white; 569 | font-size: 22px; 570 | cursor: pointer; 571 | line-height: 1; 572 | transition: transform 0.2s ease; 573 | } 574 | 575 | .close-files-btn:hover { 576 | transform: scale(1.2); 577 | } 578 | 579 | .upload-files-list { 580 | padding: 12px 16px; 581 | overflow-y: auto; 582 | max-height: 350px; 583 | } 584 | 585 | .file-item { 586 | display: flex; 587 | justify-content: space-between; 588 | align-items: center; 589 | padding: 10px 8px; 590 | border-bottom: 1px solid rgba(0, 0, 0, 0.05); 591 | transition: background-color 0.2s ease; 592 | border-radius: 8px; 593 | } 594 | 595 | .file-item:hover { 596 | background-color: rgba(84, 104, 255, 0.05); 597 | } 598 | 599 | .file-name { 600 | font-size: 14px; 601 | color: #333; 602 | white-space: nowrap; 603 | overflow: hidden; 604 | text-overflow: ellipsis; 605 | max-width: 180px; 606 | font-weight: 500; 607 | } 608 | 609 | .file-size { 610 | font-size: 12px; 611 | color: #999; 612 | margin-top: 2px; 613 | } 614 | 615 | .file-remove { 616 | color: #ff4d4f; 617 | cursor: pointer; 618 | font-size: 16px; 619 | background: none; 620 | border: none; 621 | padding: 0 5px; 622 | transition: transform 0.2s ease; 623 | } 624 | 625 | .file-remove:hover { 626 | transform: scale(1.2); 627 | } 628 | 629 | /* 示例链接样式 */ 630 | .example-link { 631 | color: var(--primary-color); 632 | text-decoration: none; 633 | display: inline-block; 634 | transition: var(--transition); 635 | padding: 2px 0; 636 | font-weight: 500; 637 | } 638 | 639 | .example-link:hover { 640 | color: var(--primary-dark); 641 | transform: translateX(2px); 642 | text-decoration: underline; 643 | } 644 | 645 | /* Markdown Styling */ 646 | .markdown { 647 | line-height: 1.6; 648 | } 649 | 650 | .markdown p { 651 | margin-bottom: 1rem; 652 | } 653 | 654 | .markdown h1, .markdown h2, .markdown h3 { 655 | margin-top: 1.5rem; 656 | margin-bottom: 1rem; 657 | } 658 | 659 | .markdown ul, .markdown ol { 660 | margin-bottom: 1rem; 661 | padding-left: 1.5rem; 662 | } 663 | 664 | .markdown li { 665 | margin-bottom: 0.5rem; 666 | } 667 | 668 | .markdown code { 669 | background-color: #f3f4f6; 670 | padding: 0.2rem 0.4rem; 671 | border-radius: 3px; 672 | font-family: 'Courier New', monospace; 673 | font-size: 0.9em; 674 | } 675 | 676 | .markdown pre { 677 | background-color: #f3f4f6; 678 | padding: 1rem; 679 | border-radius: 5px; 680 | overflow-x: auto; 681 | margin-bottom: 1rem; 682 | } 683 | 684 | .markdown pre code { 685 | background-color: transparent; 686 | padding: 0; 687 | } 688 | 689 | /* Responsive Design */ 690 | @media (max-width: 768px) { 691 | .container { 692 | padding: 0; 693 | } 694 | 695 | .chat-container { 696 | border-radius: 0; 697 | margin-bottom: 0; 698 | } 699 | 700 | .chat-header h2 { 701 | font-size: 1.5rem; 702 | } 703 | 704 | .chat-header p { 705 | font-size: 0.9rem; 706 | } 707 | 708 | .examples-grid { 709 | grid-template-columns: 1fr; 710 | } 711 | 712 | .message-content { 713 | max-width: 90%; 714 | } 715 | 716 | .upload-files-container { 717 | width: 90%; 718 | right: 5%; 719 | left: 5%; 720 | } 721 | 722 | .input-wrapper { 723 | padding: 0.35rem; 724 | } 725 | 726 | .chat-input { 727 | font-size: 0.95rem; 728 | min-height: 50px; 729 | padding: 0.6rem; 730 | padding-right: 45px; 731 | } 732 | 733 | .input-actions { 734 | margin-top: 0.3rem; 735 | } 736 | 737 | .send-button, 738 | .upload-button { 739 | width: 36px; 740 | height: 36px; 741 | min-width: 36px; 742 | } 743 | 744 | .upload-button svg { 745 | width: 18px; 746 | height: 18px; 747 | } 748 | 749 | .send-button { 750 | width: 36px; 751 | height: 36px; 752 | min-width: 36px; 753 | right: 8px; 754 | bottom: 8px; 755 | } 756 | 757 | .cancel-button { 758 | bottom: 15px; 759 | right: 15px; 760 | padding: 6px 12px; 761 | font-size: 12px; 762 | } 763 | 764 | .file-tags-container { 765 | top: -22px; /* 在移动设备上稍微调整位置 */ 766 | } 767 | 768 | .file-tags-container.expanded { 769 | top: -50px; /* 移动设备上展开时向上移动 */ 770 | } 771 | 772 | .file-tag { 773 | padding: 3px 8px; 774 | font-size: 0.7rem; 775 | } 776 | } 777 | 778 | /* 暗色模式支持 */ 779 | @media (prefers-color-scheme: dark) { 780 | :root { 781 | --text-color: #f0f4fc; 782 | --bg-color: #1a1d29; 783 | --card-bg: #252836; 784 | --message-human-bg: #3a477d; 785 | --message-ai-bg: #252836; 786 | --message-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); 787 | } 788 | 789 | body { 790 | background-color: var(--bg-color); 791 | } 792 | 793 | .input-wrapper { 794 | background-color: rgba(255, 255, 255, 0.05); 795 | border-color: rgba(255, 255, 255, 0.1); 796 | } 797 | 798 | .chat-input { 799 | color: rgba(255, 255, 255, 0.9); 800 | } 801 | 802 | .chat-input::placeholder { 803 | color: rgba(255, 255, 255, 0.5); 804 | } 805 | 806 | .upload-button { 807 | background-color: rgba(84, 104, 255, 0.1); 808 | border-color: rgba(255, 255, 255, 0.2); 809 | color: rgba(255, 255, 255, 0.9); 810 | } 811 | 812 | .upload-button:hover { 813 | background-color: rgba(84, 104, 255, 0.2); 814 | border-color: rgba(255, 255, 255, 0.3); 815 | } 816 | 817 | .upload-button:disabled { 818 | background-color: rgba(255, 255, 255, 0.05); 819 | border-color: rgba(255, 255, 255, 0.1); 820 | color: rgba(255, 255, 255, 0.3); 821 | } 822 | 823 | .cancel-notice { 824 | background-color: rgba(255, 77, 79, 0.1); 825 | border-color: rgba(255, 77, 79, 0.3); 826 | } 827 | 828 | .file-tag { 829 | background-color: #3a477d; 830 | border-color: rgba(84, 104, 255, 0.4); 831 | } 832 | 833 | .file-tag-name { 834 | color: #f0f4fc; 835 | } 836 | 837 | .file-tag:hover { 838 | background-color: #455695; 839 | } 840 | 841 | .chat-input-container { 842 | background-color: #252836; 843 | border-top-color: rgba(255, 255, 255, 0.05); 844 | } 845 | 846 | footer { 847 | background-color: #252836; 848 | border-top-color: rgba(255, 255, 255, 0.05); 849 | } 850 | 851 | .file-tags-container.expanded { 852 | background-color: rgba(37, 40, 54, 0.95); 853 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); 854 | } 855 | 856 | .markdown code { 857 | background-color: #333746; 858 | } 859 | 860 | .markdown pre { 861 | background-color: #333746; 862 | } 863 | 864 | .upload-files-container { 865 | background-color: #252836; 866 | border-color: rgba(84, 104, 255, 0.2); 867 | } 868 | 869 | .file-item { 870 | border-bottom-color: rgba(255, 255, 255, 0.05); 871 | } 872 | 873 | .file-item:hover { 874 | background-color: rgba(84, 104, 255, 0.1); 875 | } 876 | 877 | .file-name { 878 | color: #f0f4fc; 879 | } 880 | } -------------------------------------------------------------------------------- /src/main/resources/templates/chat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | langchain4j智能体 7 | 8 | 9 | 10 |
11 |
12 |
13 |

智能问答

14 |

langchain4j智能体 - 自动选择最适合的工具,智能回答您的各类问题

15 |
16 |
17 |
18 |
19 |
AI
20 | 您好!我是langchain4j智能体。我可以帮您查询GitHub信息、分析网页内容、操作文件系统或回答一般问题。

21 | 您可以尝试这些问题:
22 | - 查询langChain4j项目的最新提交历史
23 | - 分析并总结网页的主要内容
24 | - 列出当前目录下的所有文件
25 |
请告诉我您需要什么帮助? 26 |
27 |
28 | 29 |
30 | 31 |
32 |
33 |
34 | 35 |
36 |
37 | 42 | 43 | 49 |
50 | 51 |
52 |
53 | 54 | 55 |
56 |
57 | 58 |
59 |

© 2023 Melon. GitHub

60 |
61 | 62 |
63 |
64 |

已上传文件

65 | 66 |
67 |
68 | 69 |
70 |
71 | 72 | 537 | 538 | --------------------------------------------------------------------------------