diff --git a/src/backend/chat/agent_rag/document_converter/parser.py b/src/backend/chat/agent_rag/document_converter/parser.py new file mode 100644 index 0000000..d5d95a9 --- /dev/null +++ b/src/backend/chat/agent_rag/document_converter/parser.py @@ -0,0 +1,65 @@ +"""Document parsers for RAG backends.""" + +import logging +from urllib.parse import urljoin + +from django.conf import settings + +import requests + +from chat.agent_rag.document_converter.markitdown import DocumentConverter + +logger = logging.getLogger(__name__) + + +class BaseParser: + """Base class for document parsers.""" + + def parse_document(self, name: str, content_type: str, content: bytes) -> str: + """ + 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 storage. + + Args: + name (str): The name of the document. + content_type (str): The MIME type of the document (e.g., "application/pdf"). + content (bytes): The content of the document as a bytes stream. + + Returns: + str: The document content in Markdown format. + """ + raise NotImplementedError("Must be implemented in subclass.") + + +class AlbertParser(BaseParser): + """Document parser using Albert API for PDFs and DocumentConverter for other formats.""" + + endpoint = urljoin(settings.ALBERT_API_URL, "/v1/parse-beta") + + def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str: + """Parse PDF document using Albert API.""" + response = requests.post( + self.endpoint, + headers={ + "Authorization": f"Bearer {settings.ALBERT_API_KEY}", + }, + files={ + "file": (name, content, content_type), + "output_format": (None, "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: bytes) -> str: + """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( + name=name, content_type=content_type, content=content + ) diff --git a/src/backend/conversations/settings.py b/src/backend/conversations/settings.py index 2298492..4b00049 100755 --- a/src/backend/conversations/settings.py +++ b/src/backend/conversations/settings.py @@ -353,6 +353,7 @@ class Base(BraveSettings, Configuration): # Third party apps "corsheaders", "django_filters", + "solo.apps.SoloAppConfig", "dockerflow.django", "rest_framework", "parler", diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index e08c26c..6df3300 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.contrib.auth import admin as auth_admin from django.utils.translation import gettext_lazy as _ - +from solo.admin import SingletonModelAdmin from . import models @@ -107,3 +107,26 @@ class UserAdmin(auth_admin.UserAdmin): "allow_conversation_analytics", ) list_editable = ("allow_conversation_analytics",) + + +@admin.register(models.SiteConfiguration) +class SiteConfigurationAdmin(SingletonModelAdmin): + fieldsets = ( + (_("Environment Banner"), { + "description": _("Inform users about the current environment (staging, demo...)"), + "fields": ( + "environment_banner_level", + "environment_banner_title", + "environment_banner_content", + ), + }), + (_("Status / Incident Banner"), { + "description": _("Communicate maintenance windows or ongoing incidents"), + "fields": ( + "status_banner_level", + "status_banner_title", + "status_banner_content", + "status_banner_dismissible", + ), + }), + ) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5d4b8f9..17912ab 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -225,6 +225,8 @@ class ConfigView(drf.views.APIView): dict_settings["chat_upload_accept"] = ",".join(settings.RAG_FILES_ACCEPTED_FORMATS) + dict_settings["banners"] = self._get_banners() + return drf.response.Response(dict_settings) def _load_theme_customization(self): @@ -257,3 +259,30 @@ class ConfigView(drf.views.APIView): ) return theme_customization + + def _get_banners(self): + """Return active banners from SiteConfiguration.""" + + + config = models.SiteConfiguration.get_solo() + banners = {} + + if config.environment_banner_enabled : + banners["environment"] = { + "type": "environment", + "level": config.environment_banner_level, + "title": config.environment_banner_title, + "content": config.environment_banner_content, + "dismissible": False, + } + + if config.status_banner_enabled : + banners["status"] = { + "type": "status", + "level": config.status_banner_level, + "title": config.status_banner_title, + "content": config.status_banner_content, + "dismissible": config.status_banner_dismissible, + } + + return banners \ No newline at end of file diff --git a/src/backend/core/migrations/0004_siteconfiguration.py b/src/backend/core/migrations/0004_siteconfiguration.py new file mode 100644 index 0000000..b33c85f --- /dev/null +++ b/src/backend/core/migrations/0004_siteconfiguration.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.9 on 2026-01-29 10:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_alter_user_short_name'), + ] + + operations = [ + migrations.CreateModel( + name='SiteConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('environment_banner_title', models.CharField(blank=True, verbose_name='Environment banner - title')), + ('environment_banner_content', models.TextField(blank=True, verbose_name='Environment banner - content')), + ('environment_banner_level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')], default='info', max_length=20, verbose_name='Environment banner - level')), + ('status_banner_title', models.CharField(blank=True, verbose_name='Status banner - title')), + ('status_banner_content', models.TextField(blank=True, verbose_name='Status banner - content')), + ('status_banner_level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')], default='info', max_length=20, verbose_name='Status banner - level')), + ('status_banner_dismissible', models.BooleanField(default=True, help_text='Allow users to dismiss this banner', verbose_name='Status banner - dismissible')), + ], + options={ + 'verbose_name': 'Site Configuration', + }, + ), + ] diff --git a/src/backend/core/models/__init__.py b/src/backend/core/models/__init__.py new file mode 100644 index 0000000..2295974 --- /dev/null +++ b/src/backend/core/models/__init__.py @@ -0,0 +1,3 @@ +from .base import BaseModel +from .users import User, DuplicateEmailError,UserManager +from .app_config import SiteConfiguration \ No newline at end of file diff --git a/src/backend/core/models/app_config.py b/src/backend/core/models/app_config.py new file mode 100644 index 0000000..4275e71 --- /dev/null +++ b/src/backend/core/models/app_config.py @@ -0,0 +1,64 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from solo.models import SingletonModel + + +class BannerLevelChoice(models.TextChoices): + INFO = "info", _("Info") + WARNING = "warning", _("Warning") + ALERT = "alert", _("Alert") + + +class SiteConfiguration(SingletonModel): + """Site-wide configuration for banners and announcements.""" + + # Environment banner (staging, demo, QA, sandbox, etc.) + environment_banner_title = models.CharField( + blank=True, + verbose_name=_("Environment banner - title"), + ) + environment_banner_content = models.TextField( + blank=True, + verbose_name=_("Environment banner - content"), + ) + environment_banner_level = models.CharField( + verbose_name=_("Environment banner - level"), + max_length=20, + choices=BannerLevelChoice.choices, + default=BannerLevelChoice.INFO, + ) + + # Status/incident banner + status_banner_title = models.CharField( + blank=True, + verbose_name=_("Status banner - title"), + ) + status_banner_content = models.TextField( + blank=True, + verbose_name=_("Status banner - content"), + ) + status_banner_level = models.CharField( + verbose_name=_("Status banner - level"), + max_length=20, + choices=BannerLevelChoice.choices, + default=BannerLevelChoice.INFO, + ) + status_banner_dismissible = models.BooleanField( + default=True, + verbose_name=_("Status banner - dismissible"), + help_text=_("Allow users to dismiss this banner"), + ) + + def __str__(self): + return "Site Configuration" + + @property + def environment_banner_enabled(self): + return bool(self.environment_banner_title or self.environment_banner_content) + + @property + def status_banner_enabled(self): + return bool(self.status_banner_title or self.status_banner_content) + + class Meta: + verbose_name = _("Site Configuration" ) diff --git a/src/backend/core/models/base.py b/src/backend/core/models/base.py new file mode 100644 index 0000000..009aaa6 --- /dev/null +++ b/src/backend/core/models/base.py @@ -0,0 +1,43 @@ +""" +Declare and configure base models for the conversations core application +""" +from django.db import models +import uuid +from django.utils.translation import gettext_lazy as _ + +class BaseModel(models.Model): + """ + Serves as an abstract base model for other models, ensuring that records are validated + before saving as Django doesn't do it by default. + + Includes fields common to all models: a UUID primary key and creation/update timestamps. + """ + + id = models.UUIDField( + verbose_name=_("id"), + help_text=_("primary key for the record as UUID"), + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + created_at = models.DateTimeField( + verbose_name=_("created on"), + help_text=_("date and time at which a record was created"), + auto_now_add=True, + editable=False, + ) + updated_at = models.DateTimeField( + verbose_name=_("updated on"), + help_text=_("date and time at which a record was last updated"), + auto_now=True, + editable=False, + ) + + class Meta: # pylint:disable=missing-class-docstring + abstract = True + + def save(self, *args, **kwargs): + """Call `full_clean` before saving.""" + self.full_clean() + super().save(*args, **kwargs) + diff --git a/src/backend/core/models.py b/src/backend/core/models/users.py similarity index 80% rename from src/backend/core/models.py rename to src/backend/core/models/users.py index ee86ab8..65a516d 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models/users.py @@ -1,8 +1,8 @@ """ -Declare and configure the models for the conversations core application +Declare and configure the user models for the conversations core application """ -import uuid + from logging import getLogger from django.conf import settings @@ -14,6 +14,8 @@ from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneField +from .base import BaseModel + logger = getLogger(__name__) @@ -27,42 +29,6 @@ class DuplicateEmailError(Exception): super().__init__(self.message) -class BaseModel(models.Model): - """ - Serves as an abstract base model for other models, ensuring that records are validated - before saving as Django doesn't do it by default. - - Includes fields common to all models: a UUID primary key and creation/update timestamps. - """ - - id = models.UUIDField( - verbose_name=_("id"), - help_text=_("primary key for the record as UUID"), - primary_key=True, - default=uuid.uuid4, - editable=False, - ) - created_at = models.DateTimeField( - verbose_name=_("created on"), - help_text=_("date and time at which a record was created"), - auto_now_add=True, - editable=False, - ) - updated_at = models.DateTimeField( - verbose_name=_("updated on"), - help_text=_("date and time at which a record was last updated"), - auto_now=True, - editable=False, - ) - - class Meta: # pylint:disable=missing-class-docstring - abstract = True - - def save(self, *args, **kwargs): - """Call `full_clean` before saving.""" - self.full_clean() - super().save(*args, **kwargs) - class UserManager(auth_models.UserManager): """Custom manager for User model with additional methods.""" diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 8d92f2c..1b44c7b 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ "trafilatura==2.0.0", "uvicorn==0.38.0", "whitenoise==6.11.0", + "django-solo>=2.5.1", ] [project.urls] diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 3e680d9..4bd312f 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -407,6 +407,7 @@ dependencies = [ { name = "django-parler" }, { name = "django-pydantic-field" }, { name = "django-redis" }, + { name = "django-solo" }, { name = "django-storages", extra = ["s3"] }, { name = "django-timezone-field" }, { name = "djangorestframework" }, @@ -480,6 +481,7 @@ requires-dist = [ { name = "django-parler", specifier = "==2.3" }, { name = "django-pydantic-field", specifier = "==0.4.0" }, { name = "django-redis", specifier = "==6.0.0" }, + { name = "django-solo", specifier = ">=2.5.1" }, { name = "django-storages", extras = ["s3"], specifier = "==1.14.6" }, { name = "django-test-migrations", marker = "extra == 'dev'", specifier = "==1.5.0" }, { name = "django-timezone-field", specifier = ">=5.1" }, @@ -831,6 +833,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" }, ] +[[package]] +name = "django-solo" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/b3/1128d46ca1956dca02b7425bd5bc753c3a31ed21be6c3bb19b8d4c5316c9/django_solo-2.5.1.tar.gz", hash = "sha256:ca3b2e44dbb4726446941ca8d765c247123f709f051d07e62eacf97330ffde01", size = 13662, upload-time = "2026-01-01T15:46:52.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/aa/45058bdec53ce3df577be9bd40c630067788923284becc6cca6fc592d4db/django_solo-2.5.1-py3-none-any.whl", hash = "sha256:23bfe48950587b409ec4b4ef95e5e62c1c6c590dd958d8d8b99d37d5ccb53ca8", size = 16882, upload-time = "2026-01-01T15:46:51.087Z" }, +] + [[package]] name = "django-storages" version = "1.14.6" diff --git a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx index 7c6cb6f..b3bd999 100644 --- a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx @@ -41,7 +41,7 @@ export interface ConfigResponse { const LOCAL_STORAGE_KEY = 'conversations_config'; -function getCachedTranslation() { +function getCachedConfig() { try { const jsonString = localStorage.getItem(LOCAL_STORAGE_KEY); return jsonString ? (JSON.parse(jsonString) as ConfigResponse) : undefined; @@ -50,7 +50,7 @@ function getCachedTranslation() { } } -function setCachedTranslation(translations: ConfigResponse) { +function setCachedConfig(translations: ConfigResponse) { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(translations)); } @@ -62,7 +62,8 @@ export const getConfig = async (): Promise => { } const config = response.json() as Promise; - setCachedTranslation(await config); + + setCachedConfig(await config); return config; }; @@ -70,7 +71,7 @@ export const getConfig = async (): Promise => { export const KEY_CONFIG = 'config'; export function useConfig() { - const cachedData = getCachedTranslation(); + const cachedData = getCachedConfig(); const oneHour = 1000 * 60 * 60; return useQuery({ diff --git a/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx b/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx index 5db6ce4..4516767 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx @@ -137,6 +137,7 @@ export const InputChat = ({ const { isDesktop, isMobile } = useResponsiveStore(); const { data: conf } = useConfig(); + const { isFeatureFlagActivated } = useAnalytics(); const [fileUploadEnabled, setFileUploadEnabled] = useState(false); const [webSearchEnabled, setWebSearchEnabled] = useState(false); diff --git a/src/frontend/apps/conversations/src/hook/useElementHeight.tsx b/src/frontend/apps/conversations/src/hook/useElementHeight.tsx new file mode 100644 index 0000000..3270824 --- /dev/null +++ b/src/frontend/apps/conversations/src/hook/useElementHeight.tsx @@ -0,0 +1,28 @@ +// hooks/useElementHeight.ts +import { useState, useEffect, useRef, RefObject } from 'react'; + +export function useElementHeight(): [ + RefObject, + number, +] { + const ref = useRef(null); + const [height, setHeight] = useState(0); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setHeight(entry.contentRect.height); + } + }); + + observer.observe(element); + setHeight(element.offsetHeight); // initial measurement + + return () => observer.disconnect(); + }, []); + + return [ref, height]; +} diff --git a/src/frontend/apps/conversations/src/layouts/MainLayout.tsx b/src/frontend/apps/conversations/src/layouts/MainLayout.tsx index 599ecf3..7fe7e5a 100644 --- a/src/frontend/apps/conversations/src/layouts/MainLayout.tsx +++ b/src/frontend/apps/conversations/src/layouts/MainLayout.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, useEffect, useState } from 'react'; import { css } from 'styled-components'; import { Box } from '@/components'; @@ -8,11 +8,37 @@ import { Header } from '@/features/header'; import { LeftPanel } from '@/features/left-panel'; import { MAIN_LAYOUT_ID } from '@/layouts/conf'; import { useResponsiveStore } from '@/stores'; +import { useElementHeight } from '@/hook/useElementHeight'; +import { FeatureFlagState, useConfig } from '@/core'; type MainLayoutProps = { backgroundColor?: 'white' | 'grey'; }; +const Banner = ({ + title = '', + content = '', +}: { + title?: string; + content?: string; +}) => { + if (!content) { + return null; + } + return ( + + {title} +

{content}

+
+ ); +}; + export function MainLayout({ children, backgroundColor: _backgroundColor = 'white', @@ -21,15 +47,41 @@ export function MainLayout({ const { colorsTokens } = useCunninghamTheme(); const { isPanelOpen } = useChatPreferencesStore(); - const HEADER_HEIGHT = `${isDesktop ? '65' : '52'}`; + const [banners, setBanners] = useState({}); + const { data: conf } = useConfig(); + const [bannerRef, bannerHeight] = useElementHeight(); + + useEffect(() => { + setBanners(conf?.banners ?? {}); + }, [conf]); + + const HEADER_HEIGHT = `${isDesktop ? '65' : '52'}`; + console.log(banners); + console.log(bannerHeight); return ( + + + + + await page.route('**/api/v1.0/config/', async (route) => { const request = route.request(); + console.log("CONFIG") if (request.method().includes('GET')) { await route.fulfill({ json: { diff --git a/src/mail/yarn.lock b/src/mail/yarn.lock index 5e5a67d..f81e2bc 100644 --- a/src/mail/yarn.lock +++ b/src/mail/yarn.lock @@ -399,7 +399,7 @@ glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@^10.5.0: +glob@^10.3.10, glob@^10.3.3: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==