ai-chat-ui/server/api/conversation_routes.py

331 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
会话管理路由 - 对话 CRUD 和文件上传
与平台无关的通用功能,不涉及任何 LLM 平台细节。
"""
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Dict
from fastapi import File, HTTPException, UploadFile
from fastapi.responses import FileResponse
import sys
sys.path.append(str(Path(__file__).parent.parent))
from database import get_db
from utils.helpers import generate_unique_id
from core import log_error, log_exception, log_info
# 配置上传目录
upload_dir = Path(__file__).parent.parent / "uploads"
upload_dir.mkdir(exist_ok=True)
# ── 会话管理 ─────────────────────────────────────────────────────
async def get_conversations_handler(user_id: str = "default"):
"""获取所有对话处理器"""
db = get_db()
return db.list_conversations(user_id)
async def get_conversation_handler(conversation_id: str):
"""获取特定对话处理器"""
db = get_db()
conversation = db.get_conversation(conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
return conversation
async def save_conversation_handler(data: dict):
"""保存或更新对话处理器"""
try:
db = get_db()
conversation_id = data.get("id")
# 检查是否已存在
existing = db.get_conversation(conversation_id) if conversation_id else None
if existing:
# 更新现有会话
return db.update_conversation(conversation_id, data)
else:
# 创建新会话
return db.create_conversation(data)
except Exception as e:
log_error(f"Error saving conversation: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
async def delete_conversation_handler(conversation_id: str):
"""删除对话处理器(同时删除关联的 OSS 文件)"""
db = get_db()
# 先获取会话数据,提取 OSS 文件 URL
conversation = db.get_conversation(conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
# 提取所有 OSS 文件 URL
oss_urls = _extract_oss_urls_from_conversation(conversation)
# 删除 OSS 文件
if oss_urls:
try:
from utils.oss_uploader import delete_files, extract_object_key_from_url
object_keys = []
for url in oss_urls:
key = extract_object_key_from_url(url)
if key:
object_keys.append(key)
if object_keys:
result = delete_files(object_keys)
log_info(f"[删除会话] OSS 文件清理结果: 删除 {len(result['deleted'])} 个, 失败 {len(result['failed'])}")
if result['failed']:
log_error(f"[删除会话] OSS 文件删除失败: {result['failed']}")
except Exception as e:
log_error(f"[删除会话] OSS 文件删除异常: {e}")
# 继续删除会话,即使 OSS 删除失败
# 删除数据库记录
success = db.delete_conversation(conversation_id)
if success:
return {"success": True, "message": "删除成功"}
else:
raise HTTPException(status_code=404, detail="对话不存在")
def _extract_oss_urls_from_conversation(conversation: dict) -> list:
"""
从会话消息中提取所有 OSS 文件 URL
消息结构:
- content.images: 图片附件列表
- content.files: 文件附件列表
每个附件包含 url 字段
"""
urls = []
messages = conversation.get("messages", [])
for message in messages:
content = message.get("content")
if not content:
continue
# content 可能是字符串(需要解析)或已解析的字典
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
continue
# 提取图片附件
images = content.get("images", [])
for img in images:
url = img.get("url")
if url and url not in urls:
urls.append(url)
# 提取文件附件
files = content.get("files", [])
for f in files:
url = f.get("url")
if url and url not in urls:
urls.append(url)
return urls
async def update_conversation_handler(conversation_id: str, data: dict):
"""部分更新对话处理器"""
db = get_db()
result = db.update_conversation(conversation_id, data)
if result:
return result
else:
raise HTTPException(status_code=404, detail="对话不存在")
# ── 消息管理 ─────────────────────────────────────────────────────
async def add_message_handler(conversation_id: str, message: dict):
"""添加消息到对话处理器"""
db = get_db()
# 检查对话是否存在
existing = db.get_conversation(conversation_id)
if not existing:
raise HTTPException(status_code=404, detail="对话不存在")
return db.add_message(conversation_id, message)
async def update_message_handler(conversation_id: str, message_id: str, data: dict):
"""更新消息处理器"""
db = get_db()
# 检查对话是否存在
existing = db.get_conversation(conversation_id)
if not existing:
raise HTTPException(status_code=404, detail="对话不存在")
result = db.update_message(message_id, data)
if result:
return result
else:
raise HTTPException(status_code=404, detail="消息不存在")
# ── 文件上传 ─────────────────────────────────────────────────────
async def upload_file_handler(file: UploadFile = File(...)):
"""文件上传处理器"""
try:
# 允许的 MIME 类型(宽松策略)
allowed_types = {
# 图片
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/svg+xml",
# 文本类
"text/plain",
"text/csv",
"text/markdown",
"text/html",
"text/xml",
"application/json",
"application/xml",
# PDF
"application/pdf",
# Office 文档
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
}
# 允许的扩展名兜底MIME 类型可能被浏览器误判)
allowed_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".txt",
".md",
".csv",
".json",
".xml",
".yaml",
".yml",
".log",
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".py",
".js",
".ts",
".html",
".css",
}
file_extension = Path(file.filename).suffix.lower()
if (
file.content_type not in allowed_types
and file_extension not in allowed_extensions
):
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {file.content_type}{file_extension}",
)
# 生成唯一文件名
unique_filename = f"{int(datetime.utcnow().timestamp())}_{generate_unique_id()}{file_extension}"
file_path = upload_dir / unique_filename
# 保存文件到本地(临时缓存)
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# 文件关闭后再上传到 OSS
from utils.oss_uploader import upload_file as oss_upload
oss_result = oss_upload(str(file_path))
file_url = oss_result["url"]
# 返回文件信息
result = {
"url": file_url,
"name": file.filename,
"size": len(content),
"mimeType": file.content_type,
}
log_info(f"File uploaded: {result}")
return result
except Exception as e:
log_error(f"Upload error: {str(e)}")
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
def serve_upload_handler(filename: str):
"""提供上传文件访问处理器"""
file_path = upload_dir / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
return FileResponse(str(file_path))
# ── 停止生成 ─────────────────────────────────────────────────────
async def stop_generation_handler(message_id: str = None):
"""停止生成处理器"""
message = (
f"已发出停止指令消息ID: {message_id}" if message_id else "已发出停止指令"
)
return {"success": True, "message": message}
async def delete_attachment_handler(url: str):
"""删除附件处理器 - 从 OSS 删除文件"""
try:
from utils.oss_uploader import delete_file, extract_object_key_from_url
object_key = extract_object_key_from_url(url)
if not object_key:
raise HTTPException(status_code=400, detail="无效的文件 URL")
success = delete_file(object_key)
if success:
return {"success": True, "message": "文件删除成功"}
else:
raise HTTPException(status_code=500, detail="文件删除失败")
except HTTPException:
raise
except Exception as e:
log_error(f"删除附件失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")