"""Rio chat and Markdown-upload interface.""" from __future__ import annotations from dataclasses import dataclass, field from uuid import uuid4 import rio from config import ConfigurationError, Settings from n8n_client import N8NClient, WebhookError settings = Settings.from_env() client = N8NClient(settings) @dataclass class PendingFile: file: rio.FileInfo contents: bytes | None = None error: str = "" class KnowledgeApp(rio.Component): collection_name: str = "my-store" question_text: str = "" session_id: str = field(default_factory=lambda: uuid4().hex) messages: tuple[tuple[str, str], ...] = () pending_files: tuple[PendingFile, ...] = () upload_progress: str = "" chat_error: str = "" upload_error: str = "" upload_status: str = "" is_chat_loading: bool = False is_uploading: bool = False async def submit_question(self, *_: object) -> None: if self.is_chat_loading: return question = self.question_text.strip() collection = self.collection_name.strip() if not question or not collection: self.chat_error = "Введите вопрос и укажите название базы знаний." return self.chat_error = "" self.is_chat_loading = True self.force_refresh() try: answer = await client.ask(question, collection, self.session_id) except (ConfigurationError, WebhookError, ValueError) as exc: self.chat_error = str(exc) else: self.messages = self.messages + (("Вы", question), ("Ответ", answer)) self.question_text = "" finally: self.is_chat_loading = False self.force_refresh() def pick_file(self, event: rio.FilePickEvent) -> None: if self.is_uploading: return chosen = event.file error = "" if not chosen.name.lower().endswith(".md"): error = "Можно загружать только файлы .md." elif chosen.size_in_bytes == 0: error = "Выбранный файл пуст." elif chosen.size_in_bytes > settings.max_file_bytes: error = "Размер файла превышает допустимый предел." if error: self.upload_error = "\n".join(filter(None, [self.upload_error, f"{chosen.name}: {error}"])) return self.upload_status = "" if not any(item.file is chosen for item in self.pending_files): self.pending_files += (PendingFile(chosen),) def remove_file(self, event: rio.FilePickEvent) -> None: if self.is_uploading: return self.pending_files = tuple(item for item in self.pending_files if item.file is not event.file) async def upload_file(self) -> None: if self.is_uploading: return if not self.collection_name.strip(): self.upload_error = "Укажите название базы знаний." return if not self.pending_files: self.upload_error = "Выберите файлы .md." return collection = self.collection_name.strip() batch = self.pending_files self.upload_error = "" self.upload_status = "" self.is_uploading = True completed = 0 errors: list[str] = [] try: for index, item in enumerate(batch, 1): self.upload_progress = f"Отправляем {index} из {len(batch)}: {item.file.name}" item.error = "" self.force_refresh() try: if item.contents is None: try: item.contents = await item.file.read_bytes() except Exception as exc: self.pending_files = tuple(p for p in self.pending_files if p is not item) raise WebhookError("Не удалось прочитать файл. Выберите его заново.") from exc await client.upload(item.file.name, item.contents, collection) except (ConfigurationError, WebhookError, ValueError) as exc: item.error = str(exc) errors.append(f"{item.file.name}: {exc}") else: completed += 1 self.pending_files = tuple(p for p in self.pending_files if p is not item) item.contents = None self.force_refresh() self.upload_status = f"Отправлено на обработку: {completed} из {len(batch)}. Проверьте содержимое вопросом." self.upload_error = "\n".join(errors) finally: self.upload_progress = "" self.is_uploading = False self.force_refresh() def build(self) -> rio.Component: busy = self.is_chat_loading or self.is_uploading compact = self.session.window_width < 54 ink = rio.Color.from_hex("#18243B") muted = rio.Color.from_hex("#69778D") blue = rio.Color.from_hex("#3867E8") line = rio.Color.from_hex("#E4EAF3") def label(text: str, **kwargs: object) -> rio.Text: return rio.Text(text, overflow="wrap", **kwargs) def panel(content: rio.Component, **kwargs: object) -> rio.Rectangle: return rio.Rectangle( content=content, fill=rio.Color.WHITE, stroke_color=line, stroke_width=0.06, corner_radius=1.1, **kwargs, ) def notice(text: str, error: bool = False) -> rio.Rectangle: return rio.Rectangle( content=label(text, margin=0.9, fill=rio.Color.from_hex("#A33342" if error else "#23775B")), fill=rio.Color.from_hex("#FFF0F2" if error else "#EAF7F0"), corner_radius=0.6, ) upload_items: list[rio.Component] = [ label("ВАШИ ИСТОЧНИКИ", font_size=0.72, font_weight="bold", fill=muted), label("База знаний", font_size=1.4, font_weight="bold", fill=ink), label("Выберите базу для документов и вопросов.", font_size=0.9, fill=muted), rio.TextInput(text=self.bind().collection_name, label="Название базы", is_sensitive=not busy, grow_x=True), rio.Rectangle(fill=line, min_height=0.06, margin_y=0.4), label("Добавить документы", font_size=1.05, font_weight="bold", fill=ink), label("Загрузите Markdown, чтобы задавать вопросы по его содержимому.", font_size=0.85, fill=muted), rio.FilePickerArea( rio.Column( label("↑", font_size=2, fill=blue, justify="center"), label("Выберите файлы .md", font_weight="bold", justify="center"), label("или перетащите их сюда", font_size=0.8, fill=muted, justify="center"), spacing=0.35, margin=1.1, ), file_types=[".md"], multiple=True, files=[item.file for item in self.pending_files], on_pick_file=self.pick_file, on_remove_file=self.remove_file, ), label(f"Каждый файл · до {settings.max_file_bytes / 1024 / 1024:g} МиБ", font_size=0.75, fill=muted), rio.Button("Загружаем…" if self.is_uploading else f"Добавить в базу ({len(self.pending_files)})", on_press=self.upload_file, style="minor", color="primary", is_sensitive=not busy and bool(self.pending_files), is_loading=self.is_uploading, grow_x=True), ] if self.upload_progress: upload_items.append(label(self.upload_progress, fill=blue, font_size=0.85)) if self.upload_status: upload_items.append(notice(self.upload_status)) if self.upload_error: upload_items.append(notice(self.upload_error, True)) upload_items.append( rio.Rectangle( content=rio.Column( label("Как это работает", font_weight="bold", font_size=0.85), label("1. Выберите базу\n2. Добавьте документ\n3. Задайте вопрос в чате", font_size=0.85, fill=muted), spacing=0.55, margin=1, ), fill=rio.Color.from_hex("#F5F7FC"), corner_radius=0.7, margin_top=0.6, ) ) sidebar = panel(rio.Column(*upload_items, spacing=0.8, margin=1.4), min_width=0 if compact else 20, align_y=0) message_items: list[rio.Component] = [] for role, content in self.messages: mine = role == "Вы" message_items.append( rio.Rectangle( content=rio.Column( label("ВЫ" if mine else "ПОМОЩНИК", font_size=0.7, font_weight="bold", fill=blue if mine else muted), (label(content, fill=ink) if mine else rio.Markdown(content, overflow="wrap", scroll_code_x="auto", grow_x=True)), spacing=0.55, margin=1, ), fill=rio.Color.from_hex("#EEF3FF" if mine else "#F7F8FB"), corner_radius=0.8, margin_left=2 if mine else 0, margin_right=0 if mine else 2, ) ) if not message_items: message_items.append( rio.Column( rio.Rectangle( content=label("✦", font_size=2.4, fill=blue, margin=0.8, justify="center"), fill=rio.Color.from_hex("#EEF3FF"), corner_radius=1.1, align_x=0.5, ), label("Что вы хотите узнать?", font_size=1.7, font_weight="bold", justify="center", fill=ink), label("Задайте вопрос по документам в вашей базе.\nПомощник найдёт информацию и подготовит ответ.", justify="center", fill=muted), label("Например: «Какие основные выводы в документе?»", font_size=0.85, justify="center", fill=muted), spacing=1, margin_y=2, align_y=0.5, grow_x=True, ) ) if self.is_chat_loading: message_items.append(notice("Готовим ответ. Это может занять немного времени…")) composer: list[rio.Component] = [ rio.MultiLineTextInput( text=self.bind().question_text, label="Напишите ваш вопрос…", on_confirm=self.submit_question, is_sensitive=not busy, grow_x=True, ), rio.Row( label("Ответы по выбранной базе знаний", font_size=0.75, fill=muted, grow_x=True), rio.Button("Отправляем…" if self.is_chat_loading else "Отправить ↑", on_press=self.submit_question, is_sensitive=not busy, is_loading=self.is_chat_loading, shape="rounded", color="primary"), spacing=0.7, ), ] if self.chat_error: composer.append(notice(self.chat_error, True)) chat = panel( rio.Column( rio.Row( rio.Column( label("Чат с помощником", font_size=1.2, font_weight="bold", fill=ink), label("Документы превращаются в ответы", font_size=0.85, fill=muted), spacing=0.25, grow_x=True, ), spacing=1, ), rio.Rectangle(fill=line, min_height=0.06), (rio.ScrollContainer( rio.Column(*message_items, spacing=0.9, grow_x=True), scroll_x="never", sticky_bottom=True, grow_y=True, min_height=17 if compact else 19, ) if self.messages else rio.Column( *message_items, grow_y=True, min_height=21 if compact else 19, )), rio.Column(*composer, spacing=0.7), spacing=1.1, margin=1.4, grow_y=True, ), grow_x=True, grow_y=True, ) header = rio.Row( rio.Rectangle( content=label("БЗ", font_weight="bold", fill=rio.Color.WHITE, margin=0.7), fill=blue, corner_radius=0.65, ), rio.Column( label("База знаний", font_size=1.45, font_weight="bold", fill=ink), label("Ваш помощник по документам", font_size=0.85, fill=muted), spacing=0.15, grow_x=True, ), spacing=0.8, ) body = (rio.Column(chat, sidebar, spacing=1.2) if compact else rio.Row(sidebar, chat, spacing=1.4, grow_y=True)) return rio.Column(header, body, spacing=1.4, margin=0.8 if compact else 1.7, grow_x=True, grow_y=True)