2026-03-20 21:18:13 +08:00
|
|
|
import shlex
|
2026-01-22 00:28:43 +08:00
|
|
|
import uuid
|
2026-03-04 08:53:50 +08:00
|
|
|
from pathlib import Path
|
2026-01-22 00:28:43 +08:00
|
|
|
|
|
|
|
|
from fastapi import HTTPException, UploadFile
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-02-21 00:50:57 +08:00
|
|
|
from src.agents import agent_manager
|
2026-01-22 00:28:43 +08:00
|
|
|
from src.repositories.conversation_repository import ConversationRepository
|
2026-03-04 08:53:50 +08:00
|
|
|
from src.sandbox import (
|
|
|
|
|
ProvisionerSandboxBackend,
|
|
|
|
|
ensure_thread_dirs,
|
|
|
|
|
get_sandbox_provider,
|
|
|
|
|
sandbox_uploads_dir,
|
2026-01-22 00:28:43 +08:00
|
|
|
)
|
2026-03-05 22:50:38 +08:00
|
|
|
from src.services.doc_converter import ATTACHMENT_ALLOWED_EXTENSIONS, MAX_ATTACHMENT_SIZE_BYTES
|
2026-01-22 00:28:43 +08:00
|
|
|
from src.utils.datetime_utils import utc_isoformat
|
|
|
|
|
from src.utils.logging_config import logger
|
|
|
|
|
|
2026-03-04 08:53:50 +08:00
|
|
|
UPLOADS_VIRTUAL_PREFIX = "/mnt/user-data/uploads"
|
2026-02-13 22:16:11 +08:00
|
|
|
|
2026-01-22 00:28:43 +08:00
|
|
|
|
|
|
|
|
async def require_user_conversation(conv_repo: ConversationRepository, thread_id: str, user_id: str):
|
|
|
|
|
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
|
|
|
|
|
if not conversation or conversation.user_id != str(user_id) or conversation.status == "deleted":
|
|
|
|
|
raise HTTPException(status_code=404, detail="对话线程不存在")
|
|
|
|
|
return conversation
|
|
|
|
|
|
|
|
|
|
|
2026-03-05 22:50:38 +08:00
|
|
|
def _make_upload_virtual_path(file_name: str) -> str:
|
2026-03-04 08:53:50 +08:00
|
|
|
safe_name = file_name.replace("/", "_").replace("\\", "_").strip(" .")
|
|
|
|
|
return f"{UPLOADS_VIRTUAL_PREFIX}/{safe_name or 'attachment.bin'}"
|
2026-02-13 22:16:11 +08:00
|
|
|
|
|
|
|
|
|
2026-03-04 08:53:50 +08:00
|
|
|
def _artifact_url(thread_id: str, virtual_path: str) -> str:
|
|
|
|
|
return f"/api/chat/thread/{thread_id}/artifacts/{virtual_path.lstrip('/')}"
|
2026-02-13 22:16:11 +08:00
|
|
|
|
|
|
|
|
|
2026-03-05 22:50:38 +08:00
|
|
|
def _build_state_uploads(attachments: list[dict]) -> list[dict]:
|
|
|
|
|
uploads: list[dict] = []
|
2026-02-21 00:50:57 +08:00
|
|
|
for attachment in attachments:
|
2026-03-05 22:50:38 +08:00
|
|
|
path = attachment.get("path")
|
|
|
|
|
if not isinstance(path, str) or not path.strip():
|
2026-02-21 00:50:57 +08:00
|
|
|
continue
|
|
|
|
|
|
2026-03-05 22:50:38 +08:00
|
|
|
uploads.append(
|
|
|
|
|
{
|
|
|
|
|
"file_id": attachment.get("file_id"),
|
|
|
|
|
"file_name": attachment.get("file_name"),
|
|
|
|
|
"file_type": attachment.get("file_type"),
|
|
|
|
|
"file_size": attachment.get("file_size", 0),
|
|
|
|
|
"status": attachment.get("status", "uploaded"),
|
|
|
|
|
"uploaded_at": attachment.get("uploaded_at"),
|
|
|
|
|
"path": path,
|
|
|
|
|
"artifact_url": attachment.get("artifact_url"),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return uploads
|
2026-02-21 00:50:57 +08:00
|
|
|
|
|
|
|
|
|
2026-03-05 22:50:38 +08:00
|
|
|
async def _sync_thread_upload_state(
|
2026-02-21 00:50:57 +08:00
|
|
|
*,
|
|
|
|
|
thread_id: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
agent_id: str,
|
|
|
|
|
attachments: list[dict],
|
|
|
|
|
) -> None:
|
|
|
|
|
try:
|
|
|
|
|
agent = agent_manager.get_agent(agent_id)
|
|
|
|
|
if not agent:
|
2026-03-05 22:50:38 +08:00
|
|
|
logger.warning(f"Skip upload state sync: agent not found ({agent_id})")
|
2026-02-21 00:50:57 +08:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
graph = await agent.get_graph()
|
2026-02-21 00:59:29 +08:00
|
|
|
config = {"configurable": {"thread_id": thread_id, "user_id": str(user_id)}}
|
2026-02-25 11:42:31 +08:00
|
|
|
|
2026-02-21 00:50:57 +08:00
|
|
|
await graph.aupdate_state(
|
2026-02-21 00:59:29 +08:00
|
|
|
config=config,
|
2026-02-21 00:50:57 +08:00
|
|
|
values={
|
2026-03-05 22:50:38 +08:00
|
|
|
"uploads": _build_state_uploads(attachments),
|
2026-02-21 00:50:57 +08:00
|
|
|
},
|
|
|
|
|
)
|
2026-03-04 08:53:50 +08:00
|
|
|
except Exception as exc: # noqa: BLE001
|
2026-03-05 22:50:38 +08:00
|
|
|
logger.warning(f"Failed to sync upload state for thread {thread_id}: {exc}")
|
2026-02-21 00:50:57 +08:00
|
|
|
|
|
|
|
|
|
2026-01-22 00:28:43 +08:00
|
|
|
def serialize_attachment(record: dict) -> dict:
|
2026-03-05 22:50:38 +08:00
|
|
|
path = record.get("path")
|
2026-01-22 00:28:43 +08:00
|
|
|
return {
|
|
|
|
|
"file_id": record.get("file_id"),
|
|
|
|
|
"file_name": record.get("file_name"),
|
|
|
|
|
"file_type": record.get("file_type"),
|
|
|
|
|
"file_size": record.get("file_size", 0),
|
2026-03-05 22:50:38 +08:00
|
|
|
"status": record.get("status", "uploaded"),
|
2026-01-22 00:28:43 +08:00
|
|
|
"uploaded_at": record.get("uploaded_at"),
|
2026-03-05 22:50:38 +08:00
|
|
|
"path": path,
|
2026-03-04 08:53:50 +08:00
|
|
|
"artifact_url": record.get("artifact_url"),
|
|
|
|
|
"minio_url": record.get("minio_url"),
|
2026-01-22 00:28:43 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_thread_view(
|
|
|
|
|
*,
|
|
|
|
|
agent_id: str,
|
|
|
|
|
title: str | None,
|
|
|
|
|
metadata: dict | None,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
current_user_id: str,
|
|
|
|
|
) -> dict:
|
|
|
|
|
thread_id = str(uuid.uuid4())
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
conversation = await conv_repo.create_conversation(
|
|
|
|
|
user_id=str(current_user_id),
|
|
|
|
|
agent_id=agent_id,
|
|
|
|
|
title=title or "新的对话",
|
|
|
|
|
thread_id=thread_id,
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"id": conversation.thread_id,
|
|
|
|
|
"user_id": conversation.user_id,
|
|
|
|
|
"agent_id": conversation.agent_id,
|
|
|
|
|
"title": conversation.title,
|
|
|
|
|
"created_at": conversation.created_at.isoformat(),
|
|
|
|
|
"updated_at": conversation.updated_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_threads_view(
|
|
|
|
|
*,
|
2026-03-16 21:39:49 +08:00
|
|
|
agent_id: str | None,
|
2026-01-22 00:28:43 +08:00
|
|
|
db: AsyncSession,
|
|
|
|
|
current_user_id: str,
|
2026-03-06 10:14:43 +08:00
|
|
|
limit: int | None = None,
|
|
|
|
|
offset: int = 0,
|
2026-01-22 00:28:43 +08:00
|
|
|
) -> list[dict]:
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
conversations = await conv_repo.list_conversations(
|
|
|
|
|
user_id=str(current_user_id),
|
|
|
|
|
agent_id=agent_id,
|
|
|
|
|
status="active",
|
2026-03-06 10:14:43 +08:00
|
|
|
limit=limit,
|
|
|
|
|
offset=offset,
|
2026-01-22 00:28:43 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": conv.thread_id,
|
|
|
|
|
"user_id": conv.user_id,
|
|
|
|
|
"agent_id": conv.agent_id,
|
|
|
|
|
"title": conv.title,
|
2026-03-06 20:29:58 +08:00
|
|
|
"is_pinned": bool(conv.is_pinned),
|
2026-01-22 00:28:43 +08:00
|
|
|
"created_at": conv.created_at.isoformat(),
|
|
|
|
|
"updated_at": conv.updated_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
for conv in conversations
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_thread_view(
|
|
|
|
|
*,
|
|
|
|
|
thread_id: str,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
current_user_id: str,
|
|
|
|
|
) -> dict:
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
await require_user_conversation(conv_repo, thread_id, str(current_user_id))
|
|
|
|
|
deleted = await conv_repo.delete_conversation(thread_id, soft_delete=True)
|
|
|
|
|
if not deleted:
|
|
|
|
|
raise HTTPException(status_code=404, detail="对话线程不存在")
|
|
|
|
|
return {"message": "删除成功"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_thread_view(
|
|
|
|
|
*,
|
|
|
|
|
thread_id: str,
|
2026-03-06 20:29:58 +08:00
|
|
|
title: str | None = None,
|
|
|
|
|
is_pinned: bool | None = None,
|
2026-01-22 00:28:43 +08:00
|
|
|
db: AsyncSession,
|
|
|
|
|
current_user_id: str,
|
|
|
|
|
) -> dict:
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
await require_user_conversation(conv_repo, thread_id, str(current_user_id))
|
2026-03-06 20:29:58 +08:00
|
|
|
updated_conv = await conv_repo.update_conversation(thread_id, title=title, is_pinned=is_pinned)
|
2026-01-22 00:28:43 +08:00
|
|
|
if not updated_conv:
|
|
|
|
|
raise HTTPException(status_code=500, detail="更新失败")
|
|
|
|
|
return {
|
|
|
|
|
"id": updated_conv.thread_id,
|
|
|
|
|
"user_id": updated_conv.user_id,
|
|
|
|
|
"agent_id": updated_conv.agent_id,
|
|
|
|
|
"title": updated_conv.title,
|
2026-03-06 20:29:58 +08:00
|
|
|
"is_pinned": bool(updated_conv.is_pinned),
|
2026-01-22 00:28:43 +08:00
|
|
|
"created_at": updated_conv.created_at.isoformat(),
|
|
|
|
|
"updated_at": updated_conv.updated_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def upload_thread_attachment_view(
|
|
|
|
|
*,
|
|
|
|
|
thread_id: str,
|
|
|
|
|
file: UploadFile,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
current_user_id: str,
|
|
|
|
|
) -> dict:
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id))
|
2026-03-05 22:50:38 +08:00
|
|
|
if not file.filename:
|
|
|
|
|
raise HTTPException(status_code=400, detail="无法识别的文件名")
|
2026-01-22 00:28:43 +08:00
|
|
|
|
2026-03-05 22:50:38 +08:00
|
|
|
file_name = Path(file.filename).name
|
|
|
|
|
await file.seek(0)
|
|
|
|
|
file_content = await file.read()
|
|
|
|
|
file_size = len(file_content)
|
|
|
|
|
if file_size > MAX_ATTACHMENT_SIZE_BYTES:
|
|
|
|
|
max_size_mb = MAX_ATTACHMENT_SIZE_BYTES // (1024 * 1024)
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"附件过大,当前仅支持 {max_size_mb} MB 以内的文件")
|
|
|
|
|
|
|
|
|
|
upload_virtual_path = _make_upload_virtual_path(file_name)
|
|
|
|
|
artifact_url = _artifact_url(thread_id, upload_virtual_path)
|
2026-03-04 08:53:50 +08:00
|
|
|
|
|
|
|
|
ensure_thread_dirs(thread_id)
|
|
|
|
|
uploads_dir = sandbox_uploads_dir(thread_id)
|
2026-03-05 22:50:38 +08:00
|
|
|
upload_actual_path = uploads_dir / Path(upload_virtual_path).name
|
|
|
|
|
upload_actual_path.write_bytes(file_content)
|
2026-03-04 08:53:50 +08:00
|
|
|
|
|
|
|
|
provider = get_sandbox_provider()
|
|
|
|
|
connection = provider.get(thread_id, create_if_missing=False)
|
|
|
|
|
if connection is not None:
|
|
|
|
|
backend = ProvisionerSandboxBackend(thread_id=thread_id)
|
|
|
|
|
backend.upload_files(
|
|
|
|
|
[
|
2026-03-05 22:50:38 +08:00
|
|
|
(upload_virtual_path, file_content),
|
2026-03-04 08:53:50 +08:00
|
|
|
]
|
2026-02-13 22:16:11 +08:00
|
|
|
)
|
|
|
|
|
|
2026-01-22 00:28:43 +08:00
|
|
|
attachment_record = {
|
2026-03-05 22:50:38 +08:00
|
|
|
"file_id": uuid.uuid4().hex,
|
|
|
|
|
"file_name": file_name,
|
|
|
|
|
"file_type": file.content_type,
|
|
|
|
|
"file_size": file_size,
|
|
|
|
|
"status": "uploaded",
|
2026-01-22 00:28:43 +08:00
|
|
|
"uploaded_at": utc_isoformat(),
|
2026-03-05 22:50:38 +08:00
|
|
|
"path": upload_virtual_path,
|
|
|
|
|
"artifact_url": artifact_url,
|
2026-03-04 08:53:50 +08:00
|
|
|
"minio_url": None,
|
2026-03-05 22:50:38 +08:00
|
|
|
"storage_path": str(upload_actual_path),
|
2026-01-22 00:28:43 +08:00
|
|
|
}
|
2026-03-05 22:50:38 +08:00
|
|
|
|
2026-01-22 00:28:43 +08:00
|
|
|
await conv_repo.add_attachment(conversation.id, attachment_record)
|
2026-02-21 00:50:57 +08:00
|
|
|
all_attachments = await conv_repo.get_attachments(conversation.id)
|
2026-03-05 22:50:38 +08:00
|
|
|
await _sync_thread_upload_state(
|
2026-02-21 00:50:57 +08:00
|
|
|
thread_id=thread_id,
|
|
|
|
|
user_id=str(current_user_id),
|
|
|
|
|
agent_id=conversation.agent_id,
|
|
|
|
|
attachments=all_attachments,
|
|
|
|
|
)
|
2026-01-22 00:28:43 +08:00
|
|
|
|
|
|
|
|
return serialize_attachment(attachment_record)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def list_thread_attachments_view(
|
|
|
|
|
*,
|
|
|
|
|
thread_id: str,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
current_user_id: str,
|
|
|
|
|
) -> dict:
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id))
|
|
|
|
|
attachments = await conv_repo.get_attachments(conversation.id)
|
|
|
|
|
return {
|
|
|
|
|
"attachments": [serialize_attachment(item) for item in attachments],
|
|
|
|
|
"limits": {
|
|
|
|
|
"allowed_extensions": sorted(ATTACHMENT_ALLOWED_EXTENSIONS),
|
|
|
|
|
"max_size_bytes": MAX_ATTACHMENT_SIZE_BYTES,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def delete_thread_attachment_view(
|
|
|
|
|
*,
|
|
|
|
|
thread_id: str,
|
|
|
|
|
file_id: str,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
current_user_id: str,
|
|
|
|
|
) -> dict:
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id))
|
2026-03-04 08:53:50 +08:00
|
|
|
|
|
|
|
|
existing_attachments = await conv_repo.get_attachments(conversation.id)
|
|
|
|
|
target_attachment = next((item for item in existing_attachments if item.get("file_id") == file_id), None)
|
|
|
|
|
|
2026-01-22 00:28:43 +08:00
|
|
|
removed = await conv_repo.remove_attachment(conversation.id, file_id)
|
|
|
|
|
if not removed:
|
|
|
|
|
raise HTTPException(status_code=404, detail="附件不存在或已被删除")
|
2026-03-04 08:53:50 +08:00
|
|
|
|
|
|
|
|
if target_attachment:
|
2026-03-05 22:50:38 +08:00
|
|
|
candidate = target_attachment.get("storage_path")
|
|
|
|
|
if candidate:
|
2026-03-04 08:53:50 +08:00
|
|
|
try:
|
|
|
|
|
file_path = Path(candidate)
|
|
|
|
|
if file_path.exists():
|
|
|
|
|
file_path.unlink()
|
|
|
|
|
except Exception as exc: # noqa: BLE001
|
|
|
|
|
logger.warning(f"Failed to remove attachment file {candidate}: {exc}")
|
|
|
|
|
|
2026-02-21 00:50:57 +08:00
|
|
|
all_attachments = await conv_repo.get_attachments(conversation.id)
|
2026-03-05 22:50:38 +08:00
|
|
|
await _sync_thread_upload_state(
|
2026-02-21 00:50:57 +08:00
|
|
|
thread_id=thread_id,
|
|
|
|
|
user_id=str(current_user_id),
|
|
|
|
|
agent_id=conversation.agent_id,
|
|
|
|
|
attachments=all_attachments,
|
|
|
|
|
)
|
2026-03-04 08:53:50 +08:00
|
|
|
|
|
|
|
|
if target_attachment:
|
|
|
|
|
provider = get_sandbox_provider()
|
|
|
|
|
connection = provider.get(thread_id, create_if_missing=False)
|
|
|
|
|
if connection is not None:
|
|
|
|
|
backend = ProvisionerSandboxBackend(thread_id=thread_id)
|
|
|
|
|
delete_commands = []
|
2026-03-05 22:50:38 +08:00
|
|
|
path = target_attachment.get("path")
|
|
|
|
|
if isinstance(path, str) and path.strip():
|
|
|
|
|
delete_commands.append(f"rm -f {shlex.quote(path)}")
|
2026-03-04 08:53:50 +08:00
|
|
|
if delete_commands:
|
|
|
|
|
backend.execute(" && ".join(delete_commands))
|
|
|
|
|
|
2026-01-22 00:28:43 +08:00
|
|
|
return {"message": "附件已删除"}
|