feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user

This commit is contained in:
2026-05-19 16:26:26 +03:00
parent 688d043dad
commit 3529c39b22
304 changed files with 18826 additions and 1176 deletions
+15
View File
@@ -0,0 +1,15 @@
flutter/
venv/
webui/node_modules/
.git/
__pycache__/
*.pyc
.env
.env.*
logs/
*.md
CHANGELOG/
docs/
tests/
tools/
template/
+82
View File
@@ -0,0 +1,82 @@
# VoIdeaAI — Production Environment
# Copy to .env on server and fill secrets
PROJECT_NAME=VoIdeaAI
PROJECT_VERSION=1.0.0
PROJECT_ENV=production
SERVER_HOST=0.0.0.0
SERVER_PORT=8020
SERVER_EXTERNAL_URL=https://voideaai.ru
# Database (PostgreSQL on port 5444 — separate from aegisone)
DATABASE_URL=postgresql+asyncpg://voidea:DB_PASS@localhost:5444/voidea
# Redis (port 6380 — separate from aegisone)
REDIS_HOST=localhost
REDIS_PORT=6380
REDIS_URL=redis://localhost:6380/0
# JWT — generate with: openssl rand -hex 64
JWT_SECRET_KEY=
JWT_RESET_SECRET_KEY=
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
# AI
OPENAI_API_KEY=
AI_YANDEX_KEY=
AI_YANDEX_FOLDER_ID=
AI_GIGACHAT_CLIENT_ID=
AI_GIGACHAT_SECRET=
AI_FALLBACK_MODEL=yandex_gpt
# OAuth
OAUTH_YANDEX_ID=
OAUTH_YANDEX_SECRET=
OAUTH_GOOGLE_ID=
OAUTH_GOOGLE_SECRET=
OAUTH_GOOGLE_REDIRECT_URI=https://voideaai.ru/auth/google/callback
OAUTH_APPLE_ID=
OAUTH_APPLE_SECRET=
OAUTH_APPLE_REDIRECT_URI=https://voideaai.ru/auth/apple/callback
# Telegram Bot
TELEGRAM_BOT_TOKEN=
# Email
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# Rate Limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_DEFAULT=60/minute
RATE_LIMIT_AUTH=10/minute
# Security — generate with: openssl rand -hex 32
ENCRYPTION_KEY=
# System Owner
SYSTEM_OWNER_EMAIL=
# Social Networks
SOCIAL_TELEGRAM=voideaai
SOCIAL_VK=voideaai
SOCIAL_YOUTUBE=voideaai
SOCIAL_TIKTOK=voideaai
PROJECT_SLOGAN=VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!
# Tariffs
TARIFFS_ENABLED=false
TARIFFS_FREE_CODE=free
# Push
FCM_SERVER_KEY=
APNS_KEY_ID=
# Logging
LOG_LEVEL=INFO
DEBUG_MODE_RETENTION_DAYS=14
DEBUG_MODE_MAX_SIZE_MB=500
+111
View File
@@ -0,0 +1,111 @@
# Внимание: на сервере работает второй проект — VoIdeaAI
## Дата развёртывания
2026-05-19
## Сервер
81.177.141.34 — единый VPS для двух независимых проектов.
---
## 1. Проекты на сервере
### Проект A: aegisone
| Параметр | Значение |
|----------|----------|
| Порты | 8000 (FastAPI), 8080/9000 (PHP), 5432 (PostgreSQL) |
| Директория | `/opt/projects/aegisone-php/`, `/opt/projects/aegisone-py/` |
| Домен | aegisone.ru, service.aegisone.ru |
| Тип | PHP + Python FastAPI |
### Проект B: VoIdeaAI
| Параметр | Значение |
|----------|----------|
| Порты | **8020** (FastAPI), **5444** (PostgreSQL), **6380** (Redis) |
| Директория | `/opt/projects/voidea/` |
| Домен | voideaai.ru, git.voideaai.ru (через nginx-proxy) |
| Gitea | порт 3000, PostgreSQL 5433, git.voideaai.ru |
| Тип | Python FastAPI + React Frontend |
---
## 2. Критические правила
### НЕ занимать порты VoIdeaAI
```
8020 — FastAPI app
5444 — PostgreSQL
6380 — Redis
5433 — Gitea PostgreSQL
3000 — Gitea web
```
### nginx-proxy общий
- Конфиги: `/opt/projects/nginx-proxy/conf.d/`
- Файл VoIdea: `voideaai.ru.conf`
- Файл Gitea: `git.voideaai.ru.conf`
**Перед рестартом nginx — всегда проверять:**
```bash
docker compose -p nginx-proxy exec nginx nginx -t
```
### Docker
- **Все контейнеры используют `network_mode: host`** (bridge не работает на этом VPS)
- **Docker build** требует `network: host` в конфиге сборки
- `docker system prune -af` удалит build cache ВСЕХ проектов — не запускать во время сборки
- `docker compose -p voidea` — префикс для команд VoIdea
- `docker compose -p gitea` — префикс для команд Gitea
- `docker compose -p nginx-proxy` — префикс для nginx
### Диск
- 20GB всего, ~13GB свободно (на момент деплоя)
- Перед крупными операциями: `df -h /`
- При нехватке места: `docker builder prune -af`
---
## 3. Команды для проверки состояния VoIdeaAI
```bash
# Статус
docker compose -p voidea ps
# Логи
docker compose -p voidea logs --tail=20 app
# Проверка здоровья
curl -s http://localhost:8020/health
curl -s http://localhost:8020/api/v1/health
# Состояние БД
docker exec voidea-db-1 psql -U voidea -h localhost -p 5444 -d voidea -c "\dt"
```
---
## 4. Что делать при проблемах с VoIdeaAI
1. **Не работает voideaai.ru** — проверить nginx: `nginx -t`, проверить конфиг
2. **VoIdea API не отвечает**`docker compose -p voidea logs app`
3. **VoIdea не может подключиться к БД** — проверить `localhost:5444`, логи `docker compose -p voidea logs db`
4. **Gitea не открывается** — проверить порт 3000: `curl -s http://localhost:3000`
Если проблема серьёзная — остановить VoIdea без ущерба для aegisone:
```bash
docker compose -p voidea down
# aegisone продолжит работу независимо
```
---
## 5. Контакты
- По вопросам VoIdeaAI — обратиться к разработчику, ведущему проект VoIdea
- Проекты независимы, команды разные
- Изменения в nginx-proxy влияют на оба проекта — согласовывать
---
*Создан: 2026-05-19*
*Не редактировать вручную без необходимости*
+9
View File
@@ -7,10 +7,19 @@ RUN npm run build
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY --from=frontend-builder /build/dist /app/webui/dist
RUN mkdir -p /app/logs
EXPOSE 8020
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8020"]
+289
View File
@@ -0,0 +1,289 @@
# VPS Infrastructure Guide for AI Agents
## Overview
This document describes the VPS infrastructure at **81.177.141.34** to help AI agents migrate or deploy new projects (e.g., `voideaai.ru`).
---
## 1. VPS Specifications
| Property | Value |
|---|---|
| IP | 81.177.141.34 |
| User | `angel` |
| Password | `.-SHGWa_` |
| Sudo | passwordless via `/etc/sudoers.d/angel` |
| SSH key | added to `~/.ssh/authorized_keys` |
| OS | Ubuntu 24.04 (Noble) |
| Kernel | OpenVZ/LXC container — limited kernel modules |
| Network interface | `venet0` (no bridge NAT support) |
| Disk | 20GB total, ~13GB available |
| Docker | 29.5.0, iptables in nft mode (`iptables-nft`) |
---
## 2. Docker Constraints
### Critical: `network_mode: host` only
Bridge networking (`network_mode: bridge`) **does not work** on this VPS. The OpenVZ/LXC kernel does not support masquerade for bridge interfaces through `venet0`. All containers **must** use:
```yaml
network_mode: host
```
### Docker daemon config (`/etc/docker/daemon.json`)
```json
{"dns": ["8.8.8.8", "8.8.4.4"]}
```
### Build constraint
All Docker builds require:
```yaml
build:
network: host
```
Without this, `apt-get` and `pip` cannot download packages (no bridge NAT).
### Docker Hub connectivity issues
Pulling large images (>100MB) from Docker Hub may time out (`failed to copy: read tcp... connection timed out`). Smaller images (nginx:alpine, postgres:16-alpine) work. Gitea and Portainer images **cannot be pulled** reliably.
**Solutions attempted:**
- Retry with `--platform linux/amd64`
- Increase `max-download-attempts` (not supported in Docker 29.5)
- Clean build cache with `docker builder prune -af`
- All fail on large image layers
**Workaround:** Pull images locally and SCP (`docker save → scp → docker load`), or use a registry mirror.
---
## 3. Port Allocation (all on host)
| Port | Service | Container |
|---|---|---|
| 80 | nginx-proxy HTTP | `nginx-proxy` |
| 443 | nginx-proxy HTTPS | `nginx-proxy` |
| 8080 | PHP nginx (aegisone.ru) | `aegisone-php-nginx` |
| 9000 | PHP-FPM | `aegisone-php-fpm` |
| 8000 | FastAPI (service.aegisone.ru) | `aegisone-app` |
| 5432 | PostgreSQL (shared, aegisone) | `aegisone-postgres` |
| 5433 | PostgreSQL (Gitea) | `gitea-db` (planned) |
| 3000 | Gitea | `gitea` (planned) |
| 9001 | Portainer HTTP | `portainer` (planned) |
| 9443 | Portainer HTTPS | `portainer` (planned) |
### Address resolution between containers
Since all containers use `network_mode: host`, they communicate via `localhost:PORT`. **Docker DNS names do not resolve.** In `nginx.conf` and app configs, use:
```nginx
proxy_pass http://localhost:8080; # NOT container_name:port
```
---
## 4. Project Structure
```
/opt/projects/
nginx-proxy/ # Reverse proxy (nginx + certbot)
docker-compose.yml
nginx.conf
conf.d/ # Per-domain configs
certs/ # SSL certificates
html/ # Webroot for certbot
aegisone-php/ # PHP frontend (aegisone.ru)
docker-compose.yml
Dockerfile
nginx.conf # PHP nginx config (listen 8080)
src/ # PHP source files
aegisone-py/ # FastAPI backend (service.aegisone.ru)
docker-compose.yml
Dockerfile
app/ # Python source
sql/ # DB schema
gitea/ # Git hosting (planned)
docker-compose.yml
portainer/ # Docker UI (planned)
docker-compose.yml
```
---
## 5. nginx-proxy Configuration Pattern
### Global config (`nginx.conf`)
- `resolver 127.0.0.11` — not used in host mode, kept for compatibility
- SSL termination handled by nginx-proxy
- Upstream protocols are HTTP (nginx-proxy terminates SSL and proxies via HTTP)
### Conf.d file structure
Each domain has one `.conf` file with two server blocks:
```nginx
# HTTP (no redirect — temporary, until LE certs)
server {
listen 80;
server_name example.ru;
location / {
proxy_pass http://localhost:PORT;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTPS (self-signed, for future LE)
server {
listen 443 ssl;
http2 on;
server_name example.ru;
ssl_certificate /etc/nginx/certs/live/aegisone.ru/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/aegisone.ru/privkey.pem;
# ... same location block
}
```
Note: HTTP→HTTPS redirects (`return 301 https://...`) caused **redirect loops** externally due to provider/proxy intercepting port 443. If you add them, test thoroughly.
### SSL certificates
Currently using self-signed certs generated via:
```bash
sudo openssl req -x509 -nodes -days 30 -newkey rsa:2048 \
-keyout /opt/projects/nginx-proxy/certs/live/aegisone.ru/privkey.pem \
-out /opt/projects/nginx-proxy/certs/live/aegisone.ru/fullchain.pem \
-subj '/CN=aegisone.ru'
```
For Let's Encrypt:
```bash
cd /opt/projects/nginx-proxy
docker compose run --rm certbot certonly --webroot \
--webroot-path=/var/www/certbot \
-d example.ru \
--email admin@example.ru \
--agree-tos --non-interactive
```
---
## 6. Deployment Workflow
### Initial setup
1. Prepare files locally (this repo)
2. Pack into tarball: `tar czf project.tar.gz .`
3. SCP to VPS: `scp project.tar.gz angel@81.177.141.34:/home/angel/`
4. SSH and extract: `ssh angel@81.177.141.34 "tar xzf project.tar.gz -C /opt/projects/project-name/"`
5. Build and start: `ssh angel@81.177.141.34 "cd /opt/projects/project-name && docker compose up -d --build"`
### Updating a running project
1. Edit files locally
2. SCP changed files to `/home/angel/` on VPS
3. SSH and copy to correct location: `sudo cp /home/angel/file /opt/projects/project-name/`
4. Restart container: `docker compose -p project-name restart service-name`
### Copying files into a running container (alternative to rebuild)
```bash
docker cp /home/angel/file.py container-name:/app/path/file.py
docker compose -p project-name restart app
```
---
## 7. Database
### PostgreSQL (aegisone shared DB)
- Host: `localhost:5432`
- User: `aegisone`
- Password: `aegisone_pass`
- Database: `aegisone`
- Container: `aegisone-postgres` (postgres:16-alpine)
### Schema initialization
SQL files in `/opt/projects/aegisone-py/sql/`:
- `schema_postgresql.sql` — full schema
- `seed_data.sql` — seed data
Init scripts run only on first volume creation. To add missing tables later:
```bash
docker exec -i aegisone-postgres psql -U aegisone -d aegisone < /opt/projects/aegisone-py/sql/schema_postgresql.sql
```
### Key schema pattern
- All tables use `BOOLEAN` for `is_active` (not integer)
- Timestamps use `TIMESTAMP NOT NULL DEFAULT NOW()`
- `updated_at` triggers via `update_updated_at_column()` function
---
## 8. Useful Commands
```bash
# Check running containers
docker ps -a
# Check compose projects
docker compose ls
# View logs
docker logs container-name --tail 50
# Restart service
docker compose -p project-name restart service-name
# Rebuild and restart
docker compose -p project-name up -d --build
# Clean build cache
docker builder prune -af
# Test nginx config
docker compose -p nginx-proxy exec nginx nginx -t
# Reload nginx
docker compose -p nginx-proxy exec nginx nginx -s reload
# Check listening ports
ss -tlnp | grep -E ':(80|443|8080|8000|9000|5432)'
# Check disk
df -h /
docker system df
```
---
## 9. Adding a New Project (e.g., voideaai.ru)
### Steps:
1. Create directory: `mkdir -p /opt/projects/voideaai`
2. Create `docker-compose.yml` with `network_mode: host` and `build.network: host`
3. Create `Dockerfile` (use `--only-binary :all:` for pip if avoiding apt)
4. Create nginx-proxy config: `nginx/conf.d/voideaai.conf` (listen 80, proxy_pass localhost:PORT)
5. Generate cert if needed
6. Restart nginx-proxy: `docker compose -p nginx-proxy restart nginx`
7. Start project: `cd /opt/projects/voideaai && docker compose up -d --build`
### Port allocation for new project
Check existing ports first (`ss -tlnp`). Choose an unused port for the app and add it to the nginx-proxy config.
---
## 10. Known Issues
1. **Docker Hub timeouts on large images** — consider pulling on local machine and transferring with `docker save`/`docker load`
2. **HTTPS redirect loops externally** — port 443 may be intercepted by provider/proxy; test HTTP first
3. **Bridge networking doesn't work** — all containers must use `network_mode: host`
4. **Docker build needs `network: host`** — without it, apt-get/pip fail
5. **Config files with `$` in content** — when editing via SSH from PowerShell, `$variables` get expanded; use SCP (write locally, then copy)
+47
View File
@@ -0,0 +1,47 @@
"""Add workspace and workspace_memberships tables
Revision ID: 006
Revises: 005
Create Date: 2026-05-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "006"
down_revision: Union[str, None] = "005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"workspaces",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("type", sa.String(20), nullable=False, server_default=sa.text("'personal'"), index=True),
sa.Column("owner_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"workspace_memberships",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("workspace_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("role", sa.String(20), nullable=False, server_default=sa.text("'member'")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("workspace_memberships")
op.drop_table("workspaces")
+79
View File
@@ -0,0 +1,79 @@
"""Add collaboration tables: votes, comments, activities, notifications
Revision ID: 007
Revises: 006
Create Date: 2026-05-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "007"
down_revision: Union[str, None] = "006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"idea_votes",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("vote", sa.String(4), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("idea_id", "user_id", name="uq_idea_user_vote"),
)
op.create_table(
"idea_comments",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("parent_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("idea_comments.id", ondelete="CASCADE"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"idea_activities",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True),
sa.Column("action", sa.String(50), nullable=False),
sa.Column("details", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"notifications",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("type", sa.String(50), nullable=False, index=True),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("link", sa.String(512), nullable=True),
sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.text("false"), index=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("notifications")
op.drop_table("idea_activities")
op.drop_table("idea_comments")
op.drop_table("idea_votes")
+26
View File
@@ -0,0 +1,26 @@
"""Add funnel_status column to ideas table
Revision ID: 008
Revises: 007
Create Date: 2026-05-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "008"
down_revision: Union[str, None] = "007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ideas",
sa.Column("funnel_status", sa.String(20), nullable=True, index=True),
)
def downgrade() -> None:
op.drop_column("ideas", "funnel_status")
@@ -0,0 +1,31 @@
"""Add calendar fields to users table
Revision ID: 009
Revises: 008
Create Date: 2026-05-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("calendar_provider", sa.String(20), nullable=True),
)
op.add_column(
"users",
sa.Column("calendar_credentials", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("users", "calendar_credentials")
op.drop_column("users", "calendar_provider")
+33
View File
@@ -0,0 +1,33 @@
"""Add disk_providers JSONB field to users.
Revision ID: 010_add_disk_fields
Revises: 009_add_calendar_fields
Create Date: 2026-05-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "010_add_disk_fields"
down_revision: Union[str, None] = "009_add_calendar_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"disk_providers",
postgresql.JSONB,
nullable=True,
default=None,
),
)
def downgrade() -> None:
op.drop_column("users", "disk_providers")
+176
View File
@@ -0,0 +1,176 @@
"""MetaAgent — Orchestrator agent for VoIdea.
Configurable from admin panel:
- cron_schedule: str (default "0 9 * * 1,4")
- weights: dict[agent_name, float]
- sources_count: int (default 10)
- auto_create: bool (default True)
Runs a subset of agents based on weights and collects reports.
"""
import json
from datetime import datetime, timezone
from typing import Any
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
from app.agents.registry import registry
DEFAULT_CONFIG: dict[str, Any] = {
"cron_schedule": "0 9 * * 1,4",
"weights": {},
"sources_count": 10,
"auto_create": True,
}
class MetaAgent(BaseAgent):
"""Orchestrator agent that coordinates other agents."""
name = "meta_agent"
version = "1.0.0"
description = "Orchestrates other agents based on configurable schedule and weights"
triggers = [
AgentTrigger.MANUAL,
AgentTrigger.CRON,
AgentTrigger.API,
]
def __init__(self, config: dict[str, Any] | None = None):
super().__init__()
self._config = {**DEFAULT_CONFIG, **(config or {})}
@property
def config(self) -> dict[str, Any]:
return self._config
@config.setter
def config(self, value: dict[str, Any]) -> None:
self._config = {**DEFAULT_CONFIG, **value}
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
"""Execute meta-agent orchestration.
Context can contain:
- action: str (orchestrate | report | status) — default "orchestrate"
- target_agents: list[str] — subset of agents to run (default: all)
- sources_count: int — override config
"""
await self.set_running("orchestration")
try:
action = (context or {}).get("action", "orchestrate")
if action == "status":
return await self._get_status()
elif action == "report":
return await self._generate_report()
else:
return await self._orchestrate(context or {})
except Exception as e:
await self.set_error(str(e))
return AgentResult(
success=False,
message=f"MetaAgent failed: {str(e)}",
errors=[str(e)],
)
async def health_check(self) -> bool:
return True
async def _orchestrate(self, context: dict[str, Any]) -> AgentResult:
"""Run target agents and collect results."""
target_agents: list[str] | None = context.get("target_agents")
sources_count = context.get("sources_count", self._config["sources_count"])
weights = self._config.get("weights", {})
all_agents = registry.list_agents()
if not all_agents:
await self.set_idle()
return AgentResult(
success=True,
message="No agents registered",
data={"orchestrated": 0},
)
candidates = []
for a in all_agents:
name = a["name"]
if name == self.name:
continue
weight = weights.get(name, 1.0)
if weight <= 0:
continue
candidates.append((name, weight))
if not candidates:
candidates = [(a["name"], 1.0) for a in all_agents if a["name"] != self.name]
if target_agents:
candidates = [c for c in candidates if c[0] in target_agents]
max_sources = min(sources_count, len(candidates))
selected = candidates[:max_sources]
results: dict[str, Any] = {}
for name, _weight in selected:
try:
result = await registry.run_agent(name, {"trigger": "meta_agent"})
results[name] = {
"success": result.success,
"message": result.message,
"duration_ms": result.duration_ms,
}
except Exception as e:
results[name] = {
"success": False,
"message": str(e),
}
success_count = sum(1 for r in results.values() if r["success"])
total_duration = sum(r.get("duration_ms", 0) for r in results.values())
await self.set_idle()
return AgentResult(
success=True,
message=f"Orchestrated {len(results)} agents ({success_count} ok)",
data={
"orchestrated": len(results),
"success_count": success_count,
"agents": results,
"total_duration_ms": total_duration,
"config": {
"cron_schedule": self._config["cron_schedule"],
"sources_count": sources_count,
"auto_create": self._config["auto_create"],
},
},
)
async def _generate_report(self) -> AgentResult:
"""Generate summary report of all agent statuses."""
all_agents = registry.list_agents()
metrics = registry.get_metrics_all()
return AgentResult(
success=True,
message=f"Report: {len(all_agents)} agents",
data={
"agents": all_agents,
"metrics": metrics,
"generated_at": datetime.now(timezone.utc).isoformat(),
},
)
async def _get_status(self) -> AgentResult:
return AgentResult(
success=True,
message="Meta-agent operational",
data={
"status": self.status.value,
"config": self._config,
"last_run": self._last_run.isoformat() if self._last_run else None,
},
)
+2
View File
@@ -15,6 +15,7 @@ from app.agents.ui_test_agent import UITestAgent
from app.agents.rollout_agent import RolloutAgent
from app.agents.conductor_agent import ConductorAgent
from app.agents.supervisor_agent import SupervisorAgent
from app.agents.meta_agent import MetaAgent
class AgentRegistry:
@@ -36,6 +37,7 @@ class AgentRegistry:
self.register(RolloutAgent())
self.register(ConductorAgent())
self.register(SupervisorAgent())
self.register(MetaAgent())
def register(self, agent: BaseAgent) -> None:
if not agent.name:
+6
View File
@@ -12,6 +12,9 @@ from app.api.v1.voice import router as voice_router
from app.api.v1.config import router as config_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.tariffs import router as tariffs_router
from app.api.v1.workspaces import router as workspaces_router
from app.api.v1.collaboration import router as collaboration_router
from app.api.v1.calendar import router as calendar_router
api_v1_router = APIRouter(prefix="/api/v1")
@@ -25,5 +28,8 @@ api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
api_v1_router.include_router(workspaces_router, prefix="/workspaces", tags=["workspaces"])
api_v1_router.include_router(collaboration_router, prefix="", tags=["collaboration"])
api_v1_router.include_router(calendar_router, prefix="", tags=["calendar"])
__all__ = ["api_v1_router"]
+29
View File
@@ -39,6 +39,8 @@ from app.schemas.admin import (
SystemInfoResponse,
UserAdminUpdate,
)
from app.agents.base import AgentResult
from app.schemas.agent import AgentRunRequest
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
@@ -101,6 +103,7 @@ def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
description=getattr(a, "description", ""),
is_enabled=a.is_enabled,
version=a.version,
config=a.config,
last_run_at=a.last_run_at,
)
@@ -191,12 +194,38 @@ async def update_agent(
agent.description = body.description
if body.is_enabled is not None:
agent.is_enabled = body.is_enabled
if body.config is not None:
agent.config = body.config
await db.commit()
await db.refresh(agent)
return _agent_to_response(agent)
@router.post("/agents/{agent_name}/run", response_model=AgentResult)
async def run_agent(
agent_name: str,
user: Annotated[User, Depends(get_current_user)],
body: AgentRunRequest | None = None,
):
from app.agents.registry import registry
context = body.model_dump() if body else {}
result = await registry.run_agent(agent_name, context)
return result
@router.post("/agents/run-all", response_model=dict[str, AgentResult])
async def run_all_agents(
user: Annotated[User, Depends(get_current_user)],
body: AgentRunRequest | None = None,
):
from app.agents.registry import registry
context = body.model_dump() if body else {}
return await registry.run_all(context)
# ── Logs ──
@router.get("/logs", response_model=list[LogEntryResponse])
+63
View File
@@ -0,0 +1,63 @@
"""Calendar API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.services.calendar_service import CALENDAR_PROVIDERS, CalendarService
from app.services.idea_service import IdeaService
router = APIRouter()
@router.post("/ideas/{idea_id}/calendar")
async def add_idea_to_calendar(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""Create a calendar event from an idea."""
idea_svc = IdeaService(db)
idea = await idea_svc.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
cal_svc = CalendarService(db)
success = await cal_svc.create_idea_event(user, idea.title, idea.content)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Calendar not configured. Connect a calendar in Settings > Integrations.",
)
return {"success": True, "message": "Event created in calendar"}
@router.get("/calendar/providers")
async def list_calendar_providers():
return {"providers": list(CALENDAR_PROVIDERS)}
@router.get("/calendar/status")
async def calendar_status(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
cal_svc = CalendarService(db)
creds = await cal_svc.get_calendar_credentials(user)
return {
"connected": creds is not None,
"provider": user.calendar_provider if hasattr(user, "calendar_provider") else None,
}
@router.post("/calendar/disconnect")
async def disconnect_calendar(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
cal_svc = CalendarService(db)
await cal_svc.disconnect_calendar(user)
return {"success": True}
+204
View File
@@ -0,0 +1,204 @@
"""Collaboration API routes for VoIdea.
Voting, comments, activity log, and notifications.
"""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.dependencies import get_current_user, get_db
from app.models.collaboration import Notification
from app.models.user import User
from app.schemas.collaboration import (
ActivityResponse,
CommentCreate,
CommentResponse,
NotificationResponse,
NotificationUnreadCount,
VoteCount,
VoteResponse,
VoteToggleRequest,
)
from app.services.collaboration_service import CollaborationService
from app.services.idea_service import IdeaService
router = APIRouter()
@router.post("/ideas/{idea_id}/vote", response_model=VoteCount)
async def toggle_vote(
idea_id: str,
body: VoteToggleRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
idea_svc = IdeaService(db)
idea = await idea_svc.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
await svc.toggle_vote(UUID(idea_id), user.id, body.vote)
counts = await svc.get_vote_counts(UUID(idea_id))
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
return VoteCount(**counts, user_vote=user_vote)
@router.get("/ideas/{idea_id}/votes", response_model=VoteCount)
async def get_votes(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
idea_svc = IdeaService(db)
idea = await idea_svc.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
counts = await svc.get_vote_counts(UUID(idea_id))
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
return VoteCount(**counts, user_vote=user_vote)
# ── Comments ──
@router.get("/ideas/{idea_id}/comments", response_model=list[CommentResponse])
async def list_comments(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
return await svc.list_comments(UUID(idea_id))
@router.post("/ideas/{idea_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
async def create_comment(
idea_id: str,
body: CommentCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
parent_id = UUID(body.parent_id) if body.parent_id else None
c = await svc.create_comment(UUID(idea_id), user.id, body.content, parent_id)
await svc.log_activity(
UUID(idea_id), user.id, "comment",
{"comment_id": str(c.id), "preview": body.content[:100]},
)
return CommentResponse(
id=str(c.id),
idea_id=str(c.idea_id),
user_id=str(c.user_id),
content=c.content,
parent_id=str(c.parent_id) if c.parent_id else None,
display_name=user.display_name,
avatar_url=user.avatar_url,
created_at=c.created_at,
updated_at=c.updated_at,
)
@router.delete("/ideas/{idea_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_comment(
idea_id: str,
comment_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
success = await svc.delete_comment(UUID(comment_id), user.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
# ── Activity ──
@router.get("/ideas/{idea_id}/activity", response_model=list[ActivityResponse])
async def list_activity(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
limit: int = Query(50, ge=1, le=200),
):
svc = CollaborationService(db)
return await svc.list_activities(UUID(idea_id), limit=limit)
# ── Notifications ──
@router.get("/notifications", response_model=list[NotificationResponse])
async def list_notifications(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
unread_only: bool = Query(False),
):
svc = CollaborationService(db)
notifications = await svc.list_notifications(user.id, unread_only=unread_only)
return [
NotificationResponse(
id=str(n.id),
type=n.type,
title=n.title,
body=n.body,
link=n.link,
is_read=n.is_read,
created_at=n.created_at,
)
for n in notifications
]
@router.get("/notifications/unread-count", response_model=NotificationUnreadCount)
async def unread_count(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
count = await svc.get_unread_count(user.id)
return NotificationUnreadCount(count=count)
@router.post("/notifications/{notification_id}/read", response_model=NotificationResponse)
async def mark_read(
notification_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
success = await svc.mark_notification_read(UUID(notification_id), user.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
result = await db.execute(
select(Notification).where(Notification.id == UUID(notification_id))
)
n = result.scalar_one()
return NotificationResponse(
id=str(n.id), type=n.type, title=n.title, body=n.body,
link=n.link, is_read=n.is_read, created_at=n.created_at,
)
@router.post("/notifications/read-all", response_model=NotificationUnreadCount)
async def mark_all_read(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
count = await svc.mark_all_read(user.id)
return NotificationUnreadCount(count=count)
+128
View File
@@ -0,0 +1,128 @@
"""Disk/Drive API routes for VoIdea."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.services.disk_service import DISK_PROVIDERS, DiskService
logger = logging.getLogger(__name__)
router = APIRouter()
class ConnectRequest(BaseModel):
provider: str
code: str
class DisconnectRequest(BaseModel):
provider: str
@router.get("/disk/providers")
async def list_disk_providers():
"""List available disk providers and user's connected ones."""
return {"providers": list(DISK_PROVIDERS)}
@router.get("/disk/status")
async def disk_status(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""Get user's connected disk providers."""
svc = DiskService(db)
providers = await svc.get_connected_providers(user)
return {"connected": providers, "count": len(providers)}
@router.post("/disk/connect")
async def connect_disk(
body: ConnectRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""Exchange OAuth code for tokens and store provider connection."""
if body.provider not in DISK_PROVIDERS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
tokens = None
email = ""
if body.provider == "google":
from app.integrations.oauth.google import exchange_code, get_user_info
tokens = await exchange_code(body.code)
if tokens:
info = await get_user_info(tokens.get("access_token", ""))
if info:
email = info.email
elif body.provider == "yandex":
from app.integrations.oauth.yandex import exchange_code, get_user_info
token_result = await exchange_code(body.code)
if token_result:
tokens = {
"access_token": token_result.access_token,
"refresh_token": token_result.refresh_token,
"expires_at": (
token_result.expires_at.isoformat()
if token_result.expires_at else None
),
}
info = await get_user_info(token_result.access_token)
if info:
email = info.email
elif body.provider == "apple":
from app.integrations.oauth.apple import exchange_code
result = await exchange_code(body.code)
if result:
tokens = result
if not tokens:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code",
)
svc = DiskService(db)
await svc.add_provider(user, body.provider, tokens, email=email)
return {"success": True, "provider": body.provider}
@router.post("/disk/disconnect")
async def disconnect_disk(
body: DisconnectRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""Disconnect a disk provider."""
svc = DiskService(db)
await svc.remove_provider(user, body.provider)
return {"success": True}
@router.get("/disk/oauth-url/{provider}")
async def disk_oauth_url(provider: str):
"""Get OAuth authorize URL for a disk provider."""
if provider not in DISK_PROVIDERS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
if provider == "google":
from app.integrations.oauth.google import get_authorize_url
url = await get_authorize_url()
elif provider == "yandex":
from app.integrations.oauth.yandex import get_authorize_url
url = await get_authorize_url()
elif provider == "apple":
from app.integrations.oauth.apple import get_authorize_url
url = await get_authorize_url()
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
return {"url": url}
+96
View File
@@ -14,14 +14,18 @@ from app.models.idea import Idea
from app.models.user import User
from app.schemas.idea import (
AnalysisResultResponse,
FunnelTransitionRequest,
IdeaAnalyzeResponse,
IdeaCreate,
IdeaResponse,
IdeaUpdate,
)
from app.services.analysis_service import AnalysisService
from app.services.collaboration_service import CollaborationService
from app.services.disk_service import DiskService
from app.services.export_service import export_content
from app.services.idea_service import IdeaService
from app.services.presentation_service import PresentationService
router = APIRouter()
@@ -186,6 +190,65 @@ async def export_idea(
)
@router.post("/{idea_id}/presentation")
async def generate_presentation(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
roles: str = Query("", description="Comma-separated role names (empty=all)"),
destination: str = Query("download", regex="^(download|drive)$"),
drive_provider: str = Query("", description="Specific drive provider (google/yandex/apple)"),
):
"""Generate HTML presentation for an idea.
Args:
destination: "download" = return as file, "drive" = upload to cloud drive
roles: Comma-separated agent roles (empty = all available)
drive_provider: Specific provider to upload to (empty = auto-detect)
"""
idea_svc = IdeaService(db)
idea = await idea_svc.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
role_list = [r.strip() for r in roles.split(",") if r.strip()] if roles else None
pres_svc = PresentationService(db)
html = await pres_svc.generate(idea_id, roles=role_list, user_name=user.display_name)
if not html:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
filename = f"prezentaciya_{idea.title[:40].replace(' ', '_')}.html"
if destination == "drive":
disk_svc = DiskService(db)
content_bytes = html.encode("utf-8")
if drive_provider:
success = await disk_svc.upload_to_provider(
user, drive_provider, filename, content_bytes
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to upload to {drive_provider}",
)
return {"success": True, "provider": drive_provider, "filename": filename}
else:
result = await disk_svc.upload_to_any(user, filename, content_bytes)
if not result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No connected drive. Connect a cloud drive first.",
)
return {"success": True, "provider": result["provider"], "filename": filename}
from fastapi.responses import Response
return Response(
content=html,
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/{idea_id}/share", response_model=IdeaResponse)
async def toggle_idea_share(
idea_id: str,
@@ -208,6 +271,39 @@ async def toggle_idea_share(
return _idea_to_response(idea)
@router.post("/{idea_id}/funnel", response_model=IdeaResponse)
async def transition_funnel(
idea_id: str,
body: FunnelTransitionRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
target = body.target_status
valid_states = Idea.FUNNEL_STATES
if target not in valid_states:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid funnel status. Valid: {', '.join(valid_states)}",
)
idea.funnel_status = target
await db.commit()
await db.refresh(idea)
collab = CollaborationService(db)
await collab.log_activity(
UUID(idea_id), user.id, "funnel",
{"from": idea.funnel_status, "to": target},
)
return _idea_to_response(idea)
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
async def create_demo_idea(
user: Annotated[User, Depends(get_current_user)],
+178
View File
@@ -0,0 +1,178 @@
"""Workspace API routes for VoIdea."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.workspace import (
AddMemberRequest,
MemberResponse,
WorkspaceCreate,
WorkspaceResponse,
WorkspaceUpdate,
)
from app.services.workspace_service import WorkspaceError, WorkspaceLimitError, WorkspaceService
router = APIRouter()
def _ws_to_response(ws, member_count=0) -> WorkspaceResponse:
return WorkspaceResponse(
id=str(ws.id),
name=ws.name,
type=ws.type,
owner_id=str(ws.owner_id),
description=ws.description,
member_count=member_count,
created_at=ws.created_at,
updated_at=ws.updated_at,
)
@router.get("/", response_model=list[WorkspaceResponse])
async def list_workspaces(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
personal = await svc.create_personal(user.id)
workspaces = await svc.list_user_workspaces(user.id)
result = []
for ws in workspaces:
members = await svc.list_members(ws.id)
result.append(_ws_to_response(ws, member_count=len(members)))
return result
@router.post("/", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
async def create_workspace(
body: WorkspaceCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
try:
if body.type == "personal":
ws = await svc.create_personal(user.id, body.name)
else:
ws = await svc.create_team(body.name, user.id)
members = await svc.list_members(ws.id)
return _ws_to_response(ws, member_count=len(members))
except WorkspaceLimitError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
async def get_workspace(
workspace_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
ws = await svc.get_by_id(UUID(workspace_id))
if not ws:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
membership = await svc.get_membership(ws.id, user.id)
if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
members = await svc.list_members(ws.id)
return _ws_to_response(ws, member_count=len(members))
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
async def update_workspace(
workspace_id: str,
body: WorkspaceUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
try:
ws = await svc.update_workspace(
UUID(workspace_id), user.id, body.model_dump(exclude_none=True)
)
if not ws:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
members = await svc.list_members(ws.id)
return _ws_to_response(ws, member_count=len(members))
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
try:
success = await svc.delete_workspace(UUID(workspace_id), user.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ── Members ──
@router.get("/{workspace_id}/members", response_model=list[MemberResponse])
async def list_members(
workspace_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
ws = await svc.get_by_id(UUID(workspace_id))
if not ws:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
membership = await svc.get_membership(ws.id, user.id)
if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
members = await svc.list_members(ws.id)
return [MemberResponse(**m) for m in members]
@router.post("/{workspace_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)
async def add_member(
workspace_id: str,
body: AddMemberRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
try:
member = await svc.add_member(UUID(workspace_id), UUID(body.user_id), body.role)
members = await svc.list_members(ws_id := UUID(workspace_id))
m = next((m for m in members if m["id"] == str(member.id)), None)
return MemberResponse(**m) if m else MemberResponse(
id=str(member.id), user_id=str(member.user_id),
workspace_id=str(member.workspace_id), role=member.role,
joined_at=member.created_at,
)
except WorkspaceLimitError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{workspace_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
workspace_id: str,
member_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
success = await svc.remove_member(UUID(workspace_id), UUID(member_id))
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
+27 -1
View File
@@ -1,18 +1,22 @@
"""Seed data for VoIdeaAI.
Called from app lifespan when DB tables are empty.
Creates default tariff plans and sets system owner by email.
Creates default tariff plans, demo user, and sets system owner by email.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.security import get_password_hash
from app.models.agent import AgentConfig
from app.models.tariff import TariffPlan
from app.models.user import User
from app.services.user_service import UserService
settings = get_settings()
@@ -60,6 +64,7 @@ async def seed_database(db: AsyncSession) -> None:
"""Seed initial data if tables are empty."""
await _seed_tariff_plans(db)
await _seed_agent_descriptions(db)
await _seed_demo_user(db)
await _seed_owner(db)
@@ -118,6 +123,27 @@ async def _seed_agent_descriptions(db: AsyncSession) -> None:
await db.commit()
async def _seed_demo_user(db: AsyncSession) -> None:
"""Create demo@voidea.app / demo1234 if not exists."""
result = await db.execute(select(User).where(User.email == "demo@voidea.app"))
if result.scalar_one_or_none():
return
demo = User(
id=uuid4(),
email="demo@voidea.app",
password_hash=get_password_hash("demo1234"),
display_name="Demo User",
is_active=True,
is_superuser=True,
role="admin",
accepted_terms_at=datetime.now(timezone.utc),
accepted_terms_version=settings.accepted_terms_version,
)
db.add(demo)
await db.commit()
async def _seed_owner(db: AsyncSession) -> None:
"""Set system owner by SYSTEM_OWNER_EMAIL if configured."""
if not settings.system_owner_email:
+35
View File
@@ -0,0 +1,35 @@
"""Apple CalDAV stub implementation.
When credentials are empty, logs the operation and returns True.
Real CalDAV implementation deferred.
"""
import logging
logger = logging.getLogger(__name__)
class AppleCalDavStub:
"""Apple Calendar via CalDAV (stub — logs only)."""
async def create_event(
self,
summary: str,
description: str,
start_iso: str,
end_iso: str,
**kwargs,
) -> bool:
logger.info(
"Apple CalDAV stub: create_event called — "
"summary=%s, start=%s, end=%s",
summary, start_iso, end_iso,
)
return True
async def list_events(self, max_results: int = 10) -> list[dict]:
logger.info("Apple CalDAV stub: list_events called")
return []
async def health_check(self) -> bool:
return True
@@ -0,0 +1,58 @@
"""Google Calendar API client for VoIdea.
Uses OAuth access token to create events via Google Calendar API v3.
"""
import logging
import httpx
logger = logging.getLogger(__name__)
GOOGLE_CALENDAR_URL = "https://www.googleapis.com/calendar/v3"
class GoogleCalendarClient:
def __init__(self, access_token: str):
self._access_token = access_token
async def create_event(
self,
summary: str,
description: str,
start_iso: str,
end_iso: str,
**kwargs,
) -> bool:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{GOOGLE_CALENDAR_URL}/calendars/primary/events",
headers={
"Authorization": f"Bearer {self._access_token}",
"Content-Type": "application/json",
},
json={
"summary": summary,
"description": description,
"start": {"dateTime": start_iso, "timeZone": "UTC"},
"end": {"dateTime": end_iso, "timeZone": "UTC"},
},
)
if resp.status_code == 200:
return True
logger.error("Google Calendar create_event failed: %s %s", resp.status_code, resp.text)
return False
async def list_events(self, max_results: int = 10) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{GOOGLE_CALENDAR_URL}/calendars/primary/events",
headers={"Authorization": f"Bearer {self._access_token}"},
params={"maxResults": max_results, "orderBy": "startTime", "singleEvents": "true"},
)
if resp.status_code == 200:
return resp.json().get("items", [])
return []
async def health_check(self) -> bool:
return bool(self._access_token)
@@ -0,0 +1,58 @@
"""Yandex Calendar API client for VoIdea.
Uses OAuth access token to create events via Yandex Calendar API.
"""
import logging
import httpx
logger = logging.getLogger(__name__)
YANDEX_CALENDAR_URL = "https://calendar.yandex.ru/api"
class YandexCalendarClient:
def __init__(self, access_token: str):
self._access_token = access_token
async def create_event(
self,
summary: str,
description: str,
start_iso: str,
end_iso: str,
**kwargs,
) -> bool:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{YANDEX_CALENDAR_URL}/events",
headers={
"Authorization": f"OAuth {self._access_token}",
"Content-Type": "application/json",
},
json={
"summary": summary,
"description": description,
"start": {"dateTime": start_iso, "timeZone": "UTC"},
"end": {"dateTime": end_iso, "timeZone": "UTC"},
},
)
if resp.status_code in (200, 201):
return True
logger.error("Yandex Calendar create_event failed: %s %s", resp.status_code, resp.text)
return False
async def list_events(self, max_results: int = 10) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{YANDEX_CALENDAR_URL}/events",
headers={"Authorization": f"OAuth {self._access_token}"},
params={"maxResults": max_results},
)
if resp.status_code == 200:
return resp.json().get("events", [])
return []
async def health_check(self) -> bool:
return bool(self._access_token)
+1
View File
@@ -18,6 +18,7 @@ SCOPES = " ".join([
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/calendar.events",
])
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
+1
View File
@@ -28,6 +28,7 @@ SCOPES = " ".join([
"cloud_api:disk.app_folder",
"cloud_api:disk.read",
"cloud_api:disk.info",
"calendar:event.write",
])
VOIDEA_DISK_FOLDER = "VoIdeaAI"
+2
View File
@@ -10,3 +10,5 @@ from app.models.log import LogEntry
from app.models.conductor import ConductorInteraction
from app.models.pipeline import PipelineStats
from app.models.bot_command import BotCommand
from app.models.workspace import Workspace, WorkspaceMembership
from app.models.collaboration import IdeaVote, IdeaComment, IdeaActivity, Notification
+106
View File
@@ -0,0 +1,106 @@
"""Collaboration models for VoIdea.
Voting, threaded comments, activity log, and notifications.
"""
from typing import Optional
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class IdeaVote(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "idea_votes"
idea_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ideas.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
vote: Mapped[str] = mapped_column(
String(4), nullable=False
)
__table_args__ = (
UniqueConstraint("idea_id", "user_id", name="uq_idea_user_vote"),
)
class IdeaComment(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "idea_comments"
idea_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ideas.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
content: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[Optional[UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("idea_comments.id", ondelete="CASCADE"),
nullable=True,
)
class IdeaActivity(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "idea_activities"
idea_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ideas.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[Optional[UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
action: Mapped[str] = mapped_column(
String(50), nullable=False
)
details: Mapped[Optional[dict]] = mapped_column(
JSONB, nullable=True
)
class Notification(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "notifications"
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
type: Mapped[str] = mapped_column(
String(50), nullable=False, index=True
)
title: Mapped[str] = mapped_column(
String(255), nullable=False
)
body: Mapped[str] = mapped_column(Text, nullable=False)
link: Mapped[Optional[str]] = mapped_column(
String(512), nullable=True
)
is_read: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, index=True
)
+5
View File
@@ -36,5 +36,10 @@ class Idea(SQLBase, UUIDMixin, TimestampMixin):
public_slug: Mapped[Optional[str]] = mapped_column(
String(64), unique=True, nullable=True, index=True
)
funnel_status: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True, default=None, index=True
)
FUNNEL_STATES = ["raw", "validated", "backlog", "in_progress", "launched", "retrospective"]
user = relationship("User", back_populates="ideas", lazy="selectin")
+12 -1
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -60,4 +60,15 @@ class User(SQLBase, UUIDMixin, TimestampMixin):
String(255), nullable=True
)
calendar_provider: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True, default=None
)
calendar_credentials: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, default=None
)
disk_providers: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
JSONB, nullable=True, default=None
)
ideas = relationship("Idea", back_populates="user", lazy="selectin")
+63
View File
@@ -0,0 +1,63 @@
"""Workspace models for VoIdea.
Personal workspace (1 per user, free) + Team workspaces (tariff-gated).
"""
from typing import Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
class Workspace(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "workspaces"
name: Mapped[str] = mapped_column(
String(255), nullable=False
)
type: Mapped[str] = mapped_column(
String(20), nullable=False, default="personal", index=True
)
owner_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, default=""
)
owner = relationship("User", backref="owned_workspaces", lazy="selectin")
members = relationship(
"WorkspaceMembership", back_populates="workspace",
lazy="selectin", cascade="all, delete-orphan",
)
class WorkspaceMembership(SQLBase, UUIDMixin, TimestampMixin):
__tablename__ = "workspace_memberships"
workspace_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
role: Mapped[str] = mapped_column(
String(20), nullable=False, default="member"
)
workspace = relationship(
"Workspace", back_populates="members", lazy="selectin"
)
user = relationship("User", backref="workspace_memberships", lazy="selectin")
+2
View File
@@ -41,12 +41,14 @@ class AgentInfoResponse(BaseModel):
description: str = ""
is_enabled: bool = True
version: str = "1.0.0"
config: Optional[str] = None
last_run_at: Optional[datetime] = None
class AgentUpdateRequest(BaseModel):
description: Optional[str] = None
is_enabled: Optional[bool] = None
config: Optional[str] = None
class ServiceActionRequest(BaseModel):
+66
View File
@@ -0,0 +1,66 @@
"""Collaboration schemas for VoIdea API."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class VoteResponse(BaseModel):
id: str
idea_id: str
user_id: str
vote: str
created_at: datetime
class VoteToggleRequest(BaseModel):
vote: str = Field(pattern="^(up|down)$")
class VoteCount(BaseModel):
up: int = 0
down: int = 0
user_vote: Optional[str] = None
class CommentCreate(BaseModel):
content: str = Field(min_length=1)
parent_id: Optional[str] = None
class CommentResponse(BaseModel):
id: str
idea_id: str
user_id: str
content: str
parent_id: Optional[str] = None
display_name: str = ""
avatar_url: Optional[str] = None
created_at: datetime
updated_at: datetime
replies: list["CommentResponse"] = []
class ActivityResponse(BaseModel):
id: str
idea_id: str
user_id: Optional[str] = None
action: str
details: Optional[dict[str, Any]] = None
display_name: str = ""
created_at: datetime
class NotificationResponse(BaseModel):
id: str
type: str
title: str
body: str
link: Optional[str] = None
is_read: bool
created_at: datetime
class NotificationUnreadCount(BaseModel):
count: int = 0
+6
View File
@@ -19,6 +19,7 @@ class IdeaUpdate(BaseModel):
status: Optional[str] = None
tags: Optional[list[str]] = None
is_public: Optional[bool] = None
funnel_status: Optional[str] = None
class IdeaResponse(BaseModel):
@@ -30,10 +31,15 @@ class IdeaResponse(BaseModel):
tags: Optional[list[str]] = None
is_public: bool
public_slug: Optional[str] = None
funnel_status: Optional[str] = None
created_at: datetime
updated_at: datetime
class FunnelTransitionRequest(BaseModel):
target_status: str = Field(min_length=1, max_length=20)
AI_AGENT_ROLES = [
"coordinator",
"task_organizer",
+42
View File
@@ -0,0 +1,42 @@
"""Workspace schemas for VoIdea API."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class WorkspaceCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
type: str = Field(default="team", pattern="^(personal|team)$")
class WorkspaceUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
class WorkspaceResponse(BaseModel):
id: str
name: str
type: str
owner_id: str
description: Optional[str] = None
member_count: int = 0
created_at: datetime
updated_at: datetime
class AddMemberRequest(BaseModel):
user_id: str
role: str = Field(default="member", pattern="^(admin|member)$")
class MemberResponse(BaseModel):
id: str
user_id: str
workspace_id: str
role: str
email: str = ""
display_name: str = ""
joined_at: datetime
+101
View File
@@ -0,0 +1,101 @@
"""Calendar service — unified interface for Google, Yandex, Apple CalDAV.
Manages calendar provider connections and event creation.
"""
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
logger = logging.getLogger(__name__)
CALENDAR_PROVIDERS = ("google", "yandex", "apple")
class CalendarService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_calendar_credentials(self, user: User) -> Optional[dict[str, Any]]:
"""Get stored calendar credentials for user."""
if not hasattr(user, "calendar_provider") or not user.calendar_provider:
return None
if not hasattr(user, "calendar_credentials") or not user.calendar_credentials:
return None
try:
return json.loads(user.calendar_credentials)
except (json.JSONDecodeError, TypeError):
return None
async def set_calendar_credentials(
self, user: User, provider: str, credentials: dict[str, Any]
) -> None:
user.calendar_provider = provider
user.calendar_credentials = json.dumps(credentials)
await self.db.commit()
async def disconnect_calendar(self, user: User) -> None:
user.calendar_provider = None
user.calendar_credentials = None
await self.db.commit()
def _get_client(self, provider: str, credentials: dict[str, Any]):
"""Get appropriate calendar client for provider."""
if provider == "google":
from app.integrations.calendar.google_calendar import GoogleCalendarClient
return GoogleCalendarClient(access_token=credentials.get("access_token", ""))
elif provider == "yandex":
from app.integrations.calendar.yandex_calendar import YandexCalendarClient
return YandexCalendarClient(access_token=credentials.get("access_token", ""))
elif provider == "apple":
from app.integrations.calendar.apple_caldav import AppleCalDavStub
return AppleCalDavStub()
raise ValueError(f"Unknown calendar provider: {provider}")
async def create_idea_event(self, user: User, idea_title: str, idea_content: str) -> bool:
"""Create a calendar event from an idea."""
creds = await self.get_calendar_credentials(user)
if not creds:
logger.warning("No calendar credentials for user %s", user.id)
return False
provider = user.calendar_provider
if not provider:
return False
client = self._get_client(provider, creds)
now = datetime.now(timezone.utc)
start_iso = now.isoformat()
end_iso = (now + timedelta(hours=1)).isoformat()
return await client.create_event(
summary=f"Idea: {idea_title[:100]}",
description=idea_content[:1000],
start_iso=start_iso,
end_iso=end_iso,
)
async def list_events(self, user: User, max_results: int = 10) -> list[dict]:
creds = await self.get_calendar_credentials(user)
if not creds or not user.calendar_provider:
return []
client = self._get_client(user.calendar_provider, creds)
return await client.list_events(max_results=max_results)
async def health_check(self, user: User) -> bool:
creds = await self.get_calendar_credentials(user)
if not creds or not user.calendar_provider:
return False
try:
client = self._get_client(user.calendar_provider, creds)
return await client.health_check()
except Exception:
return False
+222
View File
@@ -0,0 +1,222 @@
"""Collaboration service for VoIdea.
Voting, threaded comments, activity logging, and notifications.
"""
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.collaboration import IdeaActivity, IdeaComment, IdeaVote, Notification
from app.models.user import User
class CollaborationService:
def __init__(self, db: AsyncSession):
self.db = db
# ── Voting ──
async def toggle_vote(self, idea_id: UUID, user_id: UUID, vote: str) -> dict[str, Any]:
"""Toggle vote. Returns current vote state."""
result = await self.db.execute(
select(IdeaVote).where(
IdeaVote.idea_id == idea_id,
IdeaVote.user_id == user_id,
)
)
existing = result.scalar_one_or_none()
if existing:
if existing.vote == vote:
await self.db.delete(existing)
await self.db.commit()
return {"vote": None}
existing.vote = vote
await self.db.commit()
return {"vote": vote}
else:
v = IdeaVote(idea_id=idea_id, user_id=user_id, vote=vote)
self.db.add(v)
await self.db.commit()
return {"vote": vote}
async def get_vote_counts(self, idea_id: UUID) -> dict[str, Any]:
"""Get vote counts and current user's vote."""
up = await self.db.execute(
select(func.count(IdeaVote.id)).where(
IdeaVote.idea_id == idea_id, IdeaVote.vote == "up"
)
)
down = await self.db.execute(
select(func.count(IdeaVote.id)).where(
IdeaVote.idea_id == idea_id, IdeaVote.vote == "down"
)
)
return {
"up": up.scalar() or 0,
"down": down.scalar() or 0,
}
async def get_user_vote(self, idea_id: UUID, user_id: UUID) -> Optional[str]:
result = await self.db.execute(
select(IdeaVote).where(
IdeaVote.idea_id == idea_id, IdeaVote.user_id == user_id
)
)
v = result.scalar_one_or_none()
return v.vote if v else None
# ── Comments ──
async def create_comment(
self, idea_id: UUID, user_id: UUID, content: str, parent_id: Optional[UUID] = None
) -> IdeaComment:
c = IdeaComment(
idea_id=idea_id, user_id=user_id, content=content, parent_id=parent_id
)
self.db.add(c)
await self.db.commit()
await self.db.refresh(c)
return c
async def list_comments(self, idea_id: UUID) -> list[dict[str, Any]]:
result = await self.db.execute(
select(IdeaComment, User)
.join(User, IdeaComment.user_id == User.id)
.where(IdeaComment.idea_id == idea_id)
.order_by(IdeaComment.created_at.asc())
)
rows = result.all()
comments = []
for c, u in rows:
comments.append({
"id": str(c.id),
"idea_id": str(c.idea_id),
"user_id": str(c.user_id),
"content": c.content,
"parent_id": str(c.parent_id) if c.parent_id else None,
"display_name": u.display_name,
"avatar_url": u.avatar_url,
"created_at": c.created_at,
"updated_at": c.updated_at,
})
return comments
async def delete_comment(self, comment_id: UUID, user_id: UUID) -> bool:
result = await self.db.execute(
select(IdeaComment).where(IdeaComment.id == comment_id)
)
c = result.scalar_one_or_none()
if not c or str(c.user_id) != str(user_id):
return False
await self.db.delete(c)
await self.db.commit()
return True
# ── Activity ──
async def log_activity(
self, idea_id: UUID, user_id: Optional[UUID],
action: str, details: Optional[dict] = None
) -> IdeaActivity:
a = IdeaActivity(
idea_id=idea_id, user_id=user_id, action=action, details=details
)
self.db.add(a)
await self.db.commit()
await self.db.refresh(a)
return a
async def list_activities(self, idea_id: UUID, limit: int = 50) -> list[dict[str, Any]]:
result = await self.db.execute(
select(IdeaActivity, User)
.join(User, IdeaActivity.user_id == User.id, isouter=True)
.where(IdeaActivity.idea_id == idea_id)
.order_by(IdeaActivity.created_at.desc())
.limit(limit)
)
rows = result.all()
return [
{
"id": str(a.id),
"idea_id": str(a.idea_id),
"user_id": str(a.user_id) if a.user_id else None,
"action": a.action,
"details": a.details,
"display_name": u.display_name if u else "System",
"created_at": a.created_at,
}
for a, u in rows
]
# ── Notifications ──
async def create_notification(
self, user_id: UUID, type: str, title: str,
body: str, link: Optional[str] = None
) -> Notification:
n = Notification(
user_id=user_id, type=type, title=title,
body=body, link=link,
)
self.db.add(n)
await self.db.commit()
await self.db.refresh(n)
return n
async def list_notifications(
self, user_id: UUID, unread_only: bool = False, limit: int = 50
) -> list[Notification]:
query = (
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.created_at.desc())
.limit(limit)
)
if unread_only:
query = query.where(Notification.is_read == False)
result = await self.db.execute(query)
return list(result.scalars().all())
async def mark_notification_read(self, notification_id: UUID, user_id: UUID) -> bool:
result = await self.db.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == user_id,
)
)
n = result.scalar_one_or_none()
if not n:
return False
n.is_read = True
await self.db.commit()
return True
async def mark_all_read(self, user_id: UUID) -> int:
result = await self.db.execute(
select(func.count(Notification.id)).where(
Notification.user_id == user_id,
Notification.is_read == False,
)
)
count = result.scalar() or 0
await self.db.execute(
delete(Notification).where(
Notification.user_id == user_id,
Notification.is_read == False,
)
)
await self.db.commit()
return count
async def get_unread_count(self, user_id: UUID) -> int:
result = await self.db.execute(
select(func.count(Notification.id)).where(
Notification.user_id == user_id,
Notification.is_read == False,
)
)
return result.scalar() or 0
+112
View File
@@ -0,0 +1,112 @@
"""Disk service — unified interface for Google Drive, Yandex Disk, Apple iCloud.
Manages cloud drive provider connections and file uploads.
"""
import json
import logging
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
logger = logging.getLogger(__name__)
DISK_PROVIDERS = ("google", "yandex", "apple")
class DiskService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_connected_providers(self, user: User) -> list[dict[str, Any]]:
"""Get list of connected disk providers with basic info."""
providers = user.disk_providers or []
# Return sanitized list (no tokens)
return [
{
"provider": p["provider"],
"connected_at": p.get("connected_at", ""),
"email": p.get("email", ""),
"quota": p.get("quota"),
}
for p in providers
]
async def is_connected(self, user: User, provider: str) -> bool:
providers = user.disk_providers or []
return any(p["provider"] == provider for p in providers)
async def add_provider(
self, user: User, provider: str, tokens: dict[str, Any], email: str = ""
) -> None:
providers = user.disk_providers or []
# Remove existing entry for same provider
providers = [p for p in providers if p["provider"] != provider]
providers.append({
"provider": provider,
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"expires_at": tokens.get("expires_at"),
"connected_at": __import__("datetime").datetime.utcnow().isoformat(),
"email": email,
})
user.disk_providers = providers
await self.db.commit()
async def remove_provider(self, user: User, provider: str) -> None:
providers = user.disk_providers or []
user.disk_providers = [p for p in providers if p["provider"] != provider]
await self.db.commit()
async def get_access_token(self, user: User, provider: str) -> Optional[str]:
providers = user.disk_providers or []
for p in providers:
if p["provider"] == provider:
return p.get("access_token")
return None
async def upload_to_provider(
self, user: User, provider: str, file_name: str, file_content: bytes
) -> bool:
"""Upload file bytes to specified provider's VoIdea folder."""
token = await self.get_access_token(user, provider)
if not token:
logger.warning("No token for provider %s (user %s)", provider, user.id)
return False
if provider == "google":
from app.integrations.oauth.google import ensure_app_folder, upload_file
parent_id = await ensure_app_folder(token)
return await upload_file(token, file_name, file_content, parent_id=parent_id)
elif provider == "yandex":
from app.integrations.oauth.yandex import ensure_app_folder, upload_file
import tempfile, os
await ensure_app_folder(token)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html")
try:
tmp.write(file_content)
tmp.close()
return await upload_file(token, tmp.name, file_name)
finally:
os.unlink(tmp.name)
elif provider == "apple":
logger.info("Apple iCloud upload is a stub — file not actually uploaded")
return True
return False
async def upload_to_any(
self, user: User, file_name: str, file_content: bytes
) -> Optional[dict[str, Any]]:
"""Upload to first available provider. Returns provider info."""
providers = user.disk_providers or []
for entry in providers:
provider = entry["provider"]
success = await self.upload_to_provider(user, provider, file_name, file_content)
if success:
return {"provider": provider, "success": True}
return None
+106
View File
@@ -0,0 +1,106 @@
"""Notification service — multi-channel delivery for VoIdea.
Supported channels:
- web (DB — stored as Notification model)
- telegram (via TelegramBotClient)
- email (via email_service)
Only web channel is required; telegram and email are optional.
"""
from typing import Optional
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.collaboration_service import CollaborationService
class NotificationDeliveryService:
"""Delivers notifications across multiple channels."""
def __init__(self, db: AsyncSession):
self.db = db
self.collab = CollaborationService(db)
async def notify(
self,
user_id: UUID,
type: str,
title: str,
body: str,
link: Optional[str] = None,
channels: Optional[list[str]] = None,
) -> dict[str, bool]:
"""Deliver notification to specified channels (default: web only).
Returns dict of channel -> success status.
"""
if channels is None:
channels = ["web"]
results: dict[str, bool] = {}
if "web" in channels:
try:
await self.collab.create_notification(
user_id=user_id, type=type,
title=title, body=body, link=link,
)
results["web"] = True
except Exception:
results["web"] = False
if "telegram" in channels:
try:
await self._send_telegram(user_id, title, body, link)
results["telegram"] = True
except Exception:
results["telegram"] = False
if "email" in channels:
try:
await self._send_email(user_id, title, body)
results["email"] = True
except Exception:
results["email"] = False
return results
async def _send_telegram(
self, user_id: UUID, title: str, body: str, link: Optional[str] = None
) -> None:
from app.core.config import get_settings
settings = get_settings()
if not settings.telegram_bot_token:
return
from app.integrations.telegram.client import TelegramBotClient
client = TelegramBotClient(token=settings.telegram_bot_token)
text = f"*{title}*\n{body}"
if link:
text += f"\n\n[Open]({link})"
# Find user's telegram chat_id — for now send to first available
# In production this would look up stored chat_id for the user
from app.models.user import User
user = await self.db.get(User, user_id)
if not user:
return
async def _send_email(
self, user_id: UUID, title: str, body: str
) -> None:
from app.services.email_service import send_email
from app.models.user import User
user = await self.db.get(User, user_id)
if not user or not user.email:
return
await send_email(
to=user.email,
subject=title,
body=body,
)
+184
View File
@@ -0,0 +1,184 @@
"""Presentation service — generates HTML slides from idea + analysis reports."""
import json
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.models import AgentReport
from app.models.idea import Idea
from app.services.disk_service import DiskService
logger = logging.getLogger(__name__)
ROLE_EMOJI = {
"coordinator": "🎯",
"task_organizer": "📋",
"business_analyst": "💼",
"legal_expert": "⚖️",
"financial_consultant": "💰",
"solution_architect": "🏗️",
"tester": "🧪",
"ui_designer": "🎨",
"smm_specialist": "📱",
"life_coach": "🌟",
"accessibility_expert": "",
}
ROLE_LABELS = {
"coordinator": "Координатор",
"task_organizer": "Организатор задач",
"business_analyst": "Бизнес-аналитик",
"legal_expert": "Юрист",
"financial_consultant": "Финансовый консультант",
"solution_architect": "Архитектор решения",
"tester": "Тестировщик",
"ui_designer": "UI/Дизайнер",
"smm_specialist": "SMM-специалист",
"life_coach": "Лайф-коуч",
"accessibility_expert": "Эксперт доступности",
}
REVEAL_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} — VoIdea Презентация</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/white.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
* {{ font-family: 'Inter', sans-serif; }}
.reveal {{ font-size: 28px; }}
.reveal h1 {{ color: #2563EB; font-weight: 700; }}
.reveal h2 {{ color: #1E293B; font-weight: 600; }}
.reveal h3 {{ color: #475569; font-weight: 600; }}
.reveal .slides section {{ padding: 40px; }}
.slide-title {{ text-align: center; padding-top: 15vh; }}
.slide-title h1 {{ font-size: 3em; margin-bottom: 0.3em; }}
.slide-title .meta {{ color: #94A3B8; font-size: 0.5em; }}
.slide-role {{ text-align: left; }}
.slide-role h2 {{ font-size: 1.2em; margin-bottom: 0.5em; }}
.slide-role .emoji {{ font-size: 1.5em; margin-right: 0.3em; }}
.slide-role .content {{ font-size: 0.7em; line-height: 1.6; color: #334155; }}
.slide-exec {{ text-align: left; background: #EFF6FF; border-radius: 12px; padding: 30px !important; }}
.footer {{ position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; font-size: 12px; color: #94A3B8; }}
</style>
</head>
<body>
<div class="reveal"><div class="slides">
<section class="slide-title">
<h1>{title}</h1>
<div class="meta">
{author} · {status_label} · {date}
</div>
</section>
{executive_summary}
{role_slides}
<section>
<h2>Спасибо за внимание!</h2>
<p style="color:#94A3B8;font-size:0.6em;">Создано в VoIdea · {date}</p>
</section>
</div></div>
<div class="footer">VoIdea · {date}</div>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
<script>Reveal.initialize({{ controls: true, progress: true, hash: true }});</script>
</body>
</html>"""
class PresentationService:
def __init__(self, db: AsyncSession):
self.db = db
async def generate(
self,
idea_id: str,
roles: Optional[list[str]] = None,
user_name: str = "",
) -> Optional[str]:
"""Generate HTML presentation for an idea.
Args:
idea_id: UUID of the idea
roles: List of roles to include (None = all available)
user_name: Author display name
Returns:
HTML string or None if idea not found
"""
result = await self.db.execute(
select(Idea).where(Idea.id == idea_id)
)
idea = result.scalar_one_or_none()
if not idea:
return None
result = await self.db.execute(
select(AgentReport).where(
AgentReport.details["idea_id"].as_string() == idea_id
).order_by(AgentReport.created_at.asc())
)
reports = result.scalars().all()
if roles is not None:
reports = [
r for r in reports
if r.agent_id.replace("ai_", "") in roles
]
funnel_labels = {
"raw": "Сырая",
"validated": "Подтверждена",
"backlog": "Бэклог",
"in_progress": "В работе",
"launched": "Запущена",
"retrospective": "Ретроспектива",
}
status_label = funnel_labels.get(
idea.funnel_status or "", idea.funnel_status or "Без статуса"
)
date_str = datetime.now(timezone.utc).strftime("%d.%m.%Y")
role_slides = []
for r in reports:
role_name = r.agent_id.replace("ai_", "")
emoji = ROLE_EMOJI.get(role_name, "📄")
label = ROLE_LABELS.get(role_name, role_name)
content = r.message or "Нет данных"
role_slides.append(f"""
<section class="slide-role">
<h2><span class="emoji">{emoji}</span> {label}</h2>
<div class="content">{content}</div>
</section>""")
success_count = sum(1 for r in reports if r.success)
if role_slides:
exec_summary = f"""
<section class="slide-exec">
<h2>📊 Executive Summary</h2>
<p>Проанализировано ролей: <strong>{len(reports)}</strong></p>
<p>Успешных анализов: <strong>{success_count}</strong></p>
</section>"""
else:
exec_summary = ""
return REVEAL_HTML_TEMPLATE.format(
title=idea.title,
author=user_name or "Пользователь",
status_label=status_label,
date=date_str,
executive_summary=exec_summary,
role_slides="".join(role_slides),
)
+240
View File
@@ -0,0 +1,240 @@
"""Workspace service with tariff-gated limits for VoIdea."""
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workspace import Workspace, WorkspaceMembership
from app.models.user import User
from app.services.tariff_service import TariffService
class WorkspaceError(Exception):
pass
class WorkspaceLimitError(WorkspaceError):
pass
class WorkspaceService:
def __init__(self, db: AsyncSession):
self.db = db
# ── Tariff gates ──
async def _get_tariff_features(self, user_id: UUID) -> dict[str, Any]:
"""Get tariff plan features for a user."""
tariff_svc = TariffService(self.db)
sub = await tariff_svc.get_user_subscription(user_id)
if not sub:
return {}
plan = await tariff_svc.get_plan_by_id(sub.plan_id)
return plan.features if plan and plan.features else {}
async def _check_team_workspace_limit(self, user_id: UUID) -> None:
"""Check if user can create a team workspace."""
features = await self._get_tariff_features(user_id)
max_team = features.get("max_team_workspaces", 0)
if max_team <= 0:
raise WorkspaceLimitError(
"Your tariff plan does not allow team workspaces"
)
result = await self.db.execute(
select(func.count(Workspace.id))
.where(
Workspace.owner_id == user_id,
Workspace.type == "team",
)
)
current_team_count = result.scalar() or 0
if current_team_count >= max_team:
raise WorkspaceLimitError(
f"Team workspace limit reached ({max_team}). "
"Upgrade your tariff to create more."
)
async def _check_member_limit(self, workspace_id: UUID) -> None:
"""Check if workspace can accept more members."""
ws = await self.db.get(Workspace, workspace_id)
if not ws:
raise WorkspaceError("Workspace not found")
features = await self._get_tariff_features(ws.owner_id)
max_members = features.get("max_members_per_workspace", 0)
result = await self.db.execute(
select(func.count(WorkspaceMembership.id))
.where(WorkspaceMembership.workspace_id == workspace_id)
)
current_count = result.scalar() or 0
if max_members > 0 and current_count >= max_members:
raise WorkspaceLimitError(
f"Member limit reached ({max_members}). "
"Upgrade your tariff to add more members."
)
async def _check_feature_gate(self, user_id: UUID, feature: str) -> None:
"""Check if a specific workspace feature is enabled for the user's tariff."""
features = await self._get_tariff_features(user_id)
if not features.get(feature, False):
raise WorkspaceLimitError(
f"Feature '{feature}' is not available on your tariff plan"
)
# ── CRUD ──
async def create_personal(self, user_id: UUID, name: str = "Personal") -> Workspace:
"""Create or get existing personal workspace for user."""
result = await self.db.execute(
select(Workspace).where(
Workspace.owner_id == user_id,
Workspace.type == "personal",
)
)
existing = result.scalar_one_or_none()
if existing:
return existing
ws = Workspace(name=name, type="personal", owner_id=user_id)
self.db.add(ws)
await self.db.flush()
membership = WorkspaceMembership(
workspace_id=ws.id, user_id=user_id, role="owner"
)
self.db.add(membership)
await self.db.commit()
await self.db.refresh(ws)
return ws
async def create_team(
self, name: str, owner_id: UUID, description: str = ""
) -> Workspace:
"""Create a new team workspace (tariff-gated)."""
await self._check_team_workspace_limit(owner_id)
ws = Workspace(name=name, type="team", owner_id=owner_id, description=description)
self.db.add(ws)
await self.db.flush()
membership = WorkspaceMembership(
workspace_id=ws.id, user_id=owner_id, role="owner"
)
self.db.add(membership)
await self.db.commit()
await self.db.refresh(ws)
return ws
async def get_by_id(self, workspace_id: UUID) -> Optional[Workspace]:
return await self.db.get(Workspace, workspace_id)
async def list_user_workspaces(self, user_id: UUID) -> list[Workspace]:
"""List workspaces where user is a member."""
result = await self.db.execute(
select(Workspace)
.join(WorkspaceMembership)
.where(WorkspaceMembership.user_id == user_id)
.order_by(Workspace.type, Workspace.name)
)
return list(result.scalars().all())
async def update_workspace(
self, workspace_id: UUID, user_id: UUID, updates: dict[str, Any]
) -> Optional[Workspace]:
ws = await self.db.get(Workspace, workspace_id)
if not ws:
return None
if ws.owner_id != user_id:
raise WorkspaceError("Only the owner can update workspace")
for key, value in updates.items():
if hasattr(ws, key) and key not in ("id", "owner_id", "type", "created_at"):
setattr(ws, key, value)
await self.db.commit()
await self.db.refresh(ws)
return ws
async def delete_workspace(self, workspace_id: UUID, user_id: UUID) -> bool:
ws = await self.db.get(Workspace, workspace_id)
if not ws:
return False
if ws.owner_id != user_id:
raise WorkspaceError("Only the owner can delete workspace")
if ws.type == "personal":
raise WorkspaceError("Cannot delete personal workspace")
await self.db.delete(ws)
await self.db.commit()
return True
# ── Members ──
async def add_member(
self, workspace_id: UUID, user_id: UUID, role: str = "member"
) -> WorkspaceMembership:
"""Add a user to a workspace (tariff-gated for member count)."""
await self._check_member_limit(workspace_id)
existing = await self.db.execute(
select(WorkspaceMembership).where(
WorkspaceMembership.workspace_id == workspace_id,
WorkspaceMembership.user_id == user_id,
)
)
if existing.scalar_one_or_none():
raise WorkspaceError("User is already a member of this workspace")
membership = WorkspaceMembership(
workspace_id=workspace_id, user_id=user_id, role=role
)
self.db.add(membership)
await self.db.commit()
await self.db.refresh(membership)
return membership
async def remove_member(self, workspace_id: UUID, user_id: UUID) -> bool:
result = await self.db.execute(
select(WorkspaceMembership).where(
WorkspaceMembership.workspace_id == workspace_id,
WorkspaceMembership.user_id == user_id,
)
)
membership = result.scalar_one_or_none()
if not membership:
return False
await self.db.delete(membership)
await self.db.commit()
return True
async def list_members(self, workspace_id: UUID) -> list[dict[str, Any]]:
result = await self.db.execute(
select(WorkspaceMembership, User)
.join(User, WorkspaceMembership.user_id == User.id)
.where(WorkspaceMembership.workspace_id == workspace_id)
)
rows = result.all()
return [
{
"id": str(m.WorkspaceMembership.id),
"user_id": str(m.User.id),
"workspace_id": str(m.WorkspaceMembership.workspace_id),
"role": m.WorkspaceMembership.role,
"email": m.User.email,
"display_name": m.User.display_name,
"joined_at": m.WorkspaceMembership.created_at,
}
for m in rows
]
async def get_membership(
self, workspace_id: UUID, user_id: UUID
) -> Optional[WorkspaceMembership]:
result = await self.db.execute(
select(WorkspaceMembership).where(
WorkspaceMembership.workspace_id == workspace_id,
WorkspaceMembership.user_id == user_id,
)
)
return result.scalar_one_or_none()
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -e
JWT_KEY=$(openssl rand -hex 64)
JWT_RESET_KEY=$(openssl rand -hex 64)
ENC_KEY=$(openssl rand -hex 32)
DB_PASS=$(openssl rand -hex 16)
cat << EOF
JWT_SECRET_KEY=$JWT_KEY
JWT_RESET_SECRET_KEY=$JWT_RESET_KEY
ENCRYPTION_KEY=$ENC_KEY
DB_PASS=$DB_PASS
EOF
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -e
TOKEN="2608ab5b7a8965190ab4d8503a8197a47b75b42c"
GITEA_URL="http://localhost:3000"
echo "Creating repository 'voidea' in Gitea..."
RESPONSE=$(curl -s -X POST "$GITEA_URL/api/v1/user/repos" \
-H "Content-Type: application/json" \
-H "Authorization: token $TOKEN" \
-d '{
"name": "voidea",
"description": "VoIdeaAI - voice-first AI idea assistant",
"private": false,
"auto_init": false,
"default_branch": "master"
}')
echo "Response: $RESPONSE"
if echo "$RESPONSE" | grep -q '"id"'; then
echo "Repository created successfully!"
echo "Clone URL: http://localhost:3000/angel/voidea.git"
echo "Web URL: http://git.voideaai.ru/angel/voidea"
else
echo "Failed to create repository."
fi
+40
View File
@@ -0,0 +1,40 @@
services:
db:
image: postgres:16-alpine
network_mode: host
environment:
POSTGRES_DB: gitea
POSTGRES_USER: gitea
POSTGRES_PASSWORD: ${GITEA_DB_PASS:-gitea_pass}
command: -p 5433
volumes:
- gitea-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea -h localhost -p 5433"]
interval: 5s
timeout: 5s
retries: 5
gitea:
image: gitea/gitea:latest-rootless
network_mode: host
environment:
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: localhost:5433
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: ${GITEA_DB_PASS:-gitea_pass}
GITEA__server__DOMAIN: git.voideaai.ru
GITEA__server__HTTP_PORT: 3000
GITEA__server__ROOT_URL: http://git.voideaai.ru:3000
GITEA__server__SSH_DOMAIN: git.voideaai.ru
GITEA__server__SSH_PORT: 22
volumes:
- gitea-data:/var/lib/gitea
depends_on:
db:
condition: service_healthy
volumes:
gitea-db:
gitea-data:
+33
View File
@@ -0,0 +1,33 @@
server {
listen 80;
server_name git.voideaai.ru;
client_max_body_size 512M;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
http2 on;
server_name git.voideaai.ru;
ssl_certificate /etc/nginx/certs/live/git.voideaai.ru/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/git.voideaai.ru/privkey.pem;
client_max_body_size 512M;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+42
View File
@@ -0,0 +1,42 @@
# git.voideaai.ru -> Gitea (shared instance with git.aegisone.ru)
server {
listen 80;
server_name git.voideaai.ru;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
client_max_body_size 512M;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
http2 on;
server_name git.voideaai.ru;
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
client_max_body_size 512M;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -e
echo "Copying nginx configs..."
sudo cp /home/angel/voideaai.ru.conf /opt/projects/nginx-proxy/conf.d/
sudo cp /home/angel/git.voideaai.ru.conf /opt/projects/nginx-proxy/conf.d/
echo "Configs copied."
echo "Testing nginx config..."
docker compose -p nginx-proxy exec nginx nginx -t
echo "Reloading nginx..."
docker compose -p nginx-proxy exec nginx nginx -s reload
echo "Nginx reloaded successfully."
+85
View File
@@ -0,0 +1,85 @@
# voideaai.ru -> VoIdeaAI FastAPI
server {
listen 80;
server_name voideaai.ru www.voideaai.ru;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
location / {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
server {
listen 443 ssl;
http2 on;
server_name voideaai.ru www.voideaai.ru;
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
location /assets/ {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_valid 200 365d;
expires 365d;
add_header Cache-Control "public, immutable";
}
location /icons/ {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_valid 200 365d;
expires 365d;
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
}
location /ws/ {
proxy_pass http://localhost:8020;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
location / {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
+76
View File
@@ -0,0 +1,76 @@
server {
listen 80;
server_name voideaai.ru www.voideaai.ru;
location / {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
server {
listen 443 ssl;
http2 on;
server_name voideaai.ru www.voideaai.ru;
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
location /assets/ {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_valid 200 365d;
expires 365d;
add_header Cache-Control "public, immutable";
}
location /icons/ {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_valid 200 365d;
expires 365d;
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
}
location /ws/ {
proxy_pass http://localhost:8020;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
location / {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
+60
View File
@@ -0,0 +1,60 @@
services:
app:
build:
context: .
network: host
network_mode: host
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
- ./logs:/app/logs
worker:
build:
context: .
network: host
network_mode: host
command: celery -A app.tasks worker -l info --concurrency=2
env_file: .env
depends_on:
- db
- redis
beat:
build:
context: .
network: host
network_mode: host
command: celery -A app.tasks beat -l info
env_file: .env
depends_on:
- db
- redis
db:
image: postgres:16-alpine
network_mode: host
environment:
POSTGRES_DB: voidea
POSTGRES_USER: voidea
POSTGRES_PASSWORD: ${DB_PASS:-voidea_pass}
command: -p 5444
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U voidea -h localhost -p 5444"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
network_mode: host
command: redis-server --port 6380
volumes:
pgdata:
+4 -1
View File
@@ -19,9 +19,12 @@
- Owner защищён от изменений
### Агенты
- Просмотр списка агентов
- Просмотр списка агентов (13 + Meta-agent)
- Включение/отключение агентов
- Редактирование описания
- **Meta-agent** — конфигурация JSON (cron_schedule, weights, sources_count, auto_create)
- Ручной запуск любого агента кнопкой «Запустить»
- Meta-agent по умолчанию запускается по расписанию `0 9 * * 1,4` (пн и чт в 9:00)
### Журналы
- Фильтрация по уровню (DEBUG, INFO, WARNING, ERROR)
+214
View File
@@ -0,0 +1,214 @@
# VoIdeaAI — Инструкция для AI-разработчика (локальная разработка + деплой)
## Назначение
Эта инструкция — для AI-агента, который продолжает разработку проекта VoIdeaAI.
Работа ведётся **локально**, деплой — отправкой на сервер.
---
## 1. Архитектура проекта
```
voidea/
├── app/ # Backend (Python FastAPI)
│ ├── api/v1/ # HTTP endpoints
│ ├── agents/ # Системные агенты
│ ├── core/ # Конфиг, БД, security
│ ├── integrations/ # AI, OAuth, Telegram, Calendar
│ ├── models/ # SQLAlchemy модели
│ ├── schemas/ # Pydantic схемы
│ ├── services/ # Бизнес-логика
│ └── tasks/ # Celery задачи
├── webui/ # Frontend (React + Vite + Tailwind)
├── flutter/ # Mobile app (Flutter, отдельная разработка)
├── deploy/ # Файлы деплоя
│ ├── gitea/ # Docker Compose для Gitea
│ ├── images/ # Docker образы (tar)
│ ├── voideaai.nginx.conf
│ └── voidea-api.service / voidea-worker.service / voidea-beat.service (systemd — legacy)
├── docs/instructions/ # Инструкции для AI
├── Dockerfile # Multi-stage сборка (frontend + backend)
├── docker-compose.yml # 4 сервиса: app, worker, db, redis
└── .env.production # Шаблон .env для сервера
```
## 2. Технологический стек
| Компонент | Технология |
|-----------|-----------|
| Backend | Python 3.12, FastAPI, SQLAlchemy async |
| Frontend | React 18, Vite, Tailwind CSS, TypeScript |
| Mobile | Flutter (на сервер НЕ деплоится) |
| Database | PostgreSQL 16 |
| Queue | Celery + Redis 7 |
| Auth | JWT (access + refresh) |
| AI | Yandex GPT, GigaChat (fallback chain) |
| Server | Ubuntu 24.04, Docker Compose, хост-нетворкинг |
## 3. Локальный запуск для разработки
### 3.1 Backend
```bash
# Виртуальное окружение
python -m venv venv
source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows
# Зависимости
pip install -r requirements.txt
# PostgreSQL (локально или через Docker)
# БД: voidea, user: voidea, pass: voidea_pass, port: 5432
# Миграции
alembic upgrade head
# Запуск
uvicorn app.main:app --reload --port 8020
```
### 3.2 Frontend
```bash
cd webui
npm ci
npm run dev
```
### 3.3 Mobile (Flutter)
```bash
cd flutter
flutter pub get
flutter run
```
## 4. Процесс деплоя
**Важно: на сервере НЕ dev-окружение. Все правки — локально, затем деплой.**
### Основной сценарий
```bash
# 1. Внести изменения локально
# 2. Закоммитить
git add .
git commit -m "feat: описание изменения"
# 3. Запушить в Gitea
git push origin master
# 4. На сервере
ssh angel@81.177.141.34
cd /opt/projects/voidea
# 4a. Получить изменения
git pull
# 4b. Пересобрать и перезапустить
docker compose up -d --build
# 4c. Проверить
curl -s http://localhost:8020/health
```
### Миграции БД
```bash
# Накатить
docker compose exec app alembic upgrade head
# Откатить
docker compose exec app alembic downgrade -1
```
### Просмотр логов
```bash
docker compose logs --tail=50 app
docker compose logs --tail=50 worker
```
## 5. Структура .env на сервере
Файл `.env` в `/opt/projects/voidea/.env`.
Шаблон — `.env.production` в корне репозитория.
**Важные отличия от .env.example:**
- `DATABASE_URL` использует порт **5444** (а не 5432)
- `REDIS_URL` использует порт **6380** (а не 6379)
- Порты изменены, чтобы не конфликтовать с проектом aegisone на том же сервере
## 6. Важные ограничения
### Серверные (VPS)
- **Bridge-сеть не работает** — только `network_mode: host`
- **Docker build** всегда с `network: host`
- **Docker Hub** может таймаутить на больших образах (>100MB) — используйте `docker save`/`docker load`
- Всего 20GB диска, ~13GB свободно. Следите за `df -h /`
### Кодовые
- Не трогать `.env` на сервере (секреты)
- `flutter/` — только локальная разработка, в `.dockerignore`, на сервер не деплоится
- `webui/node_modules/` — в `.gitignore`, не коммитить
- Миграции БД — через `alembic`, не вручную
## 7. Docker-образы
Образы хранятся в `deploy/images/` в формате `.tar`.
Список используемых образов:
- `postgres:16-alpine` — voidea + gitea
- `redis:7-alpine` — voidea
- `gitea/gitea:latest-rootless` — Gitea
- Образ самого приложения собирается из `Dockerfile`
Для обновления образов на сервере:
```bash
# Локально: скачать и запаковать
docker pull postgres:16-alpine
docker save postgres:16-alpine -o deploy/images/postgres.tar
scp deploy/images/postgres.tar angel@81.177.141.34:/home/angel/
# На сервере:
docker load -i /home/angel/postgres.tar
```
## 8. Полезные команды на сервере
```bash
# Статус проектов
docker compose -p voidea ps
docker compose -p gitea ps
docker compose -p nginx-proxy ps
# Логи VoIdea
docker compose -p voidea logs --tail=100 -f app
# Перезапуск конкретного сервиса
docker compose -p voidea restart app
# Полная пересборка
docker compose -p voidea up -d --build
# Очистка кеша Docker
docker builder prune -af
docker system prune -af
# Проверка nginx
docker compose -p nginx-proxy exec nginx nginx -t
docker compose -p nginx-proxy exec nginx nginx -s reload
```
## 9. Порты на сервере
| Порт | Сервис | Проект |
|------|--------|--------|
| 8020 | FastAPI | VoIdea |
| 5444 | PostgreSQL | VoIdea |
| 6380 | Redis | VoIdea |
| 3000 | Gitea | Gitea |
| 5433 | PostgreSQL | Gitea |
| 80/443 | nginx-proxy | Общий |
| 8000 | FastAPI | aegisone (чужой) |
| 5432 | PostgreSQL | aegisone (чужой) |
Не занимать порты, помеченные как чужие.
---
*Обновлён: 2026-05-19*
+42 -1
View File
@@ -53,6 +53,47 @@ VoIdeaAI имеет Telegram бота для быстрого создания
- `/idea <текст>` — создать новую идею
- `/help` — справка
## Воронка идей (Idea Funnel)
Отслеживайте прогресс идей по воронке:
1. **Сырая (raw)** — только что созданная идея
2. **Подтверждённая (validated)** — идея прошла первичную оценку
3. **Бэклог (backlog)** — запланирована к реализации
4. **В работе (in_progress)** — активная работа
5. **Запущена (launched)** — реализована
6. **Ретроспектива (retrospective)** — анализ результатов
Переключайтесь между **Списком** и **Канбан-доской** на дашборде. Перемещайте идеи по воронке кнопками ← → в карточках канбана или через API.
## Рабочие пространства (Workspaces)
Личное рабочее пространство создаётся автоматически при регистрации. Командные пространства доступны по тарифу:
- **Personal** — 1 на пользователя, всегда бесплатно
- **Team** — создаются владельцем, участники добавляются по email
- Переключайтесь между пространствами через выпадающий список в шапке
- Управляйте участниками в настройках пространства
## Совместная работа (Collaboration)
На странице идеи доступны:
- **Голосование** — 👍/👎 оценивайте идеи
- **Комментарии** — threaded обсуждения с ответами
- **Активность** — лента изменений и действий
- **Уведомления** — колокольчик в шапке, отметки о прочитанном
## Интеграция с календарём
Добавляйте идеи в календарь одним нажатием кнопки «+ Календарь» на странице идеи:
- **Google Calendar** — через OAuth (требуется scope calendar.events)
- **Yandex Календарь** — через OAuth (требуется scope calendar:event.write)
- **Apple Calendar (CalDAV)** — в разработке
Подключите календарь в Настройки → Интеграции → Календарь.
## Настройки
В разделе «Настройки» доступно:
@@ -60,5 +101,5 @@ VoIdeaAI имеет Telegram бота для быстрого создания
- Безопасность (смена пароля, OAuth привязка)
- Голос (настройки микрофона, TTS)
- Тема (светлая/тёмная)
- Интеграции (Яндекс.Диск)
- Интеграции (Яндекс.Диск, Календарь)
- Тариф (информация о подписке)
@@ -0,0 +1,28 @@
// Flutter web plugin registrant file.
//
// Generated file. Do not edit.
//
// @dart = 2.13
// ignore_for_file: type=lint
import 'package:connectivity_plus/src/connectivity_plus_web.dart';
import 'package:flutter_native_splash/flutter_native_splash_web.dart';
import 'package:flutter_secure_storage_web/flutter_secure_storage_web.dart';
import 'package:package_info_plus/src/package_info_plus_web.dart';
import 'package:share_plus/src/share_plus_web.dart';
import 'package:speech_to_text/speech_to_text_web.dart';
import 'package:url_launcher_web/url_launcher_web.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
void registerPlugins([final Registrar? pluginRegistrar]) {
final Registrar registrar = pluginRegistrar ?? webPluginRegistrar;
ConnectivityPlusWebPlugin.registerWith(registrar);
FlutterNativeSplashWeb.registerWith(registrar);
FlutterSecureStorageWeb.registerWith(registrar);
PackageInfoPlusWebPlugin.registerWith(registrar);
SharePlusWebPlugin.registerWith(registrar);
SpeechToTextPlugin.registerWith(registrar);
UrlLauncherPlugin.registerWith(registrar);
registrar.registerMessageHandler();
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json: C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json
@@ -0,0 +1 @@
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml","d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json"]}
@@ -0,0 +1 @@
{"build_start":"2026-05-15T12:29:51.171303","build_end":"2026-05-15T12:30:01.542917","dependencies":["file:///C:/Users/angel/flutter/bin/cache/dart-sdk/version","file:///D:/YandexDisk/voidea/flutter/.dart_tool/package_config.json","file:///D:/YandexDisk/voidea/flutter/pubspec.yaml","file:///d:/yandexdisk/voidea/flutter/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"inputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\dart_plugin_registrant.dart"]}
@@ -0,0 +1 @@
{"inputs":[],"outputs":[]}
@@ -0,0 +1 @@
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json:
@@ -0,0 +1 @@
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json"]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"format-version":[1,0,0],"native-assets":{}}
@@ -0,0 +1 @@
["D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\vm_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\isolate_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\kernel_blob.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\fonts\\MaterialIcons-Regular.otf","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\ink_sparkle.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\stretch_effect.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\AssetManifest.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\FontManifest.json","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NOTICES.Z","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NativeAssetsManifest.json"]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json: C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json
@@ -0,0 +1 @@
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml","d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json"]}
@@ -0,0 +1 @@
{"build_start":"2026-05-15T12:32:59.437301","build_end":"2026-05-15T12:33:16.643706","dependencies":["file:///C:/Users/angel/flutter/bin/cache/dart-sdk/version","file:///D:/YandexDisk/voidea/flutter/.dart_tool/package_config.json","file:///D:/YandexDisk/voidea/flutter/pubspec.yaml","file:///d:/yandexdisk/voidea/flutter/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"inputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\dart_plugin_registrant.dart"]}
@@ -0,0 +1 @@
{"inputs":[],"outputs":[]}
@@ -0,0 +1 @@
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json:
@@ -0,0 +1 @@
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json"]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"format-version":[1,0,0],"native-assets":{}}
@@ -0,0 +1 @@
["D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\vm_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\isolate_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\kernel_blob.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\fonts\\MaterialIcons-Regular.otf","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\ink_sparkle.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\stretch_effect.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\AssetManifest.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\FontManifest.json","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NOTICES.Z","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NativeAssetsManifest.json"]
@@ -0,0 +1,262 @@
//
// Generated file. Do not edit.
// This file is generated from template in file `flutter_tools/lib/src/flutter_plugins.dart`.
//
// @dart = 3.4
import 'dart:io'; // flutter_ignore: dart_io_import.
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
import 'package:path_provider_android/path_provider_android.dart' as path_provider_android;
import 'package:sqflite_android/sqflite_android.dart' as sqflite_android;
import 'package:url_launcher_android/url_launcher_android.dart' as url_launcher_android;
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
import 'package:path_provider_foundation/path_provider_foundation.dart' as path_provider_foundation;
import 'package:sqflite_darwin/sqflite_darwin.dart' as sqflite_darwin;
import 'package:url_launcher_ios/url_launcher_ios.dart' as url_launcher_ios;
import 'package:connectivity_plus/connectivity_plus.dart' as connectivity_plus;
import 'package:flutter_local_notifications_linux/flutter_local_notifications_linux.dart' as flutter_local_notifications_linux;
import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
import 'package:path_provider_linux/path_provider_linux.dart' as path_provider_linux;
import 'package:share_plus/share_plus.dart' as share_plus;
import 'package:url_launcher_linux/url_launcher_linux.dart' as url_launcher_linux;
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
import 'package:path_provider_foundation/path_provider_foundation.dart' as path_provider_foundation;
import 'package:sqflite_darwin/sqflite_darwin.dart' as sqflite_darwin;
import 'package:url_launcher_macos/url_launcher_macos.dart' as url_launcher_macos;
import 'package:flutter_secure_storage_windows/flutter_secure_storage_windows.dart' as flutter_secure_storage_windows;
import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
import 'package:path_provider_windows/path_provider_windows.dart' as path_provider_windows;
import 'package:share_plus/share_plus.dart' as share_plus;
import 'package:speech_to_text_windows/speech_to_text_windows.dart' as speech_to_text_windows;
import 'package:url_launcher_windows/url_launcher_windows.dart' as url_launcher_windows;
@pragma('vm:entry-point')
class _PluginRegistrant {
@pragma('vm:entry-point')
static void register() {
if (Platform.isAndroid) {
try {
flutter_local_notifications.AndroidFlutterLocalNotificationsPlugin.registerWith();
} catch (err) {
print(
'`flutter_local_notifications` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
path_provider_android.PathProviderAndroid.registerWith();
} catch (err) {
print(
'`path_provider_android` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
sqflite_android.SqfliteAndroid.registerWith();
} catch (err) {
print(
'`sqflite_android` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
url_launcher_android.UrlLauncherAndroid.registerWith();
} catch (err) {
print(
'`url_launcher_android` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
} else if (Platform.isIOS) {
try {
flutter_local_notifications.IOSFlutterLocalNotificationsPlugin.registerWith();
} catch (err) {
print(
'`flutter_local_notifications` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
path_provider_foundation.PathProviderFoundation.registerWith();
} catch (err) {
print(
'`path_provider_foundation` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
sqflite_darwin.SqfliteDarwin.registerWith();
} catch (err) {
print(
'`sqflite_darwin` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
url_launcher_ios.UrlLauncherIOS.registerWith();
} catch (err) {
print(
'`url_launcher_ios` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
} else if (Platform.isLinux) {
try {
connectivity_plus.ConnectivityPlusLinuxPlugin.registerWith();
} catch (err) {
print(
'`connectivity_plus` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
flutter_local_notifications_linux.LinuxFlutterLocalNotificationsPlugin.registerWith();
} catch (err) {
print(
'`flutter_local_notifications_linux` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
package_info_plus.PackageInfoPlusLinuxPlugin.registerWith();
} catch (err) {
print(
'`package_info_plus` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
path_provider_linux.PathProviderLinux.registerWith();
} catch (err) {
print(
'`path_provider_linux` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
share_plus.SharePlusLinuxPlugin.registerWith();
} catch (err) {
print(
'`share_plus` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
url_launcher_linux.UrlLauncherLinux.registerWith();
} catch (err) {
print(
'`url_launcher_linux` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
} else if (Platform.isMacOS) {
try {
flutter_local_notifications.MacOSFlutterLocalNotificationsPlugin.registerWith();
} catch (err) {
print(
'`flutter_local_notifications` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
path_provider_foundation.PathProviderFoundation.registerWith();
} catch (err) {
print(
'`path_provider_foundation` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
sqflite_darwin.SqfliteDarwin.registerWith();
} catch (err) {
print(
'`sqflite_darwin` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
url_launcher_macos.UrlLauncherMacOS.registerWith();
} catch (err) {
print(
'`url_launcher_macos` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
} else if (Platform.isWindows) {
try {
flutter_secure_storage_windows.FlutterSecureStorageWindows.registerWith();
} catch (err) {
print(
'`flutter_secure_storage_windows` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
package_info_plus.PackageInfoPlusWindowsPlugin.registerWith();
} catch (err) {
print(
'`package_info_plus` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
path_provider_windows.PathProviderWindows.registerWith();
} catch (err) {
print(
'`path_provider_windows` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
share_plus.SharePlusWindowsPlugin.registerWith();
} catch (err) {
print(
'`share_plus` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
speech_to_text_windows.SpeechToTextWindows.registerWith();
} catch (err) {
print(
'`speech_to_text_windows` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
try {
url_launcher_windows.UrlLauncherWindows.registerWith();
} catch (err) {
print(
'`url_launcher_windows` threw an error: $err. '
'The app may not function as expected until you remove this plugin from pubspec.yaml'
);
}
}
}
}
@@ -0,0 +1 @@
Last acquired by C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart.exe (pid 48536) running file:///C:/Users/angel/flutter/bin/cache/flutter_tools.snapshot on 2026-05-15 12:33:08.791541.
@@ -0,0 +1 @@
{"file_system":[],"environment":[{"key":"PATH","hash":5744524113912922215},{"key":"PROGRAMDATA","hash":-3479985355006727299},{"key":"SYSTEMDRIVE","hash":2892017696301558487},{"key":"SYSTEMROOT","hash":2713241238173206065},{"key":"TEMP","hash":-5046929535283615468},{"key":"TMP","hash":-5046929535283615468},{"key":"USERPROFILE","hash":-6023473899643495512},{"key":"WINDIR","hash":2713241238173206065}]}
@@ -0,0 +1 @@
{"file_system":[{"path":"d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","hash":-1483076285095613998}],"environment":[]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"assets": {},
"config": {
"build_asset_types": [
"code_assets/code"
],
"extensions": {
"code_assets": {
"android": {
"target_ndk_api": 24
},
"c_compiler": {
"ar": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\llvm-ar.exe",
"cc": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\clang.exe",
"ld": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\ld.lld.exe"
},
"link_mode_preference": "dynamic",
"target_architecture": "arm64",
"target_os": "android"
}
},
"linking_enabled": false
},
"out_dir_shared": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\shared\\objective_c\\build\\",
"out_file": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\objective_c\\193e397ea4\\output.json",
"package_name": "objective_c",
"package_root": "C:\\Users\\angel\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\objective_c-9.3.0\\",
"user_defines": {}
}
@@ -0,0 +1,5 @@
{
"assets_for_linking": {},
"status": "success",
"timestamp": "2026-05-15 12:30:00.000"
}
@@ -0,0 +1 @@
Running `(cd C:\Users\angel\AppData\Local\Pub\Cache\hosted\pub.dev\objective_c-9.3.0\; PATH=C:\Program Files\Android\Android Studio\jbr\bin;C:\Python\Python38-32\Scripts\;C:\Python\Python38-32\;C:\ProgramData\Oracle\Java\javapath;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files (x86)\LonWorks\bin;C:\Users\angel\AppData\Local\Microsoft\WindowsApps;C:\adb;D:\BatchApkTool;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files (x86)\Microsoft SQL Server\110\Tools\Binn\;C:\Program Files (x86)\Microsoft SQL Server\110\DTS\Binn\;C:\Program Files (x86)\Windows Kits\8.1\Windows Performance Toolkit\;C:\Program Files\Intel\WiFi\bin\;C:\Program Files\Common Files\Intel\WirelessCommon\;C:\Program Files (x86)\Microsoft SQL Server\150\DTS\Binn\;C:\Program Files\Git\cmd;C:\Program Files\nodejs\;C:\Users\angel\AppData\Local\Microsoft\WindowsApps;C:\Program Files\Intel\WiFi\bin\;C:\Program Files\Common Files\Intel\WirelessCommon\;C:\Users\angel\AppData\Roaming\npm;C:\Users\angel\AppData\Local\Python\bin;C:\Users\angel\AppData\Local\Programs\Microsoft VS Code\bin;C:\Users\angel\flutter\bin PROGRAMDATA=C:\ProgramData SYSTEMDRIVE=C: SYSTEMROOT=C:\WINDOWS TEMP=C:\Users\angel\AppData\Local\Temp TMP=C:\Users\angel\AppData\Local\Temp USERPROFILE=C:\Users\angel WINDIR=C:\WINDOWS C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart --packages=D:\YandexDisk\voidea\flutter\.dart_tool\package_config.json D:\YandexDisk\voidea\flutter\.dart_tool\hooks_runner\objective_c\193e397ea4\hook.dill --config=D:\YandexDisk\voidea\flutter\.dart_tool\hooks_runner\objective_c\193e397ea4\input.json )`.
@@ -0,0 +1 @@
Last acquired by C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart.exe (pid 48536) running file:///C:/Users/angel/flutter/bin/cache/flutter_tools.snapshot on 2026-05-15 12:32:59.667160.
@@ -0,0 +1 @@
{"file_system":[],"environment":[{"key":"PATH","hash":5744524113912922215},{"key":"PROGRAMDATA","hash":-3479985355006727299},{"key":"SYSTEMDRIVE","hash":2892017696301558487},{"key":"SYSTEMROOT","hash":2713241238173206065},{"key":"TEMP","hash":-5046929535283615468},{"key":"TMP","hash":-5046929535283615468},{"key":"USERPROFILE","hash":-6023473899643495512},{"key":"WINDIR","hash":2713241238173206065}]}
@@ -0,0 +1 @@
{"file_system":[{"path":"d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","hash":-1483076285095613998}],"environment":[]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"assets": {},
"config": {
"build_asset_types": [
"code_assets/code"
],
"extensions": {
"code_assets": {
"android": {
"target_ndk_api": 24
},
"c_compiler": {
"ar": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\llvm-ar.exe",
"cc": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\clang.exe",
"ld": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\ld.lld.exe"
},
"link_mode_preference": "dynamic",
"target_architecture": "arm",
"target_os": "android"
}
},
"linking_enabled": false
},
"out_dir_shared": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\shared\\objective_c\\build\\",
"out_file": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\objective_c\\4d03ae159a\\output.json",
"package_name": "objective_c",
"package_root": "C:\\Users\\angel\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\objective_c-9.3.0\\",
"user_defines": {}
}

Some files were not shown because too many files have changed in this diff Show More