Добавлены все базовые файлы для запуска проекта

This commit is contained in:
Даниил
2026-09-19 15:19:31 +10:00
commit 34535ac579
23 changed files with 1903 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
"""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)