Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e00115f7df | |||
| 12e6be02c9 | |||
| e1973d3b27 | |||
| 82e675f84c | |||
| a0fac509d4 | |||
| 1f122d197a | |||
| 7a153f9908 | |||
| f3680b6905 | |||
| 5676ce68c0 | |||
| 50a395c546 | |||
| 69bf2cab5d |
@@ -82,3 +82,6 @@ db.sqlite3
|
||||
|
||||
# Docker compose override
|
||||
compose.override.yml
|
||||
|
||||
# Docling
|
||||
docling-models
|
||||
+1
-14
@@ -12,27 +12,15 @@ and this project adheres to
|
||||
|
||||
- ✨(backend) add FindRagBackend
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) consider PDF documents as other kind of documents #234
|
||||
|
||||
## [0.0.11] - 2026-01-16
|
||||
|
||||
### Changed
|
||||
|
||||
- 📦️(front) update react
|
||||
- ✨(chat) Generate and edit conversation title
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(e2e) fix test-e2e-chromium
|
||||
- 🐛(back) fix system prompt compatibility with self-hosted models #200
|
||||
- ⚰️(back) remove dead code and unused files
|
||||
- 🐛(back) prevent tool call timeouts
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(chat) remove thinking part from frontend #227
|
||||
|
||||
## [0.0.10] - 2025-12-15
|
||||
|
||||
@@ -188,8 +176,7 @@ and this project adheres to
|
||||
- 💄(chat) add code highlighting for LLM responses #67
|
||||
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.11...main
|
||||
[0.0.11]: https://github.com/suitenumerique/conversations/releases/v0.0.11
|
||||
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.10...main
|
||||
[0.0.10]: https://github.com/suitenumerique/conversations/releases/v0.0.10
|
||||
[0.0.9]: https://github.com/suitenumerique/conversations/releases/v0.0.9
|
||||
[0.0.8]: https://github.com/suitenumerique/conversations/releases/v0.0.8
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.3-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
|
||||
@@ -8,5 +8,3 @@ LLM_CONFIGURATION_FILE_PATH = /app/conversations/configuration/llm/default.e2e.j
|
||||
# Features
|
||||
FEATURE_FLAG_WEB_SEARCH=ENABLED
|
||||
FEATURE_FLAG_DOCUMENT_UPLOAD=ENABLED
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES=3
|
||||
|
||||
@@ -7,8 +7,16 @@ from urllib.parse import urljoin
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend
|
||||
from docling.datamodel.base_models import InputFormat
|
||||
from docling.datamodel.pipeline_options import PdfPipelineOptions, TableStructureOptions
|
||||
from docling.document_converter import DocumentConverter as DoclingDocumentConverter
|
||||
from docling.document_converter import PdfFormatOption
|
||||
from docling_core.types.io import DocumentStream
|
||||
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
from chat.agent_rag.document_converter.markitdown import (
|
||||
DocumentConverter as MarkitdownDocumentConverter,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,6 +69,32 @@ class AlbertParser(BaseParser):
|
||||
"""Parse document based on content type."""
|
||||
if content_type == "application/pdf":
|
||||
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
|
||||
return DocumentConverter().convert_raw(
|
||||
return MarkitdownDocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
|
||||
class DoclingParser(BaseParser):
|
||||
"""Document parser using Docling's DocumentConverter."""
|
||||
|
||||
artifacts_path = "src/backend/docling-models"
|
||||
|
||||
def __init__(self):
|
||||
pipeline_options = PdfPipelineOptions(artifacts_path=self.artifacts_path)
|
||||
pipeline_options.do_ocr = True
|
||||
pipeline_options.do_table_structure = True
|
||||
pipeline_options.table_structure_options = TableStructureOptions(do_cell_matching=False)
|
||||
|
||||
self.converter = DoclingDocumentConverter(
|
||||
format_options={
|
||||
InputFormat.PDF: PdfFormatOption(
|
||||
pipeline_options=pipeline_options,
|
||||
backend=PyPdfiumDocumentBackend
|
||||
)}
|
||||
)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Parse document using Docling's DocumentConverter."""
|
||||
return self.converter.convert(
|
||||
DocumentStream(name=name, stream=BytesIO(content))
|
||||
).document.export_to_markdown()
|
||||
|
||||
@@ -13,7 +13,7 @@ import requests
|
||||
|
||||
from chat.agent_rag.albert_api_constants import Searches
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.parser import AlbertParser
|
||||
from chat.agent_rag.document_converter.parser import DoclingParser
|
||||
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,7 +45,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
self._documents_endpoint = urljoin(self._base_url, "/v1/documents")
|
||||
self._search_endpoint = urljoin(self._base_url, "/v1/search")
|
||||
self._default_collection_description = "Temporary collection for RAG document search"
|
||||
self.parser = AlbertParser()
|
||||
self.parser = DoclingParser()
|
||||
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
@@ -87,7 +87,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
self.collection_id = str(response.json()["id"])
|
||||
return self.collection_id
|
||||
|
||||
def delete_collection(self, **kwargs) -> None:
|
||||
def delete_collection(self) -> None:
|
||||
"""
|
||||
Delete the current collection
|
||||
"""
|
||||
@@ -98,7 +98,7 @@ class AlbertRagBackend(BaseRagBackend): # pylint: disable=too-many-instance-att
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def adelete_collection(self, **kwargs) -> None:
|
||||
async def adelete_collection(self) -> None:
|
||||
"""
|
||||
Asynchronously delete the current collection
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Implementation of the Albert API for RAG document search."""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
@@ -14,7 +13,7 @@ from chat.agent_rag.document_converter.parser import BaseParser
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseRagBackend(ABC):
|
||||
class BaseRagBackend:
|
||||
"""Base class for RAG backends."""
|
||||
|
||||
def __init__(
|
||||
@@ -63,7 +62,6 @@ class BaseRagBackend(ABC):
|
||||
)
|
||||
return collection_ids
|
||||
|
||||
@abstractmethod
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
Create a temporary collection for the search operation.
|
||||
@@ -94,7 +92,6 @@ class BaseRagBackend(ABC):
|
||||
"""
|
||||
return self.parser.parse_document(name, content_type, content)
|
||||
|
||||
@abstractmethod
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
Store the document content in the collection.
|
||||
@@ -138,22 +135,20 @@ class BaseRagBackend(ABC):
|
||||
self.store_document(name, document_content, **kwargs)
|
||||
return document_content
|
||||
|
||||
@abstractmethod
|
||||
def delete_collection(self, **kwargs) -> None:
|
||||
def delete_collection(self) -> None:
|
||||
"""
|
||||
Delete the collection.
|
||||
This method should handle the logic to delete the collection from the backend.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
async def adelete_collection(self, **kwargs) -> None:
|
||||
async def adelete_collection(self) -> None:
|
||||
"""
|
||||
Delete the collection.
|
||||
This method should handle the logic to delete the collection from the backend.
|
||||
"""
|
||||
return await sync_to_async(self.delete_collection)(**kwargs)
|
||||
return await sync_to_async(self.delete_collection)()
|
||||
|
||||
@abstractmethod
|
||||
def search(self, query: str, results_count: int = 4, **kwargs) -> RAGWebResults:
|
||||
"""
|
||||
Search the collection for the given query.
|
||||
@@ -190,9 +185,7 @@ class BaseRagBackend(ABC):
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def temporary_collection_async(
|
||||
cls, name: str, description: Optional[str] = None, **kwargs
|
||||
):
|
||||
async def temporary_collection_async(cls, name: str, description: Optional[str] = None):
|
||||
"""Context manager for RAG backend with temporary collections."""
|
||||
backend = cls()
|
||||
|
||||
@@ -200,4 +193,4 @@ class BaseRagBackend(ABC):
|
||||
try:
|
||||
yield backend
|
||||
finally:
|
||||
await backend.adelete_collection(**kwargs)
|
||||
await backend.adelete_collection()
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.utils import timezone
|
||||
import requests
|
||||
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.parser import AlbertParser
|
||||
from chat.agent_rag.document_converter.parser import DoclingParser
|
||||
from chat.agent_rag.document_rag_backends.base_rag_backend import BaseRagBackend
|
||||
from utils.oidc import with_fresh_access_token
|
||||
|
||||
@@ -42,8 +42,7 @@ class FindRagBackend(BaseRagBackend):
|
||||
self.api_key = settings.FIND_API_KEY
|
||||
self.search_endpoint = "api/v1.0/documents/search/"
|
||||
self.indexing_endpoint = "api/v1.0/documents/index/"
|
||||
self.deleting_endpoint = "api/v1.0/documents/delete/"
|
||||
self.parser = AlbertParser() # Find Rag relies on Albert parser
|
||||
self.parser = DoclingParser()
|
||||
|
||||
def create_collection(self, name: str, description: Optional[str] = None) -> str:
|
||||
"""
|
||||
@@ -52,21 +51,11 @@ class FindRagBackend(BaseRagBackend):
|
||||
self.collection_id = self.collection_id or str(uuid.uuid4())
|
||||
return self.collection_id
|
||||
|
||||
@with_fresh_access_token
|
||||
def delete_collection(self, **kwargs) -> None:
|
||||
def delete_collection(self) -> None:
|
||||
"""
|
||||
Delete the current collection
|
||||
Deletion not available
|
||||
"""
|
||||
response = requests.post(
|
||||
urljoin(settings.FIND_API_URL, self.deleting_endpoint),
|
||||
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
|
||||
json={
|
||||
"tags": [f"collection-{self.collection_id}"],
|
||||
# "service": "conversations"
|
||||
},
|
||||
timeout=settings.FIND_API_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.warning("deletion of collections is not yet supported in FindRagBackend")
|
||||
|
||||
def store_document(self, name: str, content: str, **kwargs) -> None:
|
||||
"""
|
||||
@@ -121,11 +110,12 @@ class FindRagBackend(BaseRagBackend):
|
||||
RAGWebResults: The search results.
|
||||
"""
|
||||
logger.debug("search documents in Find with query '%s'", query)
|
||||
|
||||
response = requests.post(
|
||||
urljoin(settings.FIND_API_URL, self.search_endpoint),
|
||||
headers={"Authorization": f"Bearer {kwargs['session'].get('oidc_access_token')}"},
|
||||
json={
|
||||
"q": query or "*",
|
||||
"q": query,
|
||||
"tags": [
|
||||
f"collection-{collection_id}" for collection_id in self.get_all_collection_ids()
|
||||
],
|
||||
|
||||
@@ -11,7 +11,7 @@ import requests
|
||||
|
||||
from chat.agent_rag.albert_api_constants import Searches
|
||||
from chat.agent_rag.constants import RAGWebResult, RAGWebResults, RAGWebUsage
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
from chat.agent_rag.document_converter.parser import DoclingParser
|
||||
from chat.models import ChatConversation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -80,58 +80,6 @@ class AlbertRagDocumentSearch:
|
||||
self.conversation.collection_id = str(response.json()["id"])
|
||||
return True
|
||||
|
||||
def _parse_pdf_document(self, name: str, content_type: str, content: BytesIO) -> str:
|
||||
"""
|
||||
Parse the PDF document content and return the text content.
|
||||
This method should handle the logic to convert the PDF into
|
||||
a format suitable for the Albert API.
|
||||
"""
|
||||
response = requests.post(
|
||||
self._pdf_parser_endpoint,
|
||||
headers=self._headers,
|
||||
files={
|
||||
"file": (
|
||||
name,
|
||||
content,
|
||||
content_type,
|
||||
), # Use the name as the filename in the request
|
||||
"output_format": (None, "markdown"), # Specify the output format as Markdown,
|
||||
},
|
||||
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return "\n\n".join(
|
||||
document_page["content"] for document_page in response.json().get("data", [])
|
||||
)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: BytesIO):
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
into a format suitable for the Albert API.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
# Implement the parsing logic here
|
||||
if content_type == "application/pdf":
|
||||
# Handle PDF parsing
|
||||
markdown_content = self._parse_pdf_document(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
else:
|
||||
markdown_content = DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
|
||||
return markdown_content
|
||||
|
||||
def _store_document(self, name: str, content: str):
|
||||
"""
|
||||
Store the document content in the Albert collection.
|
||||
@@ -156,7 +104,7 @@ class AlbertRagDocumentSearch:
|
||||
logger.debug(response.json())
|
||||
response.raise_for_status()
|
||||
|
||||
def parse_and_store_document(self, name: str, content_type: str, content: BytesIO):
|
||||
def parse_and_store_document(self, name: str, content_type: str, content: bytes):
|
||||
"""
|
||||
Parse the document and store it in the Albert collection.
|
||||
|
||||
@@ -165,7 +113,9 @@ class AlbertRagDocumentSearch:
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (BytesIO): The content of the document as a BytesIO stream.
|
||||
"""
|
||||
document_content = self.parse_document(name, content_type, content)
|
||||
document_content = DoclingParser().parse_document(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
self._store_document(name, document_content)
|
||||
return document_content
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import httpx
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models import get_user_agent
|
||||
from pydantic_ai.profiles import ModelProfile
|
||||
from pydantic_ai.toolsets import FunctionToolset
|
||||
|
||||
from chat.tools import get_pydantic_tools_by_name
|
||||
|
||||
@@ -173,18 +174,20 @@ class BaseAgent(Agent):
|
||||
# and pydantic_ai.models.infer_model()
|
||||
_model_instance = self.configuration.model_name
|
||||
|
||||
_system_prompt = self.get_system_prompt()
|
||||
_system_prompt = self.configuration.system_prompt
|
||||
_base_toolset = (
|
||||
[
|
||||
FunctionToolset(
|
||||
tools=[
|
||||
get_pydantic_tools_by_name(tool_name)
|
||||
for tool_name in self.configuration.tools
|
||||
]
|
||||
)
|
||||
]
|
||||
if self.configuration.tools
|
||||
else None
|
||||
)
|
||||
|
||||
_tools = self.get_tools()
|
||||
_tools = [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
|
||||
|
||||
super().__init__(model=_model_instance, instructions=_system_prompt, tools=_tools, **kwargs)
|
||||
|
||||
def get_system_prompt(self) -> str | None:
|
||||
"""Override this method to customize the system prompt."""
|
||||
return self.configuration.system_prompt
|
||||
|
||||
def get_tools(self) -> list | None:
|
||||
"""Override this method to customize tools."""
|
||||
if not self.configuration.tools:
|
||||
return []
|
||||
return [get_pydantic_tools_by_name(tool_name) for tool_name in self.configuration.tools]
|
||||
|
||||
@@ -131,25 +131,3 @@ class ConversationAgent(BaseAgent):
|
||||
if tool.name.startswith("web_search_"):
|
||||
return tool.name
|
||||
return None
|
||||
|
||||
|
||||
@dataclasses.dataclass(init=False)
|
||||
class TitleGenerationAgent(BaseAgent):
|
||||
"""Agent that generates concise, descriptive titles for conversations."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
|
||||
output_type=str,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_tools(self):
|
||||
return []
|
||||
|
||||
def get_system_prompt(self):
|
||||
return (
|
||||
"You are a title generator. Your task is to create concise, descriptive titles "
|
||||
"that accurately summarize conversation content and help the user quickly identify the "
|
||||
"conversation.\n\n"
|
||||
)
|
||||
|
||||
@@ -52,9 +52,10 @@ from pydantic_ai.messages import (
|
||||
)
|
||||
|
||||
from core.feature_flags.helpers import is_feature_enabled
|
||||
from core.file_upload.utils import generate_retrieve_policy
|
||||
|
||||
from chat import models
|
||||
from chat.agents.conversation import ConversationAgent, TitleGenerationAgent
|
||||
from chat.agents.conversation import ConversationAgent
|
||||
from chat.agents.local_media_url_processors import (
|
||||
update_history_local_urls,
|
||||
update_local_urls,
|
||||
@@ -75,7 +76,7 @@ from chat.tools.document_generic_search_rag import add_document_rag_search_tool_
|
||||
from chat.tools.document_search_rag import add_document_rag_search_tool
|
||||
from chat.tools.document_summarize import document_summarize
|
||||
from chat.vercel_ai_sdk.core import events_v4, events_v5
|
||||
from chat.vercel_ai_sdk.encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder
|
||||
from chat.vercel_ai_sdk.encoder import EventEncoder
|
||||
|
||||
# Keep at the top of the file to avoid mocking issues
|
||||
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
|
||||
@@ -129,7 +130,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
self._langfuse_available = settings.LANGFUSE_ENABLED
|
||||
self._store_analytics = self._langfuse_available and user.allow_conversation_analytics
|
||||
self.event_encoder = EventEncoder(CURRENT_EVENT_ENCODER_VERSION) # We use v4 for now
|
||||
self.event_encoder = EventEncoder("v4") # Always use v4 for now
|
||||
|
||||
self._support_streaming = True
|
||||
if (streaming := get_model_configuration(self.model_hrid).supports_streaming) is not None:
|
||||
@@ -481,19 +482,27 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
_tool_is_streaming = False
|
||||
_model_response_message_id = None
|
||||
|
||||
# Check for existing documents (any non-image attachment for this conversation)
|
||||
has_documents = await (
|
||||
# Check for existing non-PDF documents in the conversation:
|
||||
# - if no document at all: do nothing
|
||||
# - if only PDFs: prepare document URLs for the agent
|
||||
# - if other document types: add the RAG search tool
|
||||
# to allow searching in all kinds of documents
|
||||
has_not_pdf_docs = await (
|
||||
models.ChatConversationAttachment.objects.filter(
|
||||
Q(conversion_from__isnull=True) | Q(conversion_from=""),
|
||||
conversation=self.conversation,
|
||||
)
|
||||
.exclude(content_type__startswith="image/")
|
||||
.exclude(
|
||||
Q(content_type__startswith="image/") | Q(content_type="application/pdf"),
|
||||
)
|
||||
.aexists()
|
||||
)
|
||||
|
||||
should_enable_rag = conversation_has_documents or has_documents
|
||||
|
||||
if should_enable_rag:
|
||||
document_urls = []
|
||||
if not conversation_has_documents and not has_not_pdf_docs:
|
||||
# No documents to process
|
||||
pass
|
||||
elif has_not_pdf_docs:
|
||||
add_document_rag_search_tool(self.conversation_agent)
|
||||
|
||||
@self.conversation_agent.instructions
|
||||
@@ -523,6 +532,30 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
async def summarize(ctx: RunContext, *args, **kwargs) -> ToolReturn:
|
||||
"""Wrap the document_summarize tool to provide context and add the tool."""
|
||||
return await document_summarize(ctx, *args, **kwargs)
|
||||
else:
|
||||
conversation_documents = [
|
||||
cd
|
||||
async for cd in models.ChatConversationAttachment.objects.filter(
|
||||
Q(conversion_from__isnull=True) | Q(conversion_from=""),
|
||||
conversation=self.conversation,
|
||||
)
|
||||
.exclude(
|
||||
content_type__startswith="image/",
|
||||
)
|
||||
.values_list("key", "content_type")
|
||||
]
|
||||
|
||||
for doc_key, doc_content_type in conversation_documents:
|
||||
if doc_content_type == "application/pdf":
|
||||
_presigned_url = generate_retrieve_policy(doc_key)
|
||||
document_urls.append(
|
||||
DocumentUrl(
|
||||
url=_presigned_url,
|
||||
identifier=doc_key.split("/")[-1],
|
||||
media_type="application/pdf",
|
||||
)
|
||||
)
|
||||
image_key_mapping[_presigned_url] = f"/media-key/{doc_key}"
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
# MCP servers (if any) can be initialized here
|
||||
@@ -537,7 +570,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
history.append(ModelResponse(parts=[TextPart(content="ok")], kind="response"))
|
||||
|
||||
async with self.conversation_agent.iter(
|
||||
[user_prompt] + input_images,
|
||||
[user_prompt] + input_images + document_urls,
|
||||
message_history=history, # history will pass through agent's history_processors
|
||||
deps=self._context_deps,
|
||||
toolsets=mcp_servers,
|
||||
@@ -698,8 +731,8 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
await self._agent_stop_streaming(force_cache_check=True)
|
||||
|
||||
# Prepare conversation update (save deferred until after potential title generation)
|
||||
await sync_to_async(self._prepare_update_conversation)(
|
||||
# Persist conversation
|
||||
await sync_to_async(self._update_conversation)(
|
||||
final_output=run.result.new_messages(),
|
||||
usage=usage,
|
||||
final_output_from_tool=_final_output_from_tool,
|
||||
@@ -708,35 +741,6 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
image_key_mapping=image_key_mapping or None,
|
||||
)
|
||||
|
||||
generated_title = None
|
||||
|
||||
# Auto-generate title after N user messages if not manually set
|
||||
user_messages_count = sum(1 for msg in self.conversation.messages if msg.role == "user")
|
||||
|
||||
should_generate_title = (
|
||||
user_messages_count == settings.AUTO_TITLE_AFTER_USER_MESSAGES
|
||||
and not self.conversation.title_set_by_user_at
|
||||
)
|
||||
|
||||
if should_generate_title:
|
||||
if generated_title := await self._generate_title():
|
||||
self.conversation.title = generated_title
|
||||
|
||||
# Persist conversation (including any generated title)
|
||||
await sync_to_async(self.conversation.save)()
|
||||
|
||||
# Notify frontend about the title update
|
||||
if generated_title:
|
||||
yield events_v4.DataPart(
|
||||
data=[
|
||||
{
|
||||
"type": "conversation_metadata",
|
||||
"conversationId": str(self.conversation.pk),
|
||||
"title": generated_title,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if self._langfuse_available:
|
||||
langfuse.update_current_trace(
|
||||
output=run.result.output if self._store_analytics else "REDACTED"
|
||||
@@ -750,7 +754,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
),
|
||||
)
|
||||
|
||||
def _prepare_update_conversation( # noqa: PLR0913
|
||||
def _update_conversation( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
final_output: List[ModelRequest | ModelMessage],
|
||||
@@ -817,39 +821,4 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
ModelMessagesTypeAdapter.dump_json(final_output).decode("utf-8")
|
||||
)
|
||||
|
||||
async def _generate_title(self) -> str | None:
|
||||
"""Generate a title for the conversation using LLM based on first messages."""
|
||||
|
||||
# Build context from messages
|
||||
# Note: We intentionally use only msg.content for title generation.
|
||||
# Parts containing tool invocations or reasoning are excluded as they
|
||||
# don't contribute to a meaningful context here
|
||||
context = "\n".join(
|
||||
f"{msg.role}: {(msg.content or '')[:300]}" # Limit content length per message
|
||||
for msg in self.conversation.messages
|
||||
if msg.content
|
||||
)
|
||||
|
||||
language = self.language or settings.LANGUAGE_CODE
|
||||
prompt = (
|
||||
"Generate a concise title (3-5 words, max 100 characters) for this conversation.\n\n"
|
||||
"Requirements:\n"
|
||||
"- Capture the main topic or user intent\n"
|
||||
"- The title must be a simple string, no markdown\n"
|
||||
"- Help the user quickly identify the conversation\n"
|
||||
f"- Match the language of the user messages (default: {language})\n"
|
||||
"- Avoid the word 'summary' unless explicitly requested\n\n"
|
||||
"Output: Title text only, no quotes, labels, or explanation.\n\n"
|
||||
f"Conversation:\n{context}"
|
||||
)
|
||||
try:
|
||||
agent = TitleGenerationAgent()
|
||||
result = await agent.run(prompt)
|
||||
title = (result.output or "").strip()[:100] # Enforce max length (conversation.title)
|
||||
logger.info("Generated title for conversation %s: %s", self.conversation.pk, title)
|
||||
return title if title else None
|
||||
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to generate title for conversation %s: %s", self.conversation.pk, exc
|
||||
)
|
||||
return None
|
||||
self.conversation.save()
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Helpers to prevent proxy timeouts during long-running stream operations.
|
||||
|
||||
This module provides utilities to wrap synchronous and asynchronous iterators
|
||||
with keepalive messages. When a stream pauses for longer than the specified
|
||||
interval, keepalive messages are injected to prevent proxy/gateway
|
||||
timeouts while waiting for the stream data.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import AsyncIterator, Iterator
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .vercel_ai_sdk.core.events_v4 import DataPart as V4DataPart
|
||||
from .vercel_ai_sdk.core.events_v5 import DataPart as V5DataPart
|
||||
from .vercel_ai_sdk.encoder import (
|
||||
CURRENT_EVENT_ENCODER_VERSION,
|
||||
EventEncoder,
|
||||
EventEncoderVersion,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_keepalive_message() -> str:
|
||||
"""Generate a keepalive message based on encoder/SDK version."""
|
||||
if CURRENT_EVENT_ENCODER_VERSION == EventEncoderVersion.V4:
|
||||
event = V4DataPart(data=[{"status": "WAITING"}])
|
||||
else:
|
||||
event = V5DataPart(data={"status": "WAITING"})
|
||||
encoder = EventEncoder(CURRENT_EVENT_ENCODER_VERSION)
|
||||
return encoder.encode(event)
|
||||
|
||||
|
||||
async def stream_with_keepalive_async(
|
||||
stream: AsyncIterator[str],
|
||||
) -> AsyncIterator[str]:
|
||||
"""Wrap an async iterator to emit keepalive during long pauses.
|
||||
|
||||
Args:
|
||||
stream: The async iterator to wrap
|
||||
Yields:
|
||||
Items from the original stream, plus keepalive messages during pauses
|
||||
Raises:
|
||||
Any exception raised by the original stream
|
||||
"""
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
finished = asyncio.Event()
|
||||
keepalive_message = get_keepalive_message()
|
||||
|
||||
async def producer():
|
||||
"""Background task that consumes the original stream into a queue."""
|
||||
|
||||
try:
|
||||
async for stream_item in stream:
|
||||
await q.put(stream_item)
|
||||
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
|
||||
# Pass exceptions through the queue so the consumer can re-raise them.
|
||||
# This ensures errors aren't silently swallowed.
|
||||
await q.put(exc)
|
||||
finally:
|
||||
finished.set()
|
||||
await q.put(None) # Sentinel to signal completion
|
||||
|
||||
producer_task = asyncio.create_task(producer())
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.wait_for(q.get(), timeout=settings.KEEPALIVE_INTERVAL)
|
||||
if item is None:
|
||||
break
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
yield item
|
||||
except asyncio.TimeoutError:
|
||||
# No data received within interval
|
||||
if finished.is_set():
|
||||
# Producer is done, queue is empty (else we would not have timed out)
|
||||
break
|
||||
|
||||
logger.debug("Send keepalive")
|
||||
yield keepalive_message
|
||||
finally:
|
||||
# Cleanup
|
||||
producer_task.cancel()
|
||||
try:
|
||||
await producer_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
def get_current_time() -> float:
|
||||
"""Get current monotonic time, avoiding freezegun interferences.
|
||||
|
||||
Returns time.monotonic() which:
|
||||
- Is NOT affected by freezegun's @freeze_time decorator (unlike time.time())
|
||||
- Prevents issues where frozen time in main thread differs from real time in
|
||||
spawned threads, causing incorrect keepalive interval computation
|
||||
- Is the best clock for measuring time intervals
|
||||
|
||||
Wrapped in a function to ease mocking in tests.
|
||||
|
||||
Returns:
|
||||
float: Monotonic time in seconds since an arbitrary reference point
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def stream_with_keepalive_sync(stream: Iterator[str]) -> Iterator[str]:
|
||||
"""Wraps a synchronous stream with keepalive messages."""
|
||||
|
||||
q: queue.Queue = queue.Queue()
|
||||
stream_done = threading.Event()
|
||||
keepalive_message = get_keepalive_message()
|
||||
# Mutable container so threads can read/write shared timestamp
|
||||
last_yield_time = [get_current_time()]
|
||||
|
||||
def consume_stream():
|
||||
"""Read from source stream and forward chunks to queue."""
|
||||
try:
|
||||
for chunk in stream:
|
||||
if stream_done.is_set():
|
||||
return # early exit
|
||||
q.put(chunk, timeout=1) # Arbitrary timeout prevents blocking forever
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as e:
|
||||
logger.exception("Error in stream consumption")
|
||||
q.put(e)
|
||||
finally:
|
||||
stream_done.set()
|
||||
|
||||
def send_keepalives():
|
||||
"""Inject keepalive messages when idle too long.
|
||||
|
||||
Uses get_current_time() (time.monotonic) instead of time.time()
|
||||
to avoid issues with freezegun in tests.
|
||||
"""
|
||||
while not stream_done.is_set():
|
||||
# Sleep before checking to give main loop time to process and update timestamp
|
||||
time.sleep(0.5) # let main loop process first, empiric value
|
||||
if get_current_time() - last_yield_time[0] >= settings.KEEPALIVE_INTERVAL:
|
||||
try:
|
||||
q.put(keepalive_message, timeout=0.1)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
for target in (consume_stream, send_keepalives):
|
||||
threading.Thread(target=target, daemon=True).start()
|
||||
|
||||
try:
|
||||
# Continue while stream is active or queue has still items
|
||||
while not stream_done.is_set() or not q.empty():
|
||||
try:
|
||||
item = q.get(timeout=1) # short timeout, avoid blocking and stay responsive
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
# Re-raise from consume_stream
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
|
||||
yield item
|
||||
last_yield_time[0] = get_current_time()
|
||||
finally:
|
||||
# Signal threads to stop
|
||||
stream_done.set()
|
||||
@@ -1,21 +0,0 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-30 09:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("chat", "0004_chatconversationattachment_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="chatconversation",
|
||||
name="title_set_by_user_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="Timestamp when the user manually set the title. If set, prevent automatic title generation.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -44,12 +44,7 @@ class ChatConversation(BaseModel):
|
||||
null=True,
|
||||
help_text="Title of the chat conversation",
|
||||
)
|
||||
title_set_by_user_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Timestamp when the user manually set the title. If set, prevent automatic "
|
||||
"title generation.",
|
||||
)
|
||||
|
||||
ui_messages = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField # pylint: disable=no-name-in-module
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
@@ -28,12 +27,6 @@ class ChatConversationSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "title", "created_at", "updated_at", "messages", "owner"]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "messages"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# If title is being changed, mark it as user-set
|
||||
if "title" in validated_data and validated_data["title"] != instance.title:
|
||||
instance.title_set_by_user_at = timezone.now()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class ChatConversationInputSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Unit tests for the DocumentConverter.
|
||||
|
||||
Only for coverage as the DocumentConverter is a simple wrapper around MarkItDown.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from docling.document_converter import DocumentConverter
|
||||
from docling_core.types.io import DocumentStream
|
||||
|
||||
|
||||
def main():
|
||||
"""Test that the DocumentConverter calls the underlying MarkItDown converter."""
|
||||
file_path = "test.pdf"
|
||||
converter = DocumentConverter()
|
||||
|
||||
# Convert from file content instead of file path
|
||||
with open(file_path, "rb") as file:
|
||||
content = file.read()
|
||||
stream = DocumentStream(name="test.pdf", stream=BytesIO(content))
|
||||
result = converter.convert(stream)
|
||||
markdown = result.document.export_to_markdown()
|
||||
|
||||
assert markdown == "Document PDF test"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
%PDF-1.4
|
||||
%Çì�¢
|
||||
5 0 obj
|
||||
<</Length 6 0 R/Filter /FlateDecode>>
|
||||
stream
|
||||
xœMޱNÄ0†÷<…Çd¨ÏNœ¸^OÀÀ§l'¦Šc*¨âx’RªÚƒÿóo{BŽ@=ÿ›ivnq#¦«xì§ÎÕ�.
|
||||
ÍQoŽÐÌ„WÆ „#h!¨³»ú‡À˜5Sò_a Œ&¦â§�°•Ÿƒ4‡!¢ÊÅ¿ÿÑϽwÊ%çÑC—Y4[ò/a�ö³n‡D¢
|
||||
‹æhû¨Z<nØö‡�1F3Ýaj–·úì«{mùµi:uendstream
|
||||
endobj
|
||||
6 0 obj
|
||||
180
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Type/Page/MediaBox [0 0 595 842]
|
||||
/Rotate 0/Parent 3 0 R
|
||||
/Resources<</ProcSet[/PDF /Text]
|
||||
/Font 8 0 R
|
||||
>>
|
||||
/Contents 5 0 R
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Pages /Kids [
|
||||
4 0 R
|
||||
] /Count 1
|
||||
>>
|
||||
endobj
|
||||
1 0 obj
|
||||
<</Type /Catalog /Pages 3 0 R
|
||||
/Metadata 9 0 R
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<</R7
|
||||
7 0 R>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Times-Roman/Type/Font
|
||||
/Subtype/Type1>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<</Type/Metadata
|
||||
/Subtype/XML/Length 1549>>stream
|
||||
<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
|
||||
<?adobe-xap-filters esc="CRLF"?>
|
||||
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='XMP toolkit 2.9.1-13, framework 1.6'>
|
||||
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:iX='http://ns.adobe.com/iX/1.0/'>
|
||||
<rdf:Description rdf:about='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c' xmlns:pdf='http://ns.adobe.com/pdf/1.3/'><pdf:Producer>GPL Ghostscript 9.06</pdf:Producer>
|
||||
<pdf:Keywords>()</pdf:Keywords>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c' xmlns:xmp='http://ns.adobe.com/xap/1.0/'><xmp:ModifyDate>2014-12-22T00:49:20+01:00</xmp:ModifyDate>
|
||||
<xmp:CreateDate>2014-12-22T00:49:20+01:00</xmp:CreateDate>
|
||||
<xmp:CreatorTool>PDFCreator Version 1.6.0</xmp:CreatorTool></rdf:Description>
|
||||
<rdf:Description rdf:about='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c' xmlns:xapMM='http://ns.adobe.com/xap/1.0/mm/' xapMM:DocumentID='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c'/>
|
||||
<rdf:Description rdf:about='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c' xmlns:dc='http://purl.org/dc/elements/1.1/' dc:format='application/pdf'><dc:title><rdf:Alt><rdf:li xml:lang='x-default'>test_word</rdf:li></rdf:Alt></dc:title><dc:creator><rdf:Seq><rdf:li>Seb</rdf:li></rdf:Seq></dc:creator><dc:description><rdf:Seq><rdf:li>()</rdf:li></rdf:Seq></dc:description></rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
||||
|
||||
<?xpacket end='w'?>
|
||||
endstream
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Producer(GPL Ghostscript 9.06)
|
||||
/CreationDate(D:20141222004920+01'00')
|
||||
/ModDate(D:20141222004920+01'00')
|
||||
/Title(\376\377\000t\000e\000s\000t\000_\000w\000o\000r\000d)
|
||||
/Creator(\376\377\000P\000D\000F\000C\000r\000e\000a\000t\000o\000r\000 \000V\000e\000r\000s\000i\000o\000n\000 \0001\000.\0006\000.\0000)
|
||||
/Author(\376\377\000S\000e\000b)
|
||||
/Keywords()
|
||||
/Subject()>>endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000484 00000 n
|
||||
0000002268 00000 n
|
||||
0000000425 00000 n
|
||||
0000000284 00000 n
|
||||
0000000015 00000 n
|
||||
0000000265 00000 n
|
||||
0000000577 00000 n
|
||||
0000000548 00000 n
|
||||
0000000643 00000 n
|
||||
trailer
|
||||
<< /Size 10 /Root 1 0 R /Info 2 0 R
|
||||
/ID [<0CB231047435B33BCE0B1C6881DCF011><0CB231047435B33BCE0B1C6881DCF011>]
|
||||
>>
|
||||
startxref
|
||||
2648
|
||||
%%EOF
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Unit tests for the DoclingParser.
|
||||
"""
|
||||
from chat.agent_rag.document_converter.parser import DoclingParser
|
||||
|
||||
|
||||
def test_document_converter():
|
||||
"""Test that the DocumentConverter calls the underlying MarkItDown converter."""
|
||||
file_name = "test"
|
||||
content_type = "application/pdf"
|
||||
file_path = "src/backend/chat/tests/data/test.pdf"
|
||||
parser = DoclingParser()
|
||||
|
||||
with open(file_path, "rb") as file:
|
||||
content = file.read()
|
||||
result = parser.parse_document(name= file_name, content_type= content_type, content= content)
|
||||
|
||||
assert "Document PDF test" in result
|
||||
@@ -5,28 +5,21 @@ Only for coverage as the DocumentConverter is a simple wrapper around MarkItDown
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
|
||||
|
||||
@patch("chat.agent_rag.document_converter.markitdown.MarkItDown")
|
||||
def test_document_converter(mock_markitdown: MagicMock):
|
||||
def test_document_converter():
|
||||
"""Test that the DocumentConverter calls the underlying MarkItDown converter."""
|
||||
mock_conversion = MagicMock()
|
||||
mock_conversion.text_content = "converted text"
|
||||
mock_markitdown.return_value.convert_stream.return_value = mock_conversion
|
||||
|
||||
file_path = "src/backend/chat/tests/data/test.pdf"
|
||||
converter = DocumentConverter()
|
||||
|
||||
result = converter.convert_raw(
|
||||
name="test.pdf",
|
||||
content_type="application/pdf",
|
||||
content=b"test content",
|
||||
)
|
||||
with open(file_path, "rb") as file:
|
||||
content = file.read()
|
||||
result = converter.convert_raw(
|
||||
name="test.pdf",
|
||||
content_type="application/pdf",
|
||||
content=content,
|
||||
)
|
||||
|
||||
assert result == "converted text"
|
||||
converter.converter.convert_stream.assert_called_once() # pylint: disable=no-member
|
||||
args, kwargs = converter.converter.convert_stream.call_args # pylint: disable=no-member
|
||||
assert isinstance(args[0], BytesIO)
|
||||
assert kwargs["file_extension"] == ".pdf"
|
||||
assert result == "Document PDF test\n\n"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Test cases for the TitleGenerationAgent class."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import pytest
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
|
||||
from chat.agents.conversation import TitleGenerationAgent
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def base_settings(settings):
|
||||
"""Set up base settings for the tests."""
|
||||
settings.AI_BASE_URL = "https://api.llm.com/v1/"
|
||||
settings.AI_API_KEY = "test-key"
|
||||
settings.AI_MODEL = "model-XYZ"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
|
||||
settings.AI_AGENT_TOOLS = []
|
||||
settings.LLM_DEFAULT_MODEL_HRID = "default-model"
|
||||
|
||||
|
||||
def test_title_generation_agent_uses_default_model_hrid(settings):
|
||||
"""Test that TitleGenerationAgent uses LLM_DEFAULT_MODEL_HRID from settings."""
|
||||
settings.AI_MODEL = "custom-llm-model"
|
||||
settings.AI_BASE_URL = "https://custom.api.com/v1/"
|
||||
settings.AI_API_KEY = "custom-key"
|
||||
settings.LLM_DEFAULT_MODEL_HRID = "default-model"
|
||||
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert isinstance(agent._model, OpenAIChatModel)
|
||||
assert settings.LLM_CONFIGURATIONS["default-model"].model_name == "custom-llm-model"
|
||||
assert agent._model.model_name == "custom-llm-model"
|
||||
|
||||
|
||||
def test_title_generation_agent_model_configuration():
|
||||
"""Test that the agent model is properly configured."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert isinstance(agent._model, OpenAIChatModel)
|
||||
assert agent._model.model_name == "model-XYZ"
|
||||
assert str(agent._model.client.base_url) == "https://api.llm.com/v1/"
|
||||
assert agent._model.client.api_key == "test-key"
|
||||
|
||||
|
||||
def test_title_generation_agent_has_no_tools():
|
||||
"""Test that TitleGenerationAgent has no tools configured."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
assert agent._function_toolset.tools == {}
|
||||
assert not agent.get_tools()
|
||||
|
||||
|
||||
def test_title_generation_agent_instructions():
|
||||
"""Test that the agent instructions contain the system prompt."""
|
||||
agent = TitleGenerationAgent()
|
||||
|
||||
# The agent should have the title generation system prompt as instructions
|
||||
instructions = agent._instructions
|
||||
assert len(instructions) == 1
|
||||
expected = (
|
||||
"You are a title generator. Your task is to create concise, descriptive titles "
|
||||
"that accurately summarize conversation content and help the user quickly identify the "
|
||||
"conversation.\n\n"
|
||||
)
|
||||
assert instructions[0] == expected
|
||||
@@ -0,0 +1,90 @@
|
||||
%PDF-1.4
|
||||
%Çì�¢
|
||||
5 0 obj
|
||||
<</Length 6 0 R/Filter /FlateDecode>>
|
||||
stream
|
||||
xœMޱNÄ0†÷<…Çd¨ÏNœ¸^OÀÀ§l'¦Šc*¨âx’RªÚƒÿóo{BŽ@=ÿ›ivnq#¦«xì§ÎÕ�.
|
||||
ÍQoŽÐÌ„WÆ „#h!¨³»ú‡À˜5Sò_a Œ&¦â§�°•Ÿƒ4‡!¢ÊÅ¿ÿÑϽwÊ%çÑC—Y4[ò/a�ö³n‡D¢
|
||||
‹æhû¨Z<nØö‡�1F3Ýaj–·úì«{mùµi:uendstream
|
||||
endobj
|
||||
6 0 obj
|
||||
180
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Type/Page/MediaBox [0 0 595 842]
|
||||
/Rotate 0/Parent 3 0 R
|
||||
/Resources<</ProcSet[/PDF /Text]
|
||||
/Font 8 0 R
|
||||
>>
|
||||
/Contents 5 0 R
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Pages /Kids [
|
||||
4 0 R
|
||||
] /Count 1
|
||||
>>
|
||||
endobj
|
||||
1 0 obj
|
||||
<</Type /Catalog /Pages 3 0 R
|
||||
/Metadata 9 0 R
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<</R7
|
||||
7 0 R>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Times-Roman/Type/Font
|
||||
/Subtype/Type1>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<</Type/Metadata
|
||||
/Subtype/XML/Length 1549>>stream
|
||||
<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
|
||||
<?adobe-xap-filters esc="CRLF"?>
|
||||
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='XMP toolkit 2.9.1-13, framework 1.6'>
|
||||
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#' xmlns:iX='http://ns.adobe.com/iX/1.0/'>
|
||||
<rdf:Description rdf:about='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c' xmlns:pdf='http://ns.adobe.com/pdf/1.3/'><pdf:Producer>GPL Ghostscript 9.06</pdf:Producer>
|
||||
<pdf:Keywords>()</pdf:Keywords>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c' xmlns:xmp='http://ns.adobe.com/xap/1.0/'><xmp:ModifyDate>2014-12-22T00:49:20+01:00</xmp:ModifyDate>
|
||||
<xmp:CreateDate>2014-12-22T00:49:20+01:00</xmp:CreateDate>
|
||||
<xmp:CreatorTool>PDFCreator Version 1.6.0</xmp:CreatorTool></rdf:Description>
|
||||
<rdf:Description rdf:about='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c' xmlns:xapMM='http://ns.adobe.com/xap/1.0/mm/' xapMM:DocumentID='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c'/>
|
||||
<rdf:Description rdf:about='uuid:81d69fb9-8bc7-11e4-0000-66b1dd18110c' xmlns:dc='http://purl.org/dc/elements/1.1/' dc:format='application/pdf'><dc:title><rdf:Alt><rdf:li xml:lang='x-default'>test_word</rdf:li></rdf:Alt></dc:title><dc:creator><rdf:Seq><rdf:li>Seb</rdf:li></rdf:Seq></dc:creator><dc:description><rdf:Seq><rdf:li>()</rdf:li></rdf:Seq></dc:description></rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
||||
|
||||
<?xpacket end='w'?>
|
||||
endstream
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Producer(GPL Ghostscript 9.06)
|
||||
/CreationDate(D:20141222004920+01'00')
|
||||
/ModDate(D:20141222004920+01'00')
|
||||
/Title(\376\377\000t\000e\000s\000t\000_\000w\000o\000r\000d)
|
||||
/Creator(\376\377\000P\000D\000F\000C\000r\000e\000a\000t\000o\000r\000 \000V\000e\000r\000s\000i\000o\000n\000 \0001\000.\0006\000.\0000)
|
||||
/Author(\376\377\000S\000e\000b)
|
||||
/Keywords()
|
||||
/Subject()>>endobj
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000484 00000 n
|
||||
0000002268 00000 n
|
||||
0000000425 00000 n
|
||||
0000000284 00000 n
|
||||
0000000015 00000 n
|
||||
0000000265 00000 n
|
||||
0000000577 00000 n
|
||||
0000000548 00000 n
|
||||
0000000643 00000 n
|
||||
trailer
|
||||
<< /Size 10 /Root 1 0 R /Info 2 0 R
|
||||
/ID [<0CB231047435B33BCE0B1C6881DCF011><0CB231047435B33BCE0B1C6881DCF011>]
|
||||
>>
|
||||
startxref
|
||||
2648
|
||||
%%EOF
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Common test fixtures for chat conversation endpoint tests."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from django.utils import timezone
|
||||
@@ -11,9 +10,15 @@ import respx
|
||||
from freezegun import freeze_time
|
||||
|
||||
|
||||
def _create_openai_stream_data():
|
||||
"""Helper to create OpenAI stream data."""
|
||||
return (
|
||||
@pytest.fixture(name="mock_openai_stream")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response.
|
||||
|
||||
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
|
||||
"""
|
||||
openai_stream = (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
@@ -54,111 +59,12 @@ def _create_openai_stream_data():
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _create_mock_openai_route(with_delays: bool = False, delay_seconds: float = 1.0):
|
||||
"""Create a mock OpenAI stream route with optional delays."""
|
||||
openai_stream = _create_openai_stream_data()
|
||||
|
||||
async def mock_stream():
|
||||
lines = openai_stream.splitlines(keepends=True)
|
||||
for i, line in enumerate(lines):
|
||||
for line in openai_stream.splitlines(keepends=True):
|
||||
yield line.encode()
|
||||
if with_delays and i == 1:
|
||||
# Delay after second line to trigger keepalive during streaming
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
||||
return respx.post("https://www.external-ai-service.com/chat/completions").mock(
|
||||
return_value=httpx.Response(200, stream=mock_stream())
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response (no delays).
|
||||
|
||||
See https://platform.openai.com/docs/api-reference/chat-streaming/streaming
|
||||
"""
|
||||
return _create_mock_openai_route(with_delays=False)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream_slow")
|
||||
def fixture_mock_openai_stream_slow():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response with delays to trigger keepalives.
|
||||
|
||||
No @freeze_time decorator because asyncio.sleep() needs real time to work properly.
|
||||
"""
|
||||
return _create_mock_openai_route(with_delays=True, delay_seconds=1.0)
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_openai_stream_with_title_generation")
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
def fixture_mock_openai_stream_with_title_generation():
|
||||
"""
|
||||
Fixture to mock the OpenAI stream response.
|
||||
|
||||
|
||||
This fixture handles two different types of API calls made during a single request:
|
||||
|
||||
1. **Conversation (streaming)**: The main chat uses `stream=True` to get real-time
|
||||
token-by-token responses. The API returns chunked data like:
|
||||
`data: {"choices": [{"delta": {"content": "Hello"}}]}`
|
||||
|
||||
2. **Title generation (non-streaming)**: After the conversation, the backend calls
|
||||
the API again with `stream=False` to generate a title. This returns a standard
|
||||
JSON response with the complete message.
|
||||
|
||||
The `handle_request` function inspects each incoming request's body to determine
|
||||
which type of response to return:
|
||||
- `{"stream": true, ...}` → SSE streaming response
|
||||
- `{"stream": false, ...}` → JSON response with generated title
|
||||
Each call gets a new generator instance (avoiding generator exhaustion)
|
||||
"""
|
||||
|
||||
def create_stream_response():
|
||||
"""Create a fresh streaming response for each call."""
|
||||
openai_stream = _create_openai_stream_data()
|
||||
|
||||
async def mock_stream():
|
||||
for line in openai_stream.splitlines(keepends=True):
|
||||
yield line.encode()
|
||||
|
||||
return httpx.Response(200, stream=mock_stream())
|
||||
|
||||
def create_non_stream_response():
|
||||
"""Create a non-streaming response for title generation."""
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-title",
|
||||
"object": "chat.completion",
|
||||
"created": int(timezone.make_naive(timezone.now()).timestamp()),
|
||||
"model": "test-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "GENERATED TITLE",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 50, "completion_tokens": 5, "total_tokens": 55},
|
||||
},
|
||||
)
|
||||
|
||||
def handle_request(request):
|
||||
"""Route to streaming or non-streaming response based on request."""
|
||||
body = json.loads(request.content)
|
||||
if body.get("stream", False):
|
||||
return create_stream_response()
|
||||
return create_non_stream_response()
|
||||
|
||||
route = respx.post("https://www.external-ai-service.com/chat/completions").mock(
|
||||
side_effect=handle_request
|
||||
return_value=httpx.Response(200, stream=mock_stream())
|
||||
)
|
||||
|
||||
return route
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -222,133 +221,6 @@ def test_post_conversation_data_protocol(api_client, mock_openai_stream):
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
@patch("chat.keepalive.get_current_time")
|
||||
def test_post_conversation_data_protocol_triggers_keepalives(
|
||||
mock_time, api_client, mock_openai_stream
|
||||
):
|
||||
"""Test streaming response contains keepalive messages"""
|
||||
chat_conversation = ChatConversationFactory(owner__language="en-us")
|
||||
mock_time.side_effect = [float(i * 60) for i in range(10)]
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(chat_conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.get("x-vercel-ai-data-stream") == "v1"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
'2:[{"status": "WAITING"}]\n'
|
||||
)
|
||||
|
||||
assert mock_openai_stream.called
|
||||
|
||||
chat_conversation.refresh_from_db()
|
||||
assert chat_conversation.ui_messages == [
|
||||
{
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"role": "user",
|
||||
}
|
||||
]
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=timezone.now(), # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello there")],
|
||||
)
|
||||
|
||||
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
"Today is Friday 25/07/2025.\n\n"
|
||||
"Answer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"timestamp": "2025-07-25T10:36:35.297675Z",
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"details": {},
|
||||
"input_audio_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_audio_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_text_protocol(api_client, mock_openai_stream):
|
||||
@@ -1472,143 +1344,3 @@ async def test_post_conversation_async(api_client, mock_openai_stream, monkeypat
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z", tick=True)
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_conversation_async_triggers_keepalive(
|
||||
api_client, mock_openai_stream_slow, monkeypatch, caplog, settings
|
||||
):
|
||||
"""Test posting messages to a conversation using the 'data' protocol."""
|
||||
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
|
||||
|
||||
settings.KEEPALIVE_INTERVAL = 1 # s
|
||||
|
||||
chat_conversation = await sync_to_async(ChatConversationFactory)(owner__language="en-us")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
await api_client.aforce_login(chat_conversation.owner)
|
||||
|
||||
caplog.clear()
|
||||
caplog.set_level(level=logging.DEBUG, logger="chat.views")
|
||||
|
||||
response = await sync_to_async(api_client.post)(url, data, format="json") # client is sync
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.get("x-vercel-ai-data-stream") == "v1"
|
||||
assert response.streaming
|
||||
|
||||
assert "Using ASYNC streaming for chat conversation" in caplog.text
|
||||
|
||||
# Wait for the streaming content to be fully received => async iterator -> list
|
||||
# This fails it the streaming is not an async generator
|
||||
response_content = b"".join([content async for content in response.streaming_content]).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
# Replace UUIDs with placeholders for assertion
|
||||
response_content = replace_uuids_with_placeholder(response_content)
|
||||
|
||||
assert response_content == (
|
||||
'0:"Hello"\n'
|
||||
'2:[{"status": "WAITING"}]\n'
|
||||
'0:" there"\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
|
||||
)
|
||||
|
||||
assert mock_openai_stream_slow.called
|
||||
|
||||
await chat_conversation.arefresh_from_db()
|
||||
assert chat_conversation.ui_messages == [
|
||||
{
|
||||
"content": "Hello",
|
||||
"createdAt": "2025-07-03T15:22:17.105Z",
|
||||
"id": "yuPoOuBkKA4FnKvk",
|
||||
"parts": [{"text": "Hello", "type": "text"}],
|
||||
"role": "user",
|
||||
}
|
||||
]
|
||||
|
||||
assert len(chat_conversation.messages) == 2
|
||||
|
||||
assert chat_conversation.messages[0].id == IsUUID(4)
|
||||
assert chat_conversation.messages[0] == UIMessage(
|
||||
id=chat_conversation.messages[0].id, # don't test the message ID here
|
||||
createdAt=chat_conversation.messages[0].createdAt, # Mocked timestamp
|
||||
content="Hello",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello")],
|
||||
)
|
||||
|
||||
assert chat_conversation.messages[1].id == IsUUID(4)
|
||||
assert chat_conversation.messages[1] == UIMessage(
|
||||
id=chat_conversation.messages[1].id, # don't test the message ID here
|
||||
createdAt=chat_conversation.messages[1].createdAt, # Mocked timestamp
|
||||
content="Hello there",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello there")],
|
||||
)
|
||||
|
||||
_run_id = chat_conversation.pydantic_messages[0]["run_id"]
|
||||
|
||||
# using ANY because time is not frozen in this api mock
|
||||
assert chat_conversation.pydantic_messages == [
|
||||
{
|
||||
"instructions": (
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
"Today is Friday 25/07/2025.\n\nAnswer in english."
|
||||
),
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": ["Hello"],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": ANY,
|
||||
},
|
||||
],
|
||||
"run_id": _run_id,
|
||||
},
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"kind": "response",
|
||||
"model_name": "test-model",
|
||||
"parts": [{"content": "Hello there", "id": None, "part_kind": "text"}],
|
||||
"provider_details": {"finish_reason": "stop"},
|
||||
"provider_name": "openai",
|
||||
"provider_response_id": "chatcmpl-1234567890",
|
||||
"timestamp": ANY,
|
||||
"usage": {
|
||||
"cache_audio_read_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"details": {},
|
||||
"input_audio_tokens": 0,
|
||||
"input_tokens": 0,
|
||||
"output_audio_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
"run_id": _run_id,
|
||||
},
|
||||
]
|
||||
|
||||
+2
-2
@@ -121,7 +121,7 @@ def fixture_mock_document_api():
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Mock PDF parsing
|
||||
# Mock Albert PDF parsing -> deprecated
|
||||
responses.post(
|
||||
"https://albert.api.etalab.gouv.fr/v1/parse-beta",
|
||||
json={
|
||||
@@ -675,7 +675,7 @@ def test_post_conversation_with_document_upload_summarize( # pylint: disable=to
|
||||
'document discusses various topics."}\n'
|
||||
'0:"The document discusses various topics."\n'
|
||||
'f:{"messageId":"<mocked_uuid>"}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":287,"completionTokens":19}}\n'
|
||||
'd:{"finishReason":"stop","usage":{"promptTokens":283,"completionTokens":19}}\n'
|
||||
)
|
||||
|
||||
# Check that the conversation was updated
|
||||
|
||||
+31
-37
@@ -132,28 +132,29 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
)
|
||||
|
||||
async def agent_model(messages: list[ModelMessage], _info: AgentInfo):
|
||||
presigned_url = messages[0].parts[0].content[1].url
|
||||
assert presigned_url.startswith("http://localhost:9000/conversations-media-storage/")
|
||||
assert presigned_url.find("X-Amz-Signature=") != -1
|
||||
assert presigned_url.find("X-Amz-Date=") != -1
|
||||
assert presigned_url.find("X-Amz-Expires=") != -1
|
||||
|
||||
assert messages == [
|
||||
ModelRequest(
|
||||
parts=[
|
||||
UserPromptPart(content=["What is in this document?"], timestamp=timezone.now())
|
||||
UserPromptPart(
|
||||
content=[
|
||||
"What is in this document?",
|
||||
DocumentUrl(
|
||||
url=presigned_url, # presigned URL for this conversation
|
||||
media_type="application/pdf",
|
||||
identifier="sample.pdf",
|
||||
),
|
||||
],
|
||||
timestamp=timezone.now(),
|
||||
),
|
||||
],
|
||||
instructions=(
|
||||
"You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
"Answer in english.\n\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages from attached "
|
||||
"documents. Do NOT use it to summarize; for summaries, call the summarize "
|
||||
"tool instead.\n\nWhen you receive a result from the summarization tool, "
|
||||
"you MUST return it directly to the user without any modification, "
|
||||
"paraphrasing, or additional summarization.The tool already produces "
|
||||
"optimized summaries that should be presented verbatim.You may translate "
|
||||
"the summary if required, but you MUST preserve all the information from "
|
||||
"the original summary.You may add a follow-up question after the summary "
|
||||
"if needed.\n\n"
|
||||
"[Internal context] User documents are attached to this conversation. "
|
||||
"Do not request re-upload of documents; consider them already available "
|
||||
"via the internal store."
|
||||
),
|
||||
instructions=f"You are a helpful test assistant :)\n\n{today_promt_date}"
|
||||
"\n\nAnswer in english.",
|
||||
run_id=messages[0].run_id,
|
||||
)
|
||||
]
|
||||
@@ -197,7 +198,9 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
createdAt=timezone.now(),
|
||||
content="What is in this document?",
|
||||
reasoning=None,
|
||||
experimental_attachments=None, # We should fix this, but for now document appears in source
|
||||
experimental_attachments=[
|
||||
Attachment(name="sample.pdf", contentType="application/pdf", url=document_url)
|
||||
],
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
@@ -229,29 +232,20 @@ def test_post_conversation_with_local_pdf_document_url(
|
||||
{
|
||||
"instructions": "You are a helpful test assistant :)\n\n"
|
||||
f"{today_promt_date}\n\n"
|
||||
"Answer in english.\n"
|
||||
"\n"
|
||||
"Use document_search_rag ONLY to retrieve specific passages "
|
||||
"from attached documents. Do NOT use it to summarize; for "
|
||||
"summaries, call the summarize tool instead.\n"
|
||||
"\n"
|
||||
"When you receive a result from the summarization tool, you "
|
||||
"MUST return it directly to the user without any "
|
||||
"modification, paraphrasing, or additional summarization.The "
|
||||
"tool already produces optimized summaries that should be "
|
||||
"presented verbatim.You may translate the summary if "
|
||||
"required, but you MUST preserve all the information from "
|
||||
"the original summary.You may add a follow-up question after "
|
||||
"the summary if needed.\n"
|
||||
"\n"
|
||||
"[Internal context] User documents are attached to this "
|
||||
"conversation. Do not request re-upload of documents; "
|
||||
"consider them already available via the internal store.",
|
||||
"Answer in english.",
|
||||
"kind": "request",
|
||||
"parts": [
|
||||
{
|
||||
"content": [
|
||||
"What is in this document?",
|
||||
{
|
||||
"force_download": False,
|
||||
"identifier": "sample.pdf",
|
||||
"kind": "document-url",
|
||||
"media_type": "application/pdf",
|
||||
"url": document_url,
|
||||
"vendor_metadata": None,
|
||||
},
|
||||
],
|
||||
"part_kind": "user-prompt",
|
||||
"timestamp": timestamp,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -12,7 +11,6 @@ from dirty_equals import IsUUID
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
|
||||
from chat.agents.conversation import TitleGenerationAgent
|
||||
from chat.ai_sdk_types import (
|
||||
Attachment,
|
||||
TextUIPart,
|
||||
@@ -37,7 +35,6 @@ def ai_settings(settings):
|
||||
settings.AI_MODEL = "test-model"
|
||||
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
|
||||
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = None # disable auto title generation
|
||||
return settings
|
||||
|
||||
|
||||
@@ -1576,307 +1573,3 @@ def test_post_conversation_add_image_to_conversation_with_tool_history(
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="I see a cat in the picture.")],
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
@patch("chat.clients.pydantic_ai.TitleGenerationAgent", wraps=TitleGenerationAgent)
|
||||
def test_post_conversation_triggers_automatic_title_generation_after_first_message(
|
||||
mock_title_agent, api_client, mock_openai_stream_with_title_generation, settings
|
||||
):
|
||||
"""
|
||||
Test that posting the first user message triggers automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 1
|
||||
|
||||
The conversation is a new one. Posting the first message
|
||||
should trigger title generation via the TitleGenerationAgent.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 1
|
||||
conversation = ChatConversationFactory()
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
conversation.title = "initial title"
|
||||
conversation.save()
|
||||
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is in the stream
|
||||
|
||||
assert '"type": "conversation_metadata"' in response_content
|
||||
|
||||
# Refresh and verify title was updated
|
||||
conversation.refresh_from_db()
|
||||
|
||||
assert conversation.title == "GENERATED TITLE"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 2
|
||||
|
||||
# Verify TitleGenerationAgent was called
|
||||
mock_title_agent.assert_called_once()
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_triggers_automatic_title_generation_at_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that posting the 3rd user message triggers automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
|
||||
The history_conversation fixture has 2 user messages. Posting a 3rd message
|
||||
should trigger title generation via the TitleGenerationAgent.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
history_conversation.title = "initial title"
|
||||
history_conversation.save()
|
||||
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is in the stream
|
||||
|
||||
assert '"type": "conversation_metadata"' in response_content
|
||||
|
||||
# Refresh and verify title was updated
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
assert history_conversation.title == "GENERATED TITLE"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 2
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_regenerate_title_when_user_set(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that title is NOT regenerated if the user has manually set a title.
|
||||
"""
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
# Simulate user having set a custom title
|
||||
history_conversation.title = "My Custom Title"
|
||||
history_conversation.title_set_by_user_at = timezone.now()
|
||||
history_conversation.save()
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Consume the stream
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# conversation_metadata should NOT be in the stream since title wasn't generated
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was NOT changed
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
assert history_conversation.title == "My Custom Title"
|
||||
assert history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.called
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_generate_title_before_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings
|
||||
):
|
||||
"""
|
||||
Test that title is NOT generated before reaching the message threshold.
|
||||
"""
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 3
|
||||
|
||||
# Create a conversation with only 1 user message
|
||||
history_timestamp = timezone.now().replace(year=2025, month=6, day=15, hour=10, minute=30)
|
||||
conversation = ChatConversationFactory(title="initial title")
|
||||
|
||||
conversation.messages = [
|
||||
UIMessage(
|
||||
id="prev-user-msg-1",
|
||||
createdAt=history_timestamp,
|
||||
content="Hello!",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="user",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hello!")],
|
||||
),
|
||||
UIMessage(
|
||||
id="prev-assistant-msg-1",
|
||||
createdAt=history_timestamp.replace(minute=31),
|
||||
content="Hi there! How can I help you?",
|
||||
reasoning=None,
|
||||
experimental_attachments=None,
|
||||
role="assistant",
|
||||
annotations=None,
|
||||
toolInvocations=None,
|
||||
parts=[TextUIPart(type="text", text="Hi there! How can I help you?")],
|
||||
),
|
||||
]
|
||||
conversation.save()
|
||||
|
||||
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "second-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "What's machine learning?", "type": "text"}],
|
||||
"content": "What's machine learning?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(conversation.owner)
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Consume the stream
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# conversation_metadata should NOT be in the stream (only 2 user messages)
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was not updated
|
||||
conversation.refresh_from_db()
|
||||
|
||||
assert conversation.title == "initial title"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
|
||||
@freeze_time("2025-07-25T10:36:35.297675Z")
|
||||
@respx.mock
|
||||
def test_post_conversation_does_not_generate_title_after_threshold(
|
||||
api_client, mock_openai_stream_with_title_generation, settings, history_conversation
|
||||
):
|
||||
"""
|
||||
Test that posting the 3rd user message does not trigger automatic title generation.
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = 2
|
||||
|
||||
The history_conversation fixture has 2 user messages. Posting a 3rd message
|
||||
should not trigger title generation.
|
||||
"""
|
||||
# Configure the title generation threshold
|
||||
settings.AUTO_TITLE_AFTER_USER_MESSAGES = 2
|
||||
|
||||
url = f"/api/v1.0/chats/{history_conversation.pk}/conversation/?protocol=data"
|
||||
data = {
|
||||
"messages": [
|
||||
{
|
||||
"id": "third-user-msg",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Can you explain backpropagation?", "type": "text"}],
|
||||
"content": "Can you explain backpropagation?",
|
||||
"createdAt": "2025-07-25T10:36:00.000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
api_client.force_login(history_conversation.owner)
|
||||
|
||||
history_conversation.title = "initial title"
|
||||
history_conversation.save()
|
||||
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.get("Content-Type") == "text/event-stream"
|
||||
assert response.streaming
|
||||
|
||||
# Wait for the streaming content to be fully received
|
||||
response_content = b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
# Verify the conversation_metadata event is not in the stream
|
||||
|
||||
assert "conversation_metadata" not in response_content
|
||||
|
||||
# Refresh and verify title was NOT updated (past threshold)
|
||||
history_conversation.refresh_from_db()
|
||||
|
||||
# title not updated
|
||||
assert history_conversation.title == "initial title"
|
||||
# title_set_by_user_at should remain None since it was auto-generated
|
||||
assert not history_conversation.title_set_by_user_at
|
||||
|
||||
assert mock_openai_stream_with_title_generation.call_count == 1
|
||||
|
||||
@@ -28,7 +28,6 @@ def test_create_conversation(api_client):
|
||||
conversation = ChatConversation.objects.get(id=response.data["id"])
|
||||
assert conversation.owner == user
|
||||
assert conversation.title == "New Conversation"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_create_conversation_other_owner(api_client):
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ErrorDetail
|
||||
|
||||
from core.factories import UserFactory
|
||||
|
||||
@@ -27,34 +26,6 @@ def test_update_conversation(api_client):
|
||||
# Verify in database
|
||||
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
|
||||
assert conversation.title == "Updated Title"
|
||||
assert conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_update_conversation_limit_title_length(api_client):
|
||||
"""Test that updating a conversation with a title exceeding 100 characters fails validation."""
|
||||
chat_conversation = ChatConversationFactory(title="Initial title")
|
||||
|
||||
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
|
||||
# Create a 101-character title to exceed the 100-character maximum limit
|
||||
new_title = "X" * 101
|
||||
data = {"title": new_title}
|
||||
api_client.force_login(chat_conversation.owner)
|
||||
response = api_client.put(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
assert response.data == {
|
||||
"title": [
|
||||
ErrorDetail(
|
||||
string="Ensure this field has no more than 100 characters.", code="max_length"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
# Verify in database (title should remain unchanged)
|
||||
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
|
||||
assert conversation.title == "Initial title"
|
||||
assert not conversation.title_set_by_user_at
|
||||
|
||||
|
||||
def test_update_conversation_anonymous(api_client):
|
||||
|
||||
@@ -101,7 +101,7 @@ async def document_summarize( # pylint: disable=too-many-locals
|
||||
)
|
||||
documents_chunks = chunker(
|
||||
[doc[1] for doc in documents],
|
||||
overlap=settings.SUMMARIZATION_OVERLAP_SIZE,
|
||||
# overlap=settings.SUMMARIZATION_OVERLAP_SIZE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -307,26 +307,19 @@ async def web_search_brave_with_document_backend(ctx: RunContext, query: str) ->
|
||||
temp_collection_name = f"tmp-{uuid.uuid4()}"
|
||||
try:
|
||||
async with document_store_backend.temporary_collection_async(
|
||||
temp_collection_name, session=ctx.deps.session
|
||||
temp_collection_name
|
||||
) as document_store:
|
||||
# Fetch and store all documents concurrently
|
||||
tasks = [
|
||||
_fetch_and_store_async(
|
||||
result["url"],
|
||||
document_store,
|
||||
user_sub=ctx.deps.user.sub,
|
||||
session=ctx.deps.session,
|
||||
)
|
||||
_fetch_and_store_async(result["url"], document_store)
|
||||
for result in raw_search_results
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Perform RAG search
|
||||
rag_results = await document_store.asearch(
|
||||
query=query,
|
||||
query,
|
||||
results_count=settings.BRAVE_RAG_WEB_SEARCH_CHUNK_NUMBER,
|
||||
session=ctx.deps.session,
|
||||
user_sub=ctx.deps.user.sub,
|
||||
)
|
||||
logger.info("RAG search returned: %s", rag_results)
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
This module contains the EventEncoder class.
|
||||
"""
|
||||
|
||||
from .encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder, EventEncoderVersion
|
||||
from .encoder import EventEncoder
|
||||
|
||||
__all__ = ["EventEncoder", "CURRENT_EVENT_ENCODER_VERSION", "EventEncoderVersion"]
|
||||
__all__ = ["EventEncoder"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Event Encoder for Vercel AI SDK"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Union
|
||||
from typing import Literal, Union
|
||||
|
||||
from ..core.events_v4 import BaseEvent as V4BaseEvent
|
||||
from ..core.events_v4 import TextPart
|
||||
@@ -9,26 +8,16 @@ from ..core.events_v5 import BaseEvent as V5BaseEvent
|
||||
from ..core.events_v5 import TextDeltaEvent
|
||||
|
||||
|
||||
class EventEncoderVersion(str, Enum):
|
||||
"""Enumeration of supported event encoder versions."""
|
||||
|
||||
V4 = "v4"
|
||||
V5 = "v5"
|
||||
|
||||
|
||||
CURRENT_EVENT_ENCODER_VERSION = EventEncoderVersion.V4 # used encoder version
|
||||
|
||||
|
||||
class EventEncoder:
|
||||
"""
|
||||
Encodes events for the Vercel AI SDK based on the specified version.
|
||||
"""
|
||||
|
||||
def __init__(self, version: EventEncoderVersion):
|
||||
def __init__(self, version: Literal["v4", "v5"] = None):
|
||||
"""
|
||||
Initializes the EventEncoder with the specified version.
|
||||
"""
|
||||
if version not in [EventEncoderVersion.V4, EventEncoderVersion.V5]:
|
||||
if version not in ["v4", "v5"]:
|
||||
raise ValueError("Unsupported version. Supported versions are 'v4' and 'v5'.")
|
||||
|
||||
self.version = version
|
||||
@@ -39,7 +28,7 @@ class EventEncoder:
|
||||
"""
|
||||
return "text/event-stream"
|
||||
|
||||
def encode(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
|
||||
def encode(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
|
||||
"""
|
||||
Encodes an event based on the version.
|
||||
|
||||
@@ -49,15 +38,15 @@ class EventEncoder:
|
||||
str | None: The encoded event as a string,
|
||||
or None if the event type is not adapted to the SDK version.
|
||||
"""
|
||||
if self.version == EventEncoderVersion.V4 and isinstance(event, V4BaseEvent):
|
||||
if self.version == "v4" and isinstance(event, V4BaseEvent):
|
||||
return self._encode_v4_streaming(event)
|
||||
|
||||
if self.version == EventEncoderVersion.V5 and isinstance(event, V5BaseEvent):
|
||||
if self.version == "v5" and isinstance(event, V5BaseEvent):
|
||||
return self._encode_sse(event)
|
||||
|
||||
return None
|
||||
|
||||
def encode_text(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None:
|
||||
def encode_text(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str | None:
|
||||
"""
|
||||
Encodes an event based on the version.
|
||||
|
||||
@@ -67,10 +56,10 @@ class EventEncoder:
|
||||
str | None: The encoded event as a string,
|
||||
or None if the event type is not adapted to the SDK version.
|
||||
"""
|
||||
if self.version == EventEncoderVersion.V4 and isinstance(event, TextPart):
|
||||
if self.version == "v4" and isinstance(event, TextPart):
|
||||
return event.text
|
||||
|
||||
if self.version == EventEncoderVersion.V5 and isinstance(event, TextDeltaEvent):
|
||||
if self.version == "v5" and isinstance(event, TextDeltaEvent):
|
||||
return event.delta
|
||||
|
||||
return None
|
||||
@@ -81,7 +70,7 @@ class EventEncoder:
|
||||
"""
|
||||
return f"{event.type}:{event.model_dump_json(by_alias=True, exclude={'type'})}\n"
|
||||
|
||||
def _encode_sse(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str:
|
||||
def _encode_sse(self, event: Union[V5BaseEvent, V5BaseEvent]) -> str:
|
||||
"""
|
||||
Encodes an event into an SSE string.
|
||||
"""
|
||||
|
||||
@@ -28,7 +28,6 @@ from core.filters import remove_accents
|
||||
from activation_codes.permissions import IsActivatedUser
|
||||
from chat import models, serializers
|
||||
from chat.clients.pydantic_ai import AIAgentService
|
||||
from chat.keepalive import stream_with_keepalive_async, stream_with_keepalive_sync
|
||||
from chat.serializers import ChatConversationRequestSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -193,28 +192,29 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
|
||||
if is_async_mode:
|
||||
logger.debug("Using ASYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
base_stream = ai_service.stream_data_async(
|
||||
streaming_content = ai_service.stream_data_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
base_stream = ai_service.stream_text_async(
|
||||
streaming_content = ai_service.stream_text_async(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
streaming_content = stream_with_keepalive_async(base_stream)
|
||||
else:
|
||||
logger.debug("Using SYNC streaming for chat conversation.")
|
||||
if protocol == "data":
|
||||
base_stream = ai_service.stream_data(messages, force_web_search=force_web_search)
|
||||
streaming_content = ai_service.stream_data(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
else: # Default to 'text' protocol
|
||||
base_stream = ai_service.stream_text(messages, force_web_search=force_web_search)
|
||||
streaming_content = ai_service.stream_text(
|
||||
messages, force_web_search=force_web_search
|
||||
)
|
||||
|
||||
streaming_content = stream_with_keepalive_sync(base_stream)
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content,
|
||||
content_type="text/event-stream",
|
||||
headers={
|
||||
"x-vercel-ai-data-stream": "v1", # This header is used for Vercel AI streaming,
|
||||
"X-Accel-Buffering": "no", # Prevent nginx buffering
|
||||
},
|
||||
)
|
||||
return response
|
||||
@@ -375,6 +375,7 @@ class ChatConversationAttachmentViewSet(
|
||||
owner=self.request.user,
|
||||
).exists():
|
||||
raise Http404
|
||||
|
||||
file_name = serializer.validated_data["file_name"]
|
||||
extension = file_name.rpartition(".")[-1] if "." in file_name else None
|
||||
|
||||
|
||||
@@ -928,9 +928,7 @@ USER QUESTION:
|
||||
LANGFUSE_MEDIA_UPLOAD_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="LANGFUSE_MEDIA_UPLOAD_ENABLED", environ_prefix=None
|
||||
)
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = values.PositiveIntegerValue(
|
||||
default=None, environ_name="AUTO_TITLE_AFTER_USER_MESSAGES", environ_prefix=None
|
||||
)
|
||||
|
||||
# WARNING: Testing purpose only. Do not use in production.
|
||||
WARNING_MOCK_CONVERSATION_AGENT = values.BooleanValue(
|
||||
default=False,
|
||||
@@ -938,12 +936,6 @@ USER QUESTION:
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Default keepalive interval: 55s (safely below typical 60s proxy timeouts)
|
||||
# Prevents connection drops during long stream pauses while providing 5s safety margin.
|
||||
KEEPALIVE_INTERVAL = values.PositiveIntegerValue(
|
||||
default=55, environ_name="KEEPALIVE_INTERVAL", environ_prefix=None
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -1156,8 +1148,6 @@ class Test(Base):
|
||||
|
||||
POSTHOG_KEY = None
|
||||
|
||||
AUTO_TITLE_AFTER_USER_MESSAGES = None
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: la-suite-conversations\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
|
||||
"PO-Revision-Date: 2026-01-16 11:04\n"
|
||||
"PO-Revision-Date: 2025-12-15 13:49\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
|
||||
@@ -43,10 +43,11 @@ dependencies = [
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"docling",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"easyocr",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jaraco.context>=6.1.0",
|
||||
"jsonschema==4.25.1",
|
||||
"langfuse==3.10.0",
|
||||
"lxml==5.4.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-conversations",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -9,7 +9,6 @@
|
||||
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
|
||||
"start": "npx -y serve@latest out",
|
||||
"lint": "tsc --noEmit && next lint",
|
||||
"lint:fix": "tsc --noEmit && next lint --fix",
|
||||
"prettier": "prettier --write .",
|
||||
"stylelint": "stylelint \"**/*.css\"",
|
||||
"test": "jest",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { UseChatOptions, useChat as useAiSdkChat } from '@ai-sdk/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fetchAPI } from '@/api';
|
||||
import { KEY_LIST_CONVERSATION } from '@/features/chat/api/useConversations';
|
||||
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
|
||||
|
||||
const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
@@ -39,46 +36,10 @@ const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return fetchAPI(url, init);
|
||||
};
|
||||
|
||||
interface ConversationMetadataEvent {
|
||||
type: 'conversation_metadata';
|
||||
conversationId: string;
|
||||
title: string;
|
||||
}
|
||||
// Type guard to check if an item is a ConversationMetadataEvent
|
||||
function isConversationMetadataEvent(
|
||||
item: unknown,
|
||||
): item is ConversationMetadataEvent {
|
||||
return (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'type' in item &&
|
||||
item.type === 'conversation_metadata' &&
|
||||
'conversationId' in item &&
|
||||
typeof item.conversationId === 'string' &&
|
||||
'title' in item &&
|
||||
typeof item.title === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function useChat(options: Omit<UseChatOptions, 'fetch'>) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const result = useAiSdkChat({
|
||||
return useAiSdkChat({
|
||||
...options,
|
||||
maxSteps: 3,
|
||||
fetch: fetchAPIAdapter,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (result.data && Array.isArray(result.data)) {
|
||||
for (const item of result.data) {
|
||||
if (isConversationMetadataEvent(item)) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_CONVERSATION],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [result.data, queryClient]);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_CONVERSATION } from './useConversations';
|
||||
|
||||
interface RenameConversationProps {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const renameConversation = async ({
|
||||
conversationId,
|
||||
title,
|
||||
}: RenameConversationProps): Promise<void> => {
|
||||
const response = await fetchAPI(`chats/${conversationId}/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to rename the conversation',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseRenameConversationOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
RenameConversationProps
|
||||
>;
|
||||
|
||||
export const useRenameConversation = (
|
||||
options?: UseRenameConversationOptions,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, RenameConversationProps>({
|
||||
mutationFn: renameConversation,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_CONVERSATION],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils';
|
||||
import {
|
||||
Message,
|
||||
ReasoningUIPart,
|
||||
SourceUIPart,
|
||||
ToolInvocationUIPart,
|
||||
} from '@ai-sdk/ui-utils';
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -811,12 +816,30 @@ export const Chat = ({
|
||||
</Box>
|
||||
)}
|
||||
{message.parts
|
||||
?.filter((part) => part.type === 'tool-invocation')
|
||||
?.filter(
|
||||
(part) =>
|
||||
part.type === 'reasoning' ||
|
||||
part.type === 'tool-invocation',
|
||||
)
|
||||
.map(
|
||||
(part: ToolInvocationUIPart, partIndex: number) =>
|
||||
part.type === 'tool-invocation' &&
|
||||
isCurrentlyStreaming &&
|
||||
isLastAssistantMessageInConversation ? (
|
||||
(
|
||||
part: ReasoningUIPart | ToolInvocationUIPart,
|
||||
partIndex: number,
|
||||
) =>
|
||||
part.type === 'reasoning' ? (
|
||||
<Box
|
||||
key={`reasoning-${partIndex}`}
|
||||
$background="var(--c--theme--colors--greyscale-100)"
|
||||
$color="var(--c--theme--colors--greyscale-500)"
|
||||
$padding={{ all: 'sm' }}
|
||||
$radius="md"
|
||||
$css="font-size: 0.9em;"
|
||||
>
|
||||
{part.reasoning}
|
||||
</Box>
|
||||
) : part.type === 'tool-invocation' &&
|
||||
isCurrentlyStreaming &&
|
||||
isLastAssistantMessageInConversation ? (
|
||||
<ToolInvocationItem
|
||||
key={`tool-invocation-${partIndex}`}
|
||||
toolInvocation={part.toolInvocation}
|
||||
|
||||
+1
-16
@@ -1,4 +1,4 @@
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { Button as _Button, useModal } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -6,7 +6,6 @@ import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
|
||||
import { ChatConversation } from '@/features/chat/types';
|
||||
|
||||
import { ModalRemoveConversation } from './ModalRemoveConversation';
|
||||
import { ModalRenameConversation } from './ModalRenameConversation';
|
||||
|
||||
interface ConversationItemActionsProps {
|
||||
conversation: ChatConversation;
|
||||
@@ -18,16 +17,8 @@ export const ConversationItemActions = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteModal = useModal();
|
||||
const renameModal = useModal();
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
{
|
||||
label: t('Rename chat'),
|
||||
icon: 'edit',
|
||||
callback: () => renameModal.open(),
|
||||
disabled: false,
|
||||
testId: `conversation-item-actions-rename-${conversation.id}`,
|
||||
},
|
||||
{
|
||||
label: t('Delete chat'),
|
||||
icon: 'delete',
|
||||
@@ -80,12 +71,6 @@ export const ConversationItemActions = ({
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
{renameModal.isOpen && (
|
||||
<ModalRenameConversation
|
||||
onClose={renameModal.onClose}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const LeftPanelConversationItem = ({
|
||||
>
|
||||
<StyledLink
|
||||
href={`/chat/${conversation.id}/`}
|
||||
$css="overflow: auto; flex-grow: 1; color: var(--c--theme--colors--greyscale-900);"
|
||||
$css="overflow: auto; flex-grow: 1;"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
<SimpleConversationItem showAccesses conversation={conversation} />
|
||||
|
||||
+2
-5
@@ -46,7 +46,7 @@ export const ModalRemoveConversation = ({
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="tertiary"
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
@@ -79,10 +79,7 @@ export const ModalRemoveConversation = ({
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
className="--conversations--modal-remove-chat"
|
||||
data-testid="delete-chat-confirm"
|
||||
>
|
||||
<Box className="--converstions--modal-remove-chat">
|
||||
<Text $size="sm" $variation="600">
|
||||
{t('Are you sure you want to delete this conversation ?')}
|
||||
</Text>
|
||||
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, useToast } from '@/components';
|
||||
import { useRenameConversation } from '@/features/chat/api/useRenameConversation';
|
||||
import { ChatConversation } from '@/features/chat/types';
|
||||
|
||||
interface ModalRenameConversationProps {
|
||||
onClose: () => void;
|
||||
conversation: ChatConversation;
|
||||
}
|
||||
|
||||
export const ModalRenameConversation = ({
|
||||
onClose,
|
||||
conversation,
|
||||
}: ModalRenameConversationProps) => {
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const { mutate: renameConversation } = useRenameConversation({
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
'success',
|
||||
t('The conversation has been renamed.'),
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage =
|
||||
error.cause?.[0] ||
|
||||
error.message ||
|
||||
t('An error occurred while renaming the conversation');
|
||||
showToast('error', errorMessage, undefined, 4000);
|
||||
},
|
||||
});
|
||||
|
||||
const [newName, setNewName] = useState(conversation.title ?? '');
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedNewName = newName.trim();
|
||||
if (trimmedNewName) {
|
||||
renameConversation({
|
||||
conversationId: conversation.id,
|
||||
title: trimmedNewName,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
aria-label={t('Content modal to rename a conversation')}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="tertiary"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('Rename chat')}
|
||||
color="primary"
|
||||
type="submit"
|
||||
form="rename-chat-form"
|
||||
>
|
||||
{t('Rename')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.SMALL}
|
||||
title={
|
||||
<Text
|
||||
$size="h6"
|
||||
as="h6"
|
||||
$margin={{ all: '0' }}
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
>
|
||||
{t('Rename chat')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box className="--conversations--modal-rename-chat">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
id="rename-chat-form"
|
||||
data-testid="rename-chat-form"
|
||||
className="mt-s"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
label={t('New name')}
|
||||
maxLength={100}
|
||||
value={newName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
-255
@@ -1,255 +0,0 @@
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ToastProvider } from '@/components';
|
||||
import { ChatConversation } from '@/features/chat/types';
|
||||
|
||||
import { ConversationItemActions } from '../ConversationItemActions';
|
||||
|
||||
const mockPush = jest.fn();
|
||||
let mockPathname = '/';
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
pathname: mockPathname,
|
||||
route: '/',
|
||||
query: {},
|
||||
asPath: '/',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, string>) => {
|
||||
if (options) {
|
||||
return Object.entries(options).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{{${k}}}`, v),
|
||||
key,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('i18next', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/api/useRenameConversation', () => ({
|
||||
useRenameConversation: () => ({
|
||||
mutate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/api/useRemoveConversation', () => ({
|
||||
useRemoveConversation: () => ({
|
||||
mutate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<CunninghamProvider>
|
||||
<ToastProvider>{ui}</ToastProvider>
|
||||
</CunninghamProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('ConversationItemActions', () => {
|
||||
const mockConversation: ChatConversation = {
|
||||
id: 'conv-123',
|
||||
title: 'Original Title',
|
||||
messages: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPathname = '/';
|
||||
});
|
||||
|
||||
const renderComponent = (conversation = mockConversation) => {
|
||||
return renderWithProviders(
|
||||
<ConversationItemActions conversation={conversation} />,
|
||||
);
|
||||
};
|
||||
|
||||
it('renders the actions button', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-button-${mockConversation.id}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dropdown menu with correct aria-label', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
screen.getByLabelText(
|
||||
`Actions list for conversation ${mockConversation.title}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dropdown menu with fallback title when conversation has no title', () => {
|
||||
const untitledConversation = { ...mockConversation, title: '' };
|
||||
renderComponent(untitledConversation);
|
||||
|
||||
expect(
|
||||
screen.getByLabelText(
|
||||
`Actions list for conversation Untitled conversation`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown menu when clicking the actions button', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const actionsButton = screen.getByLabelText(
|
||||
`Actions list for conversation ${mockConversation.title}`,
|
||||
);
|
||||
await user.click(actionsButton);
|
||||
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-rename-${mockConversation.id}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-remove-${mockConversation.id}`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays rename and delete options in the dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const actionsButton = screen.getByLabelText(
|
||||
`Actions list for conversation ${mockConversation.title}`,
|
||||
);
|
||||
await user.click(actionsButton);
|
||||
|
||||
expect(screen.getByText('Rename chat')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete chat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens rename modal when clicking rename option', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const actionsButton = screen.getByLabelText(
|
||||
`Actions list for conversation ${mockConversation.title}`,
|
||||
);
|
||||
await user.click(actionsButton);
|
||||
|
||||
const renameOption = screen.getByTestId(
|
||||
`conversation-item-actions-rename-${mockConversation.id}`,
|
||||
);
|
||||
await user.click(renameOption);
|
||||
// Modal should be open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue(mockConversation.title);
|
||||
|
||||
expect(screen.getByTestId('rename-chat-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens delete modal when clicking delete option', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const actionsButton = screen.getByLabelText(
|
||||
`Actions list for conversation ${mockConversation.title}`,
|
||||
);
|
||||
await user.click(actionsButton);
|
||||
|
||||
const deleteOption = screen.getByTestId(
|
||||
`conversation-item-actions-remove-${mockConversation.id}`,
|
||||
);
|
||||
await user.click(deleteOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('delete-chat-confirm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render modals initially', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('delete-chat-confirm')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('rename-chat-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes rename modal when onClose is called', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
// Open dropdown and click rename
|
||||
const actionsButton = screen.getByLabelText(
|
||||
`Actions list for conversation ${mockConversation.title}`,
|
||||
);
|
||||
await user.click(actionsButton);
|
||||
await user.click(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-rename-${mockConversation.id}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Modal should be open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close the modal
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes delete modal when onClose is called', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
// Open dropdown and click delete
|
||||
const actionsButton = screen.getByLabelText(
|
||||
`Actions list for conversation ${mockConversation.title}`,
|
||||
);
|
||||
await user.click(actionsButton);
|
||||
await user.click(
|
||||
screen.getByTestId(
|
||||
`conversation-item-actions-remove-${mockConversation.id}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Modal should be open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close the modal
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
-280
@@ -1,280 +0,0 @@
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { useToast } from '@/components';
|
||||
import { useRenameConversation } from '@/features/chat/api/useRenameConversation';
|
||||
import { ChatConversation } from '@/features/chat/types';
|
||||
|
||||
import { ModalRenameConversation } from '../ModalRenameConversation';
|
||||
|
||||
jest.mock('@/components', () => ({
|
||||
...jest.requireActual('@/components'),
|
||||
useToast: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/chat/api/useRenameConversation');
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, string>) => {
|
||||
if (options) {
|
||||
return Object.entries(options).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{{${k}}}`, v),
|
||||
key,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('i18next', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
const renderWithProviders = (component: React.ReactNode) => {
|
||||
return render(<CunninghamProvider>{component}</CunninghamProvider>);
|
||||
};
|
||||
|
||||
describe('ModalRenameConversation', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
const mockRenameConversation = jest.fn();
|
||||
|
||||
const mockConversation: ChatConversation = {
|
||||
id: 'conv-123',
|
||||
title: 'Original Title',
|
||||
messages: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as ChatConversation;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useToast as jest.Mock).mockReturnValue({
|
||||
showToast: mockShowToast,
|
||||
});
|
||||
(useRenameConversation as jest.Mock).mockReturnValue({
|
||||
mutate: mockRenameConversation,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the modal with correct title and initial value', () => {
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Rename chat')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Original Title');
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rename')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates input value when user types', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
|
||||
expect(input).toHaveValue('New Title');
|
||||
});
|
||||
|
||||
it('closes modal when Cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits form with new name and shows success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
let onSuccessCallback: (() => void) | undefined;
|
||||
|
||||
(useRenameConversation as jest.Mock).mockImplementation(({ onSuccess }) => {
|
||||
onSuccessCallback = onSuccess;
|
||||
return { mutate: mockRenameConversation };
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Updated Title');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
expect(mockRenameConversation).toHaveBeenCalledWith({
|
||||
conversationId: 'conv-123',
|
||||
title: 'Updated Title',
|
||||
});
|
||||
|
||||
onSuccessCallback?.();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'success',
|
||||
'The conversation has been renamed.',
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit form when new name is empty or whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, ' ');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
expect(mockRenameConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error toast when rename fails with cause', async () => {
|
||||
const user = userEvent.setup();
|
||||
let onErrorCallback: ((error: any) => void) | undefined;
|
||||
|
||||
(useRenameConversation as jest.Mock).mockImplementation(({ onError }) => {
|
||||
onErrorCallback = onError;
|
||||
return { mutate: mockRenameConversation };
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
const error = {
|
||||
cause: ['Specific error from API'],
|
||||
message: 'Generic error',
|
||||
};
|
||||
onErrorCallback?.(error);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Specific error from API',
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast with message when no cause is provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
let onErrorCallback: ((error: any) => void) | undefined;
|
||||
|
||||
(useRenameConversation as jest.Mock).mockImplementation(({ onError }) => {
|
||||
onErrorCallback = onError;
|
||||
return { mutate: mockRenameConversation };
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
const error = {
|
||||
message: 'Network error',
|
||||
};
|
||||
onErrorCallback?.(error);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Network error',
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows default error message when error has no cause or message', async () => {
|
||||
const user = userEvent.setup();
|
||||
let onErrorCallback: ((error: any) => void) | undefined;
|
||||
|
||||
(useRenameConversation as jest.Mock).mockImplementation(({ onError }) => {
|
||||
onErrorCallback = onError;
|
||||
return { mutate: mockRenameConversation };
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Title');
|
||||
await user.click(screen.getByText('Rename'));
|
||||
|
||||
const error = {};
|
||||
onErrorCallback?.(error);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'An error occurred while renaming the conversation',
|
||||
undefined,
|
||||
4000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces maxLength of 100 characters on input', () => {
|
||||
renderWithProviders(
|
||||
<ModalRenameConversation
|
||||
onClose={mockOnClose}
|
||||
conversation={mockConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input).toHaveAttribute('maxLength', '100');
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,6 @@
|
||||
"Quick search input": "Saisie de recherche rapide",
|
||||
"Remove attachment": "Supprimer la pièce jointe",
|
||||
"Research on the web": "Rechercher sur le web",
|
||||
"Retry": "Réessayer",
|
||||
"Search": "Rechercher",
|
||||
"Search for a chat": "Rechercher un chat",
|
||||
"Search results": "Résultats de la recherche",
|
||||
@@ -101,7 +100,7 @@
|
||||
"Simple chat icon": "Icône de chat simple",
|
||||
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
|
||||
"Sorry, an error occurred. Please try again.": "Désolé, une erreur s'est produite. Veuillez réessayer.",
|
||||
"Start a new conversation": "Commencer une nouvelle conversation",
|
||||
"Start a new conversation.": "Commencer une nouvelle conversation.",
|
||||
"Start conversation": "Entamer la conversation",
|
||||
"Stop": "Stop",
|
||||
"Summarizing...": "Résumé en cours...",
|
||||
@@ -221,7 +220,6 @@
|
||||
"Quick search input": "Snelle zoekinvoer",
|
||||
"Remove attachment": "Bijlage verwijderen",
|
||||
"Research on the web": "Onderzoek op het internet",
|
||||
"Retry": "Opnieuw proberen",
|
||||
"Search": "Zoek",
|
||||
"Search for a chat": "Zoek naar een chat",
|
||||
"Search results": "Zoekresultaten",
|
||||
@@ -234,7 +232,7 @@
|
||||
"Simple chat icon": "Eenvoudig chatpictogram",
|
||||
"Something bad happens, please retry.": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"Sorry, an error occurred. Please try again.": "Sorry, er is een fout opgetreden. Probeer het opnieuw.",
|
||||
"Start a new conversation": "Begin een nieuw gesprek",
|
||||
"Start a new conversation.": "Begin een nieuw gesprek.",
|
||||
"Start conversation": "Begin een gesprek",
|
||||
"Stop": "Stop",
|
||||
"Summarizing...": "Samenvatten...",
|
||||
@@ -354,7 +352,6 @@
|
||||
"Quick search input": "Быстрый поиск",
|
||||
"Remove attachment": "Удалить вложение",
|
||||
"Research on the web": "Исследование в Интернете",
|
||||
"Retry": "Повторить",
|
||||
"Search": "Поиск",
|
||||
"Search for a chat": "Поиск беседы",
|
||||
"Search results": "Результаты поиска",
|
||||
@@ -367,7 +364,7 @@
|
||||
"Simple chat icon": "Простой значок чата",
|
||||
"Something bad happens, please retry.": "Что-то пошло не так, повторите попытку.",
|
||||
"Sorry, an error occurred. Please try again.": "Извините, произошла ошибка. Пожалуйста, попробуйте ещё раз.",
|
||||
"Start a new conversation": "Начать новую беседу",
|
||||
"Start a new conversation.": "Начать новую беседу.",
|
||||
"Start conversation": "Начать беседу",
|
||||
"Stop": "Остановить",
|
||||
"Summarizing...": "Обобщение...",
|
||||
@@ -487,7 +484,6 @@
|
||||
"Quick search input": "Швидкий пошук",
|
||||
"Remove attachment": "Видалити вкладення",
|
||||
"Research on the web": "Дослідження в Інтернеті",
|
||||
"Retry": "Повторити",
|
||||
"Search": "Пошук",
|
||||
"Search for a chat": "Пошук розмови",
|
||||
"Search results": "Результати пошуку",
|
||||
@@ -500,7 +496,7 @@
|
||||
"Simple chat icon": "Проста піктограма розмови",
|
||||
"Something bad happens, please retry.": "Сталася помилка, спробуйте ще раз.",
|
||||
"Sorry, an error occurred. Please try again.": "Вибачте, виникла помилка. Спробуйте ще раз.",
|
||||
"Start a new conversation": "Розпочати нову розмову",
|
||||
"Start a new conversation.": "Розпочати нову розмову.",
|
||||
"Start conversation": "Почати розмову",
|
||||
"Stop": "Зупинити",
|
||||
"Summarizing...": "Узагальнення...",
|
||||
|
||||
@@ -52,7 +52,7 @@ test.describe('Chat page', () => {
|
||||
|
||||
const messageContent = page.getByTestId('assistant-message-content');
|
||||
await expect(messageContent).toBeVisible();
|
||||
await expect(messageContent).not.toBeEmpty();
|
||||
await expect(messageContent).not.toBeEmpty();
|
||||
|
||||
// Check history
|
||||
const chatHistoryLink = page
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "conversations",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-config-conversations",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.10",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js ."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"extract-translation": "yarn extract-translation:conversations",
|
||||
|
||||
@@ -11355,9 +11355,9 @@ posthog-js@1.249.3:
|
||||
web-vitals "^4.2.4"
|
||||
|
||||
preact@^10.19.3:
|
||||
version "10.24.0"
|
||||
resolved "https://registry.npmjs.org/preact/-/preact-10.24.0.tgz"
|
||||
integrity sha512-aK8Cf+jkfyuZ0ZZRG9FbYqwmEiGQ4y/PUO4SuTWoyWL244nZZh7bd5h2APd4rSNDYTBNghg1L+5iJN3Skxtbsw==
|
||||
version "10.26.6"
|
||||
resolved "https://registry.npmjs.org/preact/-/preact-10.26.6.tgz"
|
||||
integrity sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==
|
||||
|
||||
prelude-ls@^1.2.1:
|
||||
version "1.2.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.10",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
+4
-4
@@ -399,10 +399,10 @@ glob-parent@~5.1.2:
|
||||
dependencies:
|
||||
is-glob "^4.0.1"
|
||||
|
||||
glob@^10.5.0:
|
||||
version "10.5.0"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
|
||||
integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==
|
||||
glob@^10.3.10, glob@^10.3.3:
|
||||
version "10.4.5"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
|
||||
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
|
||||
dependencies:
|
||||
foreground-child "^3.1.0"
|
||||
jackspeak "^3.1.2"
|
||||
|
||||
Reference in New Issue
Block a user