惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
人人都是产品经理
人人都是产品经理
S
Securelist
P
Proofpoint News Feed
H
Help Net Security
S
Schneier on Security
T
Tenable Blog
C
Cisco Blogs
S
Security @ Cisco Blogs
博客园 - 司徒正美
博客园 - 叶小钗
Cisco Talos Blog
Cisco Talos Blog
Google DeepMind News
Google DeepMind News
C
Cybersecurity and Infrastructure Security Agency CISA
Google Online Security Blog
Google Online Security Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Hacker News: Ask HN
Hacker News: Ask HN
NISL@THU
NISL@THU
云风的 BLOG
云风的 BLOG
V
Vulnerabilities – Threatpost
T
The Blog of Author Tim Ferriss
aimingoo的专栏
aimingoo的专栏
W
WeLiveSecurity
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Jina AI
Jina AI
腾讯CDC
WordPress大学
WordPress大学
Simon Willison's Weblog
Simon Willison's Weblog
Vercel News
Vercel News
小众软件
小众软件
N
Netflix TechBlog - Medium
有赞技术团队
有赞技术团队
AWS News Blog
AWS News Blog
雷峰网
雷峰网
Forbes - Security
Forbes - Security
The Hacker News
The Hacker News
博客园 - 聂微东
F
Full Disclosure
量子位
Scott Helme
Scott Helme
宝玉的分享
宝玉的分享
A
About on SuperTechFans
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Schneier on Security
Schneier on Security
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
K
Kaspersky official blog
AI
AI
SecWiki News
SecWiki News
Webroot Blog
Webroot Blog
Martin Fowler
Martin Fowler

博客园 - 外科手术医生

MACD秒解 历史的车轮 添加pdf的目录 spring ai提示词 纳瓦尔宝典-总结内容 小龙虾的skills哪里找? JMeter压测使用 Git仓库ssh不同环境配置 成功没有奇迹,只有积累----Bruce Lee 为什么要⽤ Foundry git命令下,mac环境下载依赖相关报错问题解决方案 数据分析/数据挖掘/机器学习---- 必读书目(转) 简单高效的语言 OSI模型-一图胜千言 dubbo分布式系统 JVM解剖 线程池 集合类 concurrent之CAS concurrent之volatile 架构师之路 责任链设计模式 Junit框架分析 线程详解 计划自己的时间
Spring-AI开发之旅
外科手术医生 · 2026-07-14 · via 博客园 - 外科手术医生

使用spring ai搭建智能体

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>