1376 lines
71 KiB
HTML
1376 lines
71 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>AI 对话系统 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, '"')}">`;
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function displayHistory(messages) {
|
||
const container = document.getElementById('messagesContainer');
|
||
container.innerHTML = '';
|
||
messages.forEach(m => appendMessage(m.role, m.content, m.thinking_content, 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> |