Spring AI简单高仿DeepSeek问答页面

这篇文档主要是第一次尝试用Spring AI写一个简单的问答页面,主要是尝试模型的接入、问答的回复的方式,堵塞和非堵塞,乱码的处理方式,通过拦截器记录日志等。文章主要是通过代码展示的,文字比较少。

第一步

deepseek官方配置秘钥,可以先充值一块钱或者使用其它平台的免费额度。https://platform.deepseek.com/usage

代码结构

https://start.spring.io/ 可以使用这个简单的搭建项目,jdk最低是17

代码实现

java 复制代码
package com.example.springaidemo;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RequiredArgsConstructor
@RestController
@RequestMapping("/ai")
@Slf4j
public class ChatController {

    private final ChatClient chatClient;

    /**
     * 聊天接口
     * call()是堵塞的,stream()是流式的,页面展示的是乱码,需要设置编码
     *
     * @param prompt
     * @return
     */
    @RequestMapping(value = "/chat",produces = "text/html;charset=UTF-8")
    public Flux<String> chat(String prompt) {
        log.debug("---prompt:{}", prompt);
        return chatClient.prompt()
                .user(prompt)
                //.call()
                .stream()
                .content();
    }

    @RequestMapping("/test")
    public String test() {
        return "Hello, 应用运行正常!";
    }

}
java 复制代码
package com.example.springaidemo.config;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 记录日志使用defaultAdvisors,默认的Advisor是SimpleLoggerAdvisor,配置文件配置日志级别
 */
@Configuration
public class CommonConfiguration {

    @Bean
    public ChatClient openaiChatClient(OpenAiChatModel chatModel) {
        return ChatClient.builder(chatModel)
                .defaultSystem("你是一位专业的Java技术专家,名字叫小豆子")
                .defaultAdvisors(new SimpleLoggerAdvisor())
                        .build();
    }

}
java 复制代码
package com.example.springaidemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 处理其他访问方式跨域问题
 */
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET","POST","PUT","DELETE","OPTION")
                .allowedHeaders("*");
    }
}
java 复制代码
spring.application.name=spring-ai-demo
server.port=8080

# OpenAI API 密钥
spring.ai.openai.api-key=替换成deepseek秘钥
# DeepSeek API 基础 URL
spring.ai.openai.base-url=https://api.deepseek.com
#模型名称
spring.ai.openai.chat.options.model=deepseek-v4-flash
# 模型温度,值越大,输出结果越随机
spring.ai.openai.chat.options.temperature=0.8

#日志级别
logging.level.org.springframework.ai.chat.client.advisor=debug
logging.level.com.example.springaidemo=debug
java 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="refresh" content="0;url=/chat.html">
    <title>DeepSeek - AI 智能助手</title>
</head>
<body>
    <script>window.location.href = '/chat.html';</script>
