Files
ai-chat-system/templates/index.html
T

1497 lines
78 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; }
.action-btn.edit:hover { color: #10a37f; border-color: #10a37f; }
/* 用户消息编辑样式 */
.edit-textarea:focus { outline: none; border-color: #10a37f; box-shadow: 0 0 0 2px rgba(16,163,127,0.1); }
/* 版本切换控件 - 简洁版 */
.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; }
.upload-btn { width: 48px; height: 48px; border-radius: 12px; background: #f5f5f5; border: 1px solid #ddd; cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; color: #666; }
.upload-btn:hover { background: #e8e8e8; border-color: #10a37f; 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; }
/* 文件预览 */
.file-preview-area { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.file-preview-item { position: relative; border: 1px solid #e0e0e0; border-radius: 8px; padding: 8px; background: #f8f9fa; max-width: 200px; }
.file-preview-item.image-preview { padding: 4px; }
.file-preview-item img { max-width: 180px; max-height: 120px; border-radius: 6px; }
.file-preview-item .file-icon { display: flex; align-items: center; gap: 8px; }
.file-preview-item .file-icon i { font-size: 24px; color: #10a37f; }
.file-preview-item .file-name { font-size: 12px; color: #666; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-preview-item .file-remove { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 50%; background: #ff4757; color: white; border: none; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; }
.file-preview-item .file-remove:hover { background: #ff6b7a; }
/* 快捷语句 - 横向扁平 */
.quick-phrases-bar { display: flex; align-items: center; gap: 8px; margin-top: 12px; position: relative; }
/* 工具折叠面板 */
.tools-collapsible { position: relative; margin-bottom: 8px; }
.tools-toggle-btn {
padding: 8px 12px; background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 8px;
cursor: pointer; display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #666;
transition: all 0.2s;
}
.tools-toggle-btn:hover { background: #e8e8e8; border-color: #10a37f; color: #10a37f; }
.tools-toggle-btn.active { background: #e8f5e9; border-color: #10a37f; color: #10a37f; }
.tools-badge {
background: #10a37f; color: white; font-size: 11px; padding: 1px 5px; border-radius: 10px;
min-width: 16px; text-align: center;
}
.tools-panel {
display: none; position: absolute; bottom: 100%; left: 0; margin-bottom: 8px;
background: white; border: 1px solid #e0e0e0; border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15); min-width: 200px; z-index: 100;
}
.tools-panel.show { display: block; }
.tools-panel-header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; border-bottom: 1px solid #eee; font-weight: 500; color: #333;
}
.tools-panel-header button { background: none; border: none; color: #999; cursor: pointer; padding: 4px; }
.tools-panel-header button:hover { color: #666; }
.tools-panel-content { padding: 12px 16px; max-height: 300px; overflow-y: auto; }
.tool-item { display: flex; align-items: center; gap: 8px; padding: 8px 0; }
.tool-item input { width: 16px; height: 16px; }
.tool-item label { cursor: pointer; font-size: 14px; color: #333; display: flex; align-items: center; gap: 6px; }
.tool-item label i { color: #10a37f; }
.tool-item.disabled { opacity: 0.5; }
.tool-item.disabled label { color: #999; cursor: not-allowed; }
.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; }
/* 图片放大弹窗 */
.image-lightbox { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.9); display: none; align-items: center; justify-content: center; z-index: 2000; cursor: zoom-out; }
.image-lightbox.show { display: flex; }
.image-lightbox img { max-width: 90%; max-height: 90%; border-radius: 8px; box-shadow: 0 0 30px rgba(255,255,255,0.2); }
.image-lightbox-close { position: absolute; top: 20px; right: 20px; width: 40px; height: 40px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 20px; cursor: pointer; transition: background 0.2s; }
.image-lightbox-close:hover { background: rgba(255,255,255,0.3); }
/* 对话中的图片可点击 */
.uploaded-image img { cursor: zoom-in; transition: transform 0.2s; }
.uploaded-image img:hover { transform: scale(1.02); }
.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="tools-collapsible">
<button class="tools-toggle-btn" onclick="toggleToolsPanel()" title="工具设置">
<i class="ri-tools-line"></i>
<span class="tools-badge" id="toolsBadge" style="display:none;">0</span>
</button>
<div class="tools-panel" id="toolsPanel">
<div class="tools-panel-header">
<span>工具设置</span>
<button onclick="toggleToolsPanel()"><i class="ri-close-line"></i></button>
</div>
<div class="tools-panel-content" id="toolsPanelContent"></div>
</div>
<!-- 工具警告提示 -->
<div id="tool-warning-tip" style="display:none;margin-top:8px;padding:8px 12px;background:#fff3cd;border:1px solid #ffc107;border-radius:6px;font-size:13px;color:#856404;"></div>
</div>
<div class="input-row">
<input type="file" id="fileInput" multiple accept="image/*,.pdf,.txt,.md,.json,.csv,.doc,.docx" style="display:none" onchange="handleFileUpload(event)">
<button class="upload-btn" onclick="document.getElementById('fileInput').click()" title="上传文件"><i class="ri-attachment-2"></i></button>
<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="file-preview-area" id="filePreviewArea"></div>
<div class="quick-phrases-bar">
<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>
<!-- 图片放大弹窗 -->
<div class="image-lightbox" id="imageLightbox" onclick="closeImageLightbox()">
<div class="image-lightbox-close"><i class="ri-close-line"></i></div>
<img id="lightboxImage" src="" alt="放大图片">
</div>
<!-- 隐藏的图片上传API处理 -->
<script>
// 图片上传到服务器(保存文件)
async function uploadImageToServer(base64Data, fileName) {
try {
const response = await fetch('/api/v2/upload-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Data, name: fileName })
});
const result = await response.json();
if (result.success) {
return result.path; // 返回服务器文件路径
}
return null;
} catch (e) {
console.error('图片上传失败:', e);
return null;
}
}
</script>
<!-- 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 providers = []; // 大模型池数据
let quickPhrases = [];
let lastSentMessage = null; // 记录最后发送的消息
let lastSentFiles = null; // 记录发送的文件
let lastSentMessageWithFiles = null; // 记录包含文件信息的完整消息
let pendingFiles = []; // 待发送的文件
let lastUserMessage = null; // 存储最后一条用户消息,用于重新生成
let isRegenerating = false; // 标志:正在重新生成,跳过用户消息显示
let regeneratingMessageId = null; // 正在重新生成的消息ID
let messageVersionCounter = 0; // 消息版本计数器
let messageVersions = {}; // 存储每个assistant消息的多个版本 { messageId: [{content, thinking}] }
document.addEventListener('DOMContentLoaded', () => {
loadProviders(); // 加载大模型池
loadToolsData(); // 加载工具列表
loadAgents();
loadQuickPhrases();
connectWebSocket();
loadConversations();
setupTextarea();
});
// 加载大模型池
async function loadProviders() {
try {
const res = await fetch('/api/v2/providers');
const data = await res.json();
providers = data.providers || [];
// 加载后检查工具支持(如果agents已加载)
if (agents.length > 0) showToolWarning();
} catch (e) { console.error('加载Provider失败:', e); }
}
// 检查当前Agent是否支持视觉
function checkAgentVisionSupport() {
const agent = agents.find(a => a.id === currentAgentId);
if (!agent) return false;
const provider = providers.find(p => p.id === agent.llm_provider_id);
return provider?.supports_vision || false;
}
// 检查当前Agent是否支持某个工具
function checkAgentToolSupport(toolType) {
const agent = agents.find(a => a.id === currentAgentId);
if (!agent) return false;
const agentTools = agent.tools || [];
return agentTools.includes(toolType);
}
// 获取当前Agent不支持的工具列表(用户已启用但Agent不支持)
function getUnsupportedTools() {
const unsupported = [];
// 检查所有工具checkbox(排除disabled的)
const toolCheckboxes = document.querySelectorAll('.tool-checkbox:not([disabled])');
toolCheckboxes.forEach(cb => {
if (cb.checked) {
// 检查Agent是否支持
if (!checkAgentToolSupport(cb.dataset.toolType)) {
// 获取工具显示名称
const label = cb.closest('.tool-item')?.querySelector('label')?.textContent?.trim() || cb.dataset.toolType;
unsupported.push(label);
}
}
});
return unsupported;
}
// 渲染工具选择区域(根据系统工具列表)
function renderToolToggles() {
const container = document.getElementById('toolsPanelContent');
if (!container || toolsData.length === 0) return;
// 获取当前Agent支持的工具
const agent = agents.find(a => a.id === currentAgentId);
const agentTools = agent?.tools || [];
// 渲染工具checkbox列表
let html = '';
toolsData.filter(t => t.is_active).forEach(t => {
const toolType = t.tool_type || 'unknown';
const isSupported = agentTools.includes(toolType);
const icon = getToolIconFrontend(toolType);
const disabledClass = isSupported ? '' : 'disabled';
html += `<div class="tool-item ${disabledClass}">
<input type="checkbox" class="tool-checkbox" id="tool-${toolType}" data-tool-type="${toolType}" ${isSupported ? 'checked' : ''} ${!isSupported ? 'disabled' : ''} onchange="showToolWarning()">
<label for="tool-${toolType}"><i class="${icon}"></i> ${t.name}</label>
</div>`;
});
container.innerHTML = html || '<div class="text-muted" style="padding:8px;">暂无可用工具</div>';
// 更新工具数量badge
updateToolsBadge();
}
// 更新工具数量badge
function updateToolsBadge() {
const badge = document.getElementById('toolsBadge');
const selectedCount = document.querySelectorAll('.tool-checkbox:checked').length;
if (selectedCount > 0) {
badge.textContent = selectedCount;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
// 切换工具面板显示
function toggleToolsPanel() {
const panel = document.getElementById('toolsPanel');
const btn = document.querySelector('.tools-toggle-btn');
panel.classList.toggle('show');
btn.classList.toggle('active');
}
// 前端工具图标
function getToolIconFrontend(toolType) {
const icons = {
'search': 'ri-search-line',
'calculator': 'ri-calculator-line',
'code': 'ri-code-line',
'image': 'ri-image-line',
'web': 'ri-global-line'
};
return icons[toolType] || 'ri-tools-line';
}
// 加载工具列表
let toolsData = [];
async function loadToolsData() {
try {
const res = await fetch('/api/v2/tools');
const data = await res.json();
toolsData = data.tools || [];
renderToolToggles();
} catch (e) { console.error('加载工具列表失败:', e); }
}
// 显示工具不支持提示
function showToolWarning() {
updateToolsBadge(); // 更新badge数量
const unsupported = getUnsupportedTools();
const warningDiv = document.getElementById('tool-warning-tip');
if (unsupported.length > 0) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
const msg = `<i class="ri-alert-line"></i> <strong>${agentName}</strong> 不支持 <strong>${unsupported.join('、')}</strong> 工具,请关闭或切换Agent`;
warningDiv.innerHTML = msg;
warningDiv.style.display = 'block';
// 禁用发送按钮
document.getElementById('sendBtn').disabled = true;
} else {
warningDiv.style.display = 'none';
document.getElementById('sendBtn').disabled = false;
}
}
// 加载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();
// 加载后检查工具支持
showToolWarning();
// 渲染工具选择区域
renderToolToggles();
} 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();
// 切换Agent后检查工具支持
showToolWarning();
}
}
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 && data.message.content !== lastSentMessageWithFiles) {
appendMessage('user', data.message.content);
}
lastSentMessage = null;
lastSentMessageWithFiles = 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, extraData = 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, '&quot;')}">`;
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>`;
} else {
// 用户消息添加编辑按钮
html += `<button class="action-btn edit" onclick="editUserMessage(this)" title="编辑并重新发送"><i class="ri-edit-line"></i> 编辑</button>`;
}
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;
// 如果是用户消息且有额外数据(搜索结果、图片、文件),在设置innerHTML后追加
if (role === 'user' && extraData) {
console.log('Processing extraData for user message:', extraData);
const bodyDiv = div.querySelector('.message-body');
// 处理图片(如果有服务器URL,显示图片)
if (extraData.images && extraData.images.length > 0) {
let imagesHtml = '<div class="history-images" style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">';
for (const img of extraData.images) {
if (img.url) {
// 有服务器URL,显示真实图片
imagesHtml += `<div class="history-image" style="display:inline-block;">
<img src="${img.url}" style="max-width:300px;max-height:200px;border-radius:8px;cursor:zoom-in;" onclick="openImageLightbox('${img.url}')">
</div>`;
} else {
// 没有URL,显示占位符
imagesHtml += `<div class="history-image-placeholder" style="padding:8px 12px;background:#f0f0f0;border-radius:8px;display:flex;align-items:center;gap:6px;font-size:13px;color:#666;">
<i class="ri-image-line" style="color:#10a37f;"></i>
<span>${escapeHtml(img.name || '图片')}</span>
</div>`;
}
}
imagesHtml += '</div>';
if (bodyDiv) bodyDiv.insertAdjacentHTML('beforeend', imagesHtml);
}
// 处理文本文件
if (extraData.files && extraData.files.length > 0) {
let filesHtml = '<div class="history-files" style="margin-top:8px;">';
for (const f of extraData.files) {
filesHtml += `<div class="history-file-placeholder" style="padding:6px 10px;background:#f5f5f5;border-radius:6px;margin-bottom:4px;display:flex;align-items:center;gap:6px;font-size:12px;color:#666;">
<i class="ri-file-text-line" style="color:#10a37f;"></i>
<span>${escapeHtml(f.name || '文件')}</span>
</div>`;
}
filesHtml += '</div>';
if (bodyDiv) bodyDiv.insertAdjacentHTML('beforeend', filesHtml);
}
// 处理搜索结果
if (extraData.search_results && extraData.search_results.length > 0) {
console.log('Building search results HTML for', extraData.search_results.length, 'results');
const searchHtml = buildSearchResultsHtml(extraData.search_results, extraData.search_query || content);
if (bodyDiv) bodyDiv.insertAdjacentHTML('beforeend', searchHtml);
}
}
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
}));
}
}
// 编辑用户消息并重新发送
let editingUserMessage = null; // 正在编辑的用户消息元素
function editUserMessage(btn) {
const messageDiv = btn.closest('.message.user');
if (!messageDiv) return;
// 获取消息内容
const contentDiv = messageDiv.querySelector('.user-message-text');
const originalContent = contentDiv.textContent;
const actionsDiv = messageDiv.querySelector('.message-actions');
// 保存原始状态用于取消
editingUserMessage = {
element: messageDiv,
originalContent: originalContent,
contentDiv: contentDiv,
actionsDiv: actionsDiv
};
// 将消息文本变成可编辑的textarea
contentDiv.innerHTML = `<textarea class="edit-textarea" style="width:100%;min-height:60px;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:15px;resize:vertical;">${escapeHtml(originalContent)}</textarea>`;
// 隐藏原有操作按钮,显示编辑操作按钮
actionsDiv.innerHTML = `
<button class="action-btn" onclick="confirmEditMessage()" style="background:#10a37f;color:white;border-color:#10a37f;"><i class="ri-check-line"></i> 确认</button>
<button class="action-btn" onclick="cancelEditMessage()"><i class="ri-close-line"></i> 取消</button>
`;
// 聚焦到textarea
const textarea = contentDiv.querySelector('.edit-textarea');
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
// 添加Ctrl+Enter快捷键确认
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
confirmEditMessage();
}
if (e.key === 'Escape') {
e.preventDefault();
cancelEditMessage();
}
});
}
function cancelEditMessage() {
if (!editingUserMessage) return;
// 恢复原始内容
editingUserMessage.contentDiv.innerHTML = escapeHtml(editingUserMessage.originalContent);
// 恢复原始操作按钮
editingUserMessage.actionsDiv.innerHTML = `
<button class="action-btn" onclick="copyMessage(this)"><i class="ri-file-copy-line"></i> 复制</button>
<button class="action-btn edit" onclick="editUserMessage(this)" title="编辑并重新发送"><i class="ri-edit-line"></i> 编辑</button>
`;
editingUserMessage = null;
}
function confirmEditMessage() {
if (!editingUserMessage) return;
const textarea = editingUserMessage.contentDiv.querySelector('.edit-textarea');
const newContent = textarea.value.trim();
if (!newContent) {
alert('消息内容不能为空');
return;
}
// 如果内容没有变化,直接取消编辑
if (newContent === editingUserMessage.originalContent) {
cancelEditMessage();
return;
}
// 更新消息内容
editingUserMessage.contentDiv.innerHTML = escapeHtml(newContent);
// 恢复操作按钮
editingUserMessage.actionsDiv.innerHTML = `
<button class="action-btn" onclick="copyMessage(this)"><i class="ri-file-copy-line"></i> 复制</button>
<button class="action-btn edit" onclick="editUserMessage(this)" title="编辑并重新发送"><i class="ri-edit-line"></i> 编辑</button>
`;
// 更新最后用户消息记录
lastUserMessage = newContent;
// 找到下一条assistant消息,设置重新生成标志
const container = document.getElementById('messagesContainer');
const allMessages = container.querySelectorAll('.message');
let nextAssistantId = null;
for (let i = 0; i < allMessages.length; i++) {
if (allMessages[i] === editingUserMessage.element && i + 1 < allMessages.length) {
const nextMsg = allMessages[i + 1];
if (nextMsg.classList.contains('assistant')) {
nextAssistantId = nextMsg.id || nextMsg.dataset.messageId;
}
break;
}
}
if (nextAssistantId) {
isRegenerating = true;
regeneratingMessageId = nextAssistantId;
showLoadingIndicator(nextAssistantId);
} else {
// 没有assistant消息,需要创建新的
isRegenerating = false;
regeneratingMessageId = null;
}
editingUserMessage = null;
// 重新发送编辑后的消息
document.getElementById('sendBtn').disabled = true;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
action: 'chat',
message: newContent,
conversation_id: currentConversationId,
agent_id: currentAgentId
}));
}
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function displayHistory(messages) {
const container = document.getElementById('messagesContainer');
container.innerHTML = '';
messages.forEach(m => {
console.log('displayHistory message:', m.role, 'extra_data:', m.extra_data);
appendMessage(m.role, m.content, m.thinking_content, null, m.extra_data);
});
}
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 && pendingFiles.length === 0) return;
// 检查是否有图片上传
const hasImages = pendingFiles.some(f => f.type && f.type.startsWith('image/'));
if (hasImages && !checkAgentVisionSupport()) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
alert(`⚠️ ${agentName} 不支持图片识别功能\n\n请选择支持视觉能力的Agent(如 vlm-agent),或者移除图片后再发送。`);
document.getElementById('sendBtn').disabled = false;
return;
}
// 检查工具支持
const unsupported = getUnsupportedTools();
if (unsupported.length > 0) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
alert(`⚠️ ${agentName} 不支持 ${unsupported.join('、')} 工具\n\n请关闭不支持的工具,或切换到支持该工具的Agent。`);
document.getElementById('sendBtn').disabled = false;
return;
}
document.getElementById('sendBtn').disabled = true;
input.value = '';
input.style.height = 'auto';
// 立即显示用户消息(包含文件)
lastSentMessage = msg;
appendMessageWithFiles('user', msg, pendingFiles);
// 清空文件预览
pendingFiles = [];
document.getElementById('filePreviewArea').innerHTML = '';
// 获取禁用的工具列表(所有系统工具 - 用户选中的工具)
const disabledTools = [];
const allToolTypes = toolsData.filter(t => t.is_active).map(t => t.tool_type);
const selectedTools = Array.from(document.querySelectorAll('.tool-checkbox:checked')).map(cb => cb.dataset.toolType);
allToolTypes.forEach(t => {
if (!selectedTools.includes(t)) disabledTools.push(t);
});
// 发送消息(包含文件)
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
action: 'chat',
message: msg,
conversation_id: currentConversationId,
agent_id: currentAgentId,
disabled_tools: disabledTools,
files: lastSentFiles || [] // 发送的文件列表
}));
}
lastSentFiles = null; // 清空
}
// 显示带文件的用户消息
function appendMessageWithFiles(role, content, files) {
const container = document.getElementById('messagesContainer');
container.querySelector('.welcome')?.remove();
const div = document.createElement('div');
div.className = `message ${role}`;
const avatar = role === 'user' ? '👤' : '🤖';
let html = `<div class="message-avatar">${avatar}</div><div class="message-body">`;
// 显示文本内容
if (content) {
html += `<div class="message-content"><div class="user-message-text">${escapeHtml(content)}</div></div>`;
}
// 显示文件(图片直接显示,文本文件显示名称)
if (files && files.length > 0) {
html += '<div class="uploaded-files" style="margin-top:8px">';
lastSentFiles = files.map(f => ({
name: f.name,
type: f.type,
content: f.content,
serverPath: f.serverPath // 服务器路径(用于历史记录)
}));
for (const f of files) {
if (f.type.startsWith('image/')) {
// 图片直接显示(用服务器路径或base64)
const imgSrc = f.serverPath || f.content;
html += `<div class="uploaded-image" style="margin-bottom:8px"><img src="${f.content}" style="max-width:300px;border-radius:8px" onclick="openImageLightbox('${imgSrc}')"></div>`;
} else {
// 文本文件显示名称和内容摘要
html += `<div class="uploaded-file" style="padding:8px;background:#f5f5f5;border-radius:6px;margin-bottom:8px">`;
html += `<div style="font-size:13px;color:#10a37f"><i class="ri-file-text-line"></i> ${f.name}</div>`;
if (f.content && f.content.length < 2000) {
html += `<div style="font-size:12px;color:#666;margin-top:4px">${escapeHtml(f.content.substring(0, 300))}${f.content.length > 300 ? '...' : ''}</div>`;
}
html += '</div>';
}
}
html += '</div>';
// 记录完整消息(用于判断是否重复显示)
lastSentMessageWithFiles = content + '\n[文件:' + files.map(f => f.name).join(',') + ']';
}
html += '</div>';
div.innerHTML = html;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
// 文件上传处理
async function handleFileUpload(event) {
const files = event.target.files;
const previewArea = document.getElementById('filePreviewArea');
for (const file of files) {
const fileId = 'file-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
// 读取文件内容
const reader = new FileReader();
reader.onload = async (e) => {
const base64Content = e.target.result;
// 图片:先上传到服务器保存
let serverPath = null;
if (file.type.startsWith('image/')) {
serverPath = await uploadImageToServer(base64Content, file.name);
console.log('图片上传结果:', serverPath);
}
const fileData = {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
content: base64Content, // base64数据(用于多模态模型)
serverPath: serverPath // 服务器路径(用于历史记录显示)
};
pendingFiles.push(fileData);
// 显示预览
const previewItem = document.createElement('div');
previewItem.className = 'file-preview-item';
previewItem.id = fileId + '-preview';
if (file.type.startsWith('image/')) {
previewItem.classList.add('image-preview');
// 预览用本地base64,显示更快
previewItem.innerHTML = `
<img src="${base64Content}" alt="${file.name}" style="cursor:pointer" onclick="openImageLightbox('${serverPath || base64Content}')">
<button class="file-remove" onclick="removeFile('${fileId}')"><i class="ri-close-line"></i></button>
`;
} else {
// 文件图标
let iconClass = 'ri-file-text-line';
if (file.name.endsWith('.pdf')) iconClass = 'ri-file-pdf-line';
else if (file.name.endsWith('.json')) iconClass = 'ri-code-line';
else if (file.name.endsWith('.csv')) iconClass = 'ri-table-line';
else if (file.type.includes('word') || file.name.endsWith('.doc') || file.name.endsWith('.docx')) iconClass = 'ri-file-word-line';
previewItem.innerHTML = `
<div class="file-icon">
<i class="${iconClass}"></i>
<span class="file-name">${file.name}</span>
</div>
<button class="file-remove" onclick="removeFile('${fileId}')"><i class="ri-close-line"></i></button>
`;
}
previewArea.appendChild(previewItem);
// 如果上传的是图片且当前Agent不支持视觉,显示警告提示
if (file.type.startsWith('image/') && !checkAgentVisionSupport()) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
const warningDiv = document.createElement('div');
warningDiv.className = 'vision-warning';
warningDiv.id = 'vision-warning-tip';
warningDiv.style.cssText = 'margin-top:8px;padding:8px 12px;background:#fff3cd;border:1px solid #ffc107;border-radius:6px;font-size:13px;color:#856404;';
warningDiv.innerHTML = `<i class="ri-alert-line"></i> <strong>${agentName}</strong> 不支持图片识别,请选择支持视觉的Agent或移除图片`;
// 避免重复添加
if (!document.getElementById('vision-warning-tip')) {
previewArea.appendChild(warningDiv);
}
}
};
// 根据文件类型选择读取方式
if (file.type.startsWith('image/')) {
reader.readAsDataURL(file);
} else if (file.type === 'application/pdf') {
reader.readAsDataURL(file);
} else {
// 文本类文件直接读取内容
reader.readAsText(file);
}
}
// 清空 input 以便再次选择
event.target.value = '';
}
function removeFile(fileId) {
pendingFiles = pendingFiles.filter(f => f.id !== fileId);
const preview = document.getElementById(fileId + '-preview');
if (preview) preview.remove();
// 如果没有图片了,移除视觉警告提示
const hasImages = pendingFiles.some(f => f.type && f.type.startsWith('image/'));
if (!hasImages) {
const warning = document.getElementById('vision-warning-tip');
if (warning) warning.remove();
}
}
// 发送消息
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(); });
// 图片放大弹窗
function openImageLightbox(imageSrc) {
const lightbox = document.getElementById('imageLightbox');
const lightboxImg = document.getElementById('lightboxImage');
lightboxImg.src = imageSrc;
lightbox.classList.add('show');
}
function closeImageLightbox() {
const lightbox = document.getElementById('imageLightbox');
lightbox.classList.remove('show');
}
// ESC键关闭图片弹窗
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeImageLightbox();
}
});
</script>
</body>
</html>