291 lines
14 KiB
Python
291 lines
14 KiB
Python
"""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)
|