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

1372 lines
72 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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; }
.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="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">
<div class="tool-toggle" id="toolToggleArea">
<input type="checkbox" id="enableSearch" checked onchange="showToolWarning()">
<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>
<!-- 图片放大弹窗 -->
<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(); // 加载大模型池
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 = [];
const enableSearch = document.getElementById('enableSearch').checked;
if (enableSearch && !checkAgentToolSupport('search')) {
unsupported.push('搜索');
}
return unsupported;
}
// 显示工具不支持提示
function showToolWarning() {
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`;
if (warningDiv) {
warningDiv.innerHTML = msg;
warningDiv.style.display = 'block';
} else {
const newWarning = document.createElement('div');
newWarning.id = 'tool-warning-tip';
newWarning.style.cssText = 'margin-top:8px;padding:8px 12px;background:#fff3cd;border:1px solid #ffc107;border-radius:6px;font-size:13px;color:#856404;';
newWarning.innerHTML = msg;
document.getElementById('toolToggleArea').appendChild(newWarning);
}
// 禁用发送按钮
document.getElementById('sendBtn').disabled = true;
} else {
if (warningDiv) 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();
} 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 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,
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>