2025-03-17 19:58:00 +08:00
|
|
|
|
import traceback
|
2025-03-25 05:40:07 +08:00
|
|
|
|
import uuid
|
2026-03-07 23:28:25 +08:00
|
|
|
|
from typing import Any
|
2026-03-04 08:53:50 +08:00
|
|
|
|
from mimetypes import guess_type
|
2025-09-01 22:37:03 +08:00
|
|
|
|
|
2025-12-28 22:55:42 +08:00
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
|
2026-03-04 08:53:50 +08:00
|
|
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
2026-02-25 16:26:09 +08:00
|
|
|
|
from pydantic import BaseModel, Field
|
2025-12-04 10:11:28 +08:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2025-03-25 05:40:07 +08:00
|
|
|
|
|
2026-03-17 10:16:44 +08:00
|
|
|
|
from yuxi.storage.postgres.models_business import User
|
2026-05-24 00:45:52 +08:00
|
|
|
|
from server.utils.auth_middleware import get_db, get_required_user
|
2026-03-17 10:16:44 +08:00
|
|
|
|
from yuxi import config as conf
|
|
|
|
|
|
from yuxi.models import select_model
|
2026-05-24 00:45:52 +08:00
|
|
|
|
from yuxi.services.chat_service import get_agent_state_view, stream_agent_resume
|
2026-03-27 00:20:33 +08:00
|
|
|
|
from yuxi.repositories.conversation_repository import ConversationRepository
|
2026-03-17 10:16:44 +08:00
|
|
|
|
from yuxi.services.conversation_service import (
|
2026-05-27 18:22:29 +08:00
|
|
|
|
confirm_tmp_thread_attachments_view,
|
2026-01-22 00:28:43 +08:00
|
|
|
|
create_thread_view,
|
|
|
|
|
|
delete_thread_attachment_view,
|
|
|
|
|
|
delete_thread_view,
|
2026-03-27 00:20:33 +08:00
|
|
|
|
get_thread_history_view,
|
2026-01-22 00:28:43 +08:00
|
|
|
|
list_thread_attachments_view,
|
|
|
|
|
|
list_threads_view,
|
2026-05-27 18:22:29 +08:00
|
|
|
|
parse_tmp_attachment_view,
|
2026-01-22 00:28:43 +08:00
|
|
|
|
update_thread_view,
|
|
|
|
|
|
upload_thread_attachment_view,
|
2026-05-27 18:22:29 +08:00
|
|
|
|
upload_tmp_attachment_view,
|
2025-11-08 10:51:30 +08:00
|
|
|
|
)
|
2026-03-25 03:36:08 +08:00
|
|
|
|
from yuxi.services.thread_files_service import (
|
2026-03-04 08:53:50 +08:00
|
|
|
|
list_thread_files_view,
|
|
|
|
|
|
read_thread_file_content_view,
|
|
|
|
|
|
resolve_thread_artifact_view,
|
2026-03-31 20:18:22 +08:00
|
|
|
|
save_thread_artifact_to_workspace_view,
|
2026-03-04 08:53:50 +08:00
|
|
|
|
)
|
2026-03-17 10:16:44 +08:00
|
|
|
|
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
|
2026-04-01 01:58:57 +08:00
|
|
|
|
from yuxi.utils.paths import VIRTUAL_PATH_PREFIX
|
2025-11-12 14:04:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-26 23:13:44 +08:00
|
|
|
|
# TODO:当前文件的功能过于庞杂,路由标签混乱
|
|
|
|
|
|
|
2026-05-17 13:06:25 +08:00
|
|
|
|
|
2025-11-12 14:04:34 +08:00
|
|
|
|
# 图片上传响应模型
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2024-10-02 20:11:28 +08:00
|
|
|
|
|
2025-07-22 17:29:38 +08:00
|
|
|
|
chat = APIRouter(prefix="/chat", tags=["chat"])
|
|
|
|
|
|
|
2026-05-18 09:40:59 +08:00
|
|
|
|
|
2024-10-02 20:11:28 +08:00
|
|
|
|
@chat.post("/call")
|
2025-05-02 23:56:59 +08:00
|
|
|
|
async def call(query: str = Body(...), meta: dict = Body(None), current_user: User = Depends(get_required_user)):
|
|
|
|
|
|
"""调用模型进行简单问答(需要登录)"""
|
2025-04-10 11:41:01 +08:00
|
|
|
|
meta = meta or {}
|
2025-11-05 10:15:17 +08:00
|
|
|
|
|
|
|
|
|
|
# 确保 request_id 存在
|
|
|
|
|
|
if "request_id" not in meta or not meta.get("request_id"):
|
|
|
|
|
|
meta["request_id"] = str(uuid.uuid4())
|
|
|
|
|
|
|
2026-05-17 13:06:25 +08:00
|
|
|
|
model = select_model(model_spec=meta.get("model_spec") or meta.get("model") or conf.default_model)
|
2025-09-01 22:37:03 +08:00
|
|
|
|
|
2026-01-28 11:30:55 +08:00
|
|
|
|
response = await model.call(query)
|
2024-10-02 20:11:28 +08:00
|
|
|
|
logger.debug({"query": query, "response": response.content})
|
|
|
|
|
|
|
2025-11-05 10:15:17 +08:00
|
|
|
|
return {"response": response.content, "request_id": meta["request_id"]}
|
2024-10-14 22:10:56 +08:00
|
|
|
|
|
2025-09-01 22:37:03 +08:00
|
|
|
|
|
2026-03-27 00:20:33 +08:00
|
|
|
|
@chat.post("/thread/{thread_id}/resume")
|
|
|
|
|
|
async def resume_thread_chat(
|
|
|
|
|
|
thread_id: str,
|
2026-03-07 23:28:25 +08:00
|
|
|
|
approved: bool | None = Body(None),
|
2026-03-17 03:39:48 +08:00
|
|
|
|
answer: dict | None = Body(None),
|
2025-11-01 21:34:16 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2025-12-04 10:11:28 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2025-11-01 21:34:16 +08:00
|
|
|
|
):
|
|
|
|
|
|
"""恢复被人工审批中断的对话(需要登录)"""
|
2026-03-14 23:54:52 +08:00
|
|
|
|
|
2026-03-27 00:20:33 +08:00
|
|
|
|
# 验证 thread 存在且属于当前用户
|
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
|
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
|
2026-05-17 22:44:05 +08:00
|
|
|
|
if not conversation or conversation.uid != str(current_user.uid) or conversation.status == "deleted":
|
2026-03-27 00:20:33 +08:00
|
|
|
|
raise HTTPException(status_code=404, detail="对话线程不存在")
|
|
|
|
|
|
agent_id = conversation.agent_id
|
|
|
|
|
|
|
2026-03-07 23:28:25 +08:00
|
|
|
|
def normalize_resume_input(raw_answer: Any, raw_approved: bool | None) -> Any:
|
2026-03-17 03:39:48 +08:00
|
|
|
|
def normalize_single_answer(value: Any) -> Any:
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
normalized = value.strip()
|
2026-03-07 23:28:25 +08:00
|
|
|
|
if not normalized:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail="answer 不能为空")
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
2026-03-17 03:39:48 +08:00
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
if len(value) == 0:
|
2026-03-07 23:28:25 +08:00
|
|
|
|
raise HTTPException(status_code=422, detail="answer 不能为空")
|
|
|
|
|
|
|
2026-03-17 03:39:48 +08:00
|
|
|
|
normalized_list: list[str] = []
|
|
|
|
|
|
for item in value:
|
|
|
|
|
|
if not isinstance(item, str) or not item.strip():
|
|
|
|
|
|
raise HTTPException(status_code=422, detail="answer 列表必须是非空字符串")
|
|
|
|
|
|
normalized_list.append(item.strip())
|
|
|
|
|
|
return normalized_list
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
|
if value.get("type") == "other":
|
|
|
|
|
|
text = value.get("text")
|
2026-03-07 23:28:25 +08:00
|
|
|
|
if not isinstance(text, str) or not text.strip():
|
|
|
|
|
|
raise HTTPException(status_code=422, detail="other 文本不能为空")
|
2026-03-17 03:39:48 +08:00
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(status_code=422, detail="answer 值类型不支持")
|
|
|
|
|
|
|
|
|
|
|
|
if raw_answer is not None:
|
|
|
|
|
|
if isinstance(raw_answer, dict):
|
|
|
|
|
|
if len(raw_answer) == 0:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail="answer 不能为空")
|
|
|
|
|
|
|
|
|
|
|
|
normalized_answers: dict[str, Any] = {}
|
|
|
|
|
|
for question_id, value in raw_answer.items():
|
|
|
|
|
|
normalized_question_id = str(question_id).strip()
|
|
|
|
|
|
if not normalized_question_id:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail="question_id 不能为空")
|
|
|
|
|
|
normalized_answers[normalized_question_id] = normalize_single_answer(value)
|
|
|
|
|
|
return normalized_answers
|
2026-03-07 23:28:25 +08:00
|
|
|
|
|
2026-03-17 03:39:48 +08:00
|
|
|
|
raise HTTPException(status_code=422, detail="answer 必须是对象映射 {question_id: answer}")
|
2026-03-07 23:28:25 +08:00
|
|
|
|
|
|
|
|
|
|
if raw_approved is not None:
|
|
|
|
|
|
return "approve" if raw_approved else "reject"
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(status_code=422, detail="approved 或 answer 至少提供一个")
|
|
|
|
|
|
|
|
|
|
|
|
resume_input = normalize_resume_input(answer, approved)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"Resuming agent_id: %s, thread_id: %s, approved: %s, answer_type: %s",
|
|
|
|
|
|
agent_id,
|
|
|
|
|
|
thread_id,
|
|
|
|
|
|
approved,
|
|
|
|
|
|
type(answer).__name__ if answer is not None else "None",
|
|
|
|
|
|
)
|
2025-11-01 21:34:16 +08:00
|
|
|
|
|
|
|
|
|
|
meta = {
|
|
|
|
|
|
"agent_id": agent_id,
|
|
|
|
|
|
"thread_id": thread_id,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
"uid": current_user.uid,
|
2025-11-01 21:34:16 +08:00
|
|
|
|
"approved": approved,
|
2026-03-07 23:28:25 +08:00
|
|
|
|
"answer": answer,
|
|
|
|
|
|
"resume_input": resume_input,
|
2025-11-01 21:34:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
if "request_id" not in meta or not meta.get("request_id"):
|
|
|
|
|
|
meta["request_id"] = str(uuid.uuid4())
|
2026-01-22 00:28:43 +08:00
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
stream_agent_resume(
|
|
|
|
|
|
thread_id=thread_id,
|
2026-03-07 23:28:25 +08:00
|
|
|
|
resume_input=resume_input,
|
2026-01-22 00:28:43 +08:00
|
|
|
|
meta=meta,
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
),
|
|
|
|
|
|
media_type="application/json",
|
|
|
|
|
|
)
|
2025-11-01 21:34:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-27 00:20:33 +08:00
|
|
|
|
@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)
|
2025-10-04 22:21:30 +08:00
|
|
|
|
):
|
2026-03-27 00:20:33 +08:00
|
|
|
|
"""获取对话历史消息(需要登录)- 包含用户反馈状态"""
|
2025-05-15 22:34:32 +08:00
|
|
|
|
try:
|
2026-03-27 00:20:33 +08:00
|
|
|
|
return await get_thread_history_view(
|
2026-01-22 00:28:43 +08:00
|
|
|
|
thread_id=thread_id,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-01-22 00:28:43 +08:00
|
|
|
|
db=db,
|
|
|
|
|
|
)
|
2025-05-15 22:34:32 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-03-27 00:20:33 +08:00
|
|
|
|
logger.error(f"获取对话历史消息出错: {e}, {traceback.format_exc()}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"获取对话历史消息出错: {str(e)}")
|
2025-05-15 22:34:32 +08:00
|
|
|
|
|
2025-09-01 22:37:03 +08:00
|
|
|
|
|
2026-03-27 00:20:33 +08:00
|
|
|
|
@chat.get("/thread/{thread_id}/state")
|
|
|
|
|
|
async def get_thread_state(
|
2025-11-17 12:01:59 +08:00
|
|
|
|
thread_id: str,
|
2026-06-01 22:28:45 +08:00
|
|
|
|
include_messages: bool = Query(False),
|
2025-11-17 12:01:59 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2025-12-04 10:11:28 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2025-11-17 12:01:59 +08:00
|
|
|
|
):
|
2026-03-27 00:20:33 +08:00
|
|
|
|
"""获取对话当前状态(需要登录)"""
|
2025-11-17 12:01:59 +08:00
|
|
|
|
try:
|
2026-01-22 00:28:43 +08:00
|
|
|
|
return await get_agent_state_view(
|
|
|
|
|
|
thread_id=thread_id,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-01-22 00:28:43 +08:00
|
|
|
|
db=db,
|
2026-06-01 22:28:45 +08:00
|
|
|
|
include_messages=include_messages,
|
2026-01-22 00:28:43 +08:00
|
|
|
|
)
|
2025-11-17 12:01:59 +08:00
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
2026-03-27 00:20:33 +08:00
|
|
|
|
logger.error(f"获取对话状态出错: {e}, {traceback.format_exc()}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"获取对话状态出错: {str(e)}")
|
2025-11-17 12:01:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
2025-05-15 22:34:32 +08:00
|
|
|
|
# ==================== 线程管理 API ====================
|
|
|
|
|
|
|
2025-09-01 22:37:03 +08:00
|
|
|
|
|
2025-05-15 22:34:32 +08:00
|
|
|
|
class ThreadCreate(BaseModel):
|
2025-05-24 11:29:45 +08:00
|
|
|
|
title: str | None = None
|
2025-05-15 22:34:32 +08:00
|
|
|
|
agent_id: str
|
2025-05-24 11:29:45 +08:00
|
|
|
|
metadata: dict | None = None
|
2025-05-15 22:34:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ThreadResponse(BaseModel):
|
|
|
|
|
|
id: str
|
2026-05-17 22:44:05 +08:00
|
|
|
|
uid: str
|
2025-05-15 22:34:32 +08:00
|
|
|
|
agent_id: str
|
2025-05-24 11:29:45 +08:00
|
|
|
|
title: str | None = None
|
2026-03-06 20:29:58 +08:00
|
|
|
|
is_pinned: bool = False
|
2025-10-04 22:59:25 +08:00
|
|
|
|
created_at: str
|
|
|
|
|
|
updated_at: str
|
2026-03-27 01:15:51 +08:00
|
|
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
2025-05-15 22:34:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
2025-11-08 10:51:30 +08:00
|
|
|
|
class AttachmentResponse(BaseModel):
|
|
|
|
|
|
file_id: str
|
|
|
|
|
|
file_name: str
|
|
|
|
|
|
file_type: str | None = None
|
|
|
|
|
|
file_size: int
|
|
|
|
|
|
status: str
|
|
|
|
|
|
uploaded_at: str
|
2026-03-05 22:50:38 +08:00
|
|
|
|
path: str
|
2026-03-04 08:53:50 +08:00
|
|
|
|
artifact_url: str | None = None
|
2026-03-25 05:26:00 +08:00
|
|
|
|
original_path: str | None = None
|
|
|
|
|
|
original_artifact_url: str | None = None
|
2026-03-04 08:53:50 +08:00
|
|
|
|
minio_url: str | None = None
|
2026-05-24 00:45:52 +08:00
|
|
|
|
request_id: str | None = None
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AttachmentLimits(BaseModel):
|
|
|
|
|
|
allowed_extensions: list[str]
|
|
|
|
|
|
max_size_bytes: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AttachmentListResponse(BaseModel):
|
|
|
|
|
|
attachments: list[AttachmentResponse]
|
|
|
|
|
|
limits: AttachmentLimits
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 18:22:29 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 08:53:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 20:18:22 +08:00
|
|
|
|
class SaveThreadArtifactRequest(BaseModel):
|
|
|
|
|
|
path: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SaveThreadArtifactResponse(BaseModel):
|
|
|
|
|
|
name: str
|
|
|
|
|
|
source_path: str
|
|
|
|
|
|
saved_path: str
|
|
|
|
|
|
saved_artifact_url: str
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-07-22 17:29:38 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# > === 会话管理分组 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
2025-09-01 22:37:03 +08:00
|
|
|
|
|
2025-05-15 22:34:32 +08:00
|
|
|
|
@chat.post("/thread", response_model=ThreadResponse)
|
|
|
|
|
|
async def create_thread(
|
2025-12-04 10:11:28 +08:00
|
|
|
|
thread: ThreadCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user)
|
2025-05-15 22:34:32 +08:00
|
|
|
|
):
|
2025-10-04 22:21:30 +08:00
|
|
|
|
"""创建新对话线程 (使用新存储系统)"""
|
2026-01-22 00:28:43 +08:00
|
|
|
|
return await create_thread_view(
|
2025-05-15 22:34:32 +08:00
|
|
|
|
agent_id=thread.agent_id,
|
2026-01-22 00:28:43 +08:00
|
|
|
|
title=thread.title,
|
2025-10-04 22:21:30 +08:00
|
|
|
|
metadata=thread.metadata,
|
2026-01-22 00:28:43 +08:00
|
|
|
|
db=db,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2025-05-15 22:34:32 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-05-24 11:29:45 +08:00
|
|
|
|
@chat.get("/threads", response_model=list[ThreadResponse])
|
2025-12-04 10:12:01 +08:00
|
|
|
|
async def list_threads(
|
2026-03-16 21:39:49 +08:00
|
|
|
|
agent_id: str | None = Query(None),
|
2026-03-06 10:14:43 +08:00
|
|
|
|
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),
|
2025-12-04 10:12:01 +08:00
|
|
|
|
):
|
2025-10-04 22:21:30 +08:00
|
|
|
|
"""获取用户的所有对话线程 (使用新存储系统)"""
|
2026-03-06 10:14:43 +08:00
|
|
|
|
return await list_threads_view(
|
2026-05-17 22:44:05 +08:00
|
|
|
|
agent_id=agent_id, db=db, current_uid=str(current_user.uid), limit=limit, offset=offset
|
2026-03-06 10:14:43 +08:00
|
|
|
|
)
|
2025-05-15 22:34:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@chat.delete("/thread/{thread_id}")
|
2025-12-04 10:12:01 +08:00
|
|
|
|
async def delete_thread(
|
|
|
|
|
|
thread_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user)
|
|
|
|
|
|
):
|
2025-10-04 22:21:30 +08:00
|
|
|
|
"""删除对话线程 (使用新存储系统)"""
|
2026-05-17 22:44:05 +08:00
|
|
|
|
return await delete_thread_view(thread_id=thread_id, db=db, current_uid=str(current_user.uid))
|
2025-05-15 22:34:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ThreadUpdate(BaseModel):
|
2025-05-24 11:29:45 +08:00
|
|
|
|
title: str | None = None
|
2026-03-06 20:29:58 +08:00
|
|
|
|
is_pinned: bool | None = None
|
2025-05-15 22:34:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@chat.put("/thread/{thread_id}", response_model=ThreadResponse)
|
|
|
|
|
|
async def update_thread(
|
|
|
|
|
|
thread_id: str,
|
|
|
|
|
|
thread_update: ThreadUpdate,
|
2025-12-04 10:11:28 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2025-09-01 22:37:03 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2025-05-15 22:34:32 +08:00
|
|
|
|
):
|
2025-10-04 22:21:30 +08:00
|
|
|
|
"""更新对话线程信息 (使用新存储系统)"""
|
2026-01-22 00:28:43 +08:00
|
|
|
|
return await update_thread_view(
|
2025-10-04 22:21:30 +08:00
|
|
|
|
thread_id=thread_id,
|
|
|
|
|
|
title=thread_update.title,
|
2026-03-06 20:29:58 +08:00
|
|
|
|
is_pinned=thread_update.is_pinned,
|
2026-01-22 00:28:43 +08:00
|
|
|
|
db=db,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2025-10-04 22:21:30 +08:00
|
|
|
|
)
|
2025-05-15 22:34:32 +08:00
|
|
|
|
|
2025-10-05 14:50:34 +08:00
|
|
|
|
|
2026-02-25 11:42:31 +08:00
|
|
|
|
# ================================
|
|
|
|
|
|
# > === 附件管理分组 ===
|
|
|
|
|
|
# ================================
|
|
|
|
|
|
|
2026-02-28 16:33:54 +08:00
|
|
|
|
|
2026-05-27 18:22:29 +08:00
|
|
|
|
@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),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-08 10:51:30 +08:00
|
|
|
|
@chat.post("/thread/{thread_id}/attachments", response_model=AttachmentResponse)
|
|
|
|
|
|
async def upload_thread_attachment(
|
|
|
|
|
|
thread_id: str,
|
|
|
|
|
|
file: UploadFile = File(...),
|
2025-12-04 10:11:28 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2025-11-08 10:51:30 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
):
|
2026-03-05 22:50:38 +08:00
|
|
|
|
"""上传原始附件并关联到指定对话线程。"""
|
2026-01-22 00:28:43 +08:00
|
|
|
|
return await upload_thread_attachment_view(
|
|
|
|
|
|
thread_id=thread_id,
|
|
|
|
|
|
file=file,
|
|
|
|
|
|
db=db,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-01-22 00:28:43 +08:00
|
|
|
|
)
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@chat.get("/thread/{thread_id}/attachments", response_model=AttachmentListResponse)
|
|
|
|
|
|
async def list_thread_attachments(
|
|
|
|
|
|
thread_id: str,
|
2025-12-04 10:11:28 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2025-11-08 10:51:30 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""列出当前对话线程的所有附件元信息。"""
|
2026-01-22 00:28:43 +08:00
|
|
|
|
return await list_thread_attachments_view(
|
|
|
|
|
|
thread_id=thread_id,
|
|
|
|
|
|
db=db,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-01-22 00:28:43 +08:00
|
|
|
|
)
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@chat.delete("/thread/{thread_id}/attachments/{file_id}")
|
|
|
|
|
|
async def delete_thread_attachment(
|
|
|
|
|
|
thread_id: str,
|
|
|
|
|
|
file_id: str,
|
2025-12-04 10:11:28 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2025-11-08 10:51:30 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""移除指定附件。"""
|
2026-01-22 00:28:43 +08:00
|
|
|
|
return await delete_thread_attachment_view(
|
|
|
|
|
|
thread_id=thread_id,
|
|
|
|
|
|
file_id=file_id,
|
|
|
|
|
|
db=db,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-01-22 00:28:43 +08:00
|
|
|
|
)
|
2025-11-08 10:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 08:53:50 +08:00
|
|
|
|
@chat.get("/thread/{thread_id}/files", response_model=ThreadFileListResponse)
|
|
|
|
|
|
async def list_thread_files(
|
|
|
|
|
|
thread_id: str,
|
2026-04-01 01:58:57 +08:00
|
|
|
|
path: str = Query(f"{VIRTUAL_PATH_PREFIX}"),
|
2026-03-14 23:54:52 +08:00
|
|
|
|
recursive: bool = Query(False),
|
2026-03-04 08:53:50 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""列出线程文件目录。"""
|
|
|
|
|
|
return await list_thread_files_view(
|
|
|
|
|
|
thread_id=thread_id,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-03-04 08:53:50 +08:00
|
|
|
|
db=db,
|
|
|
|
|
|
path=path,
|
2026-03-14 23:54:52 +08:00
|
|
|
|
recursive=recursive,
|
2026-03-04 08:53:50 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-03-04 08:53:50 +08:00
|
|
|
|
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,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-03-04 08:53:50 +08:00
|
|
|
|
db=db,
|
|
|
|
|
|
path=path,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
media_type = guess_type(file_path.name)[0] or "application/octet-stream"
|
2026-03-14 23:54:52 +08:00
|
|
|
|
headers = {"Content-Disposition": f'attachment; filename="{file_path.name}"'} if download else None
|
2026-03-04 08:53:50 +08:00
|
|
|
|
return FileResponse(path=file_path, media_type=media_type, headers=headers)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 20:18:22 +08:00
|
|
|
|
@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,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-03-31 20:18:22 +08:00
|
|
|
|
db=db,
|
|
|
|
|
|
path=request.path,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-05 14:50:34 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# > === 消息反馈分组 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2025-12-04 10:11:28 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2025-10-05 14:50:34 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
):
|
2026-01-16 14:06:42 +08:00
|
|
|
|
"""提交消息反馈(需要登录)"""
|
2026-01-22 00:28:43 +08:00
|
|
|
|
result = await submit_message_feedback_view(
|
|
|
|
|
|
message_id=message_id,
|
|
|
|
|
|
rating=feedback_data.rating,
|
|
|
|
|
|
reason=feedback_data.reason,
|
|
|
|
|
|
db=db,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-01-22 00:28:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
return MessageFeedbackResponse(**result)
|
2025-10-05 14:50:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@chat.get("/message/{message_id}/feedback")
|
|
|
|
|
|
async def get_message_feedback(
|
|
|
|
|
|
message_id: int,
|
2025-12-04 10:11:28 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2025-10-05 14:50:34 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
):
|
2026-01-16 14:06:42 +08:00
|
|
|
|
"""获取指定消息的用户反馈(需要登录)"""
|
2026-01-22 00:28:43 +08:00
|
|
|
|
return await get_message_feedback_view(
|
|
|
|
|
|
message_id=message_id,
|
|
|
|
|
|
db=db,
|
2026-05-17 22:44:05 +08:00
|
|
|
|
current_uid=str(current_user.uid),
|
2026-01-22 00:28:43 +08:00
|
|
|
|
)
|
2025-11-12 14:04:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# > === 多模态图片支持分组 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)}")
|