refactor: 重构聊天数据层至core并简化首页UI

This commit is contained in:
zl-q
2026-03-29 21:46:26 +08:00
parent 4db9a13bfe
commit f126d7a547
18 changed files with 568 additions and 328 deletions
+38 -16
View File
@@ -24,7 +24,6 @@ from fastapi import (
File,
Form,
Header,
HTTPException,
Query,
Request,
UploadFile,
@@ -195,16 +194,22 @@ async def stream_events(
if last_event_id is not None and (
len(last_event_id) > 32 or _LAST_EVENT_ID_RE.fullmatch(last_event_id) is None
):
raise HTTPException(
raise ApiProblemError(
status_code=422,
detail="Invalid Last-Event-ID",
detail=problem_payload(
code="AGENT_INVALID_LAST_EVENT_ID",
detail="Invalid Last-Event-ID",
),
)
sse_slot_acquired = await _acquire_sse_slot(user_id=str(current_user.id))
if not sse_slot_acquired:
raise HTTPException(
raise ApiProblemError(
status_code=429,
detail="Too many SSE connections",
detail=problem_payload(
code="AGENT_SSE_CONNECTION_LIMIT",
detail="Too many SSE connections",
),
)
async def _event_iter() -> AsyncIterator[str]:
@@ -296,14 +301,21 @@ async def upload_attachment(
) -> AttachmentUploadResponse:
payload = await file.read()
if not payload:
raise HTTPException(
raise ApiProblemError(
status_code=422,
detail="Empty attachment",
detail=problem_payload(
code="AGENT_ATTACHMENT_EMPTY",
detail="Empty attachment",
),
)
if len(payload) > _MAX_ATTACHMENT_UPLOAD_BYTES:
raise HTTPException(
raise ApiProblemError(
status_code=413,
detail="Attachment too large",
detail=problem_payload(
code="AGENT_ATTACHMENT_TOO_LARGE",
detail="Attachment too large",
params={"maxBytes": _MAX_ATTACHMENT_UPLOAD_BYTES},
),
)
attachment = await service.upload_attachment(
thread_id=thread_id,
@@ -388,9 +400,13 @@ async def transcribe(
break
total_bytes += len(chunk)
if total_bytes > _MAX_TRANSCRIBE_AUDIO_BYTES:
raise HTTPException(
raise ApiProblemError(
status_code=400,
detail="Audio file too large",
detail=problem_payload(
code="AGENT_AUDIO_TOO_LARGE",
detail="Audio file too large",
params={"maxBytes": _MAX_TRANSCRIBE_AUDIO_BYTES},
),
)
if len(header) < _WAV_HEADER_MIN_BYTES:
required = _WAV_HEADER_MIN_BYTES - len(header)
@@ -398,14 +414,20 @@ async def transcribe(
tmp_file.write(chunk)
if total_bytes == 0:
raise HTTPException(
raise ApiProblemError(
status_code=400,
detail="Empty audio file",
detail=problem_payload(
code="AGENT_AUDIO_EMPTY",
detail="Empty audio file",
),
)
if not _looks_like_wav_header(bytes(header)):
raise HTTPException(
raise ApiProblemError(
status_code=400,
detail="Unsupported audio format",
detail=problem_payload(
code="AGENT_AUDIO_UNSUPPORTED_FORMAT",
detail="Unsupported audio format",
),
)
transcript = await asr_service.transcribe_file(
@@ -414,7 +436,7 @@ async def transcribe(
return AsrTranscribeResponse(transcript=transcript)
except HTTPException:
except ApiProblemError:
raise
except RuntimeError:
raise ApiProblemError(
@@ -313,10 +313,40 @@ def test_stream_rejects_invalid_last_event_id() -> None:
headers={"Last-Event-ID": "bad-id"},
)
assert response.status_code == 422
payload = response.json()
assert payload["code"] == "AGENT_INVALID_LAST_EVENT_ID"
assert payload["detail"] == "Invalid Last-Event-ID"
finally:
app.dependency_overrides = {}
def test_stream_rejects_when_sse_connection_limit_exceeded() -> None:
app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService()
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
id=uuid4(), phone="+8613812345678"
)
client = TestClient(app)
original_acquire = agent_router._acquire_sse_slot
async def _deny_slot(*, user_id: str) -> bool:
del user_id
return False
agent_router._acquire_sse_slot = _deny_slot # type: ignore[assignment]
try:
response = client.get(
"/api/v1/agent/runs/00000000-0000-0000-0000-000000000001/events"
)
assert response.status_code == 429
payload = response.json()
assert payload["code"] == "AGENT_SSE_CONNECTION_LIMIT"
assert payload["detail"] == "Too many SSE connections"
finally:
agent_router._acquire_sse_slot = original_acquire # type: ignore[assignment]
app.dependency_overrides = {}
def test_cancel_run_returns_202_and_payload() -> None:
service = _FakeAgentService()
app.dependency_overrides[get_agent_service] = lambda: service
@@ -470,6 +500,58 @@ def test_upload_attachment_returns_reference() -> None:
app.dependency_overrides = {}
def test_upload_attachment_rejects_empty_payload_with_problem_details() -> None:
app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService()
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
id=uuid4(), phone="+8613812345678"
)
client = TestClient(app)
empty_payload = BytesIO(b"")
empty_payload.name = "empty.png"
try:
response = client.post(
"/api/v1/agent/attachments",
data={"threadId": "00000000-0000-0000-0000-000000000001"},
files={"file": ("empty.png", empty_payload, "image/png")},
)
assert response.status_code == 422
payload = response.json()
assert payload["code"] == "AGENT_ATTACHMENT_EMPTY"
assert payload["detail"] == "Empty attachment"
finally:
app.dependency_overrides = {}
def test_upload_attachment_rejects_oversized_payload_with_problem_details(
monkeypatch,
) -> None:
app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService()
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
id=uuid4(), phone="+8613812345678"
)
client = TestClient(app)
monkeypatch.setattr(agent_router, "_MAX_ATTACHMENT_UPLOAD_BYTES", 3)
file_payload = BytesIO(b"1234")
file_payload.name = "oversize.png"
try:
response = client.post(
"/api/v1/agent/attachments",
data={"threadId": "00000000-0000-0000-0000-000000000001"},
files={"file": ("oversize.png", file_payload, "image/png")},
)
assert response.status_code == 413
payload = response.json()
assert payload["code"] == "AGENT_ATTACHMENT_TOO_LARGE"
assert payload["detail"] == "Attachment too large"
assert payload["params"]["maxBytes"] == 3
finally:
app.dependency_overrides = {}
def test_create_attachment_signed_url_returns_url() -> None:
app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService()
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
@@ -548,6 +630,7 @@ def test_asr_transcribe_rejects_oversized_audio(monkeypatch) -> None:
assert response.status_code == 400
assert response.json()["detail"] == "Audio file too large"
assert response.json()["code"] == "AGENT_AUDIO_TOO_LARGE"
finally:
app.dependency_overrides = {}
@@ -569,6 +652,29 @@ def test_asr_transcribe_rejects_non_wav_audio(monkeypatch) -> None:
assert response.status_code == 400
assert response.json()["detail"] == "Unsupported audio format"
assert response.json()["code"] == "AGENT_AUDIO_UNSUPPORTED_FORMAT"
finally:
app.dependency_overrides = {}
def test_asr_transcribe_rejects_empty_wav_payload() -> None:
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
id=uuid4(), phone="+8613812345678"
)
client = TestClient(app)
empty_wav = BytesIO(b"")
empty_wav.name = "empty.wav"
try:
response = client.post(
"/api/v1/agent/transcribe",
files={"audio": ("empty.wav", empty_wav, "audio/wav")},
)
assert response.status_code == 400
assert response.json()["detail"] == "Empty audio file"
assert response.json()["code"] == "AGENT_AUDIO_EMPTY"
finally:
app.dependency_overrides = {}
@@ -590,5 +696,6 @@ def test_asr_transcribe_rejects_invalid_wav_payload(monkeypatch) -> None:
assert response.status_code == 400
assert response.json()["detail"] == "Unsupported audio format"
assert response.json()["code"] == "AGENT_AUDIO_UNSUPPORTED_FORMAT"
finally:
app.dependency_overrides = {}