From 3208efe280a7e89f455d14e33f408431be6bfa7b Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Fri, 17 Apr 2026 14:28:21 +0800 Subject: [PATCH] =?UTF-8?q?v0.0.1=20spring=20ai=E7=9A=84=E5=9F=BA=E6=9C=AC?= =?UTF-8?q?=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 38 +++ README.md | 206 +++++++++++ pom.xml | 118 +++++++ src/main/java/com/demo/MyApplication.java | 10 + .../com/demo/controller/ChatController.java | 49 +++ src/main/resources/application.yaml | 29 ++ src/main/resources/static/css/style.css | 320 ++++++++++++++++++ src/main/resources/static/index.html | 57 ++++ src/main/resources/static/js/app.js | 86 +++++ 9 files changed, 913 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/com/demo/MyApplication.java create mode 100644 src/main/java/com/demo/controller/ChatController.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/static/css/style.css create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/static/js/app.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ff6309 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea3c947 --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# Spring AI Demo + +> 🤖 一个简洁优雅的 Spring AI 对话演示项目,基于 Spring Boot 3.2.0 与 Spring AI 1.0.0-M3 构建,支持流式响应与 Markdown 渲染。 + +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.2.0-brightgreen)](https://spring.io/projects/spring-boot) +[![Spring AI](https://img.shields.io/badge/Spring%20AI-1.0.0--M3-blue)](https://spring.io/projects/spring-ai) +[![JDK](https://img.shields.io/badge/JDK-17%2B-orange)](https://openjdk.org/) +[![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE) + +## ✨ 特性 + +- 🌐 **智能对话** - 基于 OpenAI/Ollama 的自然语言交互 +- 📡 **流式响应** - 实时逐字输出,体验流畅 +- 📝 **Markdown 支持** - 完整渲染代码块、表格、列表等格式 +- 👨‍💻 **代码高亮** - Highlight.js 自动语言检测与语法着色 +- 🖼️ **向量数据库** - Milvus 集成(预留 RAG 扩展能力) +- 📔 **PDF 解析** - Apache PDFBox 文档处理支持 +- 🎨 **精美界面** - 深色主题响应式设计 + +## 🚀 快速开始 + +### 环境要求 + +- JDK 17+ +- Maven 3.8+ +- [Ollama](https://ollama.ai/) 本地服务 + +### 1. 安装 Ollama + +```bash +# macOS/Linux +curl -fsSL https://ollama.ai/install.sh | sh + +# 拉取模型 +ollama pull gpt-oss:120b-cloud + +# 启动服务 (默认端口 11434) +ollama serve +``` + +### 2. 启动项目 + +```bash +mvn spring-boot:run +``` + +### 3. 访问应用 + +- **Web 界面**: http://localhost:8080 +- **健康检查**: http://localhost:8080/api/chat/test + +## 🔧 技术栈 + +### 后端 + +| 组件 | 技术 | 版本 | +|:---|:---|:---| +| 基础框架 | Spring Boot | 3.2.0 | +| AI 框架 | Spring AI | 1.0.0-M3 | +| AI 模型 | OpenAI/Ollama | - | +| 响应式编程 | Spring WebFlux | 3.2.0 | +| 向量数据库 | Milvus Store | 1.0.0-M3 | +| 文档处理 | Apache PDFBox | 2.0.29 | + +### 前端 + +| 技术 | 用途 | +|:---|:---| +| HTML5 + CSS3 | 页面结构与样式 | +| Marked.js | Markdown 解析渲染 | +| Highlight.js | 代码语法高亮 | + +## 📁 项目结构 + +``` +springAiDemo/ +├── src/ +│ └── main/ +│ ├── java/com/demo/ +│ │ ├── MyApplication.java # Spring Boot 启动入口 +│ │ └── controller/ +│ │ └── ChatController.java # AI 聊天 REST API +│ └── resources/ +│ ├── application.yaml # 应用配置 +│ └── static/ # 前端静态资源 +│ ├── index.html # 主页面 +│ ├── css/style.css # 深色主题样式 +│ └── js/app.js # 流式响应逻辑 +├── pom.xml +└── README.md +``` + +## 💬 API 文档 + +### 端点列表 + +| 方法 | 端点 | 描述 | 参数 | +|:---|:---|:---|:---| +| GET | `/api/chat/test` | 健康检查 | - | +| GET | `/api/chat/ai` | 同步 AI 对话 | `msg` | +| GET | `/api/chat/ai/stream` | 流式 AI 对话 | `msg` | + +### 请求示例 + +**同步对话:** +```bash +curl "http://localhost:8080/api/chat/ai?msg=什么是Spring AI" +``` + +**流式对话:** +```bash +curl "http://localhost:8080/api/chat/ai/stream?msg=讲一个故事" +``` + +### 响应格式 + +- **同步**: `text/plain` - 完整回复文本 +- **流式**: `text/html;charset=UTF-8` - Server-Sent Events 分块传输 + +## 🎨 界面预览 + +🧩 **现代化深色主题** +- 渐变标题与毛玻璃效果 +- 流畅的消息气泡动画 +- Markdown 代码块高亮 +- 流式响应动画指示器 + +📝 **Markdown 支持** +- 代码块与行内代码 +- 加粗、斜体、删除线 +- 有序/无序列表 +- 引用块、表格、链接 + +## 🛠️ 配置说明 + +### 模型配置 (`application.yaml`) + +```yaml +spring: + ai: + openai: + base-url: http://localhost:11434 # Ollama 服务地址 + chat: + options: + model: gpt-oss:120b-cloud # 当前模型 + temperature: 0.7 # 生成温度 +``` + +**支持模型**(需在 Ollama 中预先拉取): +- `gpt-oss:120b-cloud` - 默认模型 +- `kimi-k2.5:cloud` - Kimi 模型 +- `gemma4:e2b` - Google Gemma 模型 + +## 🎬 架构图 + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Client │────▶│ ChatController │────▶│ ChatClient │ +│ (HTTP) │ │ (Spring MVC) │ │ (Spring AI) │ +└─────────────┘ └──────────────────┘ └────────┬────────┘ + │ + ▼ + ┌─────────────────┐ + │ Ollama/OpenAI │ + │ (LLM Provider) │ + └─────────────────┘ +``` + +## 📄 预留功能 + +以下依赖已集成,可按需启用: + +- 🔍 **Milvus 向量数据库** - RAG 语义搜索 +- 📔 **PDF 文档解析** - 文档上传与处理 +- 🗨️ **Embedding 服务** - 文本向量化 + +## 👷 已知限制 + +1. 暂无对话历史持久化 +2. 暂未实现请求频率限制 +3. JUnit 版本较旧(3.8.1),建议升级至 JUnit 5 + +## 📖 待办事项 + +- [ ] 添加全局异常处理 +- [ ] 实现 RAG 文档问答 +- [ ] 对话历史存储 +- [ ] 动态模型切换 API +- [ ] Docker 部署支持 +- [ ] 单元测试完善 + +## 📤 更新日志 + +### v1.0.0 (2026-04-17) +- 🎉 初始版本发布 +- 支持流式 AI 对话 +- Markdown 渲染与代码高亮 +- 深色主题 Web 界面 + +## 📗 License + +MIT © 2026 Spring AI Demo + +--- + +🚀 **Made with Spring AI** diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2af0b0b --- /dev/null +++ b/pom.xml @@ -0,0 +1,118 @@ + + 4.0.0 + + org.example + springAiDemo + 1.0-SNAPSHOT + jar + + springAiDemo + http://maven.apache.org + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + + + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.ai + spring-ai-milvus-store-spring-boot-starter + + + + + + + + + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + + org.apache.pdfbox + pdfbox + 2.0.29 + + + commons-fileupload + commons-fileupload + 1.5 + + + + org.projectlombok + lombok + true + + + + + junit + junit + 3.8.1 + test + + + + + + + + org.springframework.ai + spring-ai-bom + 1.0.0-M3 + pom + import + + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + + diff --git a/src/main/java/com/demo/MyApplication.java b/src/main/java/com/demo/MyApplication.java new file mode 100644 index 0000000..33f7bb2 --- /dev/null +++ b/src/main/java/com/demo/MyApplication.java @@ -0,0 +1,10 @@ +package com.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +@SpringBootApplication +public class MyApplication { + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} diff --git a/src/main/java/com/demo/controller/ChatController.java b/src/main/java/com/demo/controller/ChatController.java new file mode 100644 index 0000000..45b2e31 --- /dev/null +++ b/src/main/java/com/demo/controller/ChatController.java @@ -0,0 +1,49 @@ +package com.demo.controller; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +@RestController +@RequestMapping("/api/chat") +public class ChatController { + + private final ChatClient chatClient; + + public ChatController(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder.build(); + } + + @GetMapping("/test") + public String test(){ + return "hello world"; + } + + @GetMapping("/ai") + public String ai(@RequestParam(value = "msg",defaultValue = "你是谁")String msg) { + return this.chatClient + .prompt() + .user(msg) + .call() + .content(); + } + + /** + * 流式的聊天接口,要注意如果中文有乱码,就是编码得问题,需要添加produces = "text/html;charset=UTF-8 + * @param msg + * @return + */ + @GetMapping(value = "/ai/stream",produces = "text/html;charset=UTF-8") + public Flux aistream(@RequestParam(value = "msg",defaultValue = "你是谁")String msg) { + return this.chatClient + .prompt() + .user(msg) + .stream() + .content(); + } + +} + diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..249c1ff --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,29 @@ +server: + port: 8080 + +spring: + ai: + openai: + api-key: ollama + base-url: http://localhost:11434 + chat: + api-key: ollama + base-url: http://localhost:11434 + options: + model: gpt-oss:120b-cloud +# model: kimi-k2.5:cloud +# model: gemma4:e2b +# max-tokens: 10000 + temperature: 0.7 +# embedding: +# api-key: ollama +# base-url: http://localhost:11434 + +#spring: +# ai: +# ollama: +# base-url: http://localhost:11434 +# chat: +# options: +# model: gemma4:e2b +# temperature: 0.7 \ No newline at end of file diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..ba81e18 --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,320 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #6366f1; + --primary-hover: #4f46e5; + --bg-dark: #0f172a; + --bg-card: #1e293b; + --bg-input: #334155; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --border: #334155; + --user-bubble: #6366f1; + --ai-bubble: #334155; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-dark); + color: var(--text-primary); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.container { + width: 100%; + max-width: 800px; + height: 100vh; + max-height: 900px; + display: flex; + flex-direction: column; + padding: 20px; +} + +.header { + text-align: center; + padding: 20px 0; +} + +.header h1 { + font-size: 28px; + font-weight: 700; + background: linear-gradient(135deg, #6366f1, #a855f7); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.subtitle { + color: var(--text-secondary); + font-size: 14px; + margin-top: 4px; +} + +.chat-container { + flex: 1; + overflow-y: auto; + padding: 20px 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.welcome-message, .message { + display: flex; + gap: 12px; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.message.user { + flex-direction: row-reverse; +} + +.message-content { + max-width: 70%; + padding: 12px 16px; + border-radius: 16px; + line-height: 1.5; + font-size: 15px; +} + +.ai-avatar { + width: 36px; + height: 36px; + background: linear-gradient(135deg, #6366f1, #a855f7); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ai-avatar svg { + width: 20px; + height: 20px; + color: white; +} + +.user-avatar { + width: 36px; + height: 36px; + background: var(--bg-input); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.user-avatar svg { + width: 18px; + height: 18px; + color: var(--text-secondary); +} + +.message.user .message-content { + background: var(--user-bubble); + border-bottom-right-radius: 4px; +} + +.message.ai .message-content { + background: var(--ai-bubble); + border-bottom-left-radius: 4px; +} + +.input-container { + padding: 16px 0; + border-top: 1px solid var(--border); +} + +.input-wrapper { + display: flex; + gap: 12px; + background: var(--bg-card); + border-radius: 24px; + padding: 6px; + border: 1px solid var(--border); + transition: border-color 0.2s; +} + +.input-wrapper:focus-within { + border-color: var(--primary); +} + +#messageInput { + flex: 1; + background: transparent; + border: none; + padding: 12px 16px; + font-size: 15px; + color: var(--text-primary); + outline: none; +} + +#messageInput::placeholder { + color: var(--text-secondary); +} + +.send-btn { + width: 44px; + height: 44px; + background: var(--primary); + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, transform 0.15s; + flex-shrink: 0; +} + +.send-btn:hover { + background: var(--primary-hover); +} + +.send-btn:active { + transform: scale(0.95); +} + +.send-btn:disabled { + background: var(--bg-input); + cursor: not-allowed; + transform: none; +} + +.send-btn svg { + width: 20px; + height: 20px; + color: white; +} + +.stream-indicator { + display: none; + align-items: center; + gap: 8px; + padding: 12px 16px; + color: var(--text-secondary); + font-size: 13px; +} + +.stream-indicator.active { + display: flex; +} + +.dot { + width: 6px; + height: 6px; + background: var(--primary); + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out both; +} + +.dot:nth-child(1) { animation-delay: -0.32s; } +.dot:nth-child(2) { animation-delay: -0.16s; } + +@keyframes bounce { + 0%, 80%, 100% { transform: scale(0); } + 40% { transform: scale(1); } +} + +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.message-content { + word-wrap: break-word; +} + +.message-content pre { + background: #0d1117; + border-radius: 8px; + padding: 12px; + margin: 8px 0; + overflow-x: auto; + font-size: 13px; +} + +.message-content code { + background: #0d1117; + padding: 2px 6px; + border-radius: 4px; + font-family: 'Fira Code', Consolas, monospace; + font-size: 13px; +} + +.message-content pre code { + background: transparent; + padding: 0; +} + +.message-content p { + margin-bottom: 8px; +} + +.message-content p:last-child { + margin-bottom: 0; +} + +.message-content ul, .message-content ol { + padding-left: 20px; + margin: 8px 0; +} + +.message-content blockquote { + border-left: 3px solid var(--primary); + padding-left: 12px; + margin: 8px 0; + color: var(--text-secondary); +} + +.message-content a { + color: #818cf8; + text-decoration: none; +} + +.message-content a:hover { + text-decoration: underline; +} + +.message-content h1, .message-content h2, .message-content h3, +.message-content h4, .message-content h5, .message-content h6 { + margin: 12px 0 8px; + font-weight: 600; +} + +.message-content table { + border-collapse: collapse; + margin: 8px 0; + width: 100%; +} + +.message-content th, .message-content td { + border: 1px solid var(--border); + padding: 6px 10px; + text-align: left; +} + +.message-content th { + background: var(--bg-input); +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..6198143 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,57 @@ + + + + + + Spring AI Chat + + + + + + + +
+
+

