|
@@ -1,14 +1,17 @@
|
|
|
-from fastapi import APIRouter, HTTPException, Depends, Query
|
|
|
|
|
|
|
+from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File
|
|
|
from fastapi.responses import StreamingResponse
|
|
from fastapi.responses import StreamingResponse
|
|
|
from typing import List, Annotated, Optional
|
|
from typing import List, Annotated, Optional
|
|
|
from datetime import datetime
|
|
from datetime import datetime
|
|
|
import json
|
|
import json
|
|
|
import asyncio
|
|
import asyncio
|
|
|
import threading
|
|
import threading
|
|
|
|
|
+import tempfile
|
|
|
|
|
+import os
|
|
|
|
|
|
|
|
from ..core.ark_client import get_client
|
|
from ..core.ark_client import get_client
|
|
|
|
|
+from ..core.asr_client import transcribe_audio
|
|
|
from ..config.config import Config
|
|
from ..config.config import Config
|
|
|
-from ..schemas.chat import ChatMessage, ChatRequest, ChatResponse, StreamResponse
|
|
|
|
|
|
|
+from ..schemas.chat import ChatMessage, ChatRequest, ChatResponse, StreamResponse, FileAttachment
|
|
|
from ..utils.chat_utils import get_latest_user_message, get_previous_response_id, get_doubao_tools, get_web_search_tools, get_knowledge_search_tools
|
|
from ..utils.chat_utils import get_latest_user_message, get_previous_response_id, get_doubao_tools, get_web_search_tools, get_knowledge_search_tools
|
|
|
from ..routers.users import get_current_active_user, User
|
|
from ..routers.users import get_current_active_user, User
|
|
|
from ..db.mongo import save_chat_log, save_chat_history, get_chat_history, delete_chat_history, get_sessions
|
|
from ..db.mongo import save_chat_log, save_chat_history, get_chat_history, delete_chat_history, get_sessions
|
|
@@ -19,6 +22,129 @@ config = Config()
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
+# 允许的文件类型和大小限制
|
|
|
|
|
+ALLOWED_MEDIA_TYPES = {
|
|
|
|
|
+ "image": {"extensions": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"], "max_size": 20 * 1024 * 1024},
|
|
|
|
|
+ "video": {"extensions": [".mp4", ".mov", ".avi", ".mkv", ".webm"], "max_size": 100 * 1024 * 1024},
|
|
|
|
|
+ "audio": {"extensions": [".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"], "max_size": 50 * 1024 * 1024},
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def detect_media_type(filename: str) -> Optional[str]:
|
|
|
|
|
+ """ 根据文件后缀检测媒体类型 """
|
|
|
|
|
+ ext = os.path.splitext(filename)[1].lower()
|
|
|
|
|
+ for media_type, cfg in ALLOWED_MEDIA_TYPES.items():
|
|
|
|
|
+ if ext in cfg["extensions"]:
|
|
|
|
|
+ return media_type
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# 媒体类型 → Responses API input type 映射
|
|
|
|
|
+_MEDIA_TYPE_TO_INPUT_TYPE = {
|
|
|
|
|
+ "image": "input_image",
|
|
|
|
|
+ "video": "input_video",
|
|
|
|
|
+ "audio": "input_audio",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_multimodal_input(message: ChatMessage) -> dict:
|
|
|
|
|
+ """
|
|
|
|
|
+ 将 ChatMessage 转换为 Responses API 支持的多模态 input 格式。
|
|
|
|
|
+ - 纯文本消息:保持 {"role": "user", "content": "text"} (向后兼容)
|
|
|
|
|
+ - 带附件消息:根据 media_type 使用对应 input type:
|
|
|
|
|
+ 图片 → {"type": "input_image", "file_id": "..."}
|
|
|
|
|
+ 视频 → {"type": "input_video", "file_id": "..."}
|
|
|
|
|
+ 音频 → {"type": "input_audio", "file_id": "..."}
|
|
|
|
|
+ """
|
|
|
|
|
+ if not message.attachments:
|
|
|
|
|
+ return {"role": message.role, "content": message.content}
|
|
|
|
|
+
|
|
|
|
|
+ content_parts = []
|
|
|
|
|
+ for attachment in message.attachments:
|
|
|
|
|
+ input_type = _MEDIA_TYPE_TO_INPUT_TYPE.get(attachment.media_type, "input_file")
|
|
|
|
|
+ content_parts.append({
|
|
|
|
|
+ "type": input_type,
|
|
|
|
|
+ "file_id": attachment.file_id,
|
|
|
|
|
+ })
|
|
|
|
|
+ if message.content and message.content.strip():
|
|
|
|
|
+ content_parts.append({
|
|
|
|
|
+ "type": "input_text",
|
|
|
|
|
+ "text": message.content,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {"role": message.role, "content": content_parts}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/upload")
|
|
|
|
|
+async def upload_file(
|
|
|
|
|
+ file: UploadFile = File(...),
|
|
|
|
|
+ user_context: UserContext = Depends(resolve_user_id),
|
|
|
|
|
+):
|
|
|
|
|
+ """接收前端文件 → 调用火山方舟 Files API → 返回 file_id"""
|
|
|
|
|
+ media_type = detect_media_type(file.filename)
|
|
|
|
|
+ if not media_type:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail=f"不支持的文件类型: {file.filename}")
|
|
|
|
|
+
|
|
|
|
|
+ max_size = ALLOWED_MEDIA_TYPES[media_type]["max_size"]
|
|
|
|
|
+ content = await file.read()
|
|
|
|
|
+ if len(content) > max_size:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail=f"文件大小超出限制({max_size // 1024 // 1024}MB)")
|
|
|
|
|
+
|
|
|
|
|
+ client = get_client(user_context.app_name)
|
|
|
|
|
+ tmp_path = None
|
|
|
|
|
+ try:
|
|
|
|
|
+ with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.filename)[1]) as tmp:
|
|
|
|
|
+ tmp.write(content)
|
|
|
|
|
+ tmp_path = tmp.name
|
|
|
|
|
+
|
|
|
|
|
+ with open(tmp_path, "rb") as f:
|
|
|
|
|
+ uploaded_file = client.files.create(
|
|
|
|
|
+ file=(file.filename, f),
|
|
|
|
|
+ purpose="user_data"
|
|
|
|
|
+ )
|
|
|
|
|
+ finally:
|
|
|
|
|
+ if tmp_path and os.path.exists(tmp_path):
|
|
|
|
|
+ os.unlink(tmp_path)
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "file_id": uploaded_file.id,
|
|
|
|
|
+ "filename": file.filename,
|
|
|
|
|
+ "media_type": media_type,
|
|
|
|
|
+ "size": len(content),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/voice")
|
|
|
|
|
+async def voice_to_text(
|
|
|
|
|
+ file: UploadFile = File(...),
|
|
|
|
|
+ user_context: UserContext = Depends(resolve_user_id),
|
|
|
|
|
+):
|
|
|
|
|
+ """
|
|
|
|
|
+ 接收前端录制的语音文件 (WebM/Opus) → 调用火山引擎 ASR → 返回转写文本
|
|
|
|
|
+ 最大支持 60 秒录音,文件不超过 10MB
|
|
|
|
|
+ """
|
|
|
|
|
+ MAX_VOICE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
|
|
|
+
|
|
|
|
|
+ # 校验文件后缀
|
|
|
|
|
+ filename = file.filename or "voice.webm"
|
|
|
|
|
+ ext = os.path.splitext(filename)[1].lower()
|
|
|
|
|
+ if ext not in [".webm", ".ogg", ".wav", ".mp3", ".m4a"]:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail=f"不支持的音频格式: {ext}")
|
|
|
|
|
+
|
|
|
|
|
+ content = await file.read()
|
|
|
|
|
+ if len(content) > MAX_VOICE_SIZE:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="音频文件超过 10MB 限制")
|
|
|
|
|
+
|
|
|
|
|
+ if len(content) == 0:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="音频文件为空")
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ text = await transcribe_audio(content)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ raise HTTPException(status_code=500, detail=f"语音识别失败: {str(e)}")
|
|
|
|
|
+
|
|
|
|
|
+ return {"text": text}
|
|
|
|
|
+
|
|
|
|
|
|
|
|
async def generate_stream_response(request: ChatRequest, user_context: UserContext):
|
|
async def generate_stream_response(request: ChatRequest, user_context: UserContext):
|
|
|
session_id = request.session_id
|
|
session_id = request.session_id
|
|
@@ -35,13 +161,15 @@ async def generate_stream_response(request: ChatRequest, user_context: UserConte
|
|
|
if not latest_user_msg:
|
|
if not latest_user_msg:
|
|
|
raise ValueError("请求中没有找到user角色的消息")
|
|
raise ValueError("请求中没有找到user角色的消息")
|
|
|
|
|
|
|
|
- # 保存用户消息到 DB
|
|
|
|
|
|
|
+ # 保存用户消息到 DB(含附件信息)
|
|
|
|
|
+ user_attachments = [att.model_dump() for att in latest_user_msg.attachments] if latest_user_msg.attachments else None
|
|
|
save_chat_history(
|
|
save_chat_history(
|
|
|
user_id=user_context.user_id,
|
|
user_id=user_context.user_id,
|
|
|
session_id=session_id,
|
|
session_id=session_id,
|
|
|
role=latest_user_msg.role,
|
|
role=latest_user_msg.role,
|
|
|
content=latest_user_msg.content,
|
|
content=latest_user_msg.content,
|
|
|
timestamp=datetime.now(),
|
|
timestamp=datetime.now(),
|
|
|
|
|
+ attachments=user_attachments,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
system_prompt = f"""
|
|
system_prompt = f"""
|
|
@@ -174,7 +302,7 @@ async def generate_stream_response(request: ChatRequest, user_context: UserConte
|
|
|
"""
|
|
"""
|
|
|
system_prompt = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
|
|
system_prompt = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
|
|
|
|
|
|
|
|
- api_messages = [system_prompt, {"role": latest_user_msg.role, "content": latest_user_msg.content}]
|
|
|
|
|
|
|
+ api_messages = [system_prompt, build_multimodal_input(latest_user_msg)]
|
|
|
tools = get_web_search_tools()
|
|
tools = get_web_search_tools()
|
|
|
if knowledge_id:
|
|
if knowledge_id:
|
|
|
tools += get_knowledge_search_tools(knowledge_id)
|
|
tools += get_knowledge_search_tools(knowledge_id)
|
|
@@ -331,15 +459,17 @@ async def chat(
|
|
|
|
|
|
|
|
client = get_client(user_context.app_name)
|
|
client = get_client(user_context.app_name)
|
|
|
|
|
|
|
|
|
|
+ user_attachments = [att.model_dump() for att in latest_user_msg.attachments] if latest_user_msg.attachments else None
|
|
|
save_chat_history(
|
|
save_chat_history(
|
|
|
user_id=user_context.user_id,
|
|
user_id=user_context.user_id,
|
|
|
session_id=session_id,
|
|
session_id=session_id,
|
|
|
role=latest_user_msg.role,
|
|
role=latest_user_msg.role,
|
|
|
content=latest_user_msg.content,
|
|
content=latest_user_msg.content,
|
|
|
timestamp=datetime.now(),
|
|
timestamp=datetime.now(),
|
|
|
|
|
+ attachments=user_attachments,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- api_messages = [{"role": latest_user_msg.role, "content": latest_user_msg.content}]
|
|
|
|
|
|
|
+ api_messages = [build_multimodal_input(latest_user_msg)]
|
|
|
tools = get_doubao_tools()
|
|
tools = get_doubao_tools()
|
|
|
previous_response_id = get_previous_response_id(user_context.user_id, session_id)
|
|
previous_response_id = get_previous_response_id(user_context.user_id, session_id)
|
|
|
|
|
|
|
@@ -429,6 +559,7 @@ async def get_user_history(
|
|
|
ChatMessage(
|
|
ChatMessage(
|
|
|
role=doc["role"],
|
|
role=doc["role"],
|
|
|
content=doc["content"],
|
|
content=doc["content"],
|
|
|
|
|
+ attachments=[FileAttachment(**att) for att in doc["attachments"]] if doc.get("attachments") else None,
|
|
|
timestamp=doc.get("timestamp"),
|
|
timestamp=doc.get("timestamp"),
|
|
|
response_id=doc.get("response_id"),
|
|
response_id=doc.get("response_id"),
|
|
|
thinking=doc.get("thinking"),
|
|
thinking=doc.get("thinking"),
|