




























1.添加pom依赖包
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>org.example</groupId>
<artifactId>springAiDify</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.21.0</version>
</dependency>
<dependency>
<groupId>com.theokanning.openai-gpt3-java</groupId>
<artifactId>service</artifactId>
<version>0.18.2</version>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>converter-jackson</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>adapter-rxjava2</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
2.添加controller类
package org.tnt.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.tnt.config.BailianConfig;
import org.tnt.service.BailianService;
import org.tnt.service.DifyService;
import org.tnt.service.MockService;
import reactor.core.publisher.Flux;
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class ChatController {
private final DifyService difyService;
private final BailianService bailianService;
private final BailianConfig bailianConfig;
private final MockService mockService;
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message,
@RequestParam(defaultValue = "dify") String model) {
log.info("收到聊天请求: {}, 模型: {}", message, model);
switch (model) {
case "qwen":
return bailianService.streamChat(message, bailianConfig.getModel_qwen())
.doOnNext(text -> log.info("Qwen返回文本: {}", text))
.doOnError(error -> log.error("Qwen流式输出错误", error));
case "deepseek":
//todo bailianService
return mockService.streamChat(message)
// .doOnNext(text -> log.info("DeepSeek返回文本: {}", text))
.doOnError(error -> log.error("DeepSeek流式输出错误", error));
default:
return difyService.streamChat(message)
.doOnNext(text -> log.info("Dify返回文本: {}", text))
.doOnError(error -> log.error("Dify流式输出错误", error));
}
}
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChatPost(@RequestBody ChatRequest request) {
log.info("收到聊天请求: {}, 模型: {}", request.getMessage(), request.getModel());
String model = request.getModel() != null ? request.getModel() : "dify";
switch (model) {
case "qwen":
return bailianService.streamChat(request.getMessage(), bailianConfig.getModel_qwen())
.doOnNext(text -> log.info("Qwen返回文本: {}", text))
.doOnError(error -> log.error("Qwen流式输出错误", error));
case "deepseek":
//todo bailianService
return mockService.streamChat(request.getMessage())
// .doOnNext(text -> log.info("DeepSeek返回文本: {}", text))
.doOnError(error -> log.error("DeepSeek流式输出错误", error));
default:
return difyService.streamChat(request.getMessage())
.doOnNext(text -> log.info("Dify返回文本: {}", text))
.doOnError(error -> log.error("Dify流式输出错误", error));
}
}
@lombok.Data
static class ChatRequest {
private String message;
private String model;
}
}
3.添加service类
package org.tnt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.tnt.config.BailianConfig;
import reactor.core.publisher.Flux;
import java.io.File;
import java.io.FileWriter;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j
@Service
@RequiredArgsConstructor
public class BailianService {
private final BailianConfig bailianConfig;
private final ObjectMapper objectMapper;
public Flux<String> streamChat(String query) {
return streamChat(query, bailianConfig.getModel());
}
public Flux<String> streamChat(String query, String model) {
WebClient webClient = WebClient.builder()
.baseUrl(bailianConfig.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + bailianConfig.getApiKey())
.build();
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model);
requestBody.put("stream", true);
requestBody.put("enable_search", true);
List<Map<String, String>> messages = new ArrayList<>();
Map<String, String> systemMsg = new HashMap<>();
systemMsg.put("role", "system");
systemMsg.put("content", "# Role:首席小米产业分析师\n"”");
messages.add(systemMsg);
Map<String, String> userMsg = new HashMap<>();
userMsg.put("role", "user");
userMsg.put("content", query);
messages.add(userMsg);
requestBody.put("messages", messages);
StringBuilder accumulatedText = new StringBuilder();
return webClient.post()
.uri("/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(requestBody)
.retrieve()
.bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
.mapNotNull(ServerSentEvent::data)
.mapNotNull(this::parseChunk)
.doOnNext(text -> {
log.debug("百炼文本块: {}", text);
accumulatedText.append(text);
})
.doOnComplete(() -> {
log.info("百炼流式返回完成");
saveToMarkdown(query, accumulatedText.toString());
})
.doOnError(error -> log.error("百炼API调用失败", error));
}
@SuppressWarnings("unchecked")
private String parseChunk(String data) {
try {
if ("[DONE]".equals(data)) {
return null;
}
Map<String, Object> chunk = objectMapper.readValue(data, Map.class);
List<Map<String, Object>> choices = (List<Map<String, Object>>) chunk.get("choices");
if (choices != null && !choices.isEmpty()) {
Map<String, Object> delta = (Map<String, Object>) choices.get(0).get("delta");
if (delta != null && delta.containsKey("content")) {
String content = (String) delta.get("content");
if (content != null && !content.isEmpty()) {
return content;
}
}
}
} catch (Exception e) {
log.error("解析百炼响应失败: {}", data, e);
}
return null;
}
private void saveToMarkdown(String query, String fullText) {
try {
fullText = fullText.replaceAll("(#{1,6}\\s)", "\n\n$1");
fullText = fullText.replaceAll("(#{1,6}[^|\\n]*)(\\|)", "$1\n\n$2");
fullText = fullText.replaceAll("([^\\n])\\n(- )", "$1\n\n$2");
fullText = fullText.replaceAll("\\n{3,}", "\n\n");
fullText = fullText.trim();
String titleMatch = null;
Matcher m = Pattern.compile("^#\\s+(.+)$", Pattern.MULTILINE).matcher(fullText);
if (m.find()) {
titleMatch = m.group(1).replaceAll("[*_`~]", "").trim();
}
String title = (titleMatch != null && !titleMatch.isEmpty()) ? titleMatch : "AI对话";
String dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String fileName = title + "-" + dateStr + ".md";
File dir = new File("output");
if (!dir.exists()) {
dir.mkdirs();
}
File file = new File(dir, fileName);
try (FileWriter writer = new FileWriter(file, StandardCharsets.UTF_8)) {
writer.write(fullText);
}
log.info("百炼输出已保存到: {}", file.getAbsolutePath());
} catch (Exception e) {
log.error("保存百炼输出到Markdown文件失败", e);
}
}
}
4.添加yml
server:
port: 8080
dify:
base-url:
api-key:
workflow-path: /v1/workflows/run
aliyun:
bailian:
base-url:
api-key:
model: qwen3.7-max
logging:
level:
org.tnt: DEBUG
5.添加前端页面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dify AI 对话</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f0f2f5;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.chat-container {
width: 95%;
max-width: 900px;
height: 92vh;
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
background: #ffffff;
color: #1a1a1a;
padding: 16px 24px;
border-bottom: 1px solid #e8e8e8;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.model-selector {
display: flex;
align-items: center;
gap: 8px;
}
.model-selector label {
font-size: 13px;
color: #666;
font-weight: normal;
}
.model-selector select {
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 13px;
outline: none;
cursor: pointer;
background: white;
transition: all 0.3s;
}
.model-selector select:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.messages {
flex: 1;
padding: 20px 24px;
overflow-y: auto;
background: #fafafa;
}
.message {
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 8px;
max-width: 85%;
word-wrap: break-word;
animation: fadeIn 0.2s ease-in;
font-size: 14px;
line-height: 1.6;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.message.user {
background: #1890ff;
color: white;
margin-left: auto;
}
.message.ai {
background: white;
color: #333;
border: 1px solid #e8e8e8;
}
.message.ai h1, .message.ai h2, .message.ai h3,
.message.ai h4, .message.ai h5, .message.ai h6 {
margin-top: 12px;
margin-bottom: 8px;
font-weight: 600;
color: #1a1a1a;
}
.message.ai h1 { font-size: 18px; }
.message.ai h2 { font-size: 16px; }
.message.ai h3 { font-size: 15px; }
.message.ai p {
margin-bottom: 8px;
}
.message.ai ul, .message.ai ol {
margin-left: 20px;
margin-bottom: 8px;
}
.message.ai li {
margin-bottom: 4px;
}
.message.ai code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: #d63384;
}
.message.ai pre {
background: #f5f5f5;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 8px;
}
.message.ai pre code {
background: transparent;
padding: 0;
color: #333;
}
.message.ai blockquote {
border-left: 4px solid #1890ff;
padding-left: 12px;
margin: 8px 0;
color: #666;
font-style: italic;
}
.message.ai strong {
font-weight: 600;
color: #1a1a1a;
}
.message.ai em {
font-style: italic;
}
.message.ai a {
color: #1890ff;
text-decoration: none;
}
.message.ai a:hover {
text-decoration: underline;
}
.message-wrapper {
position: relative;
max-width: 85%;
margin-bottom: 16px;
margin-left: 0;
}
.message-wrapper.user {
margin-left: auto;
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 4px 12px;
background: #f0f0f0;
color: #666;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.download-btn:hover {
background: #1890ff;
color: white;
border-color: #1890ff;
}
.input-area {
padding: 16px 24px;
background: white;
border-top: 1px solid #e8e8e8;
display: flex;
gap: 12px;
}
input {
flex: 1;
padding: 10px 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
outline: none;
transition: all 0.3s;
}
input:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
button {
padding: 10px 24px;
background: #1890ff;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
font-weight: 500;
}
button:hover {
background: #40a9ff;
}
button:disabled {
background: #d9d9d9;
cursor: not-allowed;
}
.typing-indicator {
display: inline-block;
font-size: 13px;
color: #999;
margin-bottom: 8px;
font-style: italic;
}
.messages::-webkit-scrollbar {
width: 6px;
}
.messages::-webkit-scrollbar-track {
background: #f0f0f0;
}
.messages::-webkit-scrollbar-thumb {
background: #bfbfbf;
border-radius: 3px;
}
.messages::-webkit-scrollbar-thumb:hover {
background: #999;
}
</style>
</head>
<body>
<div class="chat-container">
<div class="header">
<div class="header-left">
<span>🤖</span>
<span>AI 助手</span>
</div>
<div class="model-selector">
<label for="modelSelect">模型:</label>
<select id="modelSelect">
<option value="dify">Dify Workflow</option>
<option value="qwen">通义千问 (qwen3.7-max)</option>
<option value="deepseek">DeepSeek (deepseek-v4-pro)</option>
</select>
</div>
</div>
<div class="messages" id="messages"></div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="输入消息..." onkeypress="handleKeyPress(event)">
<button onclick="sendMessage()" id="sendBtn">发送</button>
</div>
</div>
<script>
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const modelSelect = document.getElementById('modelSelect');
let currentAIMessage = null;
let currentMarkdownText = '';
let currentMarkdownTextCheck = '';
function handleKeyPress(event) {
if (event.key === 'Enter') {
sendMessage();
}
}
function formatMarkdown(text) {
let formatted = text;
// ✅ 标题前后加空行(提高稳定性)
formatted = formatted.replace(/(#{1,6}\s)/g, '\n\n$1');
// ✅ 列表前必须空一行(marked 硬性要求)
formatted = formatted.replace(/([^\n])\n(- )/g, '$1\n\n$2');
// ✅ 清理多余空行
// formatted = formatted.replace(/\n{3,}/g, '\n\n');
//
formatted = formatted.replace(/\*\*/g, '\n**');
return formatted;
}
let renderTimer = null;
function scheduleRender() {
clearTimeout(renderTimer);
renderTimer = setTimeout(() => {
const formattedText = formatMarkdown(currentMarkdownText);
currentAIMessage.innerHTML = marked.parse(formattedText);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}, 60); // 60ms 很关键
}
function addMessage(text, isUser) {
if (isUser) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message user';
messageDiv.textContent = text;
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
return messageDiv;
} else {
const wrapper = document.createElement('div');
wrapper.className = 'message-wrapper';
const messageDiv = document.createElement('div');
messageDiv.className = 'message ai';
const formattedText = formatMarkdown(text);
messageDiv.innerHTML = marked.parse(formattedText);
wrapper.appendChild(messageDiv);
const downloadBtn = document.createElement('button');
downloadBtn.className = 'download-btn';
downloadBtn.innerHTML = '📥 下载 Markdown';
downloadBtn.onclick = function() {
downloadMarkdown(text);
};
wrapper.appendChild(downloadBtn);
messagesDiv.appendChild(wrapper);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
return messageDiv;
}
}
function downloadMarkdown(markdownText) {
const titleMatch = markdownText.match(/^#\s+(.+)$/m);
let title = titleMatch ? titleMatch[1].replace(/[*_`~]/g, '').trim() : 'AI对话';
const now = new Date();
const dateStr = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0');
const fileName = `${title}-${dateStr}.md`;
const blob = new Blob([markdownText], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function sendMessage() {
const message = messageInput.value.trim();
if (!message) return;
const selectedModel = modelSelect.value;
const modelNames = {
'dify': 'Dify',
'qwen': '通义千问',
'deepseek': 'DeepSeek'
};
const modelName = modelNames[selectedModel] || 'AI';
addMessage(message, true);
messageInput.value = '';
sendBtn.disabled = true;
const typingIndicator = document.createElement('div');
typingIndicator.className = 'typing-indicator';
typingIndicator.textContent = `${modelName} 正在思考...`;
messagesDiv.appendChild(typingIndicator);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
currentAIMessage = document.createElement('div');
currentAIMessage.className = 'message ai';
currentAIMessage.innerHTML = '';
currentMarkdownText = '';
const currentWrapper = document.createElement('div');
currentWrapper.className = 'message-wrapper';
try {
const response = await fetch(`/api/chat/stream?message=${encodeURIComponent(message)}&model=${selectedModel}`, {
method: 'GET',
headers: {
'Accept': 'text/event-stream'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
messagesDiv.removeChild(typingIndicator);
currentWrapper.appendChild(currentAIMessage);
messagesDiv.appendChild(currentWrapper);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data:')) {
const text = line.substring(5).trim();
if (text) {
//"**时间:202"
currentMarkdownTextCheck += text;
if (isMarkdownSafeToRender(currentMarkdownTextCheck)) {
currentMarkdownText += text;
currentAIMessage.innerHTML = marked.parse(currentMarkdownText);
} else {
const formattedText = formatMarkdown(text);
currentMarkdownText += formattedText;
currentAIMessage.innerHTML = marked.parse(currentMarkdownText);
}
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
}
}
}
} catch (error) {
console.error('Error:', error);
messagesDiv.removeChild(typingIndicator);
addMessage('抱歉,发生错误: ' + error.message, false);
} finally {
sendBtn.disabled = false;
if (currentMarkdownText) {
const savedText = currentMarkdownText;
const downloadBtn = document.createElement('button');
downloadBtn.className = 'download-btn';
downloadBtn.innerHTML = '📥 下载 Markdown';
downloadBtn.onclick = function() {
downloadMarkdown(savedText);
};
currentWrapper.appendChild(downloadBtn);
}
currentAIMessage = null;
currentMarkdownText = '';
messageInput.focus();
}
function isMarkdownSafeToRender(text) {
if (!text || text.length < 2) return false;
// 1️⃣ 未闭合的加粗 **
const boldCount = (text.match(/\*\*/g) || []).length;
if (boldCount % 2 !== 0) return false;
// 2️⃣ 标题行未完成(# 后没内容)
if (/^#{1,6}\s*$/.test(text.trim())) return false;
// 3️⃣ 列表项未完成
if (text.endsWith('-') || text.endsWith('- ')) return false;
// 4️⃣ 以中文标点结尾,大概率还有下文
if (/[:,。!?、]$/.test(text.trim())) return false;
// 5️⃣ 以 URL / 邮箱截断结尾
if (/\w+$/.test(text.trim())) return false;
return true;
}
}
</script>
</body>
</html>
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。