Spring AI Chat

+

基于 Spring AI 的智能对话助手

+
+ +
+
+ +
+
+ + +
+
+ + + + AI 正在思考中... +
+
+
+ + + + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..35b9e4c --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,86 @@ +const chatContainer = document.getElementById('chatContainer'); +const messageInput = document.getElementById('messageInput'); +const sendBtn = document.getElementById('sendBtn'); +const streamIndicator = document.getElementById('streamIndicator'); + +let isStreaming = false; + +messageInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } +}); + +sendBtn.addEventListener('click', sendMessage); + +async function sendMessage() { + const message = messageInput.value.trim(); + if (!message || isStreaming) return; + + addMessage(message, 'user'); + messageInput.value = ''; + isStreaming = true; + sendBtn.disabled = true; + streamIndicator.classList.add('active'); + + const aiMessageEl = addMessage('', 'ai'); + + try { + const response = await fetch(`/api/chat/ai/stream?msg=${encodeURIComponent(message)}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let result = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value); + result += chunk; + aiMessageEl.querySelector('.message-content').innerHTML = marked.parse(result); + chatContainer.scrollTop = chatContainer.scrollHeight; + } + } catch (error) { + aiMessageEl.querySelector('.message-content').textContent = '抱歉,发生了错误: ' + error.message; + } finally { + isStreaming = false; + sendBtn.disabled = false; + streamIndicator.classList.remove('active'); + } +} + +function addMessage(text, type) { + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${type}`; + + const avatar = type === 'ai' + ? `
+ + + + + +
` + : `
+ + + + +
`; + + messageDiv.innerHTML = ` + ${avatar} +

${escapeHtml(text)}

+ `; + + chatContainer.appendChild(messageDiv); + chatContainer.scrollTop = chatContainer.scrollHeight; + return messageDiv; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +}