Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

604 lines
18 KiB
Python

import traceback
import uuid
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.storage.postgres.models_business import User
from server.utils.auth_middleware import get_db, get_required_user
from yuxi import config as conf
from yuxi.models import select_model
from yuxi.services.chat_service import get_agent_state_view
from yuxi.services.conversation_service import (
confirm_tmp_thread_attachments_view,
create_thread_view,
delete_thread_attachment_view,
delete_thread_view,
get_thread_history_view,
list_thread_attachments_view,
list_threads_view,
parse_tmp_attachment_view,
search_threads_view,
update_thread_view,
upload_thread_attachment_view,
upload_tmp_attachment_view,
)
from yuxi.services.file_preview import detect_media_type
from yuxi.services.thread_files_service import (
list_thread_files_view,
read_thread_file_content_view,
resolve_thread_artifact_view,
save_thread_artifact_to_workspace_view,
)
from yuxi.services.feedback_service import get_message_feedback_view, submit_message_feedback_view
from yuxi.utils.logging_config import logger
from yuxi.utils.image_processor import process_uploaded_image
from yuxi.utils.paths import VIRTUAL_PATH_PREFIX
# TODO:当前文件的功能过于庞杂,路由标签混乱
# 图片上传响应模型
class ImageUploadResponse(BaseModel):
success: bool
image_content: str | None = None
thumbnail_content: str | None = None
width: int | None = None
height: int | None = None
format: str | None = None
mime_type: str | None = None
size_bytes: int | None = None
error: str | None = None
chat = APIRouter(prefix="/chat", tags=["chat"])
@chat.post("/call")
async def call(query: str = Body(...), meta: dict = Body(None), current_user: User = Depends(get_required_user)):
"""调用模型进行简单问答(需要登录)"""
meta = meta or {}
# 确保 request_id 存在
if "request_id" not in meta or not meta.get("request_id"):
meta["request_id"] = str(uuid.uuid4())
model = select_model(model_spec=meta.get("model_spec") or meta.get("model") or conf.default_model)
response = await model.call(query)
logger.debug({"query": query, "response": response.content})
return {"response": response.content, "request_id": meta["request_id"]}
@chat.get("/thread/{thread_id}/history")
async def get_thread_history(
thread_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db)
):
"""获取对话历史消息(需要登录)- 包含用户反馈状态"""
try:
return await get_thread_history_view(
thread_id=thread_id,
current_uid=str(current_user.uid),
db=db,
)
except Exception as e:
logger.error(f"获取对话历史消息出错: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取对话历史消息出错: {str(e)}")
@chat.get("/thread/{thread_id}/state")
async def get_thread_state(
thread_id: str,
include_messages: bool = Query(False),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""获取对话当前状态(需要登录)"""
try:
return await get_agent_state_view(
thread_id=thread_id,
current_user=current_user,
db=db,
include_messages=include_messages,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取对话状态出错: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取对话状态出错: {str(e)}")
# ==================== 线程管理 API ====================
class ThreadCreate(BaseModel):
title: str | None = None
agent_id: str
metadata: dict | None = None
class ThreadResponse(BaseModel):
id: str
uid: str
agent_id: str
title: str | None = None
is_pinned: bool = False
created_at: str
updated_at: str
metadata: dict[str, Any] = Field(default_factory=dict)
class ThreadSearchSnippet(BaseModel):
message_id: int | None = None
content: str
created_at: str | None = None
class ThreadSearchItem(ThreadResponse):
thread_id: str
matched_count: int
message_id: int | None = None
latest_match_at: str | None = None
snippets: list[ThreadSearchSnippet] = Field(default_factory=list)
class ThreadSearchResponse(BaseModel):
items: list[ThreadSearchItem]
has_more: bool
limit: int
offset: int
class AttachmentResponse(BaseModel):
file_id: str
file_name: str
file_type: str | None = None
file_size: int
status: str
uploaded_at: str
path: str
artifact_url: str | None = None
original_path: str | None = None
original_artifact_url: str | None = None
minio_url: str | None = None
request_id: str | None = None
class AttachmentLimits(BaseModel):
allowed_extensions: list[str]
max_size_bytes: int
class AttachmentListResponse(BaseModel):
attachments: list[AttachmentResponse]
limits: AttachmentLimits
class TmpAttachmentResponse(BaseModel):
tmp_file_id: str
file_name: str
file_type: str | None = None
file_size: int
bucket_name: str
object_name: str
minio_url: str
uploaded_at: str
parse_supported: bool = False
parse_methods: list[str] = Field(default_factory=list)
class TmpAttachmentParseRequest(BaseModel):
object_name: str
file_name: str
parse_method: str | None = None
bucket_name: str | None = None
class TmpAttachmentParseResponse(BaseModel):
tmp_file_id: str
file_name: str
bucket_name: str
object_name: str
parsed_object_name: str
parsed_minio_url: str
parse_method: str
status: str
truncated: bool = False
class TmpAttachmentConfirmItem(BaseModel):
file_name: str
file_type: str | None = None
bucket_name: str
object_name: str
parsed_object_name: str | None = None
truncated: bool = False
class TmpAttachmentConfirmRequest(BaseModel):
attachments: list[TmpAttachmentConfirmItem]
class TmpAttachmentConfirmResponse(BaseModel):
attachments: list[AttachmentResponse]
class ThreadFileEntry(BaseModel):
path: str
name: str
is_dir: bool
size: int
modified_at: str | None = None
artifact_url: str | None = None
class ThreadFileListResponse(BaseModel):
path: str
files: list[ThreadFileEntry]
class ThreadFileContentResponse(BaseModel):
path: str
content: list[str]
offset: int
limit: int
total_lines: int
artifact_url: str
class SaveThreadArtifactRequest(BaseModel):
path: str
class SaveThreadArtifactResponse(BaseModel):
name: str
source_path: str
saved_path: str
saved_artifact_url: str
# =============================================================================
# > === 会话管理分组 ===
# =============================================================================
@chat.post("/thread", response_model=ThreadResponse)
async def create_thread(
thread: ThreadCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user)
):
"""创建新对话线程 (使用新存储系统)"""
return await create_thread_view(
agent_slug=thread.agent_id,
title=thread.title,
metadata=thread.metadata,
db=db,
current_uid=str(current_user.uid),
)
@chat.get("/threads", response_model=list[ThreadResponse])
async def list_threads(
agent_id: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""获取用户的所有对话线程 (使用新存储系统)"""
return await list_threads_view(
agent_slug=agent_id, db=db, current_uid=str(current_user.uid), limit=limit, offset=offset
)
@chat.get("/threads/search", response_model=ThreadSearchResponse)
async def search_threads(
q: str = Query(..., min_length=1, max_length=200),
agent_id: str | None = Query(None),
limit: int = Query(20, ge=1, le=50),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""搜索当前用户的历史对话。"""
return await search_threads_view(
query=q,
agent_id=agent_id,
db=db,
current_uid=str(current_user.uid),
limit=limit,
offset=offset,
)
@chat.delete("/thread/{thread_id}")
async def delete_thread(
thread_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user)
):
"""删除对话线程 (使用新存储系统)"""
return await delete_thread_view(thread_id=thread_id, db=db, current_uid=str(current_user.uid))
class ThreadUpdate(BaseModel):
title: str | None = None
is_pinned: bool | None = None
@chat.put("/thread/{thread_id}", response_model=ThreadResponse)
async def update_thread(
thread_id: str,
thread_update: ThreadUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""更新对话线程信息 (使用新存储系统)"""
return await update_thread_view(
thread_id=thread_id,
title=thread_update.title,
is_pinned=thread_update.is_pinned,
db=db,
current_uid=str(current_user.uid),
)
# ================================
# > === 附件管理分组 ===
# ================================
@chat.post("/attachments/tmp", response_model=TmpAttachmentResponse)
async def upload_tmp_attachment(file: UploadFile = File(...), current_user: User = Depends(get_required_user)):
"""上传附件到 MinIO tmp,暂不关联线程。"""
return await upload_tmp_attachment_view(file=file, current_uid=str(current_user.uid))
@chat.post("/attachments/tmp/parse", response_model=TmpAttachmentParseResponse)
async def parse_tmp_attachment(
request: TmpAttachmentParseRequest,
current_user: User = Depends(get_required_user),
):
"""解析 tmp 附件并返回解析后的 tmp URL。"""
return await parse_tmp_attachment_view(
object_name=request.object_name,
file_name=request.file_name,
parse_method=request.parse_method,
bucket_name=request.bucket_name,
current_uid=str(current_user.uid),
)
@chat.post("/thread/{thread_id}/attachments/confirm", response_model=TmpAttachmentConfirmResponse)
async def confirm_tmp_thread_attachments(
thread_id: str,
request: TmpAttachmentConfirmRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""将 tmp 附件正式加入线程附件列表。"""
return await confirm_tmp_thread_attachments_view(
thread_id=thread_id,
attachments=[item.model_dump() for item in request.attachments],
db=db,
current_uid=str(current_user.uid),
)
@chat.post("/thread/{thread_id}/attachments", response_model=AttachmentResponse)
async def upload_thread_attachment(
thread_id: str,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""上传原始附件并关联到指定对话线程。"""
return await upload_thread_attachment_view(
thread_id=thread_id,
file=file,
db=db,
current_uid=str(current_user.uid),
)
@chat.get("/thread/{thread_id}/attachments", response_model=AttachmentListResponse)
async def list_thread_attachments(
thread_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""列出当前对话线程的所有附件元信息。"""
return await list_thread_attachments_view(
thread_id=thread_id,
db=db,
current_uid=str(current_user.uid),
)
@chat.delete("/thread/{thread_id}/attachments/{file_id}")
async def delete_thread_attachment(
thread_id: str,
file_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""移除指定附件。"""
return await delete_thread_attachment_view(
thread_id=thread_id,
file_id=file_id,
db=db,
current_uid=str(current_user.uid),
)
@chat.get("/thread/{thread_id}/files", response_model=ThreadFileListResponse)
async def list_thread_files(
thread_id: str,
path: str = Query(f"{VIRTUAL_PATH_PREFIX}"),
recursive: bool = Query(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""列出线程文件目录。"""
return await list_thread_files_view(
thread_id=thread_id,
current_uid=str(current_user.uid),
db=db,
path=path,
recursive=recursive,
)
@chat.get("/thread/{thread_id}/files/content", response_model=ThreadFileContentResponse)
async def read_thread_file_content(
thread_id: str,
path: str = Query(...),
offset: int = Query(0, ge=0),
limit: int = Query(2000, ge=1, le=5000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""读取线程文本文件(按行分页)。"""
return await read_thread_file_content_view(
thread_id=thread_id,
current_uid=str(current_user.uid),
db=db,
path=path,
offset=offset,
limit=limit,
)
@chat.get("/thread/{thread_id}/artifacts/{path:path}")
async def get_thread_artifact(
thread_id: str,
path: str,
download: bool = Query(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""下载或预览线程文件。"""
file_path = await resolve_thread_artifact_view(
thread_id=thread_id,
current_uid=str(current_user.uid),
db=db,
path=path,
)
media_type = detect_media_type(file_path.name, file_path.read_bytes())
headers = {"Content-Disposition": f'attachment; filename="{file_path.name}"'} if download else None
return FileResponse(path=file_path, media_type=media_type, headers=headers)
@chat.post("/thread/{thread_id}/artifacts/save", response_model=SaveThreadArtifactResponse)
async def save_thread_artifact_to_workspace(
thread_id: str,
request: SaveThreadArtifactRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""保存交付物到共享 workspace/saved_artifacts 目录。"""
return await save_thread_artifact_to_workspace_view(
thread_id=thread_id,
current_uid=str(current_user.uid),
db=db,
path=request.path,
)
# =============================================================================
# > === 消息反馈分组 ===
# =============================================================================
class MessageFeedbackRequest(BaseModel):
rating: str # 'like' or 'dislike'
reason: str | None = None # Optional reason for dislike
class MessageFeedbackResponse(BaseModel):
id: int
message_id: int
rating: str
reason: str | None
created_at: str
@chat.post("/message/{message_id}/feedback", response_model=MessageFeedbackResponse)
async def submit_message_feedback(
message_id: int,
feedback_data: MessageFeedbackRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""提交消息反馈(需要登录)"""
result = await submit_message_feedback_view(
message_id=message_id,
rating=feedback_data.rating,
reason=feedback_data.reason,
db=db,
current_uid=str(current_user.uid),
)
return MessageFeedbackResponse(**result)
@chat.get("/message/{message_id}/feedback")
async def get_message_feedback(
message_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
):
"""获取指定消息的用户反馈(需要登录)"""
return await get_message_feedback_view(
message_id=message_id,
db=db,
current_uid=str(current_user.uid),
)
# =============================================================================
# > === 多模态图片支持分组 ===
# =============================================================================
@chat.post("/image/upload", response_model=ImageUploadResponse)
async def upload_image(file: UploadFile = File(...), current_user: User = Depends(get_required_user)):
"""
上传并处理图片,返回base64编码的图片数据
"""
try:
# 验证文件类型
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="只支持图片文件上传")
# 读取文件内容
image_data = await file.read()
# 检查文件大小(10MB限制,超过后会压缩到5MB)
if len(image_data) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="图片文件过大,请上传小于10MB的图片")
# 处理图片
result = process_uploaded_image(image_data, file.filename)
if not result["success"]:
raise HTTPException(status_code=400, detail=f"图片处理失败: {result['error']}")
logger.info(
f"用户 {current_user.id} 成功上传图片: {file.filename}, "
f"尺寸: {result['width']}x{result['height']}, "
f"格式: {result['format']}, "
f"大小: {result['size_bytes']} bytes"
)
return ImageUploadResponse(**result)
except HTTPException:
raise
except Exception as e:
logger.error(f"图片上传处理失败: {str(e)}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"图片处理失败: {str(e)}")