fix(attachments): sync thread state on upload/delete

This commit is contained in:
肖泽涛 2026-02-21 00:50:57 +08:00
parent 1f5636520e
commit bfae184e1c
2 changed files with 150 additions and 0 deletions

View File

@ -1,8 +1,10 @@
import uuid
from datetime import UTC, datetime
from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from src.agents import agent_manager
from src.repositories.conversation_repository import ConversationRepository
from src.services.doc_converter import (
ATTACHMENT_ALLOWED_EXTENSIONS,
@ -41,6 +43,51 @@ def _make_attachment_path(file_name: str) -> str:
return f"/attachments/{safe_name}.md"
def _build_state_files(attachments: list[dict]) -> dict:
files = {}
for attachment in attachments:
if attachment.get("status") != "parsed":
continue
file_path = attachment.get("file_path")
markdown = attachment.get("markdown")
if not file_path or not markdown:
continue
now = datetime.now(UTC).isoformat()
files[file_path] = {
"content": markdown.split("\n"),
"created_at": attachment.get("uploaded_at", now),
"modified_at": attachment.get("uploaded_at", now),
}
return files
async def _sync_thread_attachment_state(
*,
thread_id: str,
user_id: str,
agent_id: str,
attachments: list[dict],
) -> None:
try:
agent = agent_manager.get_agent(agent_id)
if not agent:
logger.warning(f"Skip attachment state sync: agent not found ({agent_id})")
return
graph = await agent.get_graph()
await graph.aupdate_state(
config={"configurable": {"thread_id": thread_id, "user_id": str(user_id)}},
values={
"attachments": attachments,
"files": _build_state_files(attachments),
},
)
except Exception as e:
logger.warning(f"Failed to sync attachment state for thread {thread_id}: {e}")
def serialize_attachment(record: dict) -> dict:
"""序列化附件记录,返回给前端"""
return {
@ -201,6 +248,13 @@ async def upload_thread_attachment_view(
"minio_url": minio_url,
}
await conv_repo.add_attachment(conversation.id, attachment_record)
all_attachments = await conv_repo.get_attachments(conversation.id)
await _sync_thread_attachment_state(
thread_id=thread_id,
user_id=str(current_user_id),
agent_id=conversation.agent_id,
attachments=all_attachments,
)
return serialize_attachment(attachment_record)
@ -235,4 +289,11 @@ async def delete_thread_attachment_view(
removed = await conv_repo.remove_attachment(conversation.id, file_id)
if not removed:
raise HTTPException(status_code=404, detail="附件不存在或已被删除")
all_attachments = await conv_repo.get_attachments(conversation.id)
await _sync_thread_attachment_state(
thread_id=thread_id,
user_id=str(current_user_id),
agent_id=conversation.agent_id,
attachments=all_attachments,
)
return {"message": "附件已删除"}

View File

@ -0,0 +1,89 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from src.services import conversation_service as svc
def test_build_state_files_only_parsed_and_with_content():
attachments = [
{
"status": "parsed",
"file_path": "/attachments/a.md",
"markdown": "line1\nline2",
"uploaded_at": "2026-02-20T00:00:00+00:00",
},
{
"status": "pending",
"file_path": "/attachments/b.md",
"markdown": "ignored",
},
{
"status": "parsed",
"file_path": "/attachments/c.md",
"markdown": "",
},
]
files = svc._build_state_files(attachments)
assert list(files.keys()) == ["/attachments/a.md"]
assert files["/attachments/a.md"]["content"] == ["line1", "line2"]
assert files["/attachments/a.md"]["created_at"] == "2026-02-20T00:00:00+00:00"
@pytest.mark.asyncio
async def test_sync_thread_attachment_state_updates_graph(monkeypatch: pytest.MonkeyPatch):
captured: dict = {}
class FakeGraph:
async def aupdate_state(self, *, config, values):
captured["config"] = config
captured["values"] = values
class FakeAgent:
async def get_graph(self):
return FakeGraph()
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda _agent_id: FakeAgent())
attachments = [
{
"status": "parsed",
"file_path": "/attachments/resume.md",
"markdown": "hello\nworld",
"uploaded_at": "2026-02-20T00:00:00+00:00",
}
]
await svc._sync_thread_attachment_state(
thread_id="thread-1",
user_id="u1",
agent_id="ChatbotAgent",
attachments=attachments,
)
assert captured["config"] == {"configurable": {"thread_id": "thread-1", "user_id": "u1"}}
assert captured["values"]["attachments"] == attachments
assert "/attachments/resume.md" in captured["values"]["files"]
@pytest.mark.asyncio
async def test_sync_thread_attachment_state_skips_when_agent_missing(monkeypatch: pytest.MonkeyPatch):
warnings: list[str] = []
fake_logger = SimpleNamespace(
warning=lambda message: warnings.append(message),
)
monkeypatch.setattr(svc, "logger", fake_logger)
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda _agent_id: None)
await svc._sync_thread_attachment_state(
thread_id="thread-1",
user_id="u1",
agent_id="MissingAgent",
attachments=[],
)
assert any("agent not found" in msg for msg in warnings)