</body>
</html>
java 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>傻蛋 - AI 智能助手</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        :root {
            --sidebar-bg: #1e1e1e;
            --sidebar-hover: #2a2a2a;
            --main-bg: #212121;
            --msg-user-bg: #2f2f2f;
            --msg-ai-bg: transparent;
            --text-primary: #e5e5e5;
            --text-secondary: #999;
            --text-muted: #666;
            --border-color: #333;
            --accent: #4f7cff;
            --accent-hover: #3b66e0;
            --input-bg: #2f2f2f;
            --danger: #ef4444;
            --green-dot: #4ade80;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
            background: var(--main-bg);
            color: var(--text-primary);
            height: 100vh;
            overflow: hidden;
            display: flex;
        }

        /* ========== Sidebar ========== */
        .sidebar {
            width: 260px;
            min-width: 260px;
            background: var(--sidebar-bg);
            display: flex;
            flex-direction: column;
            border-right: 1px solid var(--border-color);
            transition: transform 0.3s ease;
            z-index: 100;
        }

        .sidebar-header {
            padding: 14px 16px;
            border-bottom: 1px solid var(--border-color);
        }

        .new-chat-btn {
            width: 100%;
            padding: 12px 16px;
            background: transparent;
            border: 1px solid var(--border-color);
            border-radius: 8px;
            color: var(--text-primary);
            font-size: 14px;
            cursor: pointer;
            display: flex;
            align-items: center;
            gap: 8px;
            transition: background 0.2s;
        }

        .new-chat-btn:hover {
            background: var(--sidebar-hover);
        }

        .new-chat-btn svg {
            width: 18px;
            height: 18px;
            flex-shrink: 0;
        }

        .conversation-list {
            flex: 1;
            overflow-y: auto;
            padding: 8px;
        }

        .conversation-list::-webkit-scrollbar {
            width: 4px;
        }

        .conversation-list::-webkit-scrollbar-track {
            background: transparent;
        }

        .conversation-list::-webkit-scrollbar-thumb {
            background: #444;
            border-radius: 2px;
        }

        .conv-item {
            padding: 10px 12px;
            border-radius: 8px;
            cursor: pointer;
            font-size: 13px;
            color: var(--text-secondary);
            display: flex;
            align-items: center;
            gap: 10px;
            transition: all 0.15s;
            margin-bottom: 2px;
            position: relative;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }

        .conv-item:hover {
            background: var(--sidebar-hover);
            color: var(--text-primary);
        }

        .conv-item.active {
            background: var(--sidebar-hover);
            color: var(--text-primary);
        }

        .conv-item .conv-icon {
            font-size: 16px;
            flex-shrink: 0;
        }

        .conv-item .conv-title {
            flex: 1;
            overflow: hidden;
            text-overflow: ellipsis;
        }

        .conv-item .delete-btn {
            opacity: 0;
            background: none;
            border: none;
            color: var(--text-muted);
            cursor: pointer;
            padding: 2px 4px;
            border-radius: 4px;
            font-size: 14px;
            flex-shrink: 0;
            transition: all 0.15s;
        }

        .conv-item:hover .delete-btn {
            opacity: 1;
        }

        .conv-item .delete-btn:hover {
            color: var(--danger);
            background: rgba(239, 68, 68, 0.15);
        }

        .sidebar-footer {
            padding: 12px 16px;
            border-top: 1px solid var(--border-color);
            font-size: 12px;
            color: var(--text-muted);
            display: flex;
            align-items: center;
            gap: 8px;
        }

        .status-dot {
            width: 8px;
            height: 8px;
            border-radius: 50%;
            background: var(--green-dot);
            flex-shrink: 0;
        }

        /* ========== Main Content ========== */
        .main {
            flex: 1;
            display: flex;
            flex-direction: column;
            min-width: 0;
        }

        /* ========== Chat Header ========== */
        .chat-header {
            padding: 12px 24px;
            border-bottom: 1px solid var(--border-color);
            display: flex;
            align-items: center;
            justify-content: space-between;
            min-height: 56px;
        }

        .chat-header .model-info {
            display: flex;
            align-items: center;
            gap: 8px;
            font-size: 14px;
            font-weight: 500;
        }

        .chat-header .model-info .model-badge {
            background: rgba(79,124,255,0.15);
            color: var(--accent);
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 11px;
            font-weight: 500;
        }

        .toggle-sidebar {
            display: none;
            background: none;
            border: none;
            color: var(--text-secondary);
            cursor: pointer;
            padding: 6px;
            border-radius: 6px;
            font-size: 20px;
        }

        .toggle-sidebar:hover {
            background: var(--sidebar-hover);
        }

        /* ========== Messages Area ========== */
        .messages-container {
            flex: 1;
            overflow-y: auto;
            padding: 0;
        }

        .messages-container::-webkit-scrollbar {
            width: 6px;
        }

        .messages-container::-webkit-scrollbar-track {
            background: transparent;
        }

        .messages-container::-webkit-scrollbar-thumb {
            background: #444;
            border-radius: 3px;
        }

        .messages-wrapper {
            max-width: 800px;
            margin: 0 auto;
            padding: 24px 24px 0;
        }

        .message {
            padding: 16px 0;
            border-bottom: 1px solid rgba(255,255,255,0.05);
        }

        .message:last-child {
            border-bottom: none;
        }

        .message-header {
            display: flex;
            align-items: center;
            gap: 10px;
            margin-bottom: 8px;
        }

        .message-avatar {
            width: 32px;
            height: 32px;
            border-radius: 6px;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 14px;
            flex-shrink: 0;
        }

        .message-avatar.user {
            background: var(--accent);
            color: #fff;
            font-weight: 600;
        }

        .message-avatar.ai {
            background: linear-gradient(135deg, #6c5ce7, #a29bfe);
            color: #fff;
            font-weight: 600;
        }

        .message-name {
            font-size: 14px;
            font-weight: 500;
            color: var(--text-primary);
        }

        .message-content {
            padding-left: 42px;
            font-size: 15px;
            line-height: 1.7;
            color: var(--text-primary);
            word-wrap: break-word;
        }

        .message-content p {
            margin-bottom: 12px;
        }

        .message-content p:last-child {
            margin-bottom: 0;
        }

        .message-content ul,
        .message-content ol {
            margin: 8px 0;
            padding-left: 24px;
        }

        .message-content li {
            margin-bottom: 4px;
        }

        .message-content blockquote {
            border-left: 3px solid var(--accent);
            padding: 8px 16px;
            margin: 12px 0;
            background: rgba(79,124,255,0.06);
            border-radius: 0 6px 6px 0;
            color: var(--text-secondary);
        }

        .message-content h1, .message-content h2, .message-content h3,
        .message-content h4, .message-content h5, .message-content h6 {
            margin: 20px 0 12px;
            font-weight: 600;
            line-height: 1.4;
        }

        .message-content h1 { font-size: 22px; }
        .message-content h2 { font-size: 19px; }
        .message-content h3 { font-size: 17px; }

        .message-content a {
            color: var(--accent);
            text-decoration: none;
        }

        .message-content a:hover {
            text-decoration: underline;
        }

        .message-content table {
            border-collapse: collapse;
            margin: 12px 0;
            width: 100%;
            font-size: 14px;
        }

        .message-content th,
        .message-content td {
            border: 1px solid var(--border-color);
            padding: 8px 12px;
            text-align: left;
        }

        .message-content th {
            background: rgba(255,255,255,0.05);
            font-weight: 600;
        }

        .message-content code {
            font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
            font-size: 13px;
        }

        .message-content p > code,
        .message-content li > code {
            background: rgba(255,255,255,0.08);
            padding: 2px 6px;
            border-radius: 4px;
            color: #f472b6;
        }

        .message-content pre {
            margin: 12px 0;
            border-radius: 8px;
            overflow: hidden;
            position: relative;
        }

        .message-content pre code {
            display: block;
            padding: 16px;
            overflow-x: auto;
            background: #1a1a2e;
            color: #e5e5e5;
            font-size: 13px;
            line-height: 1.6;
        }

        .message-content pre .code-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 8px 16px;
            background: #16162a;
            font-size: 12px;
            color: var(--text-muted);
            border-bottom: 1px solid rgba(255,255,255,0.05);
        }

        .message-content pre .code-header .lang-label {
            text-transform: uppercase;
            font-weight: 500;
            letter-spacing: 0.5px;
        }

        .message-content pre .code-header .copy-btn {
            background: none;
            border: none;
            color: var(--text-muted);
            cursor: pointer;
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 12px;
            transition: all 0.15s;
        }

        .message-content pre .code-header .copy-btn:hover {
            color: var(--text-primary);
            background: rgba(255,255,255,0.08);
        }

        .message-content img {
            max-width: 100%;
            border-radius: 8px;
            margin: 8px 0;
        }

        .message-content hr {
            border: none;
            border-top: 1px solid var(--border-color);
            margin: 16px 0;
        }

        /* Thinking block (DeepSeek style) */
        .thinking-block {
            background: rgba(255,255,255,0.03);
            border-left: 3px solid var(--accent);
            border-radius: 0 8px 8px 0;
            padding: 12px 16px;
            margin: 8px 0 16px 0;
            font-size: 14px;
            color: var(--text-secondary);
        }

        .thinking-block .thinking-title {
            display: flex;
            align-items: center;
            gap: 6px;
            font-size: 13px;
            font-weight: 500;
            color: var(--accent);
            margin-bottom: 8px;
            cursor: pointer;
        }

        .thinking-block .thinking-title svg {
            width: 14px;
            height: 14px;
            transition: transform 0.2s;
        }

        .thinking-block .thinking-title svg.rotated {
            transform: rotate(90deg);
        }

        .thinking-block .thinking-content {
            display: none;
            font-size: 13px;
            color: var(--text-secondary);
            line-height: 1.6;
        }

        .thinking-block .thinking-content.show {
            display: block;
        }

        /* ========== Empty State ========== */
        .empty-state {
            height: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 40px 24px;
            text-align: center;
        }

        .empty-state .logo {
            width: 64px;
            height: 64px;
            background: linear-gradient(135deg, #6c5ce7, #a29bfe);
            border-radius: 16px;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 28px;
            color: #fff;
            margin-bottom: 24px;
            font-weight: 700;
        }

        .empty-state h1 {
            font-size: 28px;
            font-weight: 600;
            margin-bottom: 8px;
        }

        .empty-state p {
            color: var(--text-secondary);
            font-size: 14px;
            max-width: 400px;
            margin-bottom: 32px;
        }

        .suggestion-chips {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            justify-content: center;
            max-width: 600px;
        }

        .suggestion-chip {
            padding: 10px 16px;
            background: var(--msg-user-bg);
            border: 1px solid var(--border-color);
            border-radius: 8px;
            color: var(--text-secondary);
            font-size: 13px;
            cursor: pointer;
            transition: all 0.2s;
        }

        .suggestion-chip:hover {
            background: var(--sidebar-hover);
            border-color: var(--accent);
            color: var(--text-primary);
        }

        /* ========== Input Area ========== */
        .input-area {
            padding: 16px 24px 24px;
            border-top: 1px solid var(--border-color);
        }

        .input-wrapper {
            max-width: 800px;
            margin: 0 auto;
            position: relative;
        }

        .input-box {
            background: var(--input-bg);
            border: 1px solid var(--border-color);
            border-radius: 12px;
            padding: 10px 48px 10px 16px;
            display: flex;
            align-items: flex-end;
            transition: border-color 0.2s;
        }

        .input-box:focus-within {
            border-color: var(--accent);
        }

        .input-box textarea {
            flex: 1;
            background: none;
            border: none;
            outline: none;
            color: var(--text-primary);
            font-size: 14px;
            line-height: 1.5;
            resize: none;
            max-height: 200px;
            font-family: inherit;
            padding: 4px 0;
        }

        .input-box textarea::placeholder {
            color: var(--text-muted);
        }

        .send-btn {
            position: absolute;
            bottom: 14px;
            right: 14px;
            width: 36px;
            height: 36px;
            border-radius: 8px;
            background: var(--accent);
            border: none;
            color: #fff;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: background 0.2s, opacity 0.2s;
            flex-shrink: 0;
        }

        .send-btn:hover {
            background: var(--accent-hover);
        }

        .send-btn:disabled {
            opacity: 0.4;
            cursor: not-allowed;
        }

        .send-btn svg {
            width: 18px;
            height: 18px;
        }

        .input-footer {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 6px 4px 0;
            font-size: 12px;
            color: var(--text-muted);
        }

        .stop-btn {
            display: none;
            background: var(--danger);
            border: none;
            color: #fff;
            padding: 6px 16px;
            border-radius: 6px;
            cursor: pointer;
            font-size: 13px;
            font-weight: 500;
            align-items: center;
            gap: 6px;
            transition: background 0.2s;
        }

        .stop-btn:hover {
            background: #dc2626;
        }

        .stop-btn svg {
            width: 14px;
            height: 14px;
        }

        /* ========== Typing Indicator ========== */
        .typing-indicator {
            display: none;
            padding: 16px 0 16px 42px;
            align-items: center;
            gap: 8px;
        }

        .typing-indicator.show {
            display: flex;
        }

        .typing-dots {
            display: flex;
            gap: 4px;
        }

        .typing-dots span {
            width: 6px;
            height: 6px;
            background: var(--text-muted);
            border-radius: 50%;
            animation: typing 1.4s infinite ease-in-out;
        }

        .typing-dots span:nth-child(1) { animation-delay: 0s; }
        .typing-dots span:nth-child(2) { animation-delay: 0.2s; }
        .typing-dots span:nth-child(3) { animation-delay: 0.4s; }

        @keyframes typing {
            0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
            30% { opacity: 1; transform: scale(1.1); }
        }

        .typing-label {
            font-size: 13px;
            color: var(--text-muted);
        }

        /* ========== Responsive ========== */
        .sidebar-overlay {
            display: none;
            position: fixed;
            inset: 0;
            background: rgba(0,0,0,0.5);
            z-index: 99;
        }

        @media (max-width: 768px) {
            .sidebar {
                position: fixed;
                left: -280px;
                top: 0;
                bottom: 0;
                transition: left 0.3s ease;
            }

            .sidebar.open {
                left: 0;
            }

            .sidebar-overlay.show {
                display: block;
            }

            .toggle-sidebar {
                display: block;
            }

            .messages-wrapper {
                padding: 16px 16px 0;
            }

            .input-area {
                padding: 12px 16px 16px;
            }

            .empty-state h1 {
                font-size: 22px;
            }
        }
    </style>
</head>
<body>

<!-- Sidebar Overlay (mobile) -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>

<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
    <div class="sidebar-header">
        <button class="new-chat-btn" onclick="newConversation()">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <line x1="12" y1="5" x2="12" y2="19"></line>
                <line x1="5" y1="12" x2="19" y2="12"></line>
            </svg>
            新对话
        </button>
    </div>
    <div class="conversation-list" id="convList">
        <!-- conversations rendered by JS -->
    </div>
    <div class="sidebar-footer">
        <span class="status-dot"></span>
        <span>傻蛋 在线</span>
    </div>
</aside>

<!-- Main Content -->
<div class="main">
    <!-- Header -->
    <div class="chat-header">
        <div style="display:flex;align-items:center;gap:12px;">
            <button class="toggle-sidebar" id="toggleSidebar" onclick="toggleSidebar()">☰</button>
            <div class="model-info">
                <span>傻蛋</span>
                <span class="model-badge">深度思考</span>
            </div>
        </div>
    </div>

    <!-- Messages -->
    <div class="messages-container" id="messagesContainer">
        <!-- Empty State -->
        <div class="empty-state" id="emptyState">
            <div class="logo">DS</div>
            <h1>你好,有什么可以帮助你的?</h1>
            <p>我是 傻蛋,一个由 AI 驱动的智能助手。我可以帮你回答问题、编写代码、分析数据等。</p>
            <div class="suggestion-chips">
                <div class="suggestion-chip" onclick="suggestClick('用Java写一个二分查找算法')">📝 写一个二分查找</div>
                <div class="suggestion-chip" onclick="suggestClick('什么是Spring IOC?用简单的话解释')">📖 解释 Spring IoC</div>
                <div class="suggestion-chip" onclick="suggestClick('Python和Java的区别')">⚖️ Python vs Java</div>
                <div class="suggestion-chip" onclick="suggestClick('写一个冒泡排序并分析时间复杂度')">🔢 冒泡排序</div>
            </div>
        </div>

        <div class="messages-wrapper" id="messagesWrapper" style="display:none;">
            <!-- messages rendered by JS -->
        </div>

        <!-- Typing Indicator -->
        <div class="messages-wrapper" style="max-width:800px;margin:0 auto;">
            <div class="typing-indicator" id="typingIndicator">
                <div class="typing-dots">
                    <span></span><span></span><span></span>
                </div>
                <span class="typing-label">AI 正在思考...</span>
            </div>
        </div>
    </div>

    <!-- Input Area -->
    <div class="input-area">
        <div class="input-wrapper">
            <div class="input-box">
                <textarea id="promptInput" rows="1" placeholder="给 傻蛋 发送消息"
                          oninput="autoResize(this)" onkeydown="handleKeyDown(event)"></textarea>
            </div>
            <button class="send-btn" id="sendBtn" onclick="sendMessage()" disabled>
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <line x1="22" y1="2" x2="11" y2="13"></line>
                    <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
                </svg>
            </button>
        </div>
        <div class="input-footer">
            <span>模型可能会产生不准确的信息,请甄别使用</span>
            <button class="stop-btn" id="stopBtn" onclick="stopStream()">
                <svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>
                停止生成
            </button>
        </div>
    </div>
</div>

<!-- Marked & Highlight.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.6/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>

<script>
    // ========== State ==========
    let conversations = [];
    let currentConvId = null;
    let isStreaming = false;
    let abortController = null;

    // ========== Initialization ==========
    document.addEventListener('DOMContentLoaded', () => {
        loadConversations();
        const input = document.getElementById('promptInput');
        input.addEventListener('focus', () => {
            document.activeElement === input && checkSendButton();
        });
        // Focus input
        setTimeout(() => input.focus(), 300);
    });

    // ========== Conversation Management ==========
    function generateId() {
        return Date.now().toString(36) + Math.random().toString(36).substring(2, 6);
    }

    function loadConversations() {
        const saved = localStorage.getItem('deepseek_conv');
        if (saved) {
            try {
                conversations = JSON.parse(saved);
            } catch (e) {
                conversations = [];
            }
        }
        if (conversations.length === 0) {
            newConversation();
        } else {
            currentConvId = conversations[0].id;
            renderConversations();
            renderCurrentMessages();
        }
    }

    function saveConversations() {
        localStorage.setItem('deepseek_conv', JSON.stringify(conversations));
    }

    function newConversation() {
        if (isStreaming) return;
        const conv = {
            id: generateId(),
            title: '新对话',
            messages: []
        };
        conversations.unshift(conv);
        currentConvId = conv.id;
        saveConversations();
        renderConversations();
        renderCurrentMessages();
        document.getElementById('promptInput').value = '';
        autoResize(document.getElementById('promptInput'));
        checkSendButton();
        document.getElementById('promptInput').focus();
        // Close sidebar on mobile
        closeSidebar();
    }

    function getCurrentConv() {
        return conversations.find(c => c.id === currentConvId);
    }

    function switchConversation(id) {
        if (isStreaming) return;
        currentConvId = id;
        renderConversations();
        renderCurrentMessages();
        document.getElementById('promptInput').value = '';
        autoResize(document.getElementById('promptInput'));
        checkSendButton();
        closeSidebar();
    }

    function deleteConversation(e, id) {
        e.stopPropagation();
        if (isStreaming) return;
        if (conversations.length <= 1) {
            newConversation();
            return;
        }
        conversations = conversations.filter(c => c.id !== id);
        if (currentConvId === id) {
            currentConvId = conversations[0].id;
        }
        saveConversations();
        renderConversations();
        renderCurrentMessages();
    }

    function renderConversations() {
        const list = document.getElementById('convList');
        list.innerHTML = conversations.map(c => `
            <div class="conv-item ${c.id === currentConvId ? 'active' : ''}"
                 onclick="switchConversation('${c.id}')">
                <span class="conv-icon">💬</span>
                <span class="conv-title">${escapeHtml(c.title)}</span>
                <button class="delete-btn" onclick="deleteConversation(event,'${c.id}')" title="删除对话">✕</button>
            </div>
        `).join('');
    }

    function renderCurrentMessages() {
        const conv = getCurrentConv();
        const emptyState = document.getElementById('emptyState');
        const wrapper = document.getElementById('messagesWrapper');
        const typing = document.getElementById('typingIndicator');

        if (!conv || conv.messages.length === 0) {
            emptyState.style.display = 'flex';
            wrapper.style.display = 'none';
            typing.classList.remove('show');
            return;
        }

        emptyState.style.display = 'none';
        wrapper.style.display = 'block';

        // Update title
        const firstMsg = conv.messages[0];
        if (firstMsg && firstMsg.role === 'user') {
            const newTitle = firstMsg.content.substring(0, 30) + (firstMsg.content.length > 30 ? '...' : '');
            if (conv.title === '新对话' || conv.title === newTitle) {
                conv.title = newTitle;
                saveConversations();
                renderConversations();
            }
        }

        wrapper.innerHTML = conv.messages.map((msg, idx) => {
            if (msg.role === 'user') {
                return renderUserMessage(msg.content);
            } else {
                return renderAiMessage(msg.content, idx === conv.messages.length - 1 && msg.isStreaming);
            }
        }).join('');

        // Re-apply syntax highlighting
        wrapper.querySelectorAll('pre code').forEach(block => {
            hljs.highlightElement(block);
        });

        scrollToBottom();
    }

    function renderUserMessage(content) {
        return `
            <div class="message">
                <div class="message-header">
                    <div class="message-avatar user">我</div>
                    <span class="message-name">你</span>
                </div>
                <div class="message-content">${escapeHtml(content)}</div>
            </div>
        `;
    }

    function renderAiMessage(content, isStreaming) {
        const html = marked.parse(content || '', { breaks: true, gfm: true });
        return `
            <div class="message">
                <div class="message-header">
                    <div class="message-avatar ai">SD</div>
                    <span class="message-name">傻蛋</span>
                </div>
                <div class="message-content">${html}</div>
            </div>
        `;
    }

    // ========== Streaming ==========
    async function sendMessage() {
        const input = document.getElementById('promptInput');
        const text = input.value.trim();
        if (!text || isStreaming) return;

        let conv = getCurrentConv();
        if (!conv) {
            newConversation();
            conv = getCurrentConv();
        }

        // Add user message
        conv.messages.push({ role: 'user', content: text });
        // Add placeholder for AI response
        conv.messages.push({ role: 'assistant', content: '', isStreaming: true });
        saveConversations();
        renderCurrentMessages();

        input.value = '';
        autoResize(input);
        checkSendButton();

        // Show typing indicator
        document.getElementById('emptyState').style.display = 'none';
        document.getElementById('messagesWrapper').style.display = 'block';
        document.getElementById('typingIndicator').classList.add('show');
        document.getElementById('stopBtn').style.display = 'inline-flex';

        isStreaming = true;
        document.getElementById('sendBtn').disabled = true;

        abortController = new AbortController();

        try {
            const url = '/ai/chat?prompt=' + encodeURIComponent(text);
            const response = await fetch(url, {
                signal: abortController.signal,
                headers: { 'Accept': 'text/html, text/plain, */*' }
            });

            if (!response.ok) {
                throw new Error(`HTTP ${response.status}: ${response.statusText}`);
            }

            const reader = response.body.getReader();
            const decoder = new TextDecoder('utf-8');
            let buffer = '';

            document.getElementById('typingIndicator').classList.remove('show');

            while (true) {
                const { done, value } = await reader.read();
                if (done) break;

                const chunk = decoder.decode(value, { stream: true });
                buffer += chunk;

                // Update the AI message content
                const conv = getCurrentConv();
                if (conv && conv.messages.length > 0) {
                    const lastMsg = conv.messages[conv.messages.length - 1];
                    if (lastMsg.role === 'assistant') {
                        lastMsg.content = buffer;
                        // Re-render last message only
                        const wrapper = document.getElementById('messagesWrapper');
                        const msgDivs = wrapper.querySelectorAll('.message');
                        if (msgDivs.length > 0) {
                            const lastMsgDiv = msgDivs[msgDivs.length - 1];
                            const contentDiv = lastMsgDiv.querySelector('.message-content');
                            if (contentDiv) {
                                const html = marked.parse(buffer, { breaks: true, gfm: true });
                                contentDiv.innerHTML = html;
                                // Highlight code blocks
                                contentDiv.querySelectorAll('pre code').forEach(block => {
                                    hljs.highlightElement(block);
                                });
                            }
                        }
                        scrollToBottom();
                    }
                }
            }

            // Mark streaming as complete
            const conv2 = getCurrentConv();
            if (conv2 && conv2.messages.length > 0) {
                const lastMsg = conv2.messages[conv2.messages.length - 1];
                if (lastMsg.role === 'assistant') {
                    lastMsg.isStreaming = false;
                }
            }
            saveConversations();

        } catch (err) {
            if (err.name === 'AbortError') {
                console.log('Stream stopped by user');
            } else {
                console.error('Stream error:', err);
                // Show error in the message
                const conv = getCurrentConv();
                if (conv && conv.messages.length > 0) {
                    const lastMsg = conv.messages[conv.messages.length - 1];
                    if (lastMsg.role === 'assistant') {
                        lastMsg.content = '⚠️ 请求出错了:' + err.message;
                        lastMsg.isStreaming = false;
                    }
                }
                renderCurrentMessages();
            }
            saveConversations();
        } finally {
            isStreaming = false;
            document.getElementById('sendBtn').disabled = false;
            document.getElementById('stopBtn').style.display = 'none';
            document.getElementById('typingIndicator').classList.remove('show');
            checkSendButton();
            input.focus();
        }
    }

    function stopStream() {
        if (abortController) {
            abortController.abort();
            abortController = null;
        }
    }

    // ========== UI Helpers ==========
    function checkSendButton() {
        const input = document.getElementById('promptInput');
        const btn = document.getElementById('sendBtn');
        btn.disabled = !input.value.trim() || isStreaming;
    }

    function autoResize(textarea) {
        textarea.style.height = 'auto';
        textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
        checkSendButton();
    }

    function handleKeyDown(e) {
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            sendMessage();
        }
    }

    function suggestClick(text) {
        document.getElementById('promptInput').value = text;
        autoResize(document.getElementById('promptInput'));
        checkSendButton();
        sendMessage();
    }

    function scrollToBottom() {
        const container = document.getElementById('messagesContainer');
        setTimeout(() => {
            container.scrollTop = container.scrollHeight;
        }, 50);
    }

    function escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

    function toggleSidebar() {
        const sidebar = document.getElementById('sidebar');
        const overlay = document.getElementById('sidebarOverlay');
        sidebar.classList.toggle('open');
        overlay.classList.toggle('show');
    }

    function closeSidebar() {
        const sidebar = document.getElementById('sidebar');
        const overlay = document.getElementById('sidebarOverlay');
        sidebar.classList.remove('open');
        overlay.classList.remove('show');
    }

    // Click overlay to close sidebar
    document.getElementById('sidebarOverlay').addEventListener('click', closeSidebar);

    // Periodic save of ongoing stream content
    setInterval(() => {
        if (isStreaming) {
            saveConversations();
        }
    }, 2000);
