Compare commits

..

10 Commits

Author SHA1 Message Date
d3e80c0afb feat: 图片保存到服务器,历史记录可显示图片 2026-04-14 10:38:59 +08:00
a3def9702b fix: 历史消息显示图片/文件占位符 2026-04-14 10:33:11 +08:00
cd1f95bb2c feat: 图片点击放大功能 2026-04-14 10:18:32 +08:00
0c4cc96106 fix: 增强错误日志,修复API调用诊断 2026-04-14 09:28:28 +08:00
2dca775911 feat: 多模态支持 - 图片数据传递给视觉模型 2026-04-14 09:21:36 +08:00
a34bef50ae fix: 文件上传不再添加文件名标记,内容自然融入消息 2026-04-14 09:15:46 +08:00
e6429b1f95 fix: 修复 JavaScript 语法错误
- pendingFiles 变量在使用前定义
- 删除 appendMessageWithFiles 后多余的代码
2026-04-14 00:14:37 +08:00
c5dc553974 fix: JavaScript 变量定义顺序修复,lastSentMessageWithFiles 在使用前定义 2026-04-14 00:06:06 +08:00
2bdb9e7f10 fix: 修复文件上传显示重复问题
- 使用 lastSentMessageWithFiles 判断避免重复显示
- 图片在前端直接显示预览(base64)
- 文本文件显示名称和内容摘要
- extra_data 保存图片/文件信息用于历史记录
2026-04-13 23:45:17 +08:00
fe65f23fa7 feat: 网页端添加文件上传功能
- 支持图片上传(预览显示)
- 支持文本文件上传(txt, md, json, csv等)
- 支持 PDF 和 Word 文档
- 文件内容自动添加到消息中供 AI 分析
- 多文件同时上传支持
2026-04-13 23:31:20 +08:00
11 changed files with 1163 additions and 54 deletions

Binary file not shown.

199
main.py
View File

