1. 用户发送消息 → 前端立即显示 2. 后端收到 → 广播用户消息 → 执行搜索 → 发送搜索结果 3. AI生成回复 → 显示 - sendMessage 立即显示用户消息 - user_message 事件避免重复显示 - 后端处理顺序:广播 → 搜索 → 保存 → LLM
843 lines
44 KiB
HTML
843 lines
44 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>AI 对话系统 v2.0</title>
|
||
<!-- Favicon -->
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>">
|
||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||
<link href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.2.0/github-markdown-light.min.css" rel="stylesheet">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; height: 100vh; display: flex; }
|
||
|
||
.sidebar { width: 260px; background: #202123; color: #fff; display: flex; flex-direction: column; }
|
||
.sidebar-header { padding: 16px; border-bottom: 1px solid #4d4d4f; }
|
||
.new-chat-btn { width: 100%; padding: 12px 16px; background: transparent; border: 1px solid #4d4d4f; border-radius: 6px; color: #fff; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 14px; transition: background 0.2s; }
|
||
.new-chat-btn:hover { background: #2a2b32; }
|
||
.conversation-list { flex: 1; overflow-y: auto; padding: 8px; }
|
||
.conversation-item { padding: 12px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; color: #ececf1; }
|
||
.conversation-item:hover { background: #2a2b32; }
|
||
.conversation-item.active { background: #343541; }
|
||
.conversation-item .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
||
.conversation-item .delete-btn { opacity: 0; background: none; border: none; color: #999; cursor: pointer; padding: 4px; }
|
||
.conversation-item:hover .delete-btn { opacity: 1; }
|
||
|
||
.main-content { flex: 1; display: flex; flex-direction: column; background: #fff; }
|
||
.chat-header { padding: 16px 24px; border-bottom: 1px solid #e5e5e5; display: flex; align-items: center; justify-content: space-between; }
|
||
.chat-header h1 { font-size: 18px; font-weight: 600; }
|
||
.header-controls { display: flex; align-items: center; gap: 16px; }
|
||
.agent-selector { display: flex; align-items: center; gap: 8px; }
|
||
.agent-selector select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; cursor: pointer; min-width: 150px; }
|
||
.ws-status { font-size: 12px; color: #666; background: #f0f0f0; padding: 4px 8px; border-radius: 4px; }
|
||
.ws-status.connected { color: #10a37f; }
|
||
.tool-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
||
.tool-toggle input { width: 14px; height: 14px; }
|
||
.tool-toggle label { cursor: pointer; }
|
||
|
||
.messages-container { flex: 1; overflow-y: auto; padding: 24px; }
|
||
.message { max-width: 800px; margin: 0 auto 24px; display: flex; gap: 16px; }
|
||
.message-avatar { width: 36px; height: 36px; border-radius: 4px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 18px; }
|
||
.message.user .message-avatar { background: #5436da; color: #fff; }
|
||
.message.assistant .message-avatar { background: #19c37d; color: #fff; }
|
||
.message-body { flex: 1; }
|
||
.message-content { line-height: 1.6; position: relative; }
|
||
|
||
/* Markdown样式 */
|
||
.markdown-body { font-size: 15px; }
|
||
.markdown-body pre { background: #f6f8fa; border-radius: 6px; padding: 16px; overflow-x: auto; }
|
||
.markdown-body code { background: #f6f8fa; padding: 2px 6px; border-radius: 4px; font-size: 85%; }
|
||
.markdown-body pre code { background: transparent; padding: 0; }
|
||
.markdown-body blockquote { border-left: 4px solid #dfe2e5; padding-left: 16px; color: #6a737d; }
|
||
|
||
/* 用户消息样式 */
|
||
.user-message-text { background: #f0f0f0; padding: 12px 16px; border-radius: 12px; font-size: 15px; }
|
||
|
||
/* 消息操作按钮 */
|
||
.message-actions { display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
|
||
.action-btn { padding: 6px 12px; background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 6px; cursor: pointer; font-size: 12px; color: #666; display: flex; align-items: center; gap: 4px; transition: all 0.2s; }
|
||
.action-btn:hover { background: #e8e8e8; border-color: #ccc; }
|
||
.action-btn.copied { color: #10a37f; border-color: #10a37f; background: #e8f5e9; }
|
||
.action-btn.regenerate:hover { color: #667eea; border-color: #667eea; }
|
||
|
||
/* 版本切换控件 - 简洁版 */
|
||
.version-switcher { display: none; align-items: center; gap: 4px; margin-left: 4px; }
|
||
.version-switcher.show { display: flex; }
|
||
.version-arrow { width: 24px; height: 24px; border-radius: 4px; background: #f5f5f5; border: 1px solid #e0e0e0; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #666; font-size: 14px; transition: all 0.2s; }
|
||
.version-arrow:hover { background: #e8e8e8; border-color: #667eea; color: #667eea; }
|
||
.version-arrow:disabled { opacity: 0.4; cursor: not-allowed; background: #f5f5f5; }
|
||
.version-label { font-size: 11px; color: #888; padding: 0 2px; }
|
||
|
||
/* 加载动画 */
|
||
.loading-indicator { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; color: #667eea; }
|
||
.loading-spinner { width: 20px; height: 20px; border: 2px solid #667eea; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* 消息内容容器 */
|
||
.response-container { position: relative; }
|
||
.response-version { display: none; }
|
||
.response-version.active { display: block; }
|
||
|
||
/* 思考内容样式 */
|
||
.thinking-block { background: #f8f9fa; border-left: 3px solid #667eea; padding: 12px 16px; margin: 8px 0 16px 0; font-size: 14px; color: #666; border-radius: 4px; position: relative; }
|
||
.thinking-header { display: flex; align-items: center; justify-content: space-between; cursor: pointer; }
|
||
.thinking-header span { font-weight: 500; color: #667eea; }
|
||
.thinking-toggle { font-size: 12px; color: #667eea; }
|
||
.thinking-content { margin-top: 12px; display: none; }
|
||
.thinking-content.expanded { display: block; }
|
||
.search-results-box { margin: 12px 0; padding: 10px 12px; background: linear-gradient(135deg, #f0f7ff 0%, #e8f4f8 100%); border-radius: 8px; border: 1px solid #d0e8f0; }
|
||
.search-results-header { display: flex; align-items: center; justify-content: space-between; cursor: pointer; }
|
||
.search-results-header h5 { margin: 0; font-size: 13px; color: #10a37f; display: flex; align-items: center; gap: 6px; }
|
||
.search-results-toggle { font-size: 12px; color: #666; }
|
||
.search-results-content { margin-top: 10px; display: none; }
|
||
.search-results-content.expanded { display: block; }
|
||
.search-result-item { margin-bottom: 8px; padding: 8px 10px; background: white; border-radius: 6px; border: 1px solid #eee; }
|
||
.search-result-item:last-child { margin-bottom: 0; }
|
||
.search-result-title { font-size: 13px; color: #10a37f; font-weight: 500; margin-bottom: 4px; }
|
||
.search-result-snippet { font-size: 12px; color: #666; line-height: 1.4; }
|
||
.search-result-url { font-size: 11px; color: #999; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
/* Agent信息 */
|
||
.agent-info { font-size: 12px; color: #999; margin-top: 8px; }
|
||
|
||
/* 输入区域 */
|
||
.input-container { padding: 16px 24px; border-top: 1px solid #e5e5e5; }
|
||
.input-area { max-width: 800px; margin: 0 auto; }
|
||
.input-row { display: flex; gap: 12px; align-items: center; }
|
||
.input-row textarea { flex: 1; padding: 12px 16px; border: 1px solid #ddd; border-radius: 12px; font-size: 16px; resize: none; outline: none; max-height: 200px; min-height: 48px; line-height: 1.5; }
|
||
.input-row textarea:focus { border-color: #10a37f; }
|
||
.send-btn { width: 48px; height: 48px; border-radius: 12px; background: #10a37f; border: none; color: #fff; cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; transition: background 0.2s; }
|
||
.send-btn:hover { background: #0d8c6d; }
|
||
.send-btn:disabled { background: #ccc; cursor: not-allowed; }
|
||
|
||
/* 快捷语句 - 横向扁平 */
|
||
.quick-phrases-bar { display: flex; align-items: center; gap: 8px; margin-top: 12px; position: relative; }
|
||
.add-phrase-btn { padding: 6px 10px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 6px; cursor: pointer; font-size: 12px; color: #666; white-space: nowrap; flex-shrink: 0; }
|
||
.add-phrase-btn:hover { background: #e8e8e8; }
|
||
.phrase-list-wrapper { flex: 1; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
|
||
.phrase-list-wrapper::-webkit-scrollbar { height: 4px; }
|
||
.phrase-list-wrapper::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||
.phrase-list { display: flex; gap: 6px; padding: 4px 0; }
|
||
.phrase-tag { padding: 6px 12px; background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 16px; cursor: pointer; font-size: 13px; color: #333; white-space: nowrap; transition: all 0.2s; position: relative; }
|
||
.phrase-tag:hover { background: #e8f5e9; border-color: #10a37f; }
|
||
.phrase-tag .tag-delete { display: none; margin-left: 6px; color: #999; }
|
||
.phrase-tag:hover .tag-delete { display: inline; }
|
||
|
||
/* 弹窗 */
|
||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
|
||
.modal-overlay.show { display: flex; }
|
||
.modal-box { background: #fff; padding: 24px; border-radius: 12px; max-width: 400px; width: 90%; }
|
||
.modal-box h3 { margin-bottom: 16px; }
|
||
.modal-box input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 16px; }
|
||
.modal-buttons { display: flex; gap: 12px; justify-content: flex-end; }
|
||
.modal-buttons button { padding: 8px 16px; border-radius: 8px; cursor: pointer; }
|
||
|
||
.welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #666; }
|
||
.welcome h2 { font-size: 28px; margin-bottom: 16px; color: #333; }
|
||
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 3px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="sidebar">
|
||
<div class="sidebar-header">
|
||
<button class="new-chat-btn" onclick="createNewConversation()"><i class="ri-add-line"></i> 新对话</button>
|
||
</div>
|
||
<div class="conversation-list" id="conversationList"></div>
|
||
</div>
|
||
|
||
<div class="main-content">
|
||
<div class="chat-header">
|
||
<h1>AI 对话 v2.0</h1>
|
||
<div class="header-controls">
|
||
<div class="agent-selector">
|
||
<select id="agentSelect" onchange="switchAgent()"><option value="">加载中...</option></select>
|
||
</div>
|
||
<div class="ws-status" id="wsStatus">连接中...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="messages-container" id="messagesContainer">
|
||
<div class="welcome"><h2>👋 开始对话</h2><p>选择Agent,开始聊天</p></div>
|
||
</div>
|
||
|
||
<div class="input-container">
|
||
<div class="input-area">
|
||
<div class="input-row">
|
||
<textarea id="messageInput" placeholder="输入消息..." rows="1"></textarea>
|
||
<button class="send-btn" id="sendBtn" onclick="sendMessage()"><i class="ri-send-plane-fill"></i></button>
|
||
</div>
|
||
<div class="quick-phrases-bar">
|
||
<div class="tool-toggle">
|
||
<input type="checkbox" id="enableSearch" checked>
|
||
<label for="enableSearch"><i class="ri-search-line"></i> 搜索</label>
|
||
</div>
|
||
<button class="add-phrase-btn" onclick="showAddPhraseModal()"><i class="ri-add-line"></i> 添加</button>
|
||
<div class="phrase-list-wrapper" id="phraseListWrapper" onwheel="scrollPhrases(event)">
|
||
<div class="phrase-list" id="quickPhrasesList"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 添加快捷语句弹窗 -->
|
||
<div class="modal-overlay" id="phraseModal">
|
||
<div class="modal-box">
|
||
<h3><i class="ri-add-line"></i> 添加快捷语句</h3>
|
||
<input type="text" id="newPhraseInput" placeholder="输入内容..." maxlength="100">
|
||
<div class="modal-buttons">
|
||
<button style="background:#f5f5f5;border:1px solid #ddd;" onclick="hidePhraseModal()">取消</button>
|
||
<button style="background:#10a37f;color:#fff;border:none;" onclick="addPhrase()">添加</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Markdown渲染库 -->
|
||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||
<script>
|
||
marked.setOptions({ breaks: true, gfm: true });
|
||
|
||
let ws = null;
|
||
let userId = 'main_user';
|
||
let currentConversationId = null;
|
||
let currentAgentId = null;
|
||
let agents = [];
|
||
let quickPhrases = [];
|
||
let lastUserMessage = null; // 存储最后一条用户消息,用于重新生成
|
||
let isRegenerating = false; // 标志:正在重新生成,跳过用户消息显示
|
||
let regeneratingMessageId = null; // 正在重新生成的消息ID
|
||
let messageVersionCounter = 0; // 消息版本计数器
|
||
let messageVersions = {}; // 存储每个assistant消息的多个版本 { messageId: [{content, thinking}] }
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadAgents();
|
||
loadQuickPhrases();
|
||
connectWebSocket();
|
||
loadConversations();
|
||
setupTextarea();
|
||
});
|
||
|
||
// 加载Agent
|
||
async function loadAgents() {
|
||
try {
|
||
const res = await fetch('/api/v2/agents');
|
||
const data = await res.json();
|
||
agents = data.agents || [];
|
||
const defaultAgent = agents.find(a => a.is_default) || agents[0];
|
||
if (defaultAgent) currentAgentId = defaultAgent.id;
|
||
renderAgentSelect();
|
||
} catch (e) { console.error('加载Agent失败:', e); }
|
||
}
|
||
|
||
function renderAgentSelect() {
|
||
const select = document.getElementById('agentSelect');
|
||
select.innerHTML = agents.filter(a => a.is_active).map(a =>
|
||
`<option value="${a.id}" ${a.id === currentAgentId ? 'selected' : ''}>${a.display_name || a.name}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function updateAgentSelect(agentId) {
|
||
currentAgentId = agentId;
|
||
document.getElementById('agentSelect').value = agentId;
|
||
}
|
||
|
||
async function switchAgent() {
|
||
const newAgentId = parseInt(document.getElementById('agentSelect').value);
|
||
if (newAgentId && newAgentId !== currentAgentId) {
|
||
currentAgentId = newAgentId;
|
||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ action: 'switch_agent', agent_id: currentAgentId }));
|
||
await createNewConversation();
|
||
showAgentSwitchNotice();
|
||
}
|
||
}
|
||
|
||
function showAgentSwitchNotice() {
|
||
const agent = agents.find(a => a.id === currentAgentId);
|
||
document.getElementById('messagesContainer').innerHTML = `
|
||
<div class="welcome"><h2>🔄 已切换 Agent</h2><p style="color:#667eea;font-size:18px;"><strong>${agent?.display_name || '助手'}</strong></p></div>
|
||
`;
|
||
}
|
||
|
||
// WebSocket
|
||
function connectWebSocket() {
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
ws = new WebSocket(`${protocol}//${window.location.host}/ws/${userId}`);
|
||
ws.onopen = () => { document.getElementById('wsStatus').textContent = '已连接'; document.getElementById('wsStatus').classList.add('connected'); };
|
||
ws.onmessage = (e) => handleWebSocketMessage(JSON.parse(e.data));
|
||
ws.onclose = () => { document.getElementById('wsStatus').textContent = '断开'; document.getElementById('wsStatus').classList.remove('connected'); setTimeout(connectWebSocket, 3000); };
|
||
ws.onerror = () => document.getElementById('wsStatus').textContent = '错误';
|
||
}
|
||
|
||
function handleWebSocketMessage(data) {
|
||
switch (data.type) {
|
||
case 'history': displayHistory(data.messages); if (data.agent_id) updateAgentSelect(data.agent_id); break;
|
||
case 'conversation_created': currentConversationId = data.conversation_id; loadConversations(); break;
|
||
case 'new_conversation': currentConversationId = data.conversation_id; loadConversations(); clearMessages(); break;
|
||
case 'agent_switched': currentAgentId = data.agent_id; break;
|
||
case 'stream_end': document.getElementById('sendBtn').disabled = false; break;
|
||
case 'user_message':
|
||
lastUserMessage = data.message.content; // 存储最后一条用户消息
|
||
// 如果是刚发送的消息,已经显示了,不再重复显示
|
||
if (!isRegenerating && data.message.content !== lastSentMessage) {
|
||
appendMessage('user', data.message.content);
|
||
}
|
||
lastSentMessage = null; // 清除标记
|
||
// 注意:不要在这里重置 isRegenerating,要等 assistant_message 处理后再重置
|
||
break;
|
||
case 'assistant_message':
|
||
if (isRegenerating && regeneratingMessageId) {
|
||
// 添加新版本到现有消息
|
||
addResponseVersion(regeneratingMessageId, data.message.content, data.message.thinking_content);
|
||
regeneratingMessageId = null;
|
||
isRegenerating = false; // 在这里重置标志
|
||
} else {
|
||
appendMessage('assistant', data.message.content, data.message.thinking_content, data.message.agent_name);
|
||
}
|
||
document.getElementById('sendBtn').disabled = false;
|
||
break;
|
||
case 'error': showError(data.message); document.getElementById('sendBtn').disabled = false; break;
|
||
case 'search_results': displaySearchResults(data.results, data.query); break;
|
||
}
|
||
}
|
||
|
||
// 消息渲染
|
||
function appendMessage(role, content, thinking = null, agentName = null) {
|
||
const container = document.getElementById('messagesContainer');
|
||
container.querySelector('.welcome')?.remove();
|
||
|
||
const div = document.createElement('div');
|
||
div.className = `message ${role}`;
|
||
|
||
const avatar = role === 'user' ? '👤' : '🤖';
|
||
let messageId = null;
|
||
|
||
if (role === 'assistant') {
|
||
// 为assistant消息生成唯一ID
|
||
messageId = `msg_${++messageVersionCounter}`;
|
||
div.id = messageId;
|
||
div.dataset.messageId = messageId;
|
||
// 初始化版本存储
|
||
messageVersions[messageId] = [{ content, thinking }];
|
||
}
|
||
|
||
let html = `<div class="message-avatar">${avatar}</div><div class="message-body">`;
|
||
|
||
// 思考内容
|
||
if (thinking) {
|
||
html += `<div class="thinking-block">
|
||
<div class="thinking-header" onclick="toggleThinking(this)">
|
||
<span><i class="ri-lightbulb-line"></i> 思考过程</span>
|
||
<span class="thinking-toggle">点击展开</span>
|
||
</div>
|
||
<div class="thinking-content">${escapeHtml(thinking)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// 消息内容 - assistant使用版本容器
|
||
html += `<div class="message-content">`;
|
||
if (role === 'assistant') {
|
||
html += `<div class="response-container" id="${messageId}_container">
|
||
<div class="response-version active" data-version="0">
|
||
<div class="markdown-body">${marked.parse(content)}</div>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
html += `<div class="user-message-text">${escapeHtml(content)}</div>`;
|
||
}
|
||
html += `</div>`;
|
||
|
||
// 操作按钮 - 使用隐藏input存储原始内容
|
||
html += `<input type="hidden" class="copy-source" value="${content.replace(/"/g, '"')}">`;
|
||
html += `<div class="message-actions">`;
|
||
html += `<button class="action-btn" onclick="copyMessage(this)"><i class="ri-file-copy-line"></i> 复制</button>`;
|
||
if (role === 'assistant') {
|
||
html += `<button class="action-btn regenerate" onclick="regenerateMessage('${messageId}')"><i class="ri-refresh-line"></i> 重新生成</button>`;
|
||
// 版本切换控件 - 放在重新生成按钮后面
|
||
html += `<span class="version-switcher" id="${messageId}_version_switcher">`;
|
||
html += `<button class="version-arrow" onclick="switchVersion('${messageId}', -1)" data-dir="prev"><i class="ri-arrow-left-s-line"></i></button>`;
|
||
html += `<span class="version-label" id="${messageId}_version_label">1/1</span>`;
|
||
html += `<button class="version-arrow" onclick="switchVersion('${messageId}', 1)" data-dir="next"><i class="ri-arrow-right-s-line"></i></button>`;
|
||
html += `</span>`;
|
||
}
|
||
html += `</div>`;
|
||
|
||
// Agent信息
|
||
if (role === 'assistant' && agentName) {
|
||
html += `<div class="agent-info"><i class="ri-robot-line"></i> ${agentName}</div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
div.innerHTML = html;
|
||
container.appendChild(div);
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
// 添加新版本到现有消息
|
||
function addResponseVersion(messageId, content, thinking) {
|
||
const versions = messageVersions[messageId];
|
||
if (!versions) return;
|
||
|
||
// 添加新版本
|
||
versions.push({ content, thinking });
|
||
const newVersionIndex = versions.length - 1;
|
||
|
||
// 更新容器
|
||
const container = document.getElementById(`${messageId}_container`);
|
||
if (container) {
|
||
// 先移除loading(如果有)
|
||
hideLoadingIndicator(messageId);
|
||
|
||
// 添加思考块(如果有)
|
||
const messageBody = container.closest('.message-body');
|
||
if (thinking && messageBody) {
|
||
// 先移除旧的思考块
|
||
const oldThinking = messageBody.querySelector('.thinking-block');
|
||
if (oldThinking) oldThinking.remove();
|
||
|
||
// 添加新的思考块
|
||
const thinkingHtml = `<div class="thinking-block">
|
||
<div class="thinking-header" onclick="toggleThinking(this)">
|
||
<span><i class="ri-lightbulb-line"></i> 思考过程</span>
|
||
<span class="thinking-toggle">点击展开</span>
|
||
</div>
|
||
<div class="thinking-content">${escapeHtml(thinking)}</div>
|
||
</div>`;
|
||
messageBody.insertAdjacentHTML('afterbegin', thinkingHtml);
|
||
}
|
||
|
||
// 隐藏所有旧版本,显示最新版本
|
||
container.querySelectorAll('.response-version').forEach(v => v.classList.remove('active'));
|
||
const newVersionHtml = `<div class="response-version active" data-version="${newVersionIndex}">
|
||
<div class="markdown-body">${marked.parse(content)}</div>
|
||
</div>`;
|
||
container.insertAdjacentHTML('beforeend', newVersionHtml);
|
||
}
|
||
|
||
// 更新复制源
|
||
const messageDiv = document.getElementById(messageId);
|
||
if (messageDiv) {
|
||
const copySource = messageDiv.querySelector('.copy-source');
|
||
if (copySource) copySource.value = content;
|
||
}
|
||
|
||
// 显示版本切换控件并更新指示器
|
||
showVersionControls(messageId);
|
||
}
|
||
|
||
// 显示loading动画
|
||
function showLoadingIndicator(messageId) {
|
||
const container = document.getElementById(`${messageId}_container`);
|
||
if (container) {
|
||
// 隐藏所有版本
|
||
container.querySelectorAll('.response-version').forEach(v => v.classList.remove('active'));
|
||
// 显示loading
|
||
const loadingHtml = `<div class="loading-indicator" id="${messageId}_loading">
|
||
<div class="loading-spinner"></div>
|
||
<span>正在生成...</span>
|
||
</div>`;
|
||
container.insertAdjacentHTML('beforeend', loadingHtml);
|
||
}
|
||
}
|
||
|
||
// 隐藏loading动画
|
||
function hideLoadingIndicator(messageId) {
|
||
const loading = document.getElementById(`${messageId}_loading`);
|
||
if (loading) loading.remove();
|
||
}
|
||
|
||
// 显示版本切换控件
|
||
function showVersionControls(messageId) {
|
||
const switcher = document.getElementById(`${messageId}_version_switcher`);
|
||
const versions = messageVersions[messageId];
|
||
if (switcher && versions && versions.length > 1) {
|
||
switcher.classList.add('show');
|
||
// 新生成的版本是最后一个,切换到最新版本
|
||
const container = document.getElementById(`${messageId}_container`);
|
||
if (container) {
|
||
// 隐藏所有版本,显示最后一个
|
||
container.querySelectorAll('.response-version').forEach(v => v.classList.remove('active'));
|
||
const lastVersion = container.querySelector(`.response-version[data-version="${versions.length - 1}"]`);
|
||
if (lastVersion) lastVersion.classList.add('active');
|
||
}
|
||
updateVersionIndicator(messageId);
|
||
}
|
||
}
|
||
|
||
// 更新版本指示器
|
||
function updateVersionIndicator(messageId) {
|
||
const container = document.getElementById(`${messageId}_container`);
|
||
const label = document.getElementById(`${messageId}_version_label`);
|
||
const versions = messageVersions[messageId];
|
||
if (!container || !label || !versions) return;
|
||
|
||
// 找到当前激活的版本
|
||
const activeVersion = container.querySelector('.response-version.active');
|
||
const currentIndex = activeVersion ? parseInt(activeVersion.dataset.version) : 0;
|
||
|
||
label.textContent = `${currentIndex + 1}/${versions.length}`;
|
||
|
||
// 更新按钮状态
|
||
const switcher = document.getElementById(`${messageId}_version_switcher`);
|
||
if (switcher) {
|
||
const prevBtn = switcher.querySelector('[data-dir="prev"]');
|
||
const nextBtn = switcher.querySelector('[data-dir="next"]');
|
||
if (prevBtn) prevBtn.disabled = currentIndex === 0;
|
||
if (nextBtn) nextBtn.disabled = currentIndex === versions.length - 1;
|
||
}
|
||
}
|
||
|
||
// 切换版本
|
||
function switchVersion(messageId, direction) {
|
||
const container = document.getElementById(`${messageId}_container`);
|
||
const versions = messageVersions[messageId];
|
||
if (!container || !versions) return;
|
||
|
||
// 找到当前激活的版本
|
||
const activeVersion = container.querySelector('.response-version.active');
|
||
const currentIndex = activeVersion ? parseInt(activeVersion.dataset.version) : 0;
|
||
const newIndex = currentIndex + direction;
|
||
|
||
if (newIndex < 0 || newIndex >= versions.length) return;
|
||
|
||
// 切换显示
|
||
container.querySelectorAll('.response-version').forEach(v => {
|
||
v.classList.remove('active');
|
||
if (parseInt(v.dataset.version) === newIndex) v.classList.add('active');
|
||
});
|
||
|
||
// 更新复制源
|
||
const messageDiv = document.getElementById(messageId);
|
||
if (messageDiv) {
|
||
const copySource = messageDiv.querySelector('.copy-source');
|
||
if (copySource) copySource.value = versions[newIndex].content;
|
||
}
|
||
|
||
// 更新思考块
|
||
const messageBody = container.closest('.message-body');
|
||
if (messageBody && versions[newIndex].thinking) {
|
||
// 移除旧的思考块
|
||
const oldThinking = messageBody.querySelector('.thinking-block');
|
||
if (oldThinking) oldThinking.remove();
|
||
|
||
// 添加新版本的思考块
|
||
const thinkingHtml = `<div class="thinking-block">
|
||
<div class="thinking-header" onclick="toggleThinking(this)">
|
||
<span><i class="ri-lightbulb-line"></i> 思考过程</span>
|
||
<span class="thinking-toggle">点击展开</span>
|
||
</div>
|
||
<div class="thinking-content">${escapeHtml(versions[newIndex].thinking)}</div>
|
||
</div>`;
|
||
messageBody.insertAdjacentHTML('afterbegin', thinkingHtml);
|
||
}
|
||
|
||
updateVersionIndicator(messageId);
|
||
}
|
||
|
||
function toggleThinking(header) {
|
||
const block = header.parentElement;
|
||
const content = block.querySelector('.thinking-content');
|
||
const toggle = header.querySelector('.thinking-toggle');
|
||
if (content.classList.contains('expanded')) {
|
||
content.classList.remove('expanded');
|
||
toggle.textContent = '点击展开';
|
||
} else {
|
||
content.classList.add('expanded');
|
||
toggle.textContent = '点击折叠';
|
||
}
|
||
}
|
||
|
||
function copyMessage(btn) {
|
||
// 从隐藏input获取原始内容
|
||
const actionsDiv = btn.parentElement;
|
||
const messageBody = actionsDiv.parentElement;
|
||
const hiddenInput = messageBody.querySelector('.copy-source');
|
||
if (!hiddenInput) {
|
||
console.error('找不到复制源');
|
||
btn.innerHTML = '<i class="ri-error-line"></i> 失败';
|
||
return;
|
||
}
|
||
const text = hiddenInput.value;
|
||
|
||
// 使用传统复制方法(兼容性更好)
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = text;
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.left = '-9999px';
|
||
textarea.style.top = '0';
|
||
document.body.appendChild(textarea);
|
||
textarea.focus();
|
||
textarea.select();
|
||
|
||
try {
|
||
const success = document.execCommand('copy');
|
||
if (success) {
|
||
btn.innerHTML = '<i class="ri-check-line"></i> 已复制';
|
||
btn.classList.add('copied');
|
||
setTimeout(() => {
|
||
btn.innerHTML = '<i class="ri-file-copy-line"></i> 复制';
|
||
btn.classList.remove('copied');
|
||
}, 2000);
|
||
} else {
|
||
btn.innerHTML = '<i class="ri-error-line"></i> 失败';
|
||
}
|
||
} catch (err) {
|
||
console.error('复制失败:', err);
|
||
btn.innerHTML = '<i class="ri-error-line"></i> 失败';
|
||
}
|
||
|
||
document.body.removeChild(textarea);
|
||
}
|
||
|
||
function regenerateMessage(messageId) {
|
||
if (!lastUserMessage) {
|
||
alert('没有可重新生成的消息');
|
||
return;
|
||
}
|
||
// 设置重新生成标志,避免再次显示用户消息
|
||
isRegenerating = true;
|
||
regeneratingMessageId = messageId;
|
||
|
||
// 显示loading动画
|
||
showLoadingIndicator(messageId);
|
||
|
||
// 重新发送最后一条用户消息
|
||
document.getElementById('sendBtn').disabled = true;
|
||
if (ws?.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({
|
||
action: 'chat',
|
||
message: lastUserMessage,
|
||
conversation_id: currentConversationId,
|
||
agent_id: currentAgentId
|
||
}));
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function displayHistory(messages) {
|
||
const container = document.getElementById('messagesContainer');
|
||
container.innerHTML = '';
|
||
messages.forEach(m => appendMessage(m.role, m.content, m.thinking_content));
|
||
}
|
||
|
||
function clearMessages() {
|
||
document.getElementById('messagesContainer').innerHTML = '<div class="welcome"><h2>👋 开始对话</h2></div>';
|
||
}
|
||
|
||
function showError(msg) {
|
||
const container = document.getElementById('messagesContainer');
|
||
const div = document.createElement('div');
|
||
div.className = 'message assistant';
|
||
div.innerHTML = `<div class="message-avatar">❌</div><div class="message-body"><div class="message-content" style="color:#dc3545;">${msg}</div></div>`;
|
||
container.appendChild(div);
|
||
}
|
||
|
||
function displaySearchResults(results, query) {
|
||
if (!results || results.length === 0) return;
|
||
|
||
const container = document.getElementById('messagesContainer');
|
||
|
||
// 找到最后一条用户消息
|
||
const userMessages = container.querySelectorAll('.message.user');
|
||
const lastUserMsg = userMessages[userMessages.length - 1];
|
||
|
||
if (!lastUserMsg) {
|
||
// 没有用户消息,作为独立消息显示
|
||
const div = document.createElement('div');
|
||
div.className = 'message assistant';
|
||
div.innerHTML = `<div class="message-avatar">🔍</div><div class="message-body">${buildSearchResultsHtml(results, query)}</div>`;
|
||
container.appendChild(div);
|
||
} else {
|
||
// 在用户消息的 message-body 中追加搜索结果
|
||
const msgBody = lastUserMsg.querySelector('.message-body');
|
||
if (msgBody) {
|
||
msgBody.innerHTML += buildSearchResultsHtml(results, query);
|
||
}
|
||
}
|
||
|
||
// 滚动到底部
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function buildSearchResultsHtml(results, query) {
|
||
const resultId = 'sr-' + Date.now();
|
||
let html = `<div class="search-results-box">
|
||
<div class="search-results-header" onclick="toggleSearchResults('${resultId}')">
|
||
<h5><i class="ri-search-line"></i> 搜索: ${escapeHtml(query.substring(0, 30))}${query.length > 30 ? '...' : ''} (${results.length}条结果)</h5>
|
||
<span class="search-results-toggle" id="${resultId}-toggle">展开 <i class="ri-arrow-down-s-line"></i></span>
|
||
</div>
|
||
<div class="search-results-content" id="${resultId}">`;
|
||
|
||
for (const r of results) {
|
||
html += `<div class="search-result-item">
|
||
<div class="search-result-title">${escapeHtml(r.title)}</div>
|
||
<div class="search-result-snippet">${escapeHtml(r.snippet)}</div>
|
||
<div class="search-result-url">${escapeHtml(r.url)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += '</div></div>';
|
||
return html;
|
||
}
|
||
|
||
function toggleSearchResults(id) {
|
||
const content = document.getElementById(id);
|
||
const toggle = document.getElementById(id + '-toggle');
|
||
if (content.classList.contains('expanded')) {
|
||
content.classList.remove('expanded');
|
||
toggle.innerHTML = '展开 <i class="ri-arrow-down-s-line"></i>';
|
||
} else {
|
||
content.classList.add('expanded');
|
||
toggle.innerHTML = '收起 <i class="ri-arrow-up-s-line"></i>';
|
||
}
|
||
}
|
||
|
||
// 会话管理
|
||
async function loadConversations() {
|
||
const res = await fetch('/api/conversations');
|
||
const data = await res.json();
|
||
const conversations = data.conversations || [];
|
||
renderConversations(conversations);
|
||
if (!currentConversationId && conversations.length > 0) selectConversation(conversations[0].id);
|
||
}
|
||
|
||
function renderConversations(list) {
|
||
const container = document.getElementById('conversationList');
|
||
if (list.length === 0) { container.innerHTML = '<div style="text-align:center;color:#999;padding:40px;">暂无对话</div>'; return; }
|
||
container.innerHTML = list.map(c => `
|
||
<div class="conversation-item ${c.id === currentConversationId ? 'active' : ''}" onclick="selectConversation('${c.id}')">
|
||
<span class="title">${c.title || '新对话'}</span>
|
||
<button class="delete-btn" onclick="deleteConversation('${c.id}',event)"><i class="ri-delete-bin-line"></i></button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function selectConversation(id) {
|
||
currentConversationId = id;
|
||
renderConversations([]);
|
||
loadConversations();
|
||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ action: 'select_conversation', conversation_id: id }));
|
||
}
|
||
|
||
async function createNewConversation() {
|
||
// 检查是否已经是新建对话状态(无对话ID且无消息)
|
||
const container = document.getElementById('messagesContainer');
|
||
const hasMessages = container.querySelectorAll('.message').length > 0;
|
||
|
||
// 如果当前没有对话ID且没有消息,则不创建新对话
|
||
if (!currentConversationId && !hasMessages) {
|
||
return; // 已经是新建对话状态,无需创建
|
||
}
|
||
|
||
const res = await fetch('/api/conversations', { method: 'POST' });
|
||
const data = await res.json();
|
||
currentConversationId = data.id;
|
||
clearMessages();
|
||
loadConversations();
|
||
}
|
||
|
||
async function deleteConversation(id, e) {
|
||
e.stopPropagation();
|
||
if (!confirm('确定删除?')) return;
|
||
await fetch(`/api/conversations/${id}`, { method: 'DELETE' });
|
||
if (id === currentConversationId) { currentConversationId = null; clearMessages(); }
|
||
loadConversations();
|
||
}
|
||
|
||
// 发送消息
|
||
function sendMessage() {
|
||
const input = document.getElementById('messageInput');
|
||
const msg = input.value.trim();
|
||
if (!msg) return;
|
||
document.getElementById('sendBtn').disabled = true;
|
||
input.value = '';
|
||
input.style.height = 'auto';
|
||
|
||
// 立即显示用户消息(不等后端广播)
|
||
lastSentMessage = msg; // 记录最后发送的消息,避免重复显示
|
||
appendMessage('user', msg);
|
||
|
||
// 获取工具禁用状态
|
||
const enableSearch = document.getElementById('enableSearch').checked;
|
||
const disabledTools = [];
|
||
if (!enableSearch) disabledTools.push('search');
|
||
|
||
if (ws?.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({
|
||
action: 'chat',
|
||
message: msg,
|
||
conversation_id: currentConversationId,
|
||
agent_id: currentAgentId,
|
||
disabled_tools: disabledTools // 禁用的工具列表
|
||
}));
|
||
}
|
||
}
|
||
|
||
let lastSentMessage = null; // 记录最后发送的消息
|
||
|
||
function setupTextarea() {
|
||
const textarea = document.getElementById('messageInput');
|
||
textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
|
||
textarea.addEventListener('input', () => { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; });
|
||
}
|
||
|
||
// 快捷语句
|
||
function loadQuickPhrases() {
|
||
const saved = localStorage.getItem('quickPhrases');
|
||
quickPhrases = saved ? JSON.parse(saved) : ['请帮我总结一下', '帮我分析这个问题', '用简单的话解释一下', '给我举个例子', '有什么建议吗?'];
|
||
renderQuickPhrases();
|
||
}
|
||
|
||
function renderQuickPhrases() {
|
||
const container = document.getElementById('quickPhrasesList');
|
||
container.innerHTML = quickPhrases.map((p, i) => `
|
||
<span class="phrase-tag" onclick="usePhrase('${escapeHtml(p)}')">${p}<span class="tag-delete" onclick="deletePhrase(${i},event)"><i class="ri-close-line"></i></span></span>
|
||
`).join('');
|
||
}
|
||
|
||
function usePhrase(text) {
|
||
document.getElementById('messageInput').value = text;
|
||
document.getElementById('messageInput').focus();
|
||
}
|
||
|
||
function deletePhrase(index, e) {
|
||
e.stopPropagation();
|
||
quickPhrases.splice(index, 1);
|
||
localStorage.setItem('quickPhrases', JSON.stringify(quickPhrases));
|
||
renderQuickPhrases();
|
||
}
|
||
|
||
// 鼠标滚轮横向滚动
|
||
function scrollPhrases(e) {
|
||
const wrapper = document.getElementById('phraseListWrapper');
|
||
wrapper.scrollLeft += e.deltaY;
|
||
e.preventDefault();
|
||
}
|
||
|
||
function showAddPhraseModal() {
|
||
document.getElementById('phraseModal').classList.add('show');
|
||
document.getElementById('newPhraseInput').value = '';
|
||
document.getElementById('newPhraseInput').focus();
|
||
}
|
||
|
||
function hidePhraseModal() { document.getElementById('phraseModal').classList.remove('show'); }
|
||
|
||
function addPhrase() {
|
||
const phrase = document.getElementById('newPhraseInput').value.trim();
|
||
if (phrase) {
|
||
quickPhrases.push(phrase);
|
||
localStorage.setItem('quickPhrases', JSON.stringify(quickPhrases));
|
||
renderQuickPhrases();
|
||
hidePhraseModal();
|
||
}
|
||
}
|
||
|
||
document.getElementById('newPhraseInput').addEventListener('keydown', e => { if (e.key === 'Enter') addPhrase(); if (e.key === 'Escape') hidePhraseModal(); });
|
||
</script>
|
||
</body>
|
||
</html> |