- Project files structure: workspace/<project-slug>/files/ and /attachments/ - Database models: ProjectFile + extended Attachment - Hybrid sync: inotify + periodic scanning for manual file additions - MCP tools integration: 6 tools for agent file operations - Security: validation, quotas, file type restrictions - Features: thumbnails, text extraction, full-text search - REST API endpoints for all file operations - Detailed implementation plan and examples
1035 lines
42 KiB
Markdown
1035 lines
42 KiB
Markdown
# Files Architecture — Team Board
|
||
|
||
**Версия:** 1.0
|
||
**Дата:** 2026-02-24
|
||
**Автор:** Subagent (Research & Design)
|
||
|
||
---
|
||
|
||
## 1. Обзор
|
||
|
||
Team Board нуждается в файловой системе для двух типов файлов:
|
||
1. **Project Files** — файлы проекта (документация, код, ресурсы)
|
||
2. **Attachments** — файлы, прикреплённые к сообщениям и задачам
|
||
|
||
**Ключевые принципы:**
|
||
- Файлы хранятся на диске в настраиваемом workspace
|
||
- Структура папок по проектам: `workspace/<project-slug>/`
|
||
- Файлы, добавленные вручную, автоматически индексируются
|
||
- Агенты имеют полный доступ через MCP tools
|
||
- Единый API для всех операций с файлами
|
||
|
||
---
|
||
|
||
## 2. Структура директорий
|
||
|
||
### 2.1 Общая структура workspace
|
||
|
||
```
|
||
/data/team-board-workspace/
|
||
├── projects/ # Корневая папка проектов
|
||
│ ├── <project-slug>/ # Папка проекта (по slug)
|
||
│ │ ├── files/ # Проектные файлы (ручное добавление)
|
||
│ │ │ ├── docs/ # Документация
|
||
│ │ │ ├── assets/ # Ресурсы (изображения, шрифты)
|
||
│ │ │ ├── configs/ # Конфигурационные файлы
|
||
│ │ │ └── misc/ # Прочие файлы
|
||
│ │ ├── attachments/ # Файлы из сообщений/задач
|
||
│ │ │ ├── messages/ # По ID сообщения
|
||
│ │ │ │ ├── <message-id>/
|
||
│ │ │ │ │ ├── document.pdf
|
||
│ │ │ │ │ └── screenshot.png
|
||
│ │ │ └── tasks/ # По ID задачи
|
||
│ │ │ └── <task-id>/
|
||
│ │ │ ├── requirements.md
|
||
│ │ │ └── mockup.png
|
||
│ │ └── .meta/ # Метаданные (индекс файлов)
|
||
│ │ ├── files.index.json # Индекс проектных файлов
|
||
│ │ └── sync.log # Лог синхронизации
|
||
│ └── shared/ # Общие файлы (не привязаны к проекту)
|
||
│ ├── avatars/ # Аватары пользователей
|
||
│ ├── templates/ # Шаблоны документов
|
||
│ └── tmp/ # Временные файлы
|
||
├── .global/ # Глобальные настройки
|
||
│ ├── config.json # Конфигурация файловой системы
|
||
│ ├── mime-types.json # Настройки типов файлов
|
||
│ └── quotas.json # Квоты пользователей/проектов
|
||
└── trash/ # Корзина (soft delete)
|
||
└── <date>/
|
||
└── <original-path>
|
||
```
|
||
|
||
### 2.2 Пример для проекта "team-board"
|
||
|
||
```
|
||
/data/team-board-workspace/projects/team-board/
|
||
├── files/ # Проектные файлы
|
||
│ ├── docs/
|
||
│ │ ├── README.md # Добавлен вручную
|
||
│ │ ├── API.md # Добавлен вручную
|
||
│ │ └── diagrams/
|
||
│ │ └── architecture.png
|
||
│ ├── configs/
|
||
│ │ ├── docker-compose.yml
|
||
│ │ └── .env.example
|
||
│ └── assets/
|
||
│ ├── logo.png
|
||
│ └── icons/
|
||
├── attachments/ # Из сообщений/задач
|
||
│ ├── messages/
|
||
│ │ └── 550e8400-e29b-41d4-a716-446655440000/
|
||
│ │ ├── error-screenshot.png # Attachment к сообщению
|
||
│ │ └── logs.txt
|
||
│ └── tasks/
|
||
│ └── 550e8400-e29b-41d4-a716-446655440001/
|
||
│ └── technical-spec.pdf # Attachment к задаче
|
||
└── .meta/
|
||
├── files.index.json # Индекс проектных файлов
|
||
└── sync.log
|
||
```
|
||
|
||
### 2.3 Naming conventions
|
||
|
||
**Проекты:** `<project-slug>` (латиница, дефисы)
|
||
**Файлы:** Исходные имена сохраняются, конфликты решаются суффиксами
|
||
**Папки attachments:** UUID сообщения/задачи
|
||
**Временные файлы:** UUID + timestamp
|
||
|
||
---
|
||
|
||
## 3. Модели базы данных
|
||
|
||
### 3.1 Расширение существующей модели Attachment
|
||
|
||
```python
|
||
# Существующая модель (для attachments)
|
||
class Attachment(Base):
|
||
"""File attachment to message or task comment."""
|
||
__tablename__ = "attachments"
|
||
|
||
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("messages.id"), nullable=False)
|
||
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||
mime_type: Mapped[str | None] = mapped_column(String(100))
|
||
size: Mapped[int] = mapped_column(Integer, default=0) # bytes
|
||
storage_path: Mapped[str] = mapped_column(String(1000), nullable=False) # относительный путь
|
||
|
||
message: Mapped["Message"] = relationship(back_populates="attachments")
|
||
```
|
||
|
||
### 3.2 Новая модель для проектных файлов
|
||
|
||
```python
|
||
class ProjectFile(Base):
|
||
"""File in project files/ directory — manually added or uploaded via API."""
|
||
__tablename__ = "project_files"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||
|
||
# Файл
|
||
filename: Mapped[str] = mapped_column(String(500), nullable=False) # исходное имя
|
||
relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) # относительно project/files/
|
||
mime_type: Mapped[str | None] = mapped_column(String(100))
|
||
size: Mapped[int] = mapped_column(Integer, default=0) # bytes
|
||
checksum: Mapped[str | None] = mapped_column(String(64)) # SHA256 for deduplication
|
||
|
||
# Метаданные
|
||
source: Mapped[str] = mapped_column(String(20), default="manual") # manual | api | agent
|
||
uploaded_by: Mapped[str | None] = mapped_column(String(255)) # member slug
|
||
description: Mapped[str | None] = mapped_column(Text)
|
||
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # для категоризации
|
||
|
||
# Флаги
|
||
is_indexed: Mapped[bool] = mapped_column(Boolean, default=True) # виден в API
|
||
is_searchable: Mapped[bool] = mapped_column(Boolean, default=True) # полнотекстовый поиск
|
||
|
||
# Временные метки
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||
indexed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # когда последний раз сканировался
|
||
|
||
# Связи
|
||
project: Mapped["Project"] = relationship(back_populates="files")
|
||
```
|
||
|
||
### 3.3 Обновление модели Project
|
||
|
||
```python
|
||
class Project(Base):
|
||
# ... существующие поля ...
|
||
|
||
# Новая связь
|
||
files: Mapped[list["ProjectFile"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||
```
|
||
|
||
### 3.4 Конфигурация файловой системы
|
||
|
||
```python
|
||
class FileSystemConfig(Base):
|
||
"""Global file system configuration."""
|
||
__tablename__ = "fs_config"
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
|
||
# Пути
|
||
workspace_path: Mapped[str] = mapped_column(String(1000), nullable=False) # /data/team-board-workspace
|
||
|
||
# Лимиты
|
||
max_file_size_mb: Mapped[int] = mapped_column(Integer, default=100) # 100MB
|
||
max_files_per_project: Mapped[int] = mapped_column(Integer, default=1000)
|
||
max_total_size_gb: Mapped[int] = mapped_column(Integer, default=10) # 10GB на проект
|
||
|
||
# Разрешённые типы
|
||
allowed_extensions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # .pdf, .md, .png
|
||
blocked_extensions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # .exe, .bat
|
||
|
||
# Автоматизация
|
||
auto_scan_interval: Mapped[int] = mapped_column(Integer, default=300) # секунды (5 минут)
|
||
enable_thumbnails: Mapped[bool] = mapped_column(Boolean, default=True)
|
||
enable_text_extraction: Mapped[bool] = mapped_column(Boolean, default=True) # для поиска
|
||
|
||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||
```
|
||
|
||
---
|
||
|
||
## 4. REST API Endpoints
|
||
|
||
### 4.1 Project Files API
|
||
|
||
```python
|
||
# GET /api/projects/{project_slug}/files
|
||
async def list_project_files(
|
||
project_slug: str,
|
||
path: Optional[str] = Query(None), # фильтр по пути
|
||
tags: Optional[List[str]] = Query(None), # фильтр по тегам
|
||
mime_type: Optional[str] = Query(None), # фильтр по типу
|
||
search: Optional[str] = Query(None), # поиск в содержимом
|
||
limit: int = Query(50, le=200),
|
||
offset: int = Query(0),
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> List[ProjectFileOut]
|
||
|
||
# POST /api/projects/{project_slug}/files/upload
|
||
async def upload_project_file(
|
||
project_slug: str,
|
||
file: UploadFile,
|
||
path: Optional[str] = Form(None), # подпапка в files/
|
||
description: Optional[str] = Form(None),
|
||
tags: Optional[str] = Form(None), # JSON array
|
||
member_slug: str = Depends(get_current_member),
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> ProjectFileOut
|
||
|
||
# GET /api/projects/{project_slug}/files/{file_id}
|
||
async def get_project_file(
|
||
project_slug: str,
|
||
file_id: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> ProjectFileOut
|
||
|
||
# GET /api/projects/{project_slug}/files/{file_id}/download
|
||
async def download_project_file(
|
||
project_slug: str,
|
||
file_id: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> FileResponse
|
||
|
||
# PUT /api/projects/{project_slug}/files/{file_id}
|
||
async def update_project_file(
|
||
project_slug: str,
|
||
file_id: str,
|
||
req: ProjectFileUpdate,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> ProjectFileOut
|
||
|
||
# DELETE /api/projects/{project_slug}/files/{file_id}
|
||
async def delete_project_file(
|
||
project_slug: str,
|
||
file_id: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> Dict[str, bool]
|
||
|
||
# POST /api/projects/{project_slug}/files/scan
|
||
async def scan_project_files(
|
||
project_slug: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> Dict[str, Any] # статистика сканирования
|
||
```
|
||
|
||
### 4.2 Attachments API (расширение существующего)
|
||
|
||
```python
|
||
# POST /api/messages/{message_id}/attachments
|
||
async def upload_message_attachment(
|
||
message_id: str,
|
||
file: UploadFile,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> AttachmentOut
|
||
|
||
# GET /api/messages/{message_id}/attachments
|
||
async def list_message_attachments(
|
||
message_id: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> List[AttachmentOut]
|
||
|
||
# GET /api/attachments/{attachment_id}/download
|
||
async def download_attachment(
|
||
attachment_id: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> FileResponse
|
||
|
||
# DELETE /api/attachments/{attachment_id}
|
||
async def delete_attachment(
|
||
attachment_id: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> Dict[str, bool]
|
||
|
||
# POST /api/tasks/{task_id}/attachments
|
||
async def upload_task_attachment(
|
||
task_id: str,
|
||
file: UploadFile,
|
||
db: AsyncSession = Depends(get_db)
|
||
) -> AttachmentOut
|
||
```
|
||
|
||
### 4.3 File System Management API
|
||
|
||
```python
|
||
# GET /api/admin/files/config
|
||
async def get_fs_config() -> FileSystemConfigOut
|
||
|
||
# PUT /api/admin/files/config
|
||
async def update_fs_config(req: FileSystemConfigUpdate) -> FileSystemConfigOut
|
||
|
||
# POST /api/admin/files/scan-all
|
||
async def scan_all_projects() -> Dict[str, Any] # глобальное сканирование
|
||
|
||
# GET /api/admin/files/stats
|
||
async def get_storage_stats() -> Dict[str, Any] # использование места, статистика
|
||
|
||
# POST /api/admin/files/cleanup
|
||
async def cleanup_orphaned_files() -> Dict[str, Any] # очистка сирых файлов
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Синхронизация файловой системы и БД
|
||
|
||
### 5.1 Проблема синхронизации
|
||
|
||
**Вызов:** Файлы добавленные вручную в `files/` должны быть видны через API без перезапуска системы.
|
||
|
||
**Решения:**
|
||
1. **inotify** (Linux) — мониторинг файловой системы в реальном времени
|
||
2. **Periodic scan** — сканирование каждые N минут
|
||
3. **On-demand scan** — сканирование при API запросе
|
||
4. **Гибридный подход** (рекомендуется)
|
||
|
||
### 5.2 Гибридная реализация
|
||
|
||
```python
|
||
class FileSystemSyncService:
|
||
def __init__(self, workspace_path: str, db: AsyncSession):
|
||
self.workspace_path = workspace_path
|
||
self.db = db
|
||
self.observer = None # inotify observer
|
||
|
||
async def start_monitoring(self):
|
||
"""Запуск inotify мониторинга для активных проектов."""
|
||
from watchdog.observers import Observer
|
||
from watchdog.events import FileSystemEventHandler
|
||
|
||
class ProjectFileHandler(FileSystemEventHandler):
|
||
def __init__(self, sync_service):
|
||
self.sync_service = sync_service
|
||
|
||
def on_created(self, event):
|
||
if not event.is_directory:
|
||
asyncio.create_task(
|
||
self.sync_service.handle_file_created(event.src_path)
|
||
)
|
||
|
||
def on_deleted(self, event):
|
||
if not event.is_directory:
|
||
asyncio.create_task(
|
||
self.sync_service.handle_file_deleted(event.src_path)
|
||
)
|
||
|
||
def on_modified(self, event):
|
||
if not event.is_directory:
|
||
asyncio.create_task(
|
||
self.sync_service.handle_file_modified(event.src_path)
|
||
)
|
||
|
||
self.observer = Observer()
|
||
handler = ProjectFileHandler(self)
|
||
|
||
# Мониторим папки files/ всех активных проектов
|
||
projects = await self.get_active_projects()
|
||
for project in projects:
|
||
files_path = f"{self.workspace_path}/projects/{project.slug}/files"
|
||
if os.path.exists(files_path):
|
||
self.observer.schedule(handler, files_path, recursive=True)
|
||
|
||
self.observer.start()
|
||
|
||
async def handle_file_created(self, file_path: str):
|
||
"""Обработка создания файла."""
|
||
try:
|
||
project_slug, relative_path = self.extract_project_info(file_path)
|
||
if not project_slug:
|
||
return
|
||
|
||
# Проверяем, не дублируется ли файл
|
||
existing = await self.find_existing_file(project_slug, relative_path)
|
||
if existing:
|
||
return
|
||
|
||
# Создаём запись в БД
|
||
file_info = await self.analyze_file(file_path)
|
||
project_file = ProjectFile(
|
||
project_id=await self.get_project_id(project_slug),
|
||
filename=os.path.basename(file_path),
|
||
relative_path=relative_path,
|
||
mime_type=file_info.mime_type,
|
||
size=file_info.size,
|
||
checksum=file_info.checksum,
|
||
source="manual",
|
||
is_indexed=True,
|
||
indexed_at=datetime.now()
|
||
)
|
||
|
||
self.db.add(project_file)
|
||
await self.db.commit()
|
||
|
||
# Уведомляем через WebSocket о новом файле
|
||
await self.broadcast_file_event("file.created", project_slug, project_file)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling file creation {file_path}: {e}")
|
||
|
||
async def periodic_scan(self, project_slug: str = None):
|
||
"""Периодическое сканирование (fallback для inotify)."""
|
||
if project_slug:
|
||
projects = [await self.get_project(project_slug)]
|
||
else:
|
||
projects = await self.get_active_projects()
|
||
|
||
for project in projects:
|
||
await self.scan_project_files(project)
|
||
|
||
async def scan_project_files(self, project: Project):
|
||
"""Сканирование файлов проекта."""
|
||
files_path = f"{self.workspace_path}/projects/{project.slug}/files"
|
||
if not os.path.exists(files_path):
|
||
return
|
||
|
||
# Сканируем файловую систему
|
||
fs_files = {}
|
||
for root, dirs, files in os.walk(files_path):
|
||
for file in files:
|
||
full_path = os.path.join(root, file)
|
||
relative_path = os.path.relpath(full_path, files_path)
|
||
|
||
stat = os.stat(full_path)
|
||
fs_files[relative_path] = {
|
||
'size': stat.st_size,
|
||
'mtime': stat.st_mtime,
|
||
'full_path': full_path
|
||
}
|
||
|
||
# Получаем файлы из БД
|
||
db_files = await self.get_project_files_dict(project.id)
|
||
|
||
# Находим новые файлы
|
||
for relative_path, file_info in fs_files.items():
|
||
if relative_path not in db_files:
|
||
await self.create_db_file_record(project, relative_path, file_info)
|
||
|
||
# Находим удалённые файлы
|
||
for relative_path in db_files:
|
||
if relative_path not in fs_files:
|
||
await self.mark_file_as_deleted(db_files[relative_path])
|
||
|
||
# Находим изменённые файлы
|
||
for relative_path in set(fs_files.keys()) & set(db_files.keys()):
|
||
db_file = db_files[relative_path]
|
||
fs_file = fs_files[relative_path]
|
||
|
||
if fs_file['size'] != db_file.size:
|
||
await self.update_file_record(db_file, fs_file)
|
||
```
|
||
|
||
### 5.3 Стратегии по типам операций
|
||
|
||
| Операция | Метод | Когда |
|
||
|----------|-------|-------|
|
||
| **Добавление файла** | inotify → немедленная индексация | Real-time |
|
||
| **Удаление файла** | inotify → пометка как deleted | Real-time |
|
||
| **Изменение файла** | inotify → обновление метаданных | Real-time |
|
||
| **Fallback scan** | Periodic (каждые 5 минут) | Если inotify недоступен |
|
||
| **Full rescan** | On-demand (API или cron) | При несоответствиях |
|
||
| **Startup scan** | При запуске сервиса | Синхронизация после рестарта |
|
||
|
||
### 5.4 Индексация и метаданные
|
||
|
||
```python
|
||
class FileIndexer:
|
||
"""Извлечение метаданных и индексация контента."""
|
||
|
||
async def analyze_file(self, file_path: str) -> FileAnalysis:
|
||
"""Полный анализ файла."""
|
||
mime_type = self.detect_mime_type(file_path)
|
||
size = os.path.getsize(file_path)
|
||
checksum = await self.calculate_checksum(file_path)
|
||
|
||
# Извлекаем текст для поиска (если возможно)
|
||
text_content = None
|
||
if self.is_text_extractable(mime_type):
|
||
text_content = await self.extract_text(file_path, mime_type)
|
||
|
||
# Генерируем thumbnail для изображений
|
||
thumbnail_path = None
|
||
if self.is_image(mime_type):
|
||
thumbnail_path = await self.generate_thumbnail(file_path)
|
||
|
||
return FileAnalysis(
|
||
mime_type=mime_type,
|
||
size=size,
|
||
checksum=checksum,
|
||
text_content=text_content,
|
||
thumbnail_path=thumbnail_path
|
||
)
|
||
|
||
async def extract_text(self, file_path: str, mime_type: str) -> str:
|
||
"""Извлечение текста для полнотекстового поиска."""
|
||
if mime_type == 'application/pdf':
|
||
return await self.extract_pdf_text(file_path)
|
||
elif mime_type.startswith('text/'):
|
||
return await self.read_text_file(file_path)
|
||
elif mime_type in ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']:
|
||
return await self.extract_doc_text(file_path)
|
||
# ... другие форматы
|
||
|
||
return None
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Интеграция с агентами (MCP Tools)
|
||
|
||
### 6.1 File Tools для MCP
|
||
|
||
```python
|
||
# В picogent/src/tools/files.ts
|
||
|
||
export const listProjectFilesTool: ToolDefinition = {
|
||
name: 'list_project_files',
|
||
title: 'Список файлов проекта',
|
||
description: 'Получить список всех файлов в проекте с фильтрацией',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
project_slug: { type: 'string', description: 'Slug проекта' },
|
||
path: { type: 'string', description: 'Фильтр по пути (например, docs/)' },
|
||
search: { type: 'string', description: 'Поиск в содержимом файлов' },
|
||
tags: { type: 'array', items: { type: 'string' }, description: 'Фильтр по тегам' },
|
||
mime_type: { type: 'string', description: 'Фильтр по типу файла' }
|
||
},
|
||
required: ['project_slug']
|
||
},
|
||
async handler(params, context) {
|
||
const files = await context.trackerClient.listProjectFiles(params);
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: `Найдено файлов: ${files.length}\n${files.map(f => `- ${f.relative_path} (${f.size} bytes)`).join('\n')}`
|
||
}],
|
||
structuredContent: files
|
||
};
|
||
}
|
||
};
|
||
|
||
export const uploadProjectFileTool: ToolDefinition = {
|
||
name: 'upload_project_file',
|
||
title: 'Загрузить файл в проект',
|
||
description: 'Загрузить файл в папку files/ проекта',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
project_slug: { type: 'string', description: 'Slug проекта' },
|
||
filename: { type: 'string', description: 'Имя файла' },
|
||
content: { type: 'string', description: 'Содержимое файла (base64 для бинарных)' },
|
||
path: { type: 'string', description: 'Подпапка в files/ (например, docs/)' },
|
||
description: { type: 'string', description: 'Описание файла' },
|
||
tags: { type: 'array', items: { type: 'string' }, description: 'Теги для категоризации' }
|
||
},
|
||
required: ['project_slug', 'filename', 'content']
|
||
},
|
||
async handler(params, context) {
|
||
const result = await context.trackerClient.uploadProjectFile(params);
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: `✅ Файл ${result.filename} загружен в ${result.relative_path}`
|
||
}],
|
||
structuredContent: result
|
||
};
|
||
}
|
||
};
|
||
|
||
export const downloadProjectFileTool: ToolDefinition = {
|
||
name: 'download_project_file',
|
||
title: 'Скачать файл из проекта',
|
||
description: 'Скачать содержимое файла проекта',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
project_slug: { type: 'string', description: 'Slug проекта' },
|
||
file_id: { type: 'string', description: 'ID файла' }
|
||
},
|
||
required: ['project_slug', 'file_id']
|
||
},
|
||
async handler(params, context) {
|
||
const fileData = await context.trackerClient.downloadProjectFile(params.project_slug, params.file_id);
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: `📄 Файл ${fileData.filename} (${fileData.size} bytes):\n\n${fileData.content}`
|
||
}],
|
||
structuredContent: fileData
|
||
};
|
||
}
|
||
};
|
||
|
||
export const uploadAttachmentTool: ToolDefinition = {
|
||
name: 'upload_attachment',
|
||
title: 'Прикрепить файл к сообщению',
|
||
description: 'Загрузить файл как attachment к сообщению или задаче',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
message_id: { type: 'string', description: 'ID сообщения (для чата)' },
|
||
task_id: { type: 'string', description: 'ID задачи (для комментария)' },
|
||
filename: { type: 'string', description: 'Имя файла' },
|
||
content: { type: 'string', description: 'Содержимое файла (base64)' }
|
||
},
|
||
required: ['filename', 'content']
|
||
},
|
||
async handler(params, context) {
|
||
// Одно из полей должно быть заполнено
|
||
if (!params.message_id && !params.task_id) {
|
||
throw new Error('Необходимо указать message_id или task_id');
|
||
}
|
||
|
||
const result = await context.trackerClient.uploadAttachment(params);
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: `📎 Файл ${result.filename} прикреплён`
|
||
}],
|
||
structuredContent: result
|
||
};
|
||
}
|
||
};
|
||
|
||
export const scanProjectFilesTool: ToolDefinition = {
|
||
name: 'scan_project_files',
|
||
title: 'Сканировать файлы проекта',
|
||
description: 'Принудительно сканировать директорию проекта и синхронизировать с БД',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
project_slug: { type: 'string', description: 'Slug проекта' }
|
||
},
|
||
required: ['project_slug']
|
||
},
|
||
async handler(params, context) {
|
||
const result = await context.trackerClient.scanProjectFiles(params.project_slug);
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: `🔄 Сканирование завершено:\n- Найдено: ${result.found}\n- Добавлено: ${result.added}\n- Обновлено: ${result.updated}\n- Удалено: ${result.deleted}`
|
||
}],
|
||
structuredContent: result
|
||
};
|
||
}
|
||
};
|
||
```
|
||
|
||
### 6.2 Типичные сценарии использования агентами
|
||
|
||
**1. Изучение документации проекта:**
|
||
```typescript
|
||
// Агент получил задачу и изучает контекст
|
||
const projectFiles = await tools.list_project_files({
|
||
project_slug: "team-board",
|
||
path: "docs/",
|
||
search: "API"
|
||
});
|
||
|
||
for (const file of projectFiles.filter(f => f.filename.endsWith('.md'))) {
|
||
const content = await tools.download_project_file({
|
||
project_slug: "team-board",
|
||
file_id: file.id
|
||
});
|
||
// Анализируем содержимое...
|
||
}
|
||
```
|
||
|
||
**2. Создание технической документации:**
|
||
```typescript
|
||
// Агент создаёт техническую спецификацию по результатам работы
|
||
await tools.upload_project_file({
|
||
project_slug: "team-board",
|
||
filename: "FILES-ARCHITECTURE.md",
|
||
content: btoa(documentContent), // base64 encoding
|
||
path: "docs/",
|
||
description: "Архитектура файловой системы",
|
||
tags: ["documentation", "architecture"]
|
||
});
|
||
```
|
||
|
||
**3. Прикрепление файлов к отчётам:**
|
||
```typescript
|
||
// Агент выполнил задачу и прикрепляет лог или скриншот
|
||
await tools.upload_attachment({
|
||
task_id: "550e8400-e29b-41d4-a716-446655440001",
|
||
filename: "test-results.log",
|
||
content: btoa(testResults)
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Лимиты, квоты и безопасность
|
||
|
||
### 7.1 Ограничения по размеру и типам
|
||
|
||
```python
|
||
class FileValidationService:
|
||
def __init__(self, config: FileSystemConfig):
|
||
self.config = config
|
||
|
||
async def validate_upload(self, file: UploadFile, project: Project, member: Member) -> None:
|
||
"""Валидация загружаемого файла."""
|
||
|
||
# Проверка размера файла
|
||
if file.size > self.config.max_file_size_mb * 1024 * 1024:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"Файл слишком большой. Максимум: {self.config.max_file_size_mb}MB"
|
||
)
|
||
|
||
# Проверка расширения
|
||
extension = os.path.splitext(file.filename)[1].lower()
|
||
if extension in self.config.blocked_extensions:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Тип файла {extension} запрещён"
|
||
)
|
||
|
||
if (self.config.allowed_extensions and
|
||
extension not in self.config.allowed_extensions):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Тип файла {extension} не разрешён"
|
||
)
|
||
|
||
# Проверка квоты проекта
|
||
current_usage = await self.get_project_storage_usage(project.id)
|
||
max_size_bytes = self.config.max_total_size_gb * 1024 * 1024 * 1024
|
||
|
||
if current_usage + file.size > max_size_bytes:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"Превышена квота проекта ({self.config.max_total_size_gb}GB)"
|
||
)
|
||
|
||
# Проверка количества файлов
|
||
file_count = await self.get_project_file_count(project.id)
|
||
if file_count >= self.config.max_files_per_project:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"Превышено максимальное количество файлов ({self.config.max_files_per_project})"
|
||
)
|
||
|
||
async def scan_file_security(self, file_path: str) -> SecurityScanResult:
|
||
"""Базовое сканирование безопасности."""
|
||
result = SecurityScanResult(safe=True, warnings=[])
|
||
|
||
# Проверка на исполняемые файлы
|
||
if self.is_executable(file_path):
|
||
result.warnings.append("Файл может содержать исполняемый код")
|
||
|
||
# Проверка размера (защита от zip-bomb)
|
||
if os.path.getsize(file_path) > 500 * 1024 * 1024: # 500MB
|
||
result.safe = False
|
||
result.warnings.append("Файл слишком большой для обработки")
|
||
|
||
# Проверка на подозрительные расширения в архивах
|
||
if file_path.endswith(('.zip', '.tar', '.gz')):
|
||
suspicious_files = await self.scan_archive_contents(file_path)
|
||
if suspicious_files:
|
||
result.warnings.extend(suspicious_files)
|
||
|
||
return result
|
||
```
|
||
|
||
### 7.2 Дефолтные лимиты
|
||
|
||
| Ограничение | Значение | Настраиваемо |
|
||
|-------------|----------|--------------|
|
||
| **Размер файла** | 100MB | ✅ |
|
||
| **Файлов на проект** | 1000 | ✅ |
|
||
| **Общий размер проекта** | 10GB | ✅ |
|
||
| **Разрешённые типы** | Список расширений | ✅ |
|
||
| **Запрещённые типы** | .exe, .bat, .sh, .scr | ✅ |
|
||
|
||
### 7.3 Безопасность
|
||
|
||
**Защита путей:**
|
||
```python
|
||
def sanitize_file_path(user_path: str) -> str:
|
||
"""Очистка пути от path traversal атак."""
|
||
# Удаляем .. и другие опасные конструкции
|
||
clean_path = os.path.normpath(user_path)
|
||
clean_path = clean_path.replace('..', '').replace('~', '')
|
||
|
||
# Убираем ведущие слеши
|
||
clean_path = clean_path.lstrip('/')
|
||
|
||
return clean_path
|
||
```
|
||
|
||
**Контроль доступа:**
|
||
- Агенты видят только файлы проектов, к которым у них есть доступ
|
||
- Attachments доступны только участникам чата/задачи
|
||
- Администраторы имеют полный доступ
|
||
|
||
---
|
||
|
||
## 8. Превью изображений и обработка
|
||
|
||
### 8.1 Генерация превью
|
||
|
||
```python
|
||
class ThumbnailService:
|
||
def __init__(self, workspace_path: str):
|
||
self.workspace_path = workspace_path
|
||
self.thumbnail_sizes = [(150, 150), (300, 300)] # small, medium
|
||
|
||
async def generate_thumbnails(self, file_path: str, file_id: str) -> List[str]:
|
||
"""Генерация превью для изображений."""
|
||
if not self.is_image_file(file_path):
|
||
return []
|
||
|
||
thumbnails = []
|
||
try:
|
||
from PIL import Image
|
||
|
||
with Image.open(file_path) as image:
|
||
# Конвертируем в RGB если нужно
|
||
if image.mode in ('RGBA', 'LA', 'P'):
|
||
image = image.convert('RGB')
|
||
|
||
for size in self.thumbnail_sizes:
|
||
thumbnail = image.copy()
|
||
thumbnail.thumbnail(size, Image.Resampling.LANCZOS)
|
||
|
||
# Путь для превью
|
||
thumb_filename = f"{file_id}_{size[0]}x{size[1]}.jpg"
|
||
thumb_path = os.path.join(
|
||
self.workspace_path,
|
||
"shared/thumbnails",
|
||
thumb_filename
|
||
)
|
||
|
||
# Создаём директорию если не существует
|
||
os.makedirs(os.path.dirname(thumb_path), exist_ok=True)
|
||
|
||
# Сохраняем превью
|
||
thumbnail.save(thumb_path, 'JPEG', quality=85)
|
||
thumbnails.append(f"shared/thumbnails/{thumb_filename}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error generating thumbnails for {file_path}: {e}")
|
||
|
||
return thumbnails
|
||
|
||
def is_image_file(self, file_path: str) -> bool:
|
||
"""Проверка, является ли файл изображением."""
|
||
mime_type = mimetypes.guess_type(file_path)[0]
|
||
return mime_type and mime_type.startswith('image/')
|
||
```
|
||
|
||
### 8.2 Обновление модели для превью
|
||
|
||
```python
|
||
class ProjectFile(Base):
|
||
# ... существующие поля ...
|
||
|
||
# Превью
|
||
thumbnail_paths: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # пути к превью
|
||
has_thumbnails: Mapped[bool] = mapped_column(Boolean, default=False)
|
||
|
||
# Извлечённый текст для поиска
|
||
extracted_text: Mapped[str | None] = mapped_column(Text)
|
||
is_text_extracted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Ответы на исследовательские вопросы
|
||
|
||
### 1. Как организовать структуру директорий?
|
||
|
||
**Решение:** Двухуровневая структура:
|
||
- `workspace/projects/<project-slug>/files/` — проектные файлы (ручное добавление)
|
||
- `workspace/projects/<project-slug>/attachments/` — файлы из сообщений/задач
|
||
- Подпапки по типу контента: docs/, assets/, configs/
|
||
- Метаданные в `.meta/` для каждого проекта
|
||
|
||
### 2. Как синхронизировать файлы положенные руками с БД?
|
||
|
||
**Решение:** Гибридный подход:
|
||
- **inotify** для мониторинга файловой системы в реальном времени
|
||
- **Periodic scan** (каждые 5 минут) как fallback
|
||
- **On-demand scan** через API endpoint
|
||
- **Startup scan** при запуске сервиса
|
||
|
||
### 3. Как привязывать attachments к сообщениям/задачам?
|
||
|
||
**Решение:** Используем существующую модель `Attachment`:
|
||
- Поле `message_id` для привязки к сообщению
|
||
- Сообщения могут быть привязаны к чату (`chat_id`) или задаче (`task_id`)
|
||
- Файлы хранятся в `attachments/messages/<message-id>/` или `attachments/tasks/<task-id>/`
|
||
|
||
### 4. Нужна ли БД таблица для файлов?
|
||
|
||
**Решение:** Да, нужно две таблицы:
|
||
- `project_files` — для файлов в папке `files/` (новая)
|
||
- `attachments` — для attachments в сообщениях (существующая)
|
||
- Обе содержат метаданные, пути, размеры, checksums
|
||
- БД обеспечивает быстрый поиск, фильтрацию, авторизацию
|
||
|
||
### 5. Как агенты будут скачивать/загружать файлы через MCP tools?
|
||
|
||
**Решение:** 6 MCP tools для файлов:
|
||
- `list_project_files` — список с фильтрацией
|
||
- `upload_project_file` — загрузка в files/
|
||
- `download_project_file` — скачивание с декодированием
|
||
- `upload_attachment` — прикрепление к сообщению/задаче
|
||
- `scan_project_files` — принудительная синхронизация
|
||
- Base64 encoding для передачи бинарных данных
|
||
|
||
### 6. Лимиты: размер файла, типы, квоты?
|
||
|
||
**Решение:** Настраиваемые лимиты через `FileSystemConfig`:
|
||
- Размер файла: 100MB (по умолчанию)
|
||
- Файлов на проект: 1000
|
||
- Общий размер проекта: 10GB
|
||
- Разрешённые/запрещённые расширения
|
||
- Валидация и security scanning при загрузке
|
||
|
||
### 7. Превью для изображений?
|
||
|
||
**Решение:** Автоматическая генерация превью:
|
||
- При загрузке изображений создаются thumbnails (150x150, 300x300)
|
||
- Хранятся в `shared/thumbnails/`
|
||
- Пути сохраняются в `thumbnail_paths` поле
|
||
- Доступны через отдельный API endpoint
|
||
|
||
---
|
||
|
||
## 10. Примеры использования
|
||
|
||
### 10.1 Пользователь добавляет файл вручную
|
||
|
||
```bash
|
||
# Пользователь кладёт файл
|
||
cp ~/documents/api-spec.md /data/team-board-workspace/projects/team-board/files/docs/
|
||
|
||
# Система автоматически (через inotify):
|
||
# 1. Обнаруживает новый файл
|
||
# 2. Анализирует (MIME, размер, checksum)
|
||
# 3. Создаёт запись в project_files
|
||
# 4. Извлекает текст для поиска
|
||
# 5. Уведомляет через WebSocket
|
||
|
||
# Агент получает уведомление и может сразу прочитать файл
|
||
await tools.list_project_files({
|
||
project_slug: "team-board",
|
||
search: "API"
|
||
});
|
||
```
|
||
|
||
### 10.2 Агент загружает результат работы
|
||
|
||
```typescript
|
||
// Агент создал диаграмму архитектуры
|
||
const diagram = await generateArchitectureDiagram();
|
||
|
||
await tools.upload_project_file({
|
||
project_slug: "team-board",
|
||
filename: "architecture-diagram.png",
|
||
content: btoa(diagram), // base64
|
||
path: "docs/diagrams/",
|
||
description: "Диаграмма архитектуры файловой системы",
|
||
tags: ["diagram", "architecture"]
|
||
});
|
||
|
||
// Прикрепляем к задаче как attachment
|
||
await tools.upload_attachment({
|
||
task_id: taskId,
|
||
filename: "architecture-diagram.png",
|
||
content: btoa(diagram)
|
||
});
|
||
```
|
||
|
||
### 10.3 Поиск и анализ файлов проекта
|
||
|
||
```typescript
|
||
// Агент ищет все документы по API
|
||
const apiDocs = await tools.list_project_files({
|
||
project_slug: "team-board",
|
||
search: "API endpoint",
|
||
path: "docs/"
|
||
});
|
||
|
||
// Читает содержимое для анализа
|
||
for (const doc of apiDocs) {
|
||
const content = await tools.download_project_file({
|
||
project_slug: "team-board",
|
||
file_id: doc.id
|
||
});
|
||
|
||
// Анализирует API endpoints
|
||
const endpoints = parseApiDocumentation(content.content);
|
||
// ...
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Заключение
|
||
|
||
Предложенная архитектура файловой системы для Team Board обеспечивает:
|
||
|
||
✅ **Простоту использования** — файлы можно добавлять вручную и через API
|
||
✅ **Автоматическую синхронизацию** — inotify + periodic scan
|
||
✅ **Богатый API** — REST endpoints + MCP tools для агентов
|
||
✅ **Безопасность** — валидация, квоты, контроль доступа
|
||
✅ **Масштабируемость** — индексация, поиск, превью
|
||
✅ **Интеграцию** — единая модель для attachments и project files
|
||
|
||
**Ключевые преимущества:**
|
||
- Агенты получают полный доступ к файлам проекта через 6 MCP tools
|
||
- Пользователи могут добавлять файлы вручную без перезапуска
|
||
- Автоматическая индексация и поиск по содержимому
|
||
- Превью изображений и метаданные
|
||
- Настраиваемые лимиты и безопасность
|
||
|
||
Архитектура готова к реализации и может быть развёрнута поэтапно, начиная с базового функционала и постепенно добавляя продвинутые возможности. |