Compare commits

..

6 Commits

7 changed files with 533 additions and 36 deletions

View File

@@ -3,7 +3,7 @@ AI对话系统 v2.0.0 - 主应用
支持大模型池、Agent管理、渠道独立绑定、思考功能开关
"""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
@@ -13,6 +13,9 @@ import json
import logging
from datetime import datetime
import os
import base64
import uuid
import time
# 使用新的数据模型
from models_v2 import (
@@ -34,7 +37,14 @@ app = FastAPI(title="AI对话系统 v2.0", version="2.0.0")
# 静态文件和模板
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOADS_DIR = os.path.join(BASE_DIR, "uploads", "images")
# 确保上传目录存在
os.makedirs(UPLOADS_DIR, exist_ok=True)
# 静态文件服务
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
app.mount("/uploads", StaticFiles(directory=os.path.join(BASE_DIR, "uploads")), name="uploads")
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
# WebSocket连接管理
@@ -110,6 +120,8 @@ async def get_providers(db: Session = Depends(get_db)):
"default_model": p.default_model,
"supports_thinking": p.supports_thinking,
"thinking_model": p.thinking_model,
"supports_vision": p.supports_vision,
"vision_model": p.vision_model,
"max_tokens": p.max_tokens,
"temperature": p.temperature,
"is_active": p.is_active,
@@ -575,6 +587,56 @@ async def perform_search(data: dict, db: Session = Depends(get_db)):
return {"success": False, "message": "不支持的搜索提供商"}
# ==================== 图片上传 API ====================
@app.post("/api/v2/upload-image")
async def upload_image(data: dict):
"""上传图片到服务器,返回文件路径"""
try:
image_data = data.get('image')
file_name = data.get('name', 'image.png')
if not image_data:
return {"success": False, "message": "缺少图片数据"}
# 解析 base64 数据
if image_data.startswith('data:image/'):
# 提取格式和base64内容
header, base64_content = image_data.split(',', 1)
# 从header中提取图片格式
format_match = header.split(':')[1].split(';')[0] # 如 'image/png'
ext = format_match.split('/')[1] if '/' in format_match else 'png'
else:
base64_content = image_data
ext = 'png'
# 生成唯一文件名
timestamp = int(time.time())
unique_id = uuid.uuid4().hex[:8]
safe_name = f"{timestamp}_{unique_id}.{ext}"
# 保存文件
file_path = os.path.join(UPLOADS_DIR, safe_name)
image_bytes = base64.b64decode(base64_content)
# 检查文件大小限制10MB
if len(image_bytes) > 10 * 1024 * 1024:
return {"success": False, "message": "图片大小超过10MB限制"}
with open(file_path, 'wb') as f:
f.write(image_bytes)
# 返回可访问的URL路径
url_path = f"/uploads/images/{safe_name}"
logger.info(f"图片已保存: {file_path}, URL: {url_path}")
return {"success": True, "path": url_path, "name": safe_name}
except Exception as e:
logger.error(f"图片上传失败: {e}")
return {"success": False, "message": str(e)}
# ==================== 对话 API保留原有 ====================
@app.get("/api/conversations")
@@ -787,6 +849,7 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
# 处理文件内容,添加到消息
image_contents = [] # 图片内容(用于视觉模型)
text_contents = [] # 文本文件内容
image_paths = [] # 图片服务器路径(用于历史记录显示)
if files:
for f in files:
if f.get('type') and f['type'].startswith('image/'):
@@ -796,6 +859,13 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
'type': f['type'],
'data': f.get('content', '') # base64 数据
})
# 记录服务器路径(用于历史记录)
if f.get('serverPath'):
image_paths.append({
'name': f['name'],
'type': f['type'],
'url': f['serverPath'] # 服务器文件路径
})
# 不添加文件名文本,图片信息保存在 extra_data 中
elif f.get('content'):
# 文本文件:直接添加内容,不带文件名前缀
@@ -808,10 +878,16 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
for content in text_contents:
message += f"\n\n{content}"
# 保存图片信息到 extra_data用于历史记录
# 保存图片和文件信息到 extra_data用于历史记录
extra_data_for_msg = None
if image_contents:
# 只保存图片 URL不保存完整 base64
if image_paths:
# 图片保存服务器路径URL历史记录可以显示
extra_data_for_msg = {
'images': image_paths,
'files': [{'name': f['name'], 'type': f['type']} for f in files if not f['type'].startswith('image/')]
}
elif image_contents:
# 没有服务器路径但有问题(可能上传失败)
extra_data_for_msg = {
'images': [{'name': i['name'], 'type': i['type']} for i in image_contents],
'files': [{'name': f['name'], 'type': f['type']} for f in files if not f['type'].startswith('image/')]

View File

@@ -32,6 +32,10 @@ class LLMProvider(Base):
supports_thinking = Column(Boolean, default=False) # 是否原生支持思考
thinking_model = Column(String(100), nullable=True) # 思考模式模型名(如有单独模型)
# 视觉能力支持
supports_vision = Column(Boolean, default=False) # 是否支持图片理解(多模态)
vision_model = Column(String(100), nullable=True) # 视觉模型名(如与默认模型不同)
# 配额和限制
max_tokens = Column(Integer, default=4096)
temperature = Column(Float, default=0.7)

View File

@@ -58,8 +58,8 @@
</div>
<div class="card-body">
<table class="table">
<thead><tr><th>名称</th><th>API地址</th><th>默认模型</th><th>思考支持</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="providers-list"><tr><td colspan="6" class="text-center">加载中...</td></tr></tbody>
<thead><tr><th>名称</th><th>API地址</th><th>默认模型</th><th>思考</th><th>视觉</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="providers-list"><tr><td colspan="7" class="text-center">加载中...</td></tr></tbody>
</table>
</div>
</div>
@@ -162,6 +162,8 @@
<div class="mt-3 form-check"><input type="checkbox" class="form-check-input" id="provider-active" checked><label class="form-check-label">启用</label></div>
<hr><h6>思考功能</h6>
<div class="thinking-config"><div class="row"><div class="col-md-6 form-check"><input type="checkbox" class="form-check-input" id="provider-supports-thinking"><label class="form-check-label">支持原生思考</label></div><div class="col-md-6"><label class="form-label">思考模型名</label><input type="text" class="form-control" id="provider-thinking-model"></div></div></div>
<hr><h6>视觉能力</h6>
<div class="thinking-config"><div class="row"><div class="col-md-6 form-check"><input type="checkbox" class="form-check-input" id="provider-supports-vision"><label class="form-check-label">支持图片理解</label></div><div class="col-md-6"><label class="form-label">视觉模型名</label><input type="text" class="form-control" id="provider-vision-model" placeholder="留空则使用默认模型"></div></div><small class="text-muted mt-2 d-block">启用后可上传图片让AI识别分析内容</small></div>
<div class="mt-3"><button type="button" class="btn btn-outline-primary" onclick="fetchProviderModels()"><i class="ri-refresh-line"></i> 获取模型</button><button type="button" class="btn btn-outline-secondary" onclick="testProviderConnection()"><i class="ri-link"></i> 测试连接</button></div>
<div class="mt-2" id="provider-models-preview"></div><div class="mt-2" id="provider-test-result"></div>
</form></div>
@@ -181,7 +183,10 @@
<hr><h6>思考功能</h6>
<div class="thinking-config"><div class="form-check"><input type="checkbox" class="form-check-input" id="agent-enable-thinking" checked><label class="form-check-label">启用思考</label></div><div class="mt-2"><label class="form-label">思考提示词</label><textarea class="form-control" id="agent-thinking-prompt" rows="2"></textarea></div><div class="row mt-2"><div class="col-md-6"><label class="form-label">前缀</label><input type="text" class="form-control" id="agent-thinking-prefix"></div><div class="col-md-6"><label class="form-label">后缀</label><input type="text" class="form-control" id="agent-thinking-suffix"></div></div></div>
<hr><h6>工具配置</h6>
<div class="thinking-config"><div class="form-check"><input type="checkbox" class="form-check-input" id="agent-tool-search"><label class="form-check-label">启用搜索工具</label></div><small class="text-muted">启用后 Agent 可以使用搜索功能获取实时信息</small></div>
<div class="thinking-config">
<div id="agent-tools-list">加载中...</div>
<small class="text-muted">从系统工具列表中选择Agent可使用的工具</small>
</div>
</form></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button><button type="button" class="btn btn-primary" onclick="saveAgent()">保存</button></div>
</div></div></div>
@@ -250,7 +255,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// 全局数据
let providersData = [], agentsData = [], channelsData = [];
let providersData = [], agentsData = [], channelsData = [], toolsData = [];
// 页面切换函数
function loadPageData(page) {
@@ -260,6 +265,57 @@
if (page === 'tools') loadTools();
if (page === 'stats') loadStats();
}
// 加载工具列表用于Agent配置
async function loadToolsList() {
try {
const res = await fetch('/api/v2/tools');
const data = await res.json();
toolsData = data.tools || [];
renderAgentToolsList();
} catch (e) { console.error('加载工具列表失败:', e); }
}
// 渲染Agent工具选择列表
function renderAgentToolsList(selectedTools = []) {
const container = document.getElementById('agent-tools-list');
if (!container) return;
if (toolsData.length === 0) {
container.innerHTML = '<div class="text-muted">暂无可用工具,请先在「工具管理」中添加</div>';
return;
}
container.innerHTML = toolsData.filter(t => t.is_active).map(t => {
const toolType = t.tool_type || 'unknown';
const isSelected = selectedTools.includes(toolType);
const icon = getToolIcon(toolType);
return `<div class="form-check">
<input type="checkbox" class="form-check-input agent-tool-checkbox" id="agent-tool-${t.id}" value="${toolType}" ${isSelected ? 'checked' : ''}>
<label class="form-check-label" for="agent-tool-${t.id}">
<i class="${icon}"></i> ${t.name} <small class="text-muted">(${toolType})</small>
</label>
</div>`;
}).join('');
}
// 工具图标
function getToolIcon(toolType) {
const icons = {
'search': 'ri-search-line',
'calculator': 'ri-calculator-line',
'code': 'ri-code-line',
'image': 'ri-image-line',
'web': 'ri-global-line'
};
return icons[toolType] || 'ri-tools-line';
}
// 获取Agent选中的工具列表
function getSelectedAgentTools() {
const checkboxes = document.querySelectorAll('.agent-tool-checkbox:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
// ===== 大模型池 =====
async function loadProviders() {
@@ -274,6 +330,7 @@
tbody.innerHTML = providersData.map(p => `<tr>
<td><strong>${p.name}</strong></td><td><small>${p.api_base||'-'}</small></td><td>${p.default_model||'auto'}</td>
<td>${p.supports_thinking?'<span class="badge bg-success">支持</span>':'<span class="badge bg-secondary">不支持</span>'}</td>
<td>${p.supports_vision?'<span class="badge bg-info">支持</span>':'<span class="badge bg-secondary">不支持</span>'}</td>
<td>${p.is_active?'<span class="badge bg-success">启用</span>':'<span class="badge bg-secondary">禁用</span>'}</td>
<td><button class="btn btn-sm btn-outline-primary" onclick="editProvider(${p.id})"><i class="ri-edit-line"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteProvider(${p.id},'${p.name}')"><i class="ri-delete-bin-line"></i></button></td>
@@ -290,6 +347,8 @@
document.getElementById('provider-form').reset();
document.getElementById('provider-id').value = '';
document.getElementById('provider-active').checked = true;
document.getElementById('provider-supports-thinking').checked = false;
document.getElementById('provider-supports-vision').checked = false;
document.getElementById('provider-models-preview').innerHTML = '';
document.getElementById('provider-test-result').innerHTML = '';
new bootstrap.Modal(document.getElementById('providerModal')).show();
@@ -310,6 +369,8 @@
document.getElementById('provider-active').checked = p.is_active;
document.getElementById('provider-supports-thinking').checked = p.supports_thinking;
document.getElementById('provider-thinking-model').value = p.thinking_model || '';
document.getElementById('provider-supports-vision').checked = p.supports_vision;
document.getElementById('provider-vision-model').value = p.vision_model || '';
new bootstrap.Modal(document.getElementById('providerModal')).show();
}
@@ -326,7 +387,9 @@
description: document.getElementById('provider-description').value,
is_active: document.getElementById('provider-active').checked,
supports_thinking: document.getElementById('provider-supports-thinking').checked,
thinking_model: document.getElementById('provider-thinking-model').value
thinking_model: document.getElementById('provider-thinking-model').value,
supports_vision: document.getElementById('provider-supports-vision').checked,
vision_model: document.getElementById('provider-vision-model').value
};
const res = await fetch(id ? `/api/v2/providers/${id}` : '/api/v2/providers', { method: id ? 'PUT' : 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
const result = await res.json();
@@ -394,6 +457,7 @@
document.getElementById('agent-enable-thinking').checked = true;
document.getElementById('agent-system-prompt').value = '你是一个有用的AI助手。';
updateProviderSelect();
loadToolsList(); // 加载工具列表
new bootstrap.Modal(document.getElementById('agentModal')).show();
}
@@ -416,17 +480,16 @@
document.getElementById('agent-thinking-prompt').value = a.thinking_prompt || '';
document.getElementById('agent-thinking-prefix').value = a.thinking_prefix || '';
document.getElementById('agent-thinking-suffix').value = a.thinking_suffix || '';
// 工具配置
const tools = a.tools || [];
document.getElementById('agent-tool-search').checked = tools.includes('search');
// 工具配置 - 从工具列表渲染并勾选Agent已有的工具
loadToolsList();
setTimeout(() => renderAgentToolsList(a.tools || []), 100); // 等工具列表加载完成
new bootstrap.Modal(document.getElementById('agentModal')).show();
}
async function saveAgent() {
const id = document.getElementById('agent-id').value;
// 处理工具列表
const tools = [];
if (document.getElementById('agent-tool-search').checked) tools.push('search');
// 获取选中的工具列表
const tools = getSelectedAgentTools();
const data = {
name: document.getElementById('agent-name').value,
@@ -443,7 +506,7 @@
thinking_prompt: document.getElementById('agent-thinking-prompt').value,
thinking_prefix: document.getElementById('agent-thinking-prefix').value,
thinking_suffix: document.getElementById('agent-thinking-suffix').value,
tools: tools // 工具列表
tools: tools // 工具列表(从系统工具中选择)
};
const res = await fetch(id ? `/api/v2/agents/${id}` : '/api/v2/agents', { method: id ? 'PUT' : 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
const result = await res.json();

View File

@@ -60,6 +60,10 @@
.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; }
@@ -126,6 +130,10 @@
/* 快捷语句 - 横向扁平 */
.quick-phrases-bar { display: flex; align-items: center; gap: 8px; margin-top: 12px; position: relative; }
.tool-toggle-item { display: inline-flex; align-items: center; gap: 4px; }
.tool-toggle-item input { width: 14px; height: 14px; }
.tool-toggle-item label { font-size: 12px; color: #666; }
.tool-toggle-item label i { color: #10a37f; }
.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; }
@@ -197,10 +205,7 @@
</div>
<div class="file-preview-area" id="filePreviewArea"></div>
<div class="quick-phrases-bar">
<div class="tool-toggle">
<input type="checkbox" id="enableSearch" checked>
<label for="enableSearch"><i class="ri-search-line"></i> 搜索</label>
</div>
<div id="toolToggleArea"></div>
<button class="add-phrase-btn" onclick="showAddPhraseModal()"><i class="ri-add-line"></i> 添加</button>
<div class="phrase-list-wrapper" id="phraseListWrapper" onwheel="scrollPhrases(event)">
<div class="phrase-list" id="quickPhrasesList"></div>
@@ -228,6 +233,28 @@
<img id="lightboxImage" src="" alt="放大图片">
</div>
<!-- 隐藏的图片上传API处理 -->
<script>
// 图片上传到服务器(保存文件)
async function uploadImageToServer(base64Data, fileName) {
try {
const response = await fetch('/api/v2/upload-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Data, name: fileName })
});
const result = await response.json();
if (result.success) {
return result.path; // 返回服务器文件路径
}
return null;
} catch (e) {
console.error('图片上传失败:', e);
return null;
}
}
</script>
<!-- Markdown渲染库 -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
@@ -238,6 +265,7 @@
let currentConversationId = null;
let currentAgentId = null;
let agents = [];
let providers = []; // 大模型池数据
let quickPhrases = [];
let lastSentMessage = null; // 记录最后发送的消息
let lastSentFiles = null; // 记录发送的文件
@@ -250,6 +278,8 @@
let messageVersions = {}; // 存储每个assistant消息的多个版本 { messageId: [{content, thinking}] }
document.addEventListener('DOMContentLoaded', () => {
loadProviders(); // 加载大模型池
loadToolsData(); // 加载工具列表
loadAgents();
loadQuickPhrases();
connectWebSocket();
@@ -257,6 +287,126 @@
setupTextarea();
});
// 加载大模型池
async function loadProviders() {
try {
const res = await fetch('/api/v2/providers');
const data = await res.json();
providers = data.providers || [];
// 加载后检查工具支持如果agents已加载
if (agents.length > 0) showToolWarning();
} catch (e) { console.error('加载Provider失败:', e); }
}
// 检查当前Agent是否支持视觉
function checkAgentVisionSupport() {
const agent = agents.find(a => a.id === currentAgentId);
if (!agent) return false;
const provider = providers.find(p => p.id === agent.llm_provider_id);
return provider?.supports_vision || false;
}
// 检查当前Agent是否支持某个工具
function checkAgentToolSupport(toolType) {
const agent = agents.find(a => a.id === currentAgentId);
if (!agent) return false;
const agentTools = agent.tools || [];
return agentTools.includes(toolType);
}
// 获取当前Agent不支持的工具列表用户已启用但Agent不支持
function getUnsupportedTools() {
const unsupported = [];
// 检查所有工具checkbox
const toolCheckboxes = document.querySelectorAll('.tool-checkbox');
toolCheckboxes.forEach(cb => {
if (cb.checked && !checkAgentToolSupport(cb.dataset.toolType)) {
// 获取工具显示名称
const label = cb.nextElementSibling?.textContent?.trim() || cb.dataset.toolType;
unsupported.push(label);
}
});
return unsupported;
}
// 渲染工具选择区域(根据系统工具列表)
function renderToolToggles() {
const container = document.getElementById('toolToggleArea');
if (!container || toolsData.length === 0) return;
// 获取当前Agent支持的工具
const agent = agents.find(a => a.id === currentAgentId);
const agentTools = agent?.tools || [];
// 渲染工具checkbox列表
let html = '';
toolsData.filter(t => t.is_active).forEach(t => {
const toolType = t.tool_type || 'unknown';
const isSupported = agentTools.includes(toolType);
const icon = getToolIconFrontend(toolType);
html += `<div class="tool-toggle-item" style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">
<input type="checkbox" class="tool-checkbox" id="tool-${toolType}" data-tool-type="${toolType}" ${isSupported ? 'checked' : ''} onchange="showToolWarning()">
<label for="tool-${toolType}" style="cursor:pointer;font-size:12px;"><i class="${icon}"></i> ${t.name}</label>
</div>`;
});
// 保留原有的结构只更新工具checkbox部分
const existingWarning = document.getElementById('tool-warning-tip');
container.innerHTML = html;
if (existingWarning) container.appendChild(existingWarning);
}
// 前端工具图标
function getToolIconFrontend(toolType) {
const icons = {
'search': 'ri-search-line',
'calculator': 'ri-calculator-line',
'code': 'ri-code-line',
'image': 'ri-image-line',
'web': 'ri-global-line'
};
return icons[toolType] || 'ri-tools-line';
}
// 加载工具列表
let toolsData = [];
async function loadToolsData() {
try {
const res = await fetch('/api/v2/tools');
const data = await res.json();
toolsData = data.tools || [];
renderToolToggles();
} catch (e) { console.error('加载工具列表失败:', e); }
}
// 显示工具不支持提示
function showToolWarning() {
const unsupported = getUnsupportedTools();
const warningDiv = document.getElementById('tool-warning-tip');
if (unsupported.length > 0) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
const msg = `<i class="ri-alert-line"></i> <strong>${agentName}</strong> 不支持 <strong>${unsupported.join('、')}</strong> 工具请关闭或切换Agent`;
if (warningDiv) {
warningDiv.innerHTML = msg;
warningDiv.style.display = 'block';
} else {
const newWarning = document.createElement('div');
newWarning.id = 'tool-warning-tip';
newWarning.style.cssText = 'margin-top:8px;padding:8px 12px;background:#fff3cd;border:1px solid #ffc107;border-radius:6px;font-size:13px;color:#856404;';
newWarning.innerHTML = msg;
document.getElementById('toolToggleArea').appendChild(newWarning);
}
// 禁用发送按钮
document.getElementById('sendBtn').disabled = true;
} else {
if (warningDiv) warningDiv.style.display = 'none';
document.getElementById('sendBtn').disabled = false;
}
}
// 加载Agent
async function loadAgents() {
try {
@@ -266,6 +416,10 @@
const defaultAgent = agents.find(a => a.is_default) || agents[0];
if (defaultAgent) currentAgentId = defaultAgent.id;
renderAgentSelect();
// 加载后检查工具支持
showToolWarning();
// 渲染工具选择区域
renderToolToggles();
} catch (e) { console.error('加载Agent失败:', e); }
}
@@ -288,6 +442,8 @@
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ action: 'switch_agent', agent_id: currentAgentId }));
await createNewConversation();
showAgentSwitchNotice();
// 切换Agent后检查工具支持
showToolWarning();
}
}
@@ -399,6 +555,9 @@
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>`;
@@ -415,15 +574,22 @@
console.log('Processing extraData for user message:', extraData);
const bodyDiv = div.querySelector('.message-body');
// 处理图片
// 处理图片如果有服务器URL显示图片
if (extraData.images && extraData.images.length > 0) {
let imagesHtml = '<div class="history-images" style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">';
for (const img of extraData.images) {
// 历史记录只有图片元信息,显示占位符
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>`;
if (img.url) {
// 有服务器URL显示真实图片
imagesHtml += `<div class="history-image" style="display:inline-block;">
<img src="${img.url}" style="max-width:300px;max-height:200px;border-radius:8px;cursor:zoom-in;" onclick="openImageLightbox('${img.url}')">
</div>`;
} else {
// 没有URL显示占位符
imagesHtml += `<div class="history-image-placeholder" style="padding:8px 12px;background:#f0f0f0;border-radius:8px;display:flex;align-items:center;gap:6px;font-size:13px;color:#666;">
<i class="ri-image-line" style="color:#10a37f;"></i>
<span>${escapeHtml(img.name || '图片')}</span>
</div>`;
}
}
imagesHtml += '</div>';
if (bodyDiv) bodyDiv.insertAdjacentHTML('beforeend', imagesHtml);
@@ -694,6 +860,136 @@
}
}
// 编辑用户消息并重新发送
let editingUserMessage = null; // 正在编辑的用户消息元素
function editUserMessage(btn) {
const messageDiv = btn.closest('.message.user');
if (!messageDiv) return;
// 获取消息内容
const contentDiv = messageDiv.querySelector('.user-message-text');
const originalContent = contentDiv.textContent;
const actionsDiv = messageDiv.querySelector('.message-actions');
// 保存原始状态用于取消
editingUserMessage = {
element: messageDiv,
originalContent: originalContent,
contentDiv: contentDiv,
actionsDiv: actionsDiv
};
// 将消息文本变成可编辑的textarea
contentDiv.innerHTML = `<textarea class="edit-textarea" style="width:100%;min-height:60px;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:15px;resize:vertical;">${escapeHtml(originalContent)}</textarea>`;
// 隐藏原有操作按钮,显示编辑操作按钮
actionsDiv.innerHTML = `
<button class="action-btn" onclick="confirmEditMessage()" style="background:#10a37f;color:white;border-color:#10a37f;"><i class="ri-check-line"></i> 确认</button>
<button class="action-btn" onclick="cancelEditMessage()"><i class="ri-close-line"></i> 取消</button>
`;
// 聚焦到textarea
const textarea = contentDiv.querySelector('.edit-textarea');
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
// 添加Ctrl+Enter快捷键确认
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
confirmEditMessage();
}
if (e.key === 'Escape') {
e.preventDefault();
cancelEditMessage();
}
});
}
function cancelEditMessage() {
if (!editingUserMessage) return;
// 恢复原始内容
editingUserMessage.contentDiv.innerHTML = escapeHtml(editingUserMessage.originalContent);
// 恢复原始操作按钮
editingUserMessage.actionsDiv.innerHTML = `
<button class="action-btn" onclick="copyMessage(this)"><i class="ri-file-copy-line"></i> 复制</button>
<button class="action-btn edit" onclick="editUserMessage(this)" title="编辑并重新发送"><i class="ri-edit-line"></i> 编辑</button>
`;
editingUserMessage = null;
}
function confirmEditMessage() {
if (!editingUserMessage) return;
const textarea = editingUserMessage.contentDiv.querySelector('.edit-textarea');
const newContent = textarea.value.trim();
if (!newContent) {
alert('消息内容不能为空');
return;
}
// 如果内容没有变化,直接取消编辑
if (newContent === editingUserMessage.originalContent) {
cancelEditMessage();
return;
}
// 更新消息内容
editingUserMessage.contentDiv.innerHTML = escapeHtml(newContent);
// 恢复操作按钮
editingUserMessage.actionsDiv.innerHTML = `
<button class="action-btn" onclick="copyMessage(this)"><i class="ri-file-copy-line"></i> 复制</button>
<button class="action-btn edit" onclick="editUserMessage(this)" title="编辑并重新发送"><i class="ri-edit-line"></i> 编辑</button>
`;
// 更新最后用户消息记录
lastUserMessage = newContent;
// 找到下一条assistant消息设置重新生成标志
const container = document.getElementById('messagesContainer');
const allMessages = container.querySelectorAll('.message');
let nextAssistantId = null;
for (let i = 0; i < allMessages.length; i++) {
if (allMessages[i] === editingUserMessage.element && i + 1 < allMessages.length) {
const nextMsg = allMessages[i + 1];
if (nextMsg.classList.contains('assistant')) {
nextAssistantId = nextMsg.id || nextMsg.dataset.messageId;
}
break;
}
}
if (nextAssistantId) {
isRegenerating = true;
regeneratingMessageId = nextAssistantId;
showLoadingIndicator(nextAssistantId);
} else {
// 没有assistant消息需要创建新的
isRegenerating = false;
regeneratingMessageId = null;
}
editingUserMessage = null;
// 重新发送编辑后的消息
document.getElementById('sendBtn').disabled = true;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
action: 'chat',
message: newContent,
conversation_id: currentConversationId,
agent_id: currentAgentId
}));
}
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
@@ -839,6 +1135,26 @@
// 如果没有消息且没有文件,不发送
if (!msg && pendingFiles.length === 0) return;
// 检查是否有图片上传
const hasImages = pendingFiles.some(f => f.type && f.type.startsWith('image/'));
if (hasImages && !checkAgentVisionSupport()) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
alert(`⚠️ ${agentName} 不支持图片识别功能\n\n请选择支持视觉能力的Agent如 vlm-agent或者移除图片后再发送。`);
document.getElementById('sendBtn').disabled = false;
return;
}
// 检查工具支持
const unsupported = getUnsupportedTools();
if (unsupported.length > 0) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
alert(`⚠️ ${agentName} 不支持 ${unsupported.join('、')} 工具\n\n请关闭不支持的工具或切换到支持该工具的Agent。`);
document.getElementById('sendBtn').disabled = false;
return;
}
document.getElementById('sendBtn').disabled = true;
input.value = '';
input.style.height = 'auto';
@@ -851,10 +1167,13 @@
pendingFiles = [];
document.getElementById('filePreviewArea').innerHTML = '';
// 获取工具禁用状态
const enableSearch = document.getElementById('enableSearch').checked;
// 获取禁用的工具列表(所有系统工具 - 用户选中的工具)
const disabledTools = [];
if (!enableSearch) disabledTools.push('search');
const allToolTypes = toolsData.filter(t => t.is_active).map(t => t.tool_type);
const selectedTools = Array.from(document.querySelectorAll('.tool-checkbox:checked')).map(cb => cb.dataset.toolType);
allToolTypes.forEach(t => {
if (!selectedTools.includes(t)) disabledTools.push(t);
});
// 发送消息(包含文件)
if (ws?.readyState === WebSocket.OPEN) {
@@ -893,13 +1212,15 @@
lastSentFiles = files.map(f => ({
name: f.name,
type: f.type,
content: f.content
content: f.content,
serverPath: f.serverPath // 服务器路径(用于历史记录)
}));
for (const f of files) {
if (f.type.startsWith('image/')) {
// 图片直接显示
html += `<div class="uploaded-image" style="margin-bottom:8px"><img src="${f.content}" style="max-width:300px;border-radius:8px" onclick="openImageLightbox('${f.content}')"></div>`;
// 图片直接显示用服务器路径或base64
const imgSrc = f.serverPath || f.content;
html += `<div class="uploaded-image" style="margin-bottom:8px"><img src="${f.content}" style="max-width:300px;border-radius:8px" onclick="openImageLightbox('${imgSrc}')"></div>`;
} else {
// 文本文件显示名称和内容摘要
html += `<div class="uploaded-file" style="padding:8px;background:#f5f5f5;border-radius:6px;margin-bottom:8px">`;
@@ -924,7 +1245,7 @@
}
// 文件上传处理
function handleFileUpload(event) {
async function handleFileUpload(event) {
const files = event.target.files;
const previewArea = document.getElementById('filePreviewArea');
@@ -933,13 +1254,23 @@
// 读取文件内容
const reader = new FileReader();
reader.onload = (e) => {
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: e.target.result
content: base64Content, // base64数据用于多模态模型
serverPath: serverPath // 服务器路径(用于历史记录显示)
};
pendingFiles.push(fileData);
@@ -950,8 +1281,9 @@
if (file.type.startsWith('image/')) {
previewItem.classList.add('image-preview');
// 预览用本地base64显示更快
previewItem.innerHTML = `
<img src="${e.target.result}" alt="${file.name}">
<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 {
@@ -972,6 +1304,21 @@
}
previewArea.appendChild(previewItem);
// 如果上传的是图片且当前Agent不支持视觉显示警告提示
if (file.type.startsWith('image/') && !checkAgentVisionSupport()) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
const warningDiv = document.createElement('div');
warningDiv.className = 'vision-warning';
warningDiv.id = 'vision-warning-tip';
warningDiv.style.cssText = 'margin-top:8px;padding:8px 12px;background:#fff3cd;border:1px solid #ffc107;border-radius:6px;font-size:13px;color:#856404;';
warningDiv.innerHTML = `<i class="ri-alert-line"></i> <strong>${agentName}</strong> 不支持图片识别请选择支持视觉的Agent或移除图片`;
// 避免重复添加
if (!document.getElementById('vision-warning-tip')) {
previewArea.appendChild(warningDiv);
}
}
};
// 根据文件类型选择读取方式
@@ -993,6 +1340,13 @@
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();
}
}
// 发送消息

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB