💻 源码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>传智教育EduRAG智慧问答系统</title>
    <!-- 引入 Font Awesome 图标库 -->
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" rel="stylesheet">
    <!-- 引入 marked.js 用于解析 Markdown -->
    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
    <style>
        :root {
            --primary-color: #4a6fa5; /* 主要颜色 */
            --secondary-color: #6c757d; /* 次要颜色 */
            --accent-color: #61dafb; /* 强调颜色 */
            --bg-color: #f8f9fa; /* 背景颜色 */
            --text-color: #343a40; /* 文本颜色 */
            --border-color: #dee2e6; /* 边框颜色 */
            --hover-color: #e9ecef; /* 悬停颜色 */
        }

        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
            color: var(--text-color);
            background-color: var(--bg-color);
            display: flex;
            flex-direction: column;
            height: 100vh;
        }

        header {
            background-color: var(--primary-color);
            color: white;
            padding: 1rem;
            text-align: center;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
            padding: 1rem;
            display: flex;
            flex: 1;
            flex-direction: column;
        }

        .chat-container {
            display: flex;
            height: calc(100vh - 180px);
            border: 1px solid var(--border-color);
            border-radius: 8px;
            overflow: hidden;
            background-color: white;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        }

        .sidebar {
            width: 250px;
            background-color: #f1f3f5;
            padding: 1rem;
            border-right: 1px solid var(--border-color);
            display: flex;
            flex-direction: column;
        }

        .main-content {
            flex: 1;
            display: flex;
            flex-direction: column;
            position: relative;
        }

        .chat-history {
            flex: 1;
            padding: 1rem;
            overflow-y: auto;
            scroll-behavior: smooth;
        }

        .chat-input-container {
            padding: 1rem;
            border-top: 1px solid var(--border-color);
            background-color: white;
            display: flex;
            flex-direction: column;
        }

        .source-filter {
            display: flex;
            align-items: center;
            margin-bottom: 0.5rem;
        }

        .source-filter select {
            margin-left: 0.5rem;
            padding: 0.3rem;
            border-radius: 4px;
            border: 1px solid var(--border-color);
        }

        .input-group {
            display: flex;
        }

        .chat-input {
            flex: 1;
            padding: 0.75rem;
            border: 1px solid var(--border-color);
            border-radius: 4px 0 0 4px;
            font-size: 1rem;
            resize: none;
            height: 45px;
            max-height: 150px;
            overflow-y: auto;
        }

        .chat-input:focus {
            outline: none;
            border-color: var(--primary-color);
        }

        .send-btn {
            background-color: var(--primary-color);
            color: white;
            border: none;
            padding: 0 1.25rem;
            border-radius: 0 4px 4px 0;
            cursor: pointer;
            font-size: 1rem;
            transition: background-color 0.2s;
        }

        .send-btn:hover {
            background-color: #3a5a8c;
        }

        .send-btn:disabled {
            background-color: var(--secondary-color);
            cursor: not-allowed;
        }

        .message {
            margin-bottom: 1rem;
            display: flex;
            flex-direction: column;
        }

        .message-content {
            padding: 0.75rem 1rem;
            border-radius: 8px;
            max-width: 80%;
            word-break: break-word;
        }

        .user-message {
            align-self: flex-end;
        }

        .user-message .message-content {
            background-color: var(--primary-color);
            color: white;
            align-self: flex-end;
        }

        .system-message {
            align-self: flex-start;
        }

        .system-message .message-content {
            background-color: #e9ecef;
            align-self: flex-start;
        }

        /* 代码块样式 */
        .message-content pre {
            background-color: #f4f4f4;
            padding: 1rem;
            border-radius: 4px;
            overflow-x: auto;
        }

        .message-content code {
            font-family: 'Courier New', Courier, monospace;
            font-size: 0.9rem;
        }

        .typing-indicator {
            display: inline-block;
            padding: 0.75rem 1rem;
            background-color: #e9ecef;
            border-radius: 8px;
            margin-bottom: 1rem;
        }

        .typing-indicator span {
            display: inline-block;
            width: 8px;
            height: 8px;
            border-radius: 50%;
            background-color: var(--secondary-color);
            margin-right: 4px;
            animation: typing 1s infinite;
        }

        .typing-indicator span:nth-child(2) {
            animation-delay: 0.2s;
        }

        .typing-indicator span:nth-child(3) {
            animation-delay: 0.4s;
            margin-right: 0;
        }

        @keyframes typing {
            0%, 100% {
                transform: translateY(0);
            }
            50% {
                transform: translateY(-5px);
            }
        }

        .session-controls {
            display: flex;
            justify-content: space-between;
            margin-bottom: 1rem;
        }

        .new-session-btn, .clear-history-btn {
            background-color: var(--secondary-color);
            color: white;
            border: none;
            padding: 0.5rem 1rem;
            border-radius: 4px;
            cursor: pointer;
            font-size: 0.9rem;
            transition: background-color 0.2s;
        }

        .new-session-btn:hover, .clear-history-btn:hover {
            background-color: #5a6268;
        }

        .session-info {
            font-size: 0.9rem;
            color: var(--secondary-color);
            margin-top: 1rem;
        }

        .history-title {
            font-weight: bold;
            margin-bottom: 1rem;
            color: var(--primary-color);
        }

        .history-list {
            flex: 1;
            overflow-y: auto;
        }

        .history-item {
            padding: 0.75rem;
            border-bottom: 1px solid var(--border-color);
            cursor: pointer;
            transition: background-color 0.2s;
        }

        .history-item:hover {
            background-color: var(--hover-color);
        }

        .history-question {
            font-weight: bold;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }

        .history-answer {
            color: var(--secondary-color);
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            font-size: 0.85rem;
        }

        .loading-overlay {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: rgba(255, 255, 255, 0.8);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 10;
            visibility: hidden;
        }

        .spinner {
            width: 40px;
            height: 40px;
            border: 4px solid rgba(0, 0, 0, 0.1);
            border-radius: 50%;
            border-left-color: var(--primary-color);
            animation: spin 1s linear infinite;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        /* 响应式设计 */
        @media (max-width: 768px) {
            .chat-container {
                flex-direction: column;
            }

            .sidebar {
                width: 100%;
                border-right: none;
                border-bottom: 1px solid var(--border-color);
                max-height: 200px;
            }
        }
    </style>
</head>
<body>
    <header>
        <h1>传智教育EduRAG智慧问答系统</h1>
    </header>

    <div class="container">
        <div class="chat-container">
            <div class="sidebar">
                <!-- 会话控制按钮 -->
                <div class="session-controls">
                    <button class="new-session-btn" id="newSessionBtn">
                        <i class="fas fa-plus"></i> 新会话
                    </button>
                    <button class="clear-history-btn" id="clearHistoryBtn">
                        <i class="fas fa-trash"></i> 清除历史
                    </button>
                </div>
                <div class="history-title">历史会话</div>
                <div class="history-list" id="historyList">
                    <!-- 历史会话将动态添加到这里 -->
                </div>
                <div class="session-info" id="sessionInfo"></div>
            </div>

            <div class="main-content">
                <div class="chat-history" id="chatHistory">
                    <!-- 消息将动态添加到这里 -->
                    <div class="message system-message">
                        <div class="message-content">
                            您好!我是智能问答助手,有什么我可以帮您的吗?
                        </div>
                    </div>
                </div>

                <div class="chat-input-container">
                    <div class="source-filter">
                        <label for="sourceFilter">学科类别:</label>
                        <select id="sourceFilter">
                            <option value="">全部</option>
                            <!-- 学科类别选项将动态添加 -->
                        </select>
                    </div>
                    <div class="input-group">
                        <textarea class="chat-input" id="chatInput" placeholder="请输入您的问题..."
                            rows="1" oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'"></textarea>
                        <button class="send-btn" id="sendBtn">
                            <i class="fas fa-paper-plane"></i>
                        </button>
                    </div>
                </div>

                <div class="loading-overlay" id="loadingOverlay">
                    <div class="spinner"></div>
                </div>
            </div>
        </div>
    </div>

    <script>
        // 全局变量
        let currentSessionId = null; // 当前会话 ID
        let isResponseInProgress = false; // 是否正在处理响应
        let socket = null; // WebSocket 连接

        // DOM 元素
        const chatHistory = document.getElementById('chatHistory'); // 聊天历史容器
        const chatInput = document.getElementById('chatInput'); // 输入框
        const sendBtn = document.getElementById('sendBtn'); // 发送按钮
        const sourceFilter = document.getElementById('sourceFilter'); // 学科过滤器
        const historyList = document.getElementById('historyList'); // 历史列表
        const sessionInfo = document.getElementById('sessionInfo'); // 会话信息
        const newSessionBtn = document.getElementById('newSessionBtn'); // 新会话按钮
        const clearHistoryBtn = document.getElementById('clearHistoryBtn'); // 清除历史按钮
        const loadingOverlay = document.getElementById('loadingOverlay'); // 加载遮罩

        // API URL 前缀,根据部署环境调整
        const API_BASE_URL = ''; // 如果 API 和前端在同一域,可以留空

        // 页面加载完成后执行
        document.addEventListener('DOMContentLoaded', async () => {
            // 创建新会话
            await createNewSession();

            // 加载学科类别
            await loadSources();

            // 事件监听器
            sendBtn.addEventListener('click', sendMessage); // 发送按钮点击事件
            chatInput.addEventListener('keydown', (e) => { // 输入框键盘事件
                if (e.key === 'Enter' && !e.shiftKey) {
                    e.preventDefault();
                    sendMessage();
                }
            });

            newSessionBtn.addEventListener('click', createNewSession); // 新会话按钮点击事件
            clearHistoryBtn.addEventListener('click', clearHistory); // 清除历史按钮点击事件
        });

        // 创建新会话
        async function createNewSession() {
            try {
                const response = await fetch(`${API_BASE_URL}/api/create_session`, {
                    method: 'POST'
                });

                if (!response.ok) {
                    throw new Error('创建会话失败');
                }

                const data = await response.json();
                currentSessionId = data.session_id; // 更新当前会话 ID

                // 更新会话信息
                sessionInfo.textContent = `会话ID: ${currentSessionId.slice(0, 8)}...`;

                // 清空聊天历史
                clearChatHistory();

                // 加载会话历史
                await loadHistory();

            } catch (error) {
                console.error('创建会话错误:', error);
                alert('创建会话失败,请刷新页面重试');
            }
        }

        // 加载学科类别
        async function loadSources() {
            try {
                const response = await fetch(`${API_BASE_URL}/api/sources`);

                if (!response.ok) {
                    throw new Error('获取学科类别失败');
                }

                const data = await response.json();

                // 清空现有选项
                sourceFilter.innerHTML = '<option value="">全部</option>';

                // 添加学科类别选项
                data.sources.forEach(source => {
                    const option = document.createElement('option');
                    option.value = source;
                    option.textContent = source;
                    sourceFilter.appendChild(option);
                });

            } catch (error) {
                console.error('加载学科类别错误:', error);
            }
        }

        // 加载历史消息(支持多轮 QA)
        async function loadHistory() {
            if (!currentSessionId) return;

            try {
                const response = await fetch(`${API_BASE_URL}/api/history/${currentSessionId}`);

                if (!response.ok) {
                    throw new Error('获取历史记录失败');
                }

                const data = await response.json();

                // 清空历史列表
                historyList.innerHTML = '';

                // 将历史记录按会话分组(每组最多 10 轮)
                const sessionItem = document.createElement('div');
                sessionItem.className = 'history-item';
                sessionItem.innerHTML = `
                    <div class="history-question">会话 ${currentSessionId.slice(0, 8)}...</div>
                    <div class="history-answer">共 ${data.history.length} 轮对话</div>
                `;

                // 点击历史记录项显示整个会话
                sessionItem.addEventListener('click', () => {
                    // 清空现有聊天记录
                    clearChatHistory();

                    // 添加整个会话的 QA 对
                    data.history.forEach(item => {
                        addMessage('user', item.question); // 添加用户问题
                        addMessage('system', item.answer); // 添加系统回答
                    });

                    // 滚动到底部
                    scrollToBottom();
                });

                historyList.appendChild(sessionItem);

            } catch (error) {
                console.error('加载历史记录错误:', error);
            }
        }

        // 清除历史记录
        async function clearHistory() {
            if (!currentSessionId) return;

            try {
                const response = await fetch(`${API_BASE_URL}/api/history/${currentSessionId}`, {
                    method: 'DELETE'
                });

                if (!response.ok) {
                    throw new Error('清除历史记录失败');
                }

                // 清空历史列表
                historyList.innerHTML = '';

                // 清空聊天历史
                clearChatHistory();

                // 添加欢迎消息
                chatHistory.innerHTML = `
                    <div class="message system-message">
                        <div class="message-content">
                            历史记录已清除,有什么我可以帮您的吗?
                        </div>
                    </div>
                `;

            } catch (error) {
                console.error('清除历史记录错误:', error);
                alert('清除历史记录失败');
            }
        }

        // 清空聊天历史
        function clearChatHistory() {
            chatHistory.innerHTML = `
                <div class="message system-message">
                    <div class="message-content">
                        您好!我是智能问答助手,有什么我可以帮您的吗?
                    </div>
                </div>
            `;
        }

        // 发送消息
        async function sendMessage() {
            const query = chatInput.value.trim();
            const filter = sourceFilter.value;

            if (!query || isResponseInProgress) return;

            // 重置输入框
            chatInput.value = '';
            chatInput.style.height = 'auto';

            // 添加用户消息到聊天窗口
            addMessage('user', query);

            // 设置正在响应标志
            isResponseInProgress = true;

            // 添加正在输入指示器
            const typingIndicator = document.createElement('div');
            typingIndicator.className = 'typing-indicator';
            typingIndicator.innerHTML = '<span></span><span></span><span></span>';
            chatHistory.appendChild(typingIndicator);

            // 滚动到底部
            scrollToBottom();

            try {
                // 发送查询请求
                const response = await fetch(`${API_BASE_URL}/api/query`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        query: query,
                        source_filter: filter,
                        session_id: currentSessionId
                    })
                });

                if (!response.ok) {
                    throw new Error('查询失败');
                }

                const data = await response.json();

                // 移除输入指示器
                if (typingIndicator.parentNode) {
                    chatHistory.removeChild(typingIndicator);
                }

                // 如果需要流式输出,使用 WebSocket
                if (data.is_streaming) {
                    // 创建系统消息容器,但内容为空
                    const messageElement = document.createElement('div');
                    messageElement.className = 'message system-message';

                    const contentElement = document.createElement('div');
                    contentElement.className = 'message-content';
                    contentElement.innerHTML = ''; // 初始化为空

                    messageElement.appendChild(contentElement);
                    chatHistory.appendChild(messageElement);

                    // 建立 WebSocket 连接进行流式输出
                    await handleStreamingResponse(query, filter, contentElement);
                } else {
                    // 直接添加格式化后的答案
                    addMessage('system', data.answer);
                }

                // 重新加载历史记录
                await loadHistory();

            } catch (error) {
                console.error('发送消息错误:', error);

                // 移除输入指示器
                if (typingIndicator.parentNode) {
                    chatHistory.removeChild(typingIndicator);
                }

                // 添加错误消息
                addMessage('system', `抱歉,发生错误: ${error.message}`);
            } finally {
                // 清除正在响应标志
                isResponseInProgress = false;

                // 滚动到底部
                scrollToBottom();
            }
        }

        // 处理流式响应
        async function handleStreamingResponse(query, filter, contentElement) {
            // 关闭现有连接
            if (socket && socket.readyState === WebSocket.OPEN) {
                socket.close();
            }

            return new Promise((resolve, reject) => {
                // WebSocket URL
                const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
                const wsUrl = `${wsProtocol}//${window.location.host}${API_BASE_URL}/api/stream`;

                // 创建 WebSocket 连接
                socket = new WebSocket(wsUrl);

                let accumulatedContent = ''; // 累积流式内容

                // 连接建立时
                socket.onopen = function() {
                    console.log('WebSocket 连接已建立');

                    // 发送查询请求
                    socket.send(JSON.stringify({
                        query: query,
                        source_filter: filter,
                        session_id: currentSessionId
                    }));
                };

                // 接收消息时
                socket.onmessage = function(event) {
                    const data = JSON.parse(event.data);

                    switch (data.type) {
                        case 'start':
                            console.log('流式响应开始');
                            break;

                        case 'token':
                            // 追加 token 到内容
                            accumulatedContent += data.token;
                            // 使用 marked.js 解析 Markdown 格式
                            contentElement.innerHTML = marked.parse(accumulatedContent);
                            scrollToBottom();
                            break;

                        case 'end':
                            console.log('流式响应结束');
                            socket.close();
                            resolve();
                            break;

                        case 'error':
                            console.error('流式响应错误:', data.error);
                            contentElement.innerHTML += `<p>[错误: ${data.error}]</p>`;
                            socket.close();
                            reject(new Error(data.error));
                            break;
                    }
                };

                // 发生错误时
                socket.onerror = function(error) {
                    console.error('WebSocket 错误:', error);
                    contentElement.innerHTML += '<p>[连接错误]</p>';
                    reject(error);
                };

                // 连接关闭时
                socket.onclose = function() {
                    console.log('WebSocket 连接已关闭');
                    resolve();
                };
            });
        }

        // 添加消息到聊天窗口(支持 Markdown 格式)
        function addMessage(type, content) {
            const messageElement = document.createElement('div');
            messageElement.className = `message ${type === 'user' ? 'user-message' : 'system-message'}`;

            const contentElement = document.createElement('div');
            contentElement.className = 'message-content';
            // 使用 marked.js 解析 Markdown 格式(支持换行和代码块)
            contentElement.innerHTML = marked.parse(content);

            messageElement.appendChild(contentElement);
            chatHistory.appendChild(messageElement);

            // 滚动到底部
            scrollToBottom();
        }

        // 滚动到底部
        function scrollToBottom() {
            chatHistory.scrollTop = chatHistory.scrollHeight;
        }
    </script>
</body>
</html>