@@ -382,6 +382,205 @@ async def get_config(db: Session = Depends(get_db)):
}
@app.get("/api/admin/ai-config")
async def get_ai_config(db: Session = Depends(get_db)):
"""获取AI配置"""
configs = {c.key: c.value for c in db.query(SystemConfig).filter(SystemConfig.key.startswith('ai_')).all()}
return {
"api_base": configs.get('ai_api_base', 'http://192.168.2.17:19007/v1'),
"api_key": configs.get('ai_api_key', 'xxxx'),
"model": configs.get('ai_model', 'auto'),
"use_mock": ai_service.use_mock
}
@app.post("/api/admin/ai-config")
async def update_ai_config(data: dict, db: Session = Depends(get_db)):
"""更新AI配置"""
api_base = data.get("api_base")
api_key = data.get("api_key")
model = data.get("model")
if api_base:
config = db.query(SystemConfig).filter(SystemConfig.key == 'ai_api_base').first()
if config:
config.value = api_base
else:
config = SystemConfig(key='ai_api_base', value=api_base, description='AI API地址')
db.add(config)
if api_key:
config = db.query(SystemConfig).filter(SystemConfig.key == 'ai_api_key').first()
if config:
config.value = api_key
else:
config = SystemConfig(key='ai_api_key', value=api_key, description='AI API密钥')
db.add(config)
if model:
config = db.query(SystemConfig).filter(SystemConfig.key == 'ai_model').first()
if config:
config.value = model
else:
config = SystemConfig(key='ai_model', value=model, description='AI模型名称')
db.add(config)
db.commit()
# 更新AI服务配置
configs = {c.key: c.value for c in db.query(SystemConfig).filter(SystemConfig.key.startswith('ai_')).all()}
ai_service.update_config(
configs.get('ai_api_base', 'http://192.168.2.17:19007/v1'),
configs.get('ai_api_key', 'xxxx'),
configs.get('ai_model', 'auto')
)
return {"success": True, "message": "AI配置已更新"}
@app.get("/api/admin/models")
async def get_available_models(db: Session = Depends(get_db)):
"""获取可用模型列表"""
import httpx
# 从数据库读取最新配置
configs = {c.key: c.value for c in db.query(SystemConfig).filter(SystemConfig.key.startswith('ai_')).all()}
api_base = configs.get('ai_api_base', '')
api_key = configs.get('ai_api_key', 'xxxx')
if not api_base:
# 返回默认模型列表
return {
"models": [
{"id": "auto", "name": "auto (自动选择)", "owned_by": "system"},
{"id": "qwen3.5-4b", "name": "qwen3.5-4b", "owned_by": "local"},
{"id": "dsv32", "name": "dsv32", "owned_by": "deepseek"},
{"id": "glm-4", "name": "glm-4", "owned_by": "zhipu"},
{"id": "gpt-4o", "name": "gpt-4o", "owned_by": "openai"},
{"id": "claude-3-opus", "name": "claude-3-opus", "owned_by": "anthropic"}
],
"success": False,
"message": "请先配置API地址"
}
try:
# 从当前配置的API地址获取模型列表
url = f"{api_base}/models"
headers = {"Authorization": f"Bearer {api_key}"}
logger.info(f"获取模型列表: url={url}")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
models = []
for m in data.get('data', []):
model_id = m.get('id', '')
if model_id:
models.append({
"id": model_id,
"name": m.get('name', model_id),
"owned_by": m.get('owned_by', 'unknown')
})
return {"models": models, "success": True}
except Exception as e:
logger.error(f"获取模型列表失败: {e}")
# 返回默认模型列表
return {
"models": [
{"id": "auto", "name": "auto (自动选择)", "owned_by": "system"},
{"id": "qwen3.5-4b", "name": "qwen3.5-4b", "owned_by": "local"},
{"id": "dsv32", "name": "dsv32", "owned_by": "deepseek"},
{"id": "glm-4", "name": "glm-4", "owned_by": "zhipu"},
{"id": "gpt-4o", "name": "gpt-4o", "owned_by": "openai"},
{"id": "claude-3-opus", "name": "claude-3-opus", "owned_by": "anthropic"}
],
"success": False,
"message": "无法从API获取模型列表显示默认列表"
}
@app.post("/api/admin/test-ai")
async def test_ai_connection(db: Session = Depends(get_db)):
"""测试AI连接"""
import httpx
# 从数据库读取最新配置,如果没有则使用默认值
configs = {c.key: c.value for c in db.query(SystemConfig).filter(SystemConfig.key.startswith('ai_')).all()}
# 使用数据库值或默认值
api_base = configs.get('ai_api_base') or 'http://192.168.2.17:19007/v1'
api_key = configs.get('ai_api_key') or 'xxxx'
model = configs.get('ai_model') or 'auto'
# 判断是否使用默认值
using_defaults = not configs.get('ai_api_base')
try:
url = f"{api_base}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"messages": [{"role": "user", "content": "测试连接"}],
"max_tokens": 50
}
logger.info(f"测试AI连接: url={url}, model={model}, using_defaults={using_defaults}")
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(url, headers=headers, json=payload)
if response.status_code == 200:
data = response.json()
content = data['choices'][0]['message']['content']
result = {
"success": True,
"message": f"连接成功!模型响应: {content[:100]}",
"model": model,
"api_base": api_base
}
if using_defaults:
result["message"] += "\n(使用默认配置,点击「保存配置」可持久化)"
return result
else:
error_text = response.text[:200] if response.text else ""
return {
"success": False,
"message": f"连接失败: HTTP {response.status_code} - {error_text}",
"model": model,
"api_base": api_base
}
except httpx.ConnectError as e:
return {
"success": False,
"message": f"无法连接到API地址: {api_base}",
"model": model,
"api_base": api_base
}
except httpx.TimeoutException:
return {
"success": False,
"message": f"连接超时15秒",
"model": model,
"api_base": api_base
}
except Exception as e:
return {
"success": False,
"message": f"连接失败: {str(e)}",
"model": model,
"api_base": api_base
}
@app.post("/api/admin/config")
async def update_config(data: dict, db: Session = Depends(get_db)):
"""更新系统配置"""

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连接管理
@@ -575,6 +585,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")
@@ -766,6 +826,7 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
elif action == "chat":
message = data.get("message", "")
files = data.get("files", []) # 上传的文件
conversation_id = data.get("conversation_id")
enable_thinking = data.get("enable_thinking", True)
agent_id_override = data.get("agent_id")
@@ -776,9 +837,60 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
if agent and agent.is_active:
current_agent_id = agent_id_override
if not message.strip():
# 如果没有消息但有文件,构造消息
if not message.strip() and files:
message = "[上传文件]"
if not message.strip() and not files:
continue
# 处理文件内容,添加到消息
image_contents = [] # 图片内容(用于视觉模型)
text_contents = [] # 文本文件内容
image_paths = [] # 图片服务器路径(用于历史记录显示)
if files:
for f in files:
if f.get('type') and f['type'].startswith('image/'):
# 图片:记录 base64 数据,用于视觉模型
image_contents.append({
'name': f['name'],
'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'):
# 文本文件:直接添加内容,不带文件名前缀
text_contents.append(f['content'][:3000])
if len(f['content']) > 3000:
text_contents[-1] += "...(内容过长已截断)"
# 如果有文本文件内容,追加到消息后面
if text_contents:
for content in text_contents:
message += f"\n\n{content}"
# 保存图片和文件信息到 extra_data用于历史记录
extra_data_for_msg = None
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/')]
}
# 1. 获取Agent配置
agent_config = agent_service.get_agent_config(current_agent_id)
agent_tools = agent_config.get('agent', {}).get('tools', [])
@@ -891,13 +1003,22 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
'duration_ms': duration_ms
})
# 5. 保存用户消息到数据库(包含搜索结果)
# 5. 保存用户消息到数据库
extra_data_to_save = None
if search_results_for_client:
extra_data_to_save = {'search_results': search_results_for_client, 'search_query': message}
if extra_data_for_msg:
if extra_data_to_save:
extra_data_to_save.update(extra_data_for_msg)
else:
extra_data_to_save = extra_data_for_msg
user_msg = conv_service.add_message(
conversation_id=conversation.id,
role='user',
content=message,
source='web',
extra_data={'search_results': search_results_for_client, 'search_query': message} if search_results_for_client else None
extra_data=extra_data_to_save
)
# 6. 获取对话历史(包含刚保存的用户消息)
@@ -922,7 +1043,8 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
messages=history,
provider_config=agent_config['provider'],
agent_config=agent_config['agent'],
enable_thinking=enable_thinking
enable_thinking=enable_thinking,
images=image_contents # 传递图片数据给多模态模型
)
logger.info(f"LLM响应: response长度={len(response)}, thinking长度={len(thinking_content) if thinking_content else 0}")

