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

1376 lines
71 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 对话系统 v3.1</title>
<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; min-height: 100vh; }
/* ===== 历史对话列表页 ===== */
.history-view { display: block; }
.history-view.hidden { display: none; }
.history-header {
background: #fff;
padding: 16px 24px;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.history-header h1 { font-size: 20px; font-weight: 600; color: #333; }
.history-header-right { display: flex; align-items: center; gap: 16px; }
.agent-selector-mini { display: flex; align-items: center; gap: 8px; }
.agent-selector-mini select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
min-width: 120px;
}
.new-chat-btn-top {
padding: 10px 20px;
background: #10a37f;
border: none;
border-radius: 8px;
color: #fff;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.2s;
}
.new-chat-btn-top:hover { background: #0d8c6d; }
.history-content {
padding: 24px;
max-width: 900px;
margin: 0 auto;
}
.history-empty {
text-align: center;
padding: 60px 20px;
color: #999;
}
.history-empty i { font-size: 48px; color: #ddd; margin-bottom: 16px; }
.history-empty p { font-size: 16px; }
.history-list { display: flex; flex-direction: column; gap: 12px; }
.history-item {
background: #fff;
border-radius: 12px;
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
}
.history-item:hover {
border-color: #10a37f;
box-shadow: 0 2px 8px rgba(16,163,127,0.1);
}
.history-item-left { flex: 1; }
.history-item-title { font-size: 16px; font-weight: 500; color: #333; margin-bottom: 6px; }
.history-item-meta { font-size: 13px; color: #999; }
.history-item-meta span { margin-right: 12px; }
.history-item-actions { display: flex; gap: 8px; }
.history-item-actions button {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f5f5f5;
border: none;
cursor: pointer;
color: #666;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.history-item-actions button:hover { background: #fee; color: #ff4757; }
/* ===== 对话页面 ===== */
.chat-view { display: none; height: 100vh; flex-direction: column; }
.chat-view.active { display: flex; }
.chat-header {
padding: 16px 24px;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.chat-header-left { display: flex; align-items: center; gap: 16px; }
.back-btn {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f5f5f5;
border: none;
cursor: pointer;
color: #666;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.back-btn:hover { background: #e8e8e8; color: #10a37f; }
.chat-header h1 { font-size: 18px; font-weight: 600; }
.header-controls { display: flex; align-items: center; gap: 16px; }
.ws-status { font-size: 12px; color: #666; background: #f0f0f0; padding: 4px 8px; border-radius: 4px; }
.ws-status.connected { color: #10a37f; }
/* Agent信息侧边栏 */
.chat-area { display: flex; flex: 1; overflow: hidden; }
.agent-info-sidebar {
width: 200px;
background: #f8f9fa;
border-right: 1px solid #e0e0e0;
padding: 16px;
display: flex;
flex-direction: column;
}
.agent-info-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.agent-avatar { width: 48px; height: 48px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; display: flex; align-items: center; justify-content: center; color: white; font-size: 24px; }
.agent-name-area { flex: 1; }
.agent-name-area h3 { font-size: 16px; margin: 0; color: #333; }
.agent-name-area small { color: #999; font-size: 12px; }
.agent-info-section { margin-top: 16px; }
.agent-info-section h4 { font-size: 13px; color: #666; margin: 0 0 8px 0; font-weight: 500; }
.agent-info-section p { font-size: 12px; color: #333; line-height: 1.5; margin: 0; }
.agent-capabilities { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.capability-tag { padding: 4px 8px; background: #e8f5e9; border-radius: 6px; font-size: 11px; color: #10a37f; }
.capability-tag.disabled { background: #f5f5f5; color: #999; }
.agent-model-info { margin-top: 12px; padding: 8px; background: white; border-radius: 8px; border: 1px solid #e0e0e0; }
.agent-model-info label { font-size: 11px; color: #999; }
.agent-model-info span { font-size: 12px; color: #333; display: block; margin-top: 2px; }
/* 消息容器 */
.messages-container { flex: 1; overflow-y: auto; padding: 24px; background: #fff; }
.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-info { font-size: 12px; color: #999; margin-top: 8px; }
/* 输入区域 */
.input-container { padding: 16px 24px; border-top: 1px solid #e5e5e5; background: #fff; }
.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="history-view" id="historyView">
<div class="history-header">
<h1><i class="ri-message-3-line" style="color:#10a37f"></i> AI 对话历史</h1>
<div class="history-header-right">
<div class="agent-selector-mini">
<select id="agentSelectMini" onchange="switchAgent()"><option value="">加载中...</option></select>
</div>
<button class="new-chat-btn-top" onclick="createNewConversation()"><i class="ri-add-line"></i> 新对话</button>
</div>
</div>
<div class="history-content">
<div class="history-list" id="historyList"></div>
</div>
</div>
<!-- ===== 对话页面 ===== -->
<div class="chat-view" id="chatView">
<div class="chat-header">
<div class="chat-header-left">
<button class="back-btn" onclick="goBackToHistory()" title="返回历史列表"><i class="ri-arrow-left-line"></i></button>
<h1 id="chatTitle">AI 对话</h1>
</div>
<div class="header-controls">
<div class="ws-status" id="wsStatus">连接中...</div>
</div>
</div>
<div class="chat-area">
<!-- Agent信息侧边栏 -->
<div class="agent-info-sidebar" id="agentInfoSidebar">
<div class="agent-info-header">
<div class="agent-avatar" id="agentAvatar">🤖</div>
<div class="agent-name-area">
<h3 id="agentDisplayName">加载中...</h3>
<small id="agentName">agent-name</small>
</div>
</div>
<div class="agent-info-section">
<h4>简介</h4>
<p id="agentDescription">-</p>
</div>
<div class="agent-info-section">
<h4>能力</h4>
<div class="agent-capabilities" id="agentCapabilities"></div>
</div>
<div class="agent-model-info">
<label>模型</label>
<span id="agentModelInfo">-</span>
</div>
</div>
<!-- 消息容器 -->
<div class="messages-container" id="messagesContainer">
<div class="welcome"><h2>👋 开始对话</h2><p>选择Agent开始聊天</p></div>
</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 id="tool-warning-tip" style="display:none;margin-top:4px;padding:6px 10px;background:#fff3cd;border:1px solid #ffc107;border-radius:6px;font-size:12px;color:#856404;"></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>
<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;
let messageVersionCounter = 0;
let messageVersions = {};
let currentConversationTitle = '新对话';
document.addEventListener('DOMContentLoaded', () => {
loadProviders();
loadAgents();
loadQuickPhrases();
connectWebSocket();
loadConversations();
setupTextarea();
});
// ===== 页面切换 =====
function showHistoryView() {
document.getElementById('historyView').classList.remove('hidden');
document.getElementById('chatView').classList.remove('active');
}
function showChatView() {
document.getElementById('historyView').classList.add('hidden');
document.getElementById('chatView').classList.add('active');
}
function goBackToHistory() {
showHistoryView();
}
// ===== Provider & Agent =====
async function loadProviders() {
try {
const res = await fetch('/api/v2/providers');
const data = await res.json();
providers = data.providers || [];
if (agents.length > 0) renderAgentInfoSidebar();
} catch (e) { console.error('加载Provider失败:', e); }
}
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;
}
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;
renderAgentSelectMini();
renderAgentInfoSidebar();
} catch (e) { console.error('加载Agent失败:', e); }
}
function renderAgentInfoSidebar() {
const agent = agents.find(a => a.id === currentAgentId);
if (!agent) return;
document.getElementById('agentDisplayName').textContent = agent.display_name || agent.name;
document.getElementById('agentName').textContent = agent.name;
const avatar = document.getElementById('agentAvatar');
avatar.textContent = agent.display_name?.charAt(0) || agent.name?.charAt(0) || '🤖';
document.getElementById('agentDescription').textContent = agent.description || '暂无描述';
const capabilitiesHtml = [];
const provider = providers.find(p => p.id === agent.llm_provider_id);
if (provider) {
if (provider.supports_thinking) capabilitiesHtml.push('<span class="capability-tag"><i class="ri-lightbulb-line"></i> 思考</span>');
if (provider.supports_vision) capabilitiesHtml.push('<span class="capability-tag"><i class="ri-image-line"></i> 视觉</span>');
if (provider.supports_function_calling) capabilitiesHtml.push('<span class="capability-tag"><i class="ri-tools-line"></i> 工具调用</span>');
else capabilitiesHtml.push('<span class="capability-tag disabled"><i class="ri-tools-line"></i> 工具(手动)</span>');
const model = agent.model_override || provider.default_model || 'auto';
document.getElementById('agentModelInfo').textContent = model;
}
const agentTools = agent.tools || [];
if (agentTools.includes('search')) capabilitiesHtml.push('<span class="capability-tag"><i class="ri-search-line"></i> 搜索</span>');
document.getElementById('agentCapabilities').innerHTML = capabilitiesHtml.join('') || '<span class="capability-tag disabled">基础对话</span>';
}
function renderAgentSelectMini() {
const select = document.getElementById('agentSelectMini');
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('');
}
async function switchAgent() {
const newAgentId = parseInt(document.getElementById('agentSelectMini').value);
if (newAgentId && newAgentId !== currentAgentId) {
currentAgentId = newAgentId;
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ action: 'switch_agent', agent_id: currentAgentId }));
await createNewConversation();
renderAgentInfoSidebar();
}
}
// ===== 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) currentAgentId = data.agent_id;
renderAgentSelectMini();
renderAgentInfoSidebar();
break;
case 'conversation_created':
currentConversationId = data.conversation_id;
loadConversations();
break;
case 'new_conversation':
currentConversationId = data.conversation_id;
loadConversations();
clearMessages();
showChatView();
break;
case 'agent_switched':
currentAgentId = data.agent_id;
renderAgentSelectMini();
renderAgentInfoSidebar();
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;
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') {
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>`;
}
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>`;
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>`;
if (role === 'assistant' && agentName) {
html += `<div class="agent-info"><i class="ri-robot-line"></i> ${agentName}</div>`;
}
html += '</div>';
div.innerHTML = html;
if (role === 'user' && extraData) {
const bodyDiv = div.querySelector('.message-body');
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) {
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 {
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) {
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) {
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);
}
function showLoadingIndicator(messageId) {
const container = document.getElementById(`${messageId}_container`);
if (container) {
container.querySelectorAll('.response-version').forEach(v => v.classList.remove('active'));
const loadingHtml = `<div class="loading-indicator" id="${messageId}_loading">
<div class="loading-spinner"></div>
<span>正在生成...</span>
</div>`;
container.insertAdjacentHTML('beforeend', loadingHtml);
}
}
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) {
const actionsDiv = btn.parentElement;
const messageBody = actionsDiv.parentElement;
const hiddenInput = messageBody.querySelector('.copy-source');
if (!hiddenInput) { 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;
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 };
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>
`;
const textarea = contentDiv.querySelector('.edit-textarea');
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
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;
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 {
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 => appendMessage(m.role, m.content, m.thinking_content, null, m.extra_data));
}
function clearMessages() {
document.getElementById('messagesContainer').innerHTML = '<div class="welcome"><h2>👋 开始对话</h2></div>';
document.getElementById('chatTitle').textContent = '新对话';
}
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 {
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 || [];
renderHistoryList(conversations);
}
function renderHistoryList(list) {
const container = document.getElementById('historyList');
if (list.length === 0) {
container.innerHTML = `<div class="history-empty">
<i class="ri-message-3-line"></i>
<p>暂无对话记录</p>
<p style="font-size:14px;margin-top:8px">点击右上角「新对话」开始聊天</p>
</div>`;
return;
}
container.innerHTML = list.map(c => {
const title = c.title || '新对话';
const time = formatTime(c.updated_at || c.created_at);
const msgCount = c.message_count || 0;
return `<div class="history-item" onclick="selectConversation('${c.id}')">
<div class="history-item-left">
<div class="history-item-title">${escapeHtml(title)}</div>
<div class="history-item-meta">
<span><i class="ri-time-line"></i> ${time}</span>
<span><i class="ri-message-2-line"></i> ${msgCount}条</span>
</div>
</div>
<div class="history-item-actions">
<button onclick="deleteConversation('${c.id}',event)" title="删除"><i class="ri-delete-bin-line"></i></button>
</div>
</div>`;
}).join('');
}
function formatTime(timestamp) {
if (!timestamp) return '未知时间';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
// 今天:显示时间
if (diff < 24 * 60 * 60 * 1000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
// 昨天
if (diff < 48 * 60 * 60 * 1000 && date.getDate() === now.getDate() - 1) {
return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
// 本周
if (diff < 7 * 24 * 60 * 60 * 1000) {
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return weekdays[date.getDay()] + ' ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
// 其他:显示日期
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
}
async function selectConversation(id) {
currentConversationId = id;
showChatView();
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'select_conversation', conversation_id: id }));
}
// 获取对话标题
const res = await fetch(`/api/conversations/${id}`);
const data = await res.json();
currentConversationTitle = data.title || '新对话';
document.getElementById('chatTitle').textContent = currentConversationTitle;
}
async function createNewConversation() {
// 检查是否已经是新建状态
const container = document.getElementById('messagesContainer');
const hasMessages = container.querySelectorAll('.message').length > 0;
if (!currentConversationId && !hasMessages) return;
const res = await fetch('/api/conversations', { method: 'POST' });
const data = await res.json();
currentConversationId = data.id;
currentConversationTitle = '新对话';
clearMessages();
showChatView();
loadConversations();
}
async function deleteConversation(id, e) {
e.stopPropagation();
if (!confirm('确定删除此对话?')) return;
await fetch(`/api/conversations/${id}`, { method: 'DELETE' });
if (id === currentConversationId) {
currentConversationId = null;
clearMessages();
showHistoryView();
}
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或者移除图片后再发送。`);
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 = '';
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
action: 'chat',
message: msg,
conversation_id: currentConversationId,
agent_id: currentAgentId,
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/')) {
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, 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');
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);
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);
}
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() {
document.getElementById('imageLightbox').classList.remove('show');
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeImageLightbox(); });
</script>
</body>
</html>