diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py index 6c07b89..17138ae 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py @@ -85,7 +85,7 @@ async def debug_agent( retrieval_log_id=rag_result.retrieval_log_id, tool_trace=rag_result.tool_trace, ) - result = ModelClientService.debug_model( + result = await ModelClientService.debug_model_async( model, rag_result, { diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py index aa2d463..5e88090 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json from dataclasses import dataclass from typing import Any @@ -93,6 +94,11 @@ class ModelClientService: "knowledgeIds": rag_result.knowledge_ids, } + @staticmethod + async def debug_model_async(model: ModelConfig, rag_result: RagResult, overrides: dict[str, Any]) -> dict[str, Any]: + """Run the blocking provider client outside the ASGI event loop.""" + return await asyncio.to_thread(ModelClientService.debug_model, model, rag_result, overrides) + def _mock_answer(rag_result: RagResult) -> str: if not rag_result.is_hit: diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py index 8d5f1de..12ed9f4 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date import asyncio import json +import time import pytest import httpx @@ -22,6 +23,7 @@ from app.services.chat_service import ChatService from app.services.secret_service import ENCRYPTED_PREFIX, MASKED_SECRET, SecretService from app.services.security_state_service import SecurityStateService from app.services.auth_service import AuthService +from app.services.model_service import ModelClientService from app.core.observability import RequestObservabilityMiddleware @@ -138,6 +140,26 @@ def test_request_id_is_returned_without_breaking_response(): assert response.json() == {"ok": True} +def test_agent_debug_model_call_does_not_block_event_loop(monkeypatch): + def blocking_debug(*_args, **_kwargs): + time.sleep(0.15) + return {"ok": True} + + monkeypatch.setattr(ModelClientService, "debug_model", blocking_debug) + + async def verify_concurrency(): + started = time.perf_counter() + task = asyncio.create_task(ModelClientService.debug_model_async(object(), object(), {})) + await asyncio.sleep(0.02) + event_loop_delay = time.perf_counter() - started + result = await task + return event_loop_delay, result + + delay, result = asyncio.run(verify_concurrency()) + assert delay < 0.10 + assert result == {"ok": True} + + def test_readiness_reports_dependency_state(monkeypatch): from app.api import health diff --git a/ai_knowledge_base_v2/apps/user-client/src/components/ChatComposer.vue b/ai_knowledge_base_v2/apps/user-client/src/components/ChatComposer.vue index e819310..dd6cf69 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/components/ChatComposer.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/components/ChatComposer.vue @@ -23,14 +23,23 @@ function resize() { element.style.overflowY = element.scrollHeight > 108 ? "auto" : "hidden"; } -function send() { +async function send() { const text = input.value.trim(); if (!text) return; + // Sending has been accepted by the composer. Clear immediately instead of + // keeping the submitted text visible until the entire AI stream finishes. + input.value = ""; + await nextTick(); + resize(); emit("send", text, async (success) => { - if (!success) return; - input.value = ""; - await nextTick(); - resize(); + // Restore only when the parent rejected/failed the request and the user has + // not entered replacement text in the meantime. + if (!success && !input.value) { + input.value = text; + await nextTick(); + resize(); + textarea.value?.focus(); + } }); }