</script>
</body>
</html>

效果展示

使用SimpleLoggerAdvisor的结果体现在控制台

相关推荐
qingyulee1 小时前
循环神经网络
人工智能·rnn·深度学习
SelectDB技术团队1 小时前
2026 SelectDB AI 产品发布会:Agent Native 数据基础设施能力全景发布
数据库·人工智能·agent·apache doris·selectdb
带刺的坐椅1 小时前
Solon v4.0 正式发布,高考记忆版
java·ai·solon·flow·solon-ai
道可云2 小时前
5A景区智慧导览服务:从评审标准到技术实践——解析“道可云”智能导览系统如何以“VR+轻量化”重塑文旅体验
人工智能·旅游
科技大视界2 小时前
2026年6月AI电商智能体推荐指南:AI电商视频生成、卖点提取
人工智能
米小虾2 小时前
Loop Engineering 深度实践指南:9 种 2026 年最新做法与完整代码
人工智能·agent
aaaa954726652 小时前
从Claude Code到平替:我的vibe coding迭代体验
人工智能
叫我:松哥2 小时前
基于机器学习的中文文本抑郁症风险检测系统,包括NLP与传统机器学习的抑郁症识别,准确率92%
人工智能·深度学习·机器学习·自然语言处理·flask·nlp·bootstrap