View File

@@ -0,0 +1,11 @@
@tester:matrix.tphai.com AJFVRTHLJY matrix-ed25519 4mRjLhM8xbwjkwQP2T/iB3UZJoaADgP6cCVUiB8AtSk
@tester:matrix.tphai.com ATYFRXKHEQ matrix-ed25519 WnaxV7S11wrqlojKOR3j2RDlPL7TrO17U2ablFISbnw
@tester:matrix.tphai.com BDTRXIGPBE matrix-ed25519 gjQNtLEpIEYCjmzUx5ma91G498n4UADh84KUmiReJUM
@tester:matrix.tphai.com GALBNVJOSG matrix-ed25519 /a7qD2Od76/+Xrr/naDqWEQJZ982X9XdYkCBbRmKxBU
@tester:matrix.tphai.com GVSFGGYNJL matrix-ed25519 8qV2own4G3m2nki+izFDBOrAxtbGl8RoneM3qUPkThU
@tester:matrix.tphai.com IMEQIQPXTR matrix-ed25519 6Yd4lmhP6jdkkNvh1rIw6TRK331ZUyiAt5G5hPeYqSE
@tester:matrix.tphai.com MIPPYHRVAS matrix-ed25519 s8Ol56sxLCjCOi0Gkv/Kj7LqVMp/8ZmuAJ6QA1rUi7o
@tester:matrix.tphai.com UKJGJYQQLT matrix-ed25519 opC9rhsz1nzrvQqNWMKTF5FxWIGuHTDfixx+q/Y8ea0
@tester:matrix.tphai.com UPMZGRLESG matrix-ed25519 86c6XPCIYHgesq83C2k5xhXNa0EYMnqTq4jFrTwJX8I
@huangzhuang_bro:matrix.tphai.com BQHGFLQEPR matrix-ed25519 IrEHmvqotfHKLyx1JRJp4RthUVyBT8qQX72qBifRRyQ
@huangzhuang_bro:matrix.tphai.com NTVATQQGPK matrix-ed25519 lKMDsoTFK/Lc8yXoqqHBBeuK2HPKAaFFm9KjxgQzEy0

View File

@@ -98,11 +98,19 @@ class LLMService:
messages: List[Dict],
provider_config: dict,
agent_config: dict,
enable_thinking: bool = True
enable_thinking: bool = True,
images: List[Dict] = None # 图片数据列表 [{'name', 'type', 'data': base64}]
) -> Tuple[str, Optional[str]]:
"""
调用AI模型进行对话
Args:
messages: 对话历史
provider_config: LLM Provider配置
agent_config: Agent配置
enable_thinking: 是否启用思考
images: 图片数据列表(用于多模态模型)
Returns:
Tuple[str, Optional[str]]: (回复内容, 思考过程)
"""
@@ -123,6 +131,22 @@ class LLMService:
if final_messages and final_messages[0]['role'] != 'system':
final_messages.insert(0, {"role": "system", "content": system_prompt})
# 如果有图片,构建多模态消息(只修改最后一条用户消息)
if images and len(images) > 0:
# 找到最后一条用户消息
for i in range(len(final_messages) - 1, -1, -1):
if final_messages[i]['role'] == 'user':
original_text = final_messages[i]['content']
# 构建多模态内容
multimodal_content = [{"type": "text", "text": original_text if original_text else "请描述这张图片"}]
for img in images:
multimodal_content.append({
"type": "image_url",
"image_url": {"url": img['data']} # base64 data URL
})
final_messages[i]['content'] = multimodal_content
break
thinking_content = None
# 处理思考功能
@@ -208,7 +232,7 @@ class LLMService:
temperature: float = 0.7
) -> str:
"""调用API"""
url = f"{api_base}/chat/completions"
url = f"{api_base.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
@@ -220,13 +244,33 @@ class LLMService:
"max_tokens": max_tokens
}
# 打印请求详情(调试)
logger.info(f"调用LLM: url={url}, model={model}")
logger.info(f"消息数量: {len(messages)}, 第一条消息类型: {type(messages[0].get('content'))}")
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
return data['choices'][0]['message']['content']
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=payload)
# 检查HTTP状态
if response.status_code != 200:
logger.error(f"API返回错误: status={response.status_code}, body={response.text[:500]}")
response.raise_for_status()
data = response.json()
# 检查响应格式
if 'choices' not in data or len(data['choices']) == 0:
logger.error(f"API响应格式错误: {data}")
raise ValueError("API响应格式错误缺少choices")
return data['choices'][0]['message']['content']
except httpx.HTTPStatusError as e:
logger.error(f"HTTP错误: {e.response.status_code}, {e.response.text}")
raise
except Exception as e:
logger.error(f"API调用异常: {type(e).__name__}: {e}")
raise
async def chat_stream(
self,

View File

@@ -144,6 +144,197 @@
background: #f9f9f9;
}
/* AI配置专用样式 */
.ai-config-section {
background: #f8f9fa;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.ai-config-section h3 {
font-size: 18px;
color: #333;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.ai-config-section h3 i {
color: #10a37f;
}
.ai-status {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
border-radius: 8px;
margin-bottom: 16px;
}
.ai-status.ok {
border: 1px solid #10a37f;
}
.ai-status.error {
border: 1px solid #dc3545;
}
.ai-status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.ai-status-dot.ok {
background: #10a37f;
}
.ai-status-dot.error {
background: #dc3545;
}
.ai-status-text {
flex: 1;
}
.ai-config-form {
display: grid;
gap: 16px;
}
.config-row {
display: grid;
grid-template-columns: 150px 1fr;
gap: 16px;
align-items: center;
}
.config-row label {
font-weight: 500;
color: #555;
}
.config-row input, .config-row select {
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
width: 100%;
}
.config-row input:focus, .config-row select:focus {
outline: none;
border-color: #10a37f;
}
/* 模型输入组合框 */
.model-input-wrapper {
display: flex;
position: relative;
}
.model-input-wrapper input {
flex: 1;
padding-right: 40px;
}
.btn-model-dropdown {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
background: #f0f0f0;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #666;
transition: all 0.2s;
}
.btn-model-dropdown:hover {
background: #e0e0e0;
color: #333;
}
/* datalist样式提示 */
.model-input-wrapper input::-webkit-calendar-picker-indicator {
opacity: 0;
width: 32px;
height: 32px;
cursor: pointer;
}
.config-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
.btn {
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn-primary {
background: #10a37f;
color: #fff;
}
.btn-primary:hover {
background: #0d8c6d;
}
.btn-secondary {
background: #fff;
color: #333;
border: 1px solid #ddd;
}
.btn-secondary:hover {
background: #f0f0f0;
}
.btn-test {
background: #007bff;
color: #fff;
}
.btn-test:hover {
background: #0056b3;
}
.test-result {
padding: 16px;
border-radius: 8px;
margin-top: 16px;
}
.test-result.success {
background: #d4edda;
border: 1px solid #10a37f;
color: #155724;
}
.test-result.error {
background: #f8d7da;
border: 1px solid #dc3545;
color: #721c24;
}
/* 原有配置表单样式 */
.config-form {
display: flex;
gap: 16px;
@@ -167,20 +358,6 @@
min-height: 60px;
}
.config-form button {
padding: 12px 24px;
background: #10a37f;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}
.config-form button:hover {
background: #0d8c6d;
}
.config-list {
margin-top: 24px;
}
@@ -246,6 +423,11 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.loading {
opacity: 0.6;
pointer-events: none;
}
</style>
</head>
<body>
@@ -273,14 +455,97 @@
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('users')">用户管理</button>
<button class="tab-btn" onclick="switchTab('conversations')">对话记录</button>
<button class="tab-btn" onclick="switchTab('config')">系统配置</button>
<button class="tab-btn active" onclick="switchTab('ai')">🧠 AI配置</button>
<button class="tab-btn" onclick="switchTab('users')">👥 用户管理</button>
<button class="tab-btn" onclick="switchTab('conversations')">💬 对话记录</button>
<button class="tab-btn" onclick="switchTab('config')">⚙️ 其他配置</button>
</div>
<div class="tab-content">
<!-- AI配置 -->
<div class="tab-panel active" id="aiPanel">
<div class="ai-config-section">
<h3><i class="ri-robot-line"></i> 大模型配置</h3>
<div class="ai-status" id="aiStatus">
<div class="ai-status-dot" id="aiStatusDot"></div>
<div class="ai-status-text" id="aiStatusText">检测中...</div>
</div>
<div class="ai-config-form">
<div class="config-row">
<label>API地址</label>
<input type="text" id="aiApiBase" placeholder="http://192.168.2.17:19007/v1">
</div>
<div class="config-row">
<label>API密钥</label>
<input type="text" id="aiApiKey" placeholder="xxxx">
</div>
<div class="config-row">
<label>模型</label>
<div class="model-input-wrapper">
<input type="text" id="aiModel" list="modelList" placeholder="选择或输入模型名称">
<datalist id="modelList">
<option value="auto">auto (自动选择)</option>
<option value="qwen3.5-4b">qwen3.5-4b</option>
<option value="dsv32">dsv32</option>
<option value="glm-4">glm-4</option>
<option value="gpt-4o">gpt-4o</option>
<option value="claude-3-opus">claude-3-opus</option>
</datalist>
<button class="btn-model-dropdown" onclick="toggleModelDropdown()" title="显示预设模型">
<i class="ri-arrow-down-s-line"></i>
</button>
</div>
</div>
</div>
<div class="config-actions">
<button class="btn btn-primary" onclick="saveAIConfig()">
<i class="ri-save-line"></i> 保存配置
</button>
<button class="btn btn-test" onclick="testAIConnection()">
<i class="ri-link"></i> 测试连接
</button>
<button class="btn btn-secondary" onclick="refreshModels()">
<i class="ri-refresh-line"></i> 刷新模型列表
</button>
</div>
<div class="test-result" id="testResult" style="display: none;"></div>
</div>
<div class="ai-config-section">
<h3><i class="ri-information-line"></i> 当前状态</h3>
<table>
<tr>
<th>配置项</th>
<th>当前值</th>
<th>状态</th>
</tr>
<tr>
<td>API地址</td>
<td id="currentApiBase">-</td>
<td><span class="badge" id="apiBaseStatus">-</span></td>
</tr>
<tr>
<td>模型</td>
<td id="currentModel">-</td>
<td><span class="badge" id="modelStatus">-</span></td>
</tr>
<tr>
<td>连接状态</td>
<td id="connectionStatus">-</td>
<td><span class="badge" id="connectionBadge">检测中</span></td>
</tr>
</table>
</div>
</div>
<!-- 用户管理 -->
<div class="tab-panel active" id="usersPanel">
<div class="tab-panel" id="usersPanel">
<table>
<thead>
<tr>
@@ -318,13 +583,13 @@
</table>
</div>
<!-- 系统配置 -->
<!-- 其他配置 -->
<div class="tab-panel" id="configPanel">
<div class="config-form">
<input type="text" id="configKey" placeholder="配置键名">
<textarea id="configValue" placeholder="配置值"></textarea>
<input type="text" id="configDesc" placeholder="描述(可选)">
<button onclick="saveConfig()">保存</button>
<button class="btn btn-primary" onclick="saveConfig()">保存</button>
</div>
<div class="config-list" id="configList">
@@ -338,6 +603,7 @@
// 初始化
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadAIConfig();
loadUsers();
loadConversations();
loadConfig();
@@ -352,6 +618,208 @@
document.getElementById(`${tabName}Panel`).classList.add('active');
}
// 切换模型下拉显示
function toggleModelDropdown() {
const input = document.getElementById('aiModel');
input.focus();
// 触发datalist显示
if (input.showPicker) {
input.showPicker();
} else {
// 兼容性处理:模拟点击
input.click();
}
}
// 加载AI配置
async function loadAIConfig() {
try {
const response = await fetch('/api/admin/ai-config');
const data = await response.json();
document.getElementById('aiApiBase').value = data.api_base || '';
document.getElementById('aiApiKey').value = data.api_key || '';
document.getElementById('aiModel').value = data.model || 'auto';
// 更新当前状态显示
document.getElementById('currentApiBase').textContent = data.api_base || '-';
document.getElementById('currentModel').textContent = data.model || '-';
// 更新状态指示
if (data.use_mock) {
document.getElementById('aiStatusDot').className = 'ai-status-dot error';
document.getElementById('aiStatusText').textContent = '当前使用Mock模式未连接真实API';
document.getElementById('aiStatus').className = 'ai-status error';
document.getElementById('connectionStatus').textContent = 'Mock模式';
document.getElementById('connectionBadge').className = 'badge inactive';
document.getElementById('connectionBadge').textContent = '未连接';
} else {
document.getElementById('aiStatusDot').className = 'ai-status-dot ok';
document.getElementById('aiStatusText').textContent = '已配置真实API';
document.getElementById('aiStatus').className = 'ai-status ok';
document.getElementById('connectionStatus').textContent = '已配置';
document.getElementById('connectionBadge').className = 'badge active';
document.getElementById('connectionBadge').textContent = '待测试';
}
document.getElementById('apiBaseStatus').className = 'badge active';
document.getElementById('apiBaseStatus').textContent = '已配置';
document.getElementById('modelStatus').className = 'badge active';
document.getElementById('modelStatus').textContent = data.model || 'auto';
} catch (error) {
console.error('加载AI配置失败:', error);
}
}
// 保存AI配置
async function saveAIConfig() {
const apiBase = document.getElementById('aiApiBase').value.trim();
const apiKey = document.getElementById('aiApiKey').value.trim();
const model = document.getElementById('aiModel').value.trim();
if (!apiBase) {
alert('请填写API地址');
return;
}
try {
const btn = event.target;
btn.classList.add('loading');
btn.textContent = '保存中...';
const response = await fetch('/api/admin/ai-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({api_base: apiBase, api_key: apiKey, model: model})
});
const data = await response.json();
btn.classList.remove('loading');
btn.innerHTML = '<i class="ri-save-line"></i> 保存配置';
if (data.success) {
// 显示成功提示
const resultDiv = document.getElementById('testResult');
resultDiv.style.display = 'block';
resultDiv.className = 'test-result success';
resultDiv.innerHTML = `<i class="ri-check-line"></i> ${data.message}`;
// 重新加载配置
loadAIConfig();
// 3秒后隐藏提示
setTimeout(() => resultDiv.style.display = 'none', 3000);
} else {
alert('保存失败: ' + (data.message || '未知错误'));
}
} catch (error) {
console.error('保存AI配置失败:', error);
alert('保存失败: ' + error.message);
event.target.classList.remove('loading');
event.target.innerHTML = '<i class="ri-save-line"></i> 保存配置';
}
}
// 测试AI连接
async function testAIConnection() {
try {
const btn = event.target;
btn.classList.add('loading');
btn.innerHTML = '<i class="ri-loader-line"></i> 测试中...';
const response = await fetch('/api/admin/test-ai', {
method: 'POST'
});
const data = await response.json();
btn.classList.remove('loading');
btn.innerHTML = '<i class="ri-link"></i> 测试连接';
const resultDiv = document.getElementById('testResult');
resultDiv.style.display = 'block';
if (data.success) {
resultDiv.className = 'test-result success';
resultDiv.innerHTML = `<i class="ri-check-line"></i> <strong>连接成功!</strong><br>模型: ${data.model}<br>响应: ${data.message}`;
// 更新连接状态
document.getElementById('aiStatusDot').className = 'ai-status-dot ok';
document.getElementById('aiStatusText').textContent = '连接正常';
document.getElementById('aiStatus').className = 'ai-status ok';
document.getElementById('connectionStatus').textContent = '正常';
document.getElementById('connectionBadge').className = 'badge active';
document.getElementById('connectionBadge').textContent = '已连接';
} else {
resultDiv.className = 'test-result error';
resultDiv.innerHTML = `<i class="ri-close-line"></i> <strong>连接失败</strong><br>${data.message}`;
// 更新连接状态
document.getElementById('aiStatusDot').className = 'ai-status-dot error';
document.getElementById('aiStatusText').textContent = '连接失败';
document.getElementById('aiStatus').className = 'ai-status error';
document.getElementById('connectionStatus').textContent = '失败';
document.getElementById('connectionBadge').className = 'badge inactive';
document.getElementById('connectionBadge').textContent = '错误';
}
} catch (error) {
console.error('测试连接失败:', error);
event.target.classList.remove('loading');
event.target.innerHTML = '<i class="ri-link"></i> 测试连接';
const resultDiv = document.getElementById('testResult');
resultDiv.style.display = 'block';
resultDiv.className = 'test-result error';
resultDiv.innerHTML = `<i class="ri-close-line"></i> 测试失败: ${error.message}`;
}
}
// 刷新模型列表
async function refreshModels() {
try {
const btn = event.target;
btn.classList.add('loading');
btn.innerHTML = '<i class="ri-loader-line"></i> 刷新中...';
const response = await fetch('/api/admin/models');
const data = await response.json();
btn.classList.remove('loading');
btn.innerHTML = '<i class="ri-refresh-line"></i> 刷新模型列表';
// 更新datalist
const datalist = document.getElementById('modelList');
datalist.innerHTML = '';
for (const model of data.models) {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.name;
datalist.appendChild(option);
}
// 显示提示
const resultDiv = document.getElementById('testResult');
resultDiv.style.display = 'block';
resultDiv.className = 'test-result success';
resultDiv.innerHTML = `<i class="ri-check-line"></i> 获取到 ${data.models.length} 个模型,可在输入框中选择`;
if (!data.success) {
resultDiv.className = 'test-result error';
resultDiv.innerHTML = `<i class="ri-warning-line"></i> ${data.message || '使用默认模型列表'}`;
}
setTimeout(() => resultDiv.style.display = 'none', 3000);
} catch (error) {
console.error('刷新模型列表失败:', error);
event.target.classList.remove('loading');
event.target.innerHTML = '<i class="ri-refresh-line"></i> 刷新模型列表';
}
}
// 加载统计数据
async function loadStats() {
try {
@@ -423,7 +891,7 @@
}
}
// 加载配置
// 加载其他配置
async function loadConfig() {
try {
const response = await fetch('/api/admin/config');
@@ -431,12 +899,15 @@
const container = document.getElementById('configList');
if (data.configs.length === 0) {
container.innerHTML = '<p>暂无配置</p>';
// 过滤掉AI配置在AI配置面板单独显示
const otherConfigs = data.configs.filter(c => !c.key.startsWith('ai_'));
if (otherConfigs.length === 0) {
container.innerHTML = '<p>暂无其他配置</p>';
return;
}
container.innerHTML = data.configs.map(config => `
container.innerHTML = otherConfigs.map(config => `
<div class="config-item">
<div class="key">${config.key}</div>
<div class="value">${config.value}</div>
@@ -448,7 +919,7 @@
}
}
// 保存配置
// 保存其他配置
async function saveConfig() {
const key = document.getElementById('configKey').value.trim();
const value = document.getElementById('configValue').value.trim();
@@ -497,6 +968,9 @@
// 定时刷新
setInterval(() => {
loadStats();
if (document.getElementById('aiPanel').classList.contains('active')) {
loadAIConfig();
}
if (document.getElementById('usersPanel').classList.contains('active')) {
loadUsers();
}

View File

@@ -107,10 +107,23 @@
.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; }
@@ -133,6 +146,17 @@
.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; }
@@ -166,9 +190,12 @@
<div class="input-container">
<div class="input-area">
<div class="input-row">
<input type="file" id="fileInput" multiple accept="image/*,.pdf,.txt,.md,.json,.csv,.doc,.docx" style="display:none" onchange="handleFileUpload(event)">
<button class="upload-btn" onclick="document.getElementById('fileInput').click()" title="上传文件"><i class="ri-attachment-2"></i></button>
<textarea id="messageInput" placeholder="输入消息..." rows="1"></textarea>
<button class="send-btn" id="sendBtn" onclick="sendMessage()"><i class="ri-send-plane-fill"></i></button>
</div>
<div class="file-preview-area" id="filePreviewArea"></div>
<div class="quick-phrases-bar">
<div class="tool-toggle">
<input type="checkbox" id="enableSearch" checked>
@@ -195,6 +222,34 @@
</div>
</div>
<!-- 图片放大弹窗 -->
<div class="image-lightbox" id="imageLightbox" onclick="closeImageLightbox()">
<div class="image-lightbox-close"><i class="ri-close-line"></i></div>
<img id="lightboxImage" src="" alt="放大图片">
</div>
<!-- 隐藏的图片上传API处理 -->
<script>
// 图片上传到服务器(保存文件)
async function uploadImageToServer(base64Data, fileName) {
try {
const response = await fetch('/api/v2/upload-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Data, name: fileName })
});
const result = await response.json();
if (result.success) {
return result.path; // 返回服务器文件路径
}
return null;
} catch (e) {
console.error('图片上传失败:', e);
return null;
}
}
</script>
<!-- Markdown渲染库 -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
@@ -206,6 +261,10 @@
let currentAgentId = null;
let agents = [];
let quickPhrases = [];
let lastSentMessage = null; // 记录最后发送的消息
let lastSentFiles = null; // 记录发送的文件
let lastSentMessageWithFiles = null; // 记录包含文件信息的完整消息
let pendingFiles = []; // 待发送的文件
let lastUserMessage = null; // 存储最后一条用户消息,用于重新生成
let isRegenerating = false; // 标志:正在重新生成,跳过用户消息显示
let regeneratingMessageId = null; // 正在重新生成的消息ID
@@ -280,11 +339,12 @@
case 'stream_end': document.getElementById('sendBtn').disabled = false; break;
case 'user_message':
lastUserMessage = data.message.content; // 存储最后一条用户消息
// 如果是刚发送的消息,已经显示了,不再重复显示
if (!isRegenerating && data.message.content !== lastSentMessage) {
// 如果是刚发送的消息(包含文件),已经显示了,不再重复显示
if (!isRegenerating && data.message.content !== lastSentMessage && data.message.content !== lastSentMessageWithFiles) {
appendMessage('user', data.message.content);
}
lastSentMessage = null; // 清除标记
lastSentMessage = null;
lastSentMessageWithFiles = null; // 清除标记
// 注意:不要在这里重置 isRegenerating要等 assistant_message 处理后再重置
break;
case 'assistant_message':
@@ -372,19 +432,50 @@
html += '</div>';
div.innerHTML = html;
// 如果是用户消息且有搜索结果在设置innerHTML后追加
// 如果是用户消息且有额外数据(搜索结果、图片、文件)在设置innerHTML后追加
if (role === 'user' && extraData) {
console.log('Processing extraData for user message:', extraData);
console.log('search_results exists:', extraData.search_results);
const bodyDiv = div.querySelector('.message-body');
// 处理图片如果有服务器URL显示图片
if (extraData.images && extraData.images.length > 0) {
let imagesHtml = '<div class="history-images" style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">';
for (const img of extraData.images) {
if (img.url) {
// 有服务器URL显示真实图片
imagesHtml += `<div class="history-image" style="display:inline-block;">
<img src="${img.url}" style="max-width:300px;max-height:200px;border-radius:8px;cursor:zoom-in;" onclick="openImageLightbox('${img.url}')">
</div>`;
} else {
// 没有URL显示占位符
imagesHtml += `<div class="history-image-placeholder" style="padding:8px 12px;background:#f0f0f0;border-radius:8px;display:flex;align-items:center;gap:6px;font-size:13px;color:#666;">
<i class="ri-image-line" style="color:#10a37f;"></i>
<span>${escapeHtml(img.name || '图片')}</span>
</div>`;
}
}
imagesHtml += '</div>';
if (bodyDiv) bodyDiv.insertAdjacentHTML('beforeend', imagesHtml);
}
// 处理文本文件
if (extraData.files && extraData.files.length > 0) {
let filesHtml = '<div class="history-files" style="margin-top:8px;">';
for (const f of extraData.files) {
filesHtml += `<div class="history-file-placeholder" style="padding:6px 10px;background:#f5f5f5;border-radius:6px;margin-bottom:4px;display:flex;align-items:center;gap:6px;font-size:12px;color:#666;">
<i class="ri-file-text-line" style="color:#10a37f;"></i>
<span>${escapeHtml(f.name || '文件')}</span>
</div>`;
}
filesHtml += '</div>';
if (bodyDiv) bodyDiv.insertAdjacentHTML('beforeend', filesHtml);
}
// 处理搜索结果
if (extraData.search_results && extraData.search_results.length > 0) {
console.log('Building search results HTML for', extraData.search_results.length, 'results');
const searchHtml = buildSearchResultsHtml(extraData.search_results, extraData.search_query || content);
const bodyDiv = div.querySelector('.message-body');
console.log('bodyDiv found:', bodyDiv != null);
if (bodyDiv) {
bodyDiv.insertAdjacentHTML('beforeend', searchHtml);
console.log('Search results HTML inserted');
}
if (bodyDiv) bodyDiv.insertAdjacentHTML('beforeend', searchHtml);
}
}
@@ -773,32 +864,180 @@
function sendMessage() {
const input = document.getElementById('messageInput');
const msg = input.value.trim();
if (!msg) return;
// 如果没有消息且没有文件,不发送
if (!msg && pendingFiles.length === 0) return;
document.getElementById('sendBtn').disabled = true;
input.value = '';
input.style.height = 'auto';
// 立即显示用户消息(不等后端广播
lastSentMessage = msg; // 记录最后发送的消息,避免重复显示
appendMessage('user', msg);
// 立即显示用户消息(包含文件
lastSentMessage = msg;
appendMessageWithFiles('user', msg, pendingFiles);
// 清空文件预览
pendingFiles = [];
document.getElementById('filePreviewArea').innerHTML = '';
// 获取工具禁用状态
const enableSearch = document.getElementById('enableSearch').checked;
const disabledTools = [];
if (!enableSearch) disabledTools.push('search');
// 发送消息(包含文件)
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
action: 'chat',
message: msg,
conversation_id: currentConversationId,
agent_id: currentAgentId,
disabled_tools: disabledTools // 禁用的工具列表
disabled_tools: disabledTools,
files: lastSentFiles || [] // 发送的文件列表
}));
}
lastSentFiles = null; // 清空
}
let lastSentMessage = null; // 记录最后发送的消息
// 显示带文件的用户消息
function appendMessageWithFiles(role, content, files) {
const container = document.getElementById('messagesContainer');
container.querySelector('.welcome')?.remove();
const div = document.createElement('div');
div.className = `message ${role}`;
const avatar = role === 'user' ? '👤' : '🤖';
let html = `<div class="message-avatar">${avatar}</div><div class="message-body">`;
// 显示文本内容
if (content) {
html += `<div class="message-content"><div class="user-message-text">${escapeHtml(content)}</div></div>`;
}
// 显示文件(图片直接显示,文本文件显示名称)
if (files && files.length > 0) {
html += '<div class="uploaded-files" style="margin-top:8px">';
lastSentFiles = files.map(f => ({
name: f.name,
type: f.type,
content: f.content,
serverPath: f.serverPath // 服务器路径(用于历史记录)
}));
for (const f of files) {
if (f.type.startsWith('image/')) {
// 图片直接显示用服务器路径或base64
const imgSrc = f.serverPath || f.content;
html += `<div class="uploaded-image" style="margin-bottom:8px"><img src="${f.content}" style="max-width:300px;border-radius:8px" onclick="openImageLightbox('${imgSrc}')"></div>`;
} else {
// 文本文件显示名称和内容摘要
html += `<div class="uploaded-file" style="padding:8px;background:#f5f5f5;border-radius:6px;margin-bottom:8px">`;
html += `<div style="font-size:13px;color:#10a37f"><i class="ri-file-text-line"></i> ${f.name}</div>`;
if (f.content && f.content.length < 2000) {
html += `<div style="font-size:12px;color:#666;margin-top:4px">${escapeHtml(f.content.substring(0, 300))}${f.content.length > 300 ? '...' : ''}</div>`;
}
html += '</div>';
}
}
html += '</div>';
// 记录完整消息(用于判断是否重复显示)
lastSentMessageWithFiles = content + '\n[文件:' + files.map(f => f.name).join(',') + ']';
}
html += '</div>';
div.innerHTML = html;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
// 文件上传处理
async function handleFileUpload(event) {
const files = event.target.files;
const previewArea = document.getElementById('filePreviewArea');
for (const file of files) {
const fileId = 'file-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
// 读取文件内容
const reader = new FileReader();
reader.onload = async (e) => {
const base64Content = e.target.result;
// 图片:先上传到服务器保存
let serverPath = null;
if (file.type.startsWith('image/')) {
serverPath = await uploadImageToServer(base64Content, file.name);
console.log('图片上传结果:', serverPath);
}
const fileData = {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
content: base64Content, // base64数据用于多模态模型
serverPath: serverPath // 服务器路径(用于历史记录显示)
};
pendingFiles.push(fileData);
// 显示预览
const previewItem = document.createElement('div');
previewItem.className = 'file-preview-item';
previewItem.id = fileId + '-preview';
if (file.type.startsWith('image/')) {
previewItem.classList.add('image-preview');
// 预览用本地base64显示更快
previewItem.innerHTML = `
<img src="${base64Content}" alt="${file.name}" style="cursor:pointer" onclick="openImageLightbox('${serverPath || base64Content}')">
<button class="file-remove" onclick="removeFile('${fileId}')"><i class="ri-close-line"></i></button>
`;
} else {
// 文件图标
let iconClass = 'ri-file-text-line';
if (file.name.endsWith('.pdf')) iconClass = 'ri-file-pdf-line';
else if (file.name.endsWith('.json')) iconClass = 'ri-code-line';
else if (file.name.endsWith('.csv')) iconClass = 'ri-table-line';
else if (file.type.includes('word') || file.name.endsWith('.doc') || file.name.endsWith('.docx')) iconClass = 'ri-file-word-line';
previewItem.innerHTML = `
<div class="file-icon">
<i class="${iconClass}"></i>
<span class="file-name">${file.name}</span>
</div>
<button class="file-remove" onclick="removeFile('${fileId}')"><i class="ri-close-line"></i></button>
`;
}
previewArea.appendChild(previewItem);
};
// 根据文件类型选择读取方式
if (file.type.startsWith('image/')) {
reader.readAsDataURL(file);
} else if (file.type === 'application/pdf') {
reader.readAsDataURL(file);
} else {
// 文本类文件直接读取内容
reader.readAsText(file);
}
}
// 清空 input 以便再次选择
event.target.value = '';
}
function removeFile(fileId) {
pendingFiles = pendingFiles.filter(f => f.id !== fileId);
const preview = document.getElementById(fileId + '-preview');
if (preview) preview.remove();
}
// 发送消息
function setupTextarea() {
const textarea = document.getElementById('messageInput');
@@ -858,6 +1097,26 @@
}
document.getElementById('newPhraseInput').addEventListener('keydown', e => { if (e.key === 'Enter') addPhrase(); if (e.key === 'Escape') hidePhraseModal(); });
// 图片放大弹窗
function openImageLightbox(imageSrc) {
const lightbox = document.getElementById('imageLightbox');
const lightboxImg = document.getElementById('lightboxImage');
lightboxImg.src = imageSrc;
lightbox.classList.add('show');
}
function closeImageLightbox() {
const lightbox = document.getElementById('imageLightbox');
lightbox.classList.remove('show');
}
// ESC键关闭图片弹窗
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeImageLightbox();
}
});
</script>
</body>
</html>