Добавлены все базовые файлы для запуска проекта
This commit is contained in:
+290
@@ -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)
|
||||
Reference in New Issue
Block a user