feat: initial MAIA monorepo - frontend, backend, diarization API

- PWA React 18 + Vite + TypeScript com gravacao continua e upload de chunks
- Backend FastAPI + Celery + PostgreSQL + Redis para processamento assincrono
- Diarization API com SpeechBrain ECAPA-TDNN para identificacao de speakers
- Integracao com Whisper.cpp GPU (large-v3) para transcricao
- Integracao com LLM deepseek-v3.1 para analise de reunioes
- Autenticacao SSO via Keycloak
- README.md completo com arquitetura, instalacao e configuracao
- CHANGELOG.md com historico v1.0.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Arlei Nalasso 2026-04-23 17:52:10 -03:00
commit 34e0661b0d
110 changed files with 16777 additions and 0 deletions

77
.gitignore vendored Normal file
View File

@ -0,0 +1,77 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
.venv/
env/
ENV/
*.egg-info/
dist/
build/
.eggs/
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
*.log
# Node.js / Frontend
node_modules/
dist/
.next/
out/
.nuxt/
*.local
# Environment
.env
.env.local
.env.*.local
.env.production
*.env
# OS
.DS_Store
Thumbs.db
desktop.ini
# IDEs
.vscode/
.idea/
*.swp
*.swo
# Docker / Podman
*.tar
*.tar.gz
# Data / Models (não versionar modelos grandes)
*.bin
*.pt
*.onnx
voice_db/
speechbrain_cache/
uploads/
media/
# Audio files (storage)
storage/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Tests
.coverage
htmlcov/
.pytest_cache/
# Alembic (manter só as migrations, não o cache)
__pycache__/

75
CHANGELOG.md Normal file
View File

@ -0,0 +1,75 @@
# Changelog
Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
---
## [1.0.0] - 2026-04-23
### Adicionado
#### Frontend (PWA)
- PWA (Progressive Web App) instalável com React 18 + Vite + TypeScript
- Gravação contínua de áudio com MediaRecorder API (timeslice, sem gaps entre chunks)
- Upload automático de chunks de áudio com retry automático em caso de falha
- VU meter (visualizador de volume) na tela de gravação
- Autenticação via Keycloak SSO com refresh automático de token
- Tela de histórico de reuniões com busca e filtros
- Tela de detalhes da reunião: transcrição, análise e speakers
- Administração: gerenciamento de usuários, roles e delete de reuniões
- Enrollment de voz para identificação automática de speakers
- Branding MAIA: logo, cores e tipografia ASAP Telecom
- PWA instalável em Android (Chrome) e iOS (Safari)
- Suporte offline básico via Service Worker (Workbox)
#### Backend API
- API REST com FastAPI + SQLAlchemy + Alembic
- PostgreSQL como banco de dados relacional
- Celery + Redis para processamento assíncrono de transcrições e análises
- Recepção e armazenamento de chunks de áudio
- Concatenação automática de chunks ao finalizar reunião
- Integração com Whisper GPU (large-v3) para transcrição
- Integração com Diarization API para identificação de speakers
- Integração com LLM (deepseek-v3.1) para análise e resumo
- Autenticação JWT via Keycloak com verificação de roles
- Endpoint de administração com controle de acesso por role
- Título automático de reunião: "Nome Usuário DD/MM/YYYY, HH:MM"
- Gestão de speakers: cadastro, enrollment e listagem
- API de busca no histórico de reuniões
#### Diarization API
- API FastAPI dedicada para transcrição + diarização
- Integração com Whisper.cpp via HTTP (GPU AMD, servidor dedicado)
- Diarização de speakers com SpeechBrain ECAPA-TDNN (~85% precisão)
- Fallback para pyannote.audio quando necessário
- Banco de vozes para identificação automática de speakers conhecidos
- Enrollment de novas vozes via API
- Processamento de áudio com librosa (normalização, VAD)
- Speaker confidence threshold configurável
#### Infraestrutura
- Containerização com Podman
- Nginx para servir o frontend e reverse proxy
- Serviços gerenciados via systemd
- GPU Server dedicado (10.100.16.13) para Whisper large-v3
- LLM Server com Ollama (deepseek-v3.1:671b-cloud)
- Keycloak para SSO centralizado
---
## [Unreleased]
### Planejado
- Exportação de reuniões em PDF e DOCX
- Integração com Google Calendar e Outlook
- Resumos automáticos por e-mail
- Suporte a múltiplos idiomas na transcrição
- Dashboard de analytics de reuniões
- API pública documentada com Swagger
---
[1.0.0]: https://github.com/ASAPTelecom/Maia/releases/tag/v1.0.0

307
README.md Normal file
View File

@ -0,0 +1,307 @@
# MAIA — Meeting AI Assistant
<p align="center">
<img src="frontend/public/maia-logo.png" alt="MAIA Logo" width="120"/>
</p>
<p align="center">
<strong>Grave, transcreva e analise suas reuniões com Inteligência Artificial</strong>
</p>
<p align="center">
<a href="#-instalação">Instalação</a>
<a href="#-arquitetura">Arquitetura</a>
<a href="#-configuração">Configuração</a>
<a href="#-deploy">Deploy</a>
<a href="#-endpoints-da-api">API</a>
</p>
---
## 📖 Sobre o Projeto
O **MAIA** (Meeting AI Assistant) é uma plataforma desenvolvida pela **ASAP Telecom** para gravar, transcrever e analisar reuniões automaticamente usando Inteligência Artificial.
### ✨ Funcionalidades Principais
- 🎙️ **Gravação contínua** de reuniões via navegador (sem gaps, usando MediaRecorder API com timeslice)
- 📝 **Transcrição automática** com Whisper.cpp na GPU (modelo large-v3, alta precisão)
- 👥 **Diarização de speakers** — identifica quem falou o quê (SpeechBrain ECAPA-TDNN, ~85% precisão)
- 🤖 **Análise com IA** — resumo, pontos de ação e insights com LLM (deepseek-v3.1)
- 🔐 **Autenticação SSO** via Keycloak
- 📱 **PWA instalável** em Android e iOS
- 🔎 **Histórico** com busca de reuniões passadas
- 👤 **Identificação de speakers** por enrollment de voz
---
## 🏗️ Arquitetura
```
┌─────────────────────────────────────────────────────────────────┐
│ CLIENTE │
│ Browser / PWA (React 18 + Vite + TypeScript) │
│ MediaRecorder API → chunks de áudio → upload automático │
└────────────────────────┬────────────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────────────┐
│ APP SERVER (5.0.0.181) │
│ ┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Nginx :80 │ │ Backend API :8090 │ │ Diarization :5060│ │
│ │ (Frontend) │ │ FastAPI + Celery │ │ FastAPI+SpeechBr.│ │
│ └─────────────┘ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────────────┐ ┌────────▼─────────┐ │ │
│ │ PostgreSQL │ │ Redis (Celery) │ │ │
│ └─────────────┘ └──────────────────┘ │ │
└────────────────────────────┬──────────────────────┼────────────┘
│ │
┌──────────────▼──────┐ ┌──────────▼───────────┐
│ GPU SERVER │ │ LLM Server │
│ 10.100.16.13 :5003 │ │ asapai.asaptelecom. │
│ Whisper.cpp GPU │ │ com.br/ollama │
│ AMD GPU, large-v3 │ │ deepseek-v3.1:671b │
└─────────────────────┘ └──────────────────────┘
┌─────────────────────┐
│ Keycloak SSO │
│ Autenticação/Authz │
└─────────────────────┘
```
### 📁 Estrutura do Monorepo
```
Maia/
├── frontend/ # PWA React 18 + Vite + TypeScript
├── backend/ # API FastAPI + Celery + PostgreSQL
├── diarization/ # API de Diarização com SpeechBrain
├── README.md
└── CHANGELOG.md
```
---
## ⚙️ Requisitos
| Componente | Requisito |
|------------|-----------|
| Node.js | 18+ |
| Python | 3.11+ |
| PostgreSQL | 14+ |
| Redis | 7+ |
| GPU | AMD/NVIDIA (para Whisper GPU) |
| RAM | 16 GB mínimo (32 GB recomendado) |
| VRAM | 8 GB mínimo para Whisper large-v3 |
---
## 🚀 Instalação
### Frontend
```bash
cd frontend
npm install
cp .env.example .env
# Edite o .env com as variáveis necessárias
npm run dev # desenvolvimento
npm run build # produção
```
### Backend
```bash
cd backend
python -m venv venv
source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows
pip install -r requirements.txt
cp .env.example .env
# Edite o .env com as variáveis necessárias
alembic upgrade head # aplicar migrações
uvicorn app.main:app --host 0.0.0.0 --port 8090
```
### Diarization API
```bash
cd diarization
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn main:app --host 0.0.0.0 --port 5060
```
---
## 🔧 Configuração
### Variáveis de Ambiente — Backend (`backend/.env`)
```env
DATABASE_URL=postgresql://user:password@localhost:5432/maia
REDIS_URL=redis://localhost:6379/0
KEYCLOAK_URL=https://seu-keycloak.exemplo.com
KEYCLOAK_REALM=seu-realm
KEYCLOAK_CLIENT_ID=maia-backend
OLLAMA_URL=https://llm.exemplo.com/ollama
OLLAMA_MODEL=deepseek-v3.1:671b-cloud
WHISPER_URL=http://gpu-server:5003
```
### Variáveis de Ambiente — Diarization API (`diarization/.env`)
```env
WHISPER_ORIGIN=http://gpu-server:5003
VOICE_DB_PATH=/data/voice_db
SPEAKER_CONFIDENCE_THRESHOLD=0.75
HF_TOKEN=seu_token_huggingface
SPEECHBRAIN_CACHE=/data/speechbrain_cache
```
### Variáveis de Ambiente — Frontend (`frontend/.env`)
```env
VITE_API_URL=http://seu-backend:8090
VITE_KEYCLOAK_URL=https://seu-keycloak.exemplo.com
VITE_KEYCLOAK_REALM=seu-realm
VITE_KEYCLOAK_CLIENT_ID=maia-frontend
```
---
## 🐳 Deploy
### Com Podman/Docker Compose
```bash
# Na raiz do projeto
podman-compose up -d
```
### Deploy Manual
#### Backend (systemd)
```bash
# /etc/systemd/system/maia-backend.service
[Unit]
Description=MAIA Backend API
After=network.target postgresql.service redis.service
[Service]
User=maia
WorkingDirectory=/opt/Backend/meeting-assistant-api
ExecStart=/opt/Backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8090
Restart=always
[Install]
WantedBy=multi-user.target
```
#### Celery Worker
```bash
celery -A app.celery_app worker --loglevel=info -Q transcription,analysis
```
#### Frontend (Nginx)
```nginx
server {
listen 80;
root /var/www/maia;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:8090/;
}
}
```
---
## 🔌 Endpoints Principais da API
### Backend API (`http://server:8090`)
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `POST` | `/meetings/` | Criar nova reunião |
| `GET` | `/meetings/` | Listar reuniões |
| `GET` | `/meetings/{id}` | Detalhes da reunião |
| `DELETE` | `/meetings/{id}` | Deletar reunião |
| `POST` | `/meetings/{id}/upload-chunk` | Upload de chunk de áudio |
| `POST` | `/meetings/{id}/finalize` | Finalizar e processar reunião |
| `GET` | `/meetings/{id}/transcript` | Transcrição completa |
| `GET` | `/meetings/{id}/analysis` | Análise da IA |
| `POST` | `/speakers/enroll` | Enrollar speaker com voz |
| `GET` | `/speakers/` | Listar speakers |
| `GET` | `/admin/users` | (Admin) Listar usuários |
### Diarization API (`http://server:5060`)
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `POST` | `/transcribe` | Transcrever + diarizar áudio |
| `POST` | `/enroll` | Enrollar voz de speaker |
| `GET` | `/speakers` | Listar speakers cadastrados |
| `DELETE` | `/speakers/{name}` | Remover speaker |
| `GET` | `/health` | Health check |
---
## 🛠️ Tecnologias
### Frontend
| Tecnologia | Versão | Uso |
|------------|--------|-----|
| React | 18 | Framework UI |
| Vite | 5 | Build tool |
| TypeScript | 5 | Tipagem estática |
| Zustand | 4 | Gerenciamento de estado |
| Axios | 1.x | Cliente HTTP |
| Workbox | 7 | PWA / Service Worker |
| MediaRecorder API | — | Gravação de áudio |
### Backend
| Tecnologia | Versão | Uso |
|------------|--------|-----|
| FastAPI | 0.110+ | Framework API |
| SQLAlchemy | 2.x | ORM |
| Alembic | 1.x | Migrações de banco |
| PostgreSQL | 14+ | Banco de dados |
| Celery | 5.x | Processamento assíncrono |
| Redis | 7+ | Broker/Cache |
| httpx | 0.27+ | Cliente HTTP assíncrono |
### Diarização & IA
| Tecnologia | Uso |
|------------|-----|
| Whisper.cpp | Transcrição de áudio (GPU) |
| SpeechBrain ECAPA-TDNN | Diarização de speakers |
| pyannote.audio | Diarização (fallback) |
| librosa | Processamento de áudio |
| deepseek-v3.1 | Análise e resumo de reuniões |
### Infraestrutura
| Tecnologia | Uso |
|------------|-----|
| Podman | Containerização |
| Nginx | Reverse proxy / Servir frontend |
| systemd | Gerenciamento de serviços |
| Keycloak | SSO / Autenticação |
---
## 📄 Licença
Proprietário — ASAP Telecom. Todos os direitos reservados.
---
<p align="center">Desenvolvido com ❤️ pela equipe ASAP Telecom</p>

11
backend/.env.example Normal file
View File

@ -0,0 +1,11 @@
WHISPER_SERVER_URL=http://host.docker.internal:9000
OLLAMA_URL=http://host.docker.internal:11434
OLLAMA_MODEL=llama3:8b
DATABASE_URL=postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant
REDIS_URL=redis://redis:6379/0
JWT_SECRET=change_this_to_a_strong_secret_key_in_production
JWT_EXPIRE_MINUTES=60
UPLOAD_MAX_SIZE_MB=500
SPEAKER_CONFIDENCE_THRESHOLD=0.75
AUDIO_STORAGE_PATH=/opt/Backend/meeting-assistant-api/storage/audio
ALLOWED_ORIGINS=*

20
backend/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /opt/Backend/meeting-assistant-api/storage/audio
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

82
backend/README.md Normal file
View File

@ -0,0 +1,82 @@
# Meeting Assistant API
FastAPI orchestration backend for Meeting Assistant - middleware between Mobile App, Whisper, and Ollama.
## Architecture
```
Mobile App → FastAPI API → Whisper (transcription) + Ollama (AI analysis)
→ PostgreSQL (data) + Redis (queues/cache)
→ Celery Worker (async processing)
```
## Quick Start
```bash
cp .env.example .env
docker-compose up -d
docker-compose exec api alembic upgrade head
```
## API Documentation
- **Swagger UI:** http://localhost:8080/docs
- **ReDoc:** http://localhost:8080/redoc
## Endpoints
### Auth
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login (returns JWT)
- `POST /api/auth/refresh` - Refresh access token
- `GET /api/auth/me` - Get current user
### Meetings
- `POST /api/meetings/upload` - Upload audio + trigger processing
- `GET /api/meetings` - List meetings
- `GET /api/meetings/{id}` - Get meeting details
- `GET /api/meetings/{id}/status` - Get processing status
- `GET /api/meetings/{id}/analyses` - Get all analyses
- `GET /api/meetings/{id}/analyses/{type}` - Get specific analysis
- `PUT /api/meetings/{id}/participants/{label}` - Update speaker name
- `DELETE /api/meetings/{id}` - Delete meeting
### WebSocket
- `WS /ws/meetings/{meeting_id}` - Real-time status updates
### Health
- `GET /health` - Health check
## Processing Pipeline
1. Audio uploaded → saved to storage/
2. Celery task queued
3. Audio sent to Whisper for diarized transcription
4. Transcription sent to Ollama for parallel analyses:
- `minutes` - Structured meeting minutes
- `sentiment` - Emotional tone analysis
- `psychological` - Behavioral patterns
- `communication` - Communication quality feedback
- `summary` - Executive summary
5. Status updated to `completed`
6. WebSocket notification sent
## Environment Variables
See `.env.example` for all configuration options.
## Development
```bash
# View logs
docker-compose logs -f api
# Run migrations
docker-compose exec api alembic upgrade head
# Create new migration
docker-compose exec api alembic revision --autogenerate -m "description"
# Check celery worker
docker-compose logs -f worker
```

41
backend/alembic.ini Normal file
View File

@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

57
backend/alembic/env.py Normal file
View File

@ -0,0 +1,57 @@
import os
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
database_url = os.environ.get("DATABASE_URL", "")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
from app.database import Base
from app.models.user import User
from app.models.meeting import Meeting
from app.models.analysis import Analysis
from app.models.participant import MeetingParticipant
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,89 @@
"""initial_schema
Revision ID: 5965c1a94621
Revises:
Create Date: 2026-04-13 20:25:03.459694
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '5965c1a94621'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('password_hash', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_table('meetings',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('title', sa.String(), nullable=True),
sa.Column('date', sa.DateTime(), nullable=False),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('audio_path', sa.String(), nullable=True),
sa.Column('status', sa.Enum('uploading', 'processing', 'transcribing', 'analyzing', 'completed', 'error', name='meetingstatus'), nullable=False),
sa.Column('transcription', sa.JSON(), nullable=True),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_meetings_id'), 'meetings', ['id'], unique=False)
op.create_index(op.f('ix_meetings_user_id'), 'meetings', ['user_id'], unique=False)
op.create_table('analyses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('meeting_id', sa.UUID(), nullable=False),
sa.Column('type', sa.Enum('minutes', 'sentiment', 'psychological', 'communication', 'summary', name='analysistype'), nullable=False),
sa.Column('content', sa.JSON(), nullable=False),
sa.Column('model_used', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['meeting_id'], ['meetings.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_analyses_id'), 'analyses', ['id'], unique=False)
op.create_index(op.f('ix_analyses_meeting_id'), 'analyses', ['meeting_id'], unique=False)
op.create_table('meeting_participants',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('meeting_id', sa.UUID(), nullable=False),
sa.Column('speaker_label', sa.String(), nullable=False),
sa.Column('voice_profile_id', sa.UUID(), nullable=True),
sa.Column('identified_name', sa.String(), nullable=True),
sa.Column('confidence_score', sa.Float(), nullable=True),
sa.Column('talk_time_seconds', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['meeting_id'], ['meetings.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_meeting_participants_id'), 'meeting_participants', ['id'], unique=False)
op.create_index(op.f('ix_meeting_participants_meeting_id'), 'meeting_participants', ['meeting_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_meeting_participants_meeting_id'), table_name='meeting_participants')
op.drop_index(op.f('ix_meeting_participants_id'), table_name='meeting_participants')
op.drop_table('meeting_participants')
op.drop_index(op.f('ix_analyses_meeting_id'), table_name='analyses')
op.drop_index(op.f('ix_analyses_id'), table_name='analyses')
op.drop_table('analyses')
op.drop_index(op.f('ix_meetings_user_id'), table_name='meetings')
op.drop_index(op.f('ix_meetings_id'), table_name='meetings')
op.drop_table('meetings')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

0
backend/app/__init__.py Normal file
View File

22
backend/app/config.py Normal file
View File

@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
WHISPER_SERVER_URL: str = "http://localhost:9000"
OLLAMA_URL: str = "http://localhost:11434"
OLLAMA_MODEL: str = "llama3:8b"
OLLAMA_API_KEY: str = ""
DATABASE_URL: str = "postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant"
REDIS_URL: str = "redis://redis:6379/0"
JWT_SECRET: str = "change_this_to_a_strong_secret_key_in_production"
JWT_EXPIRE_MINUTES: int = 60
JWT_REFRESH_EXPIRE_DAYS: int = 7
UPLOAD_MAX_SIZE_MB: int = 500
SPEAKER_CONFIDENCE_THRESHOLD: float = 0.75
AUDIO_STORAGE_PATH: str = "/opt/Backend/meeting-assistant-api/storage/audio"
ALLOWED_ORIGINS: str = "*"
class Config:
env_file = ".env"
extra = "allow"
settings = Settings()

25
backend/app/database.py Normal file
View File

@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import settings
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

92
backend/app/main.py Normal file
View File

@ -0,0 +1,92 @@
import logging
import json
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
from app.routers import auth, meetings, analyses
from app.config import settings
from app.services.websocket_service import manager
class JSONFormatter(logging.Formatter):
def format(self, record):
log_record = {
"level": record.levelname,
"message": record.getMessage(),
"logger": record.name,
"time": self.formatTime(record),
}
if record.exc_info:
log_record["exception"] = self.formatException(record.exc_info)
return json.dumps(log_record, ensure_ascii=False)
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logging.basicConfig(handlers=[handler], level=logging.INFO, force=True)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(json.dumps({"event": "startup", "message": "Meeting Assistant API starting"}))
yield
logger.info(json.dumps({"event": "shutdown", "message": "Meeting Assistant API stopping"}))
app = FastAPI(
title="Meeting Assistant API",
description="Orchestration server for Meeting Assistant - middleware between Mobile App and AI services",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
origins = settings.ALLOWED_ORIGINS.split(",") if settings.ALLOWED_ORIGINS != "*" else ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(meetings.router, prefix="/api/meetings", tags=["meetings"])
app.include_router(analyses.router, prefix="/api", tags=["analyses"])
@app.get("/health", tags=["health"])
async def health_check():
return {
"status": "healthy",
"service": "meeting-assistant-api",
"version": "1.0.0",
}
@app.websocket("/ws/meetings/{meeting_id}")
async def websocket_endpoint(websocket: WebSocket, meeting_id: str):
await manager.connect(websocket, meeting_id)
try:
while True:
data = await websocket.receive_text()
await manager.send_message(meeting_id, {"type": "ping", "data": data})
except WebSocketDisconnect:
manager.disconnect(websocket, meeting_id)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(json.dumps({
"event": "unhandled_exception",
"error": str(exc),
"path": str(request.url),
"method": request.method,
}))
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)

View File

@ -0,0 +1,4 @@
from app.models.user import User
from app.models.meeting import Meeting, MeetingStatus
from app.models.analysis import Analysis, AnalysisType
from app.models.participant import MeetingParticipant

View File

@ -0,0 +1,28 @@
import uuid
import enum
from datetime import datetime
from sqlalchemy import Column, String, DateTime, JSON, Enum, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class AnalysisType(str, enum.Enum):
minutes = "minutes"
sentiment = "sentiment"
psychological = "psychological"
communication = "communication"
summary = "summary"
class Analysis(Base):
__tablename__ = "analyses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
meeting_id = Column(UUID(as_uuid=True), ForeignKey("meetings.id"), nullable=False, index=True)
type = Column(Enum(AnalysisType), nullable=False)
content = Column(JSON, nullable=False, default=dict)
model_used = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
meeting = relationship("Meeting", back_populates="analyses")

View File

@ -0,0 +1,41 @@
import uuid
import enum
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer, JSON, Enum, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class MeetingStatus(str, enum.Enum):
receiving_chunks = "receiving_chunks"
uploading = "uploading"
processing = "processing"
transcribing = "transcribing"
analyzing = "analyzing"
completed = "completed"
error = "error"
class Meeting(Base):
__tablename__ = "meetings"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
title = Column(String, nullable=True)
date = Column(DateTime, default=datetime.utcnow, nullable=False)
duration = Column(Integer, nullable=True)
audio_path = Column(String, nullable=True)
status = Column(Enum(MeetingStatus), default=MeetingStatus.uploading, nullable=False)
transcription = Column(JSON, nullable=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
user = relationship("User", back_populates="meetings")
analyses = relationship("Analysis", back_populates="meeting", cascade="all, delete-orphan")
participants = relationship("MeetingParticipant", back_populates="meeting", cascade="all, delete-orphan")
@property
def owner_email(self):
return self.user.email if self.user else None
@property
def owner_name(self):
return self.user.name if self.user else None

View File

@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer, Float, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class MeetingParticipant(Base):
__tablename__ = "meeting_participants"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
meeting_id = Column(UUID(as_uuid=True), ForeignKey("meetings.id"), nullable=False, index=True)
speaker_label = Column(String, nullable=False)
voice_profile_id = Column(UUID(as_uuid=True), nullable=True)
identified_name = Column(String, nullable=True)
confidence_score = Column(Float, nullable=True)
talk_time_seconds = Column(Integer, nullable=True)
meeting = relationship("Meeting", back_populates="participants")

View File

@ -0,0 +1,18 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, DateTime, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
name = Column(String, nullable=False)
roles = Column(JSON, default=list, nullable=False, server_default="[]")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
meetings = relationship("Meeting", back_populates="user", cascade="all, delete-orphan")

View File

@ -0,0 +1 @@
from app.routers import auth, meetings, analyses

View File

@ -0,0 +1,5 @@
from fastapi import APIRouter
router = APIRouter()
# Analysis endpoints are served through /api/meetings/{id}/analyses
# See app/routers/meetings.py for implementation

View File

@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.auth import UserRegister, UserLogin, TokenRefresh, TokenResponse, UserResponse
from app.services.auth_service import (
register_user,
login_user,
refresh_tokens,
get_current_user,
get_or_create_sso_user,
)
from app.utils.keycloak_auth import validate_keycloak_token, has_role, extract_user_info
from app.utils.security import create_access_token, create_refresh_token
MAIA_ROLES = ["maia.app", "maia.admin"]
router = APIRouter()
_bearer = HTTPBearer()
@router.post("/register", response_model=UserResponse, status_code=201)
def register(data: UserRegister, db: Session = Depends(get_db)):
return register_user(db, data.email, data.password, data.name)
@router.post("/login", response_model=TokenResponse)
def login(data: UserLogin, db: Session = Depends(get_db)):
return login_user(db, data.email, data.password)
@router.post("/refresh", response_model=TokenResponse)
def refresh(data: TokenRefresh):
return refresh_tokens(data.refresh_token)
@router.get("/me", response_model=UserResponse)
def me(current_user=Depends(get_current_user)):
return current_user
class KeycloakAuthRequest(BaseModel):
kc_token: str
@router.post("/keycloak", response_model=TokenResponse)
def keycloak_login(data: KeycloakAuthRequest, db: Session = Depends(get_db)):
"""Exchange a Keycloak RS256 access token for internal HS256 JWT tokens."""
payload = validate_keycloak_token(data.kc_token)
if not payload:
raise HTTPException(status_code=401, detail="Token Keycloak invalido ou expirado")
if not has_role(payload, "maia.app"):
raise HTTPException(status_code=403, detail="Acesso negado: role maia.app nao encontrada")
user_info = extract_user_info(payload)
# Collect all maia.* roles the user has
user_roles = [r for r in MAIA_ROLES if has_role(payload, r)]
user = get_or_create_sso_user(db, user_info, roles=user_roles)
access_token = create_access_token(str(user.id))
refresh_token = create_refresh_token(str(user.id))
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}

View File

@ -0,0 +1,378 @@
from datetime import datetime
import uuid
from fastapi.responses import FileResponse
import os
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form, Query, Body
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models.meeting import Meeting, MeetingStatus
from app.models.participant import MeetingParticipant
from app.schemas.meeting import MeetingResponse, MeetingStatusResponse, ParticipantUpdate, ParticipantResponse
from app.services.auth_service import get_current_user, is_admin
from app.services.upload_service import save_audio_file
from app.services.chunk_service import save_chunk, assemble_chunks, cleanup_chunks
from app.tasks.processing import process_meeting
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
AUDIO_STORAGE = "/opt/Backend/meeting-assistant-api/storage/audio"
def _get_meeting_for_user(meeting_id: str, current_user, db: Session) -> Meeting:
"""Return meeting if user owns it or is admin. Raises 404 otherwise."""
q = db.query(Meeting).options(joinedload(Meeting.user)).filter(Meeting.id == meeting_id)
if not is_admin(current_user):
q = q.filter(Meeting.user_id == current_user.id)
meeting = q.first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
return meeting
@router.post("/upload", response_model=MeetingResponse, status_code=201)
async def upload_meeting(
file: UploadFile = File(...),
title: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
meeting_id = str(uuid.uuid4())
audio_path = await save_audio_file(file, meeting_id)
meeting = Meeting(
id=meeting_id, title=title or file.filename or "Meeting",
audio_path=audio_path, status=MeetingStatus.uploading, user_id=current_user.id,
)
db.add(meeting)
db.commit()
db.refresh(meeting)
process_meeting.delay(meeting_id)
logger.info(f"Meeting {meeting_id} created and queued for processing")
return meeting
@router.post("/start", response_model=MeetingResponse, status_code=201)
async def start_meeting(
title: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
meeting_id = str(uuid.uuid4())
from datetime import timezone, timedelta
br_tz = timezone(timedelta(hours=-3))
now_br = datetime.now(br_tz)
default_title = f"{current_user.name} {now_br.strftime('%d/%m/%Y, %H:%M')}"
meeting = Meeting(
id=meeting_id, title=title or default_title,
status=MeetingStatus.receiving_chunks, user_id=current_user.id,
)
db.add(meeting)
db.commit()
db.refresh(meeting)
logger.info(f"Meeting {meeting_id} started (chunk mode)")
return meeting
@router.get("", response_model=List[MeetingResponse])
def list_meetings(
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
"""List meetings. Admins see all meetings with owner info; users see only their own."""
q = db.query(Meeting).options(joinedload(Meeting.user))
if not is_admin(current_user):
q = q.filter(Meeting.user_id == current_user.id)
meetings = q.order_by(Meeting.created_at.desc()).offset(skip).limit(limit).all()
return meetings
@router.post("/{meeting_id}/chunks", status_code=200)
async def upload_chunk(
meeting_id: str,
file: UploadFile = File(...),
chunk_index: int = Form(...),
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
meeting = db.query(Meeting).filter(
Meeting.id == meeting_id, Meeting.user_id == current_user.id
).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
if meeting.status not in (MeetingStatus.receiving_chunks, MeetingStatus.uploading):
raise HTTPException(status_code=409, detail=f"Meeting not accepting chunks (status: {meeting.status})")
ext = os.path.splitext(file.filename or "chunk.webm")[1].lower() or ".webm"
data = await file.read()
save_chunk(data, meeting_id, chunk_index, ext)
logger.info(f"Chunk {chunk_index} received for meeting {meeting_id} ({len(data)} bytes)")
return {"meeting_id": meeting_id, "chunk_index": chunk_index, "size": len(data)}
@router.post("/{meeting_id}/finalize", response_model=MeetingResponse)
async def finalize_meeting(
meeting_id: str,
title: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
meeting = db.query(Meeting).filter(
Meeting.id == meeting_id, Meeting.user_id == current_user.id
).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
if title and not meeting.title:
meeting.title = title
try:
audio_path = assemble_chunks(meeting_id)
meeting.audio_path = audio_path
meeting.status = MeetingStatus.uploading
db.commit()
cleanup_chunks(meeting_id)
process_meeting.delay(meeting_id)
logger.info(f"Meeting {meeting_id} finalized and queued for processing")
except Exception as e:
meeting.status = MeetingStatus.error
db.commit()
logger.error(f"Failed to finalize meeting {meeting_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to assemble audio: {str(e)}")
db.refresh(meeting)
return meeting
@router.get("/{meeting_id}", response_model=MeetingResponse)
def get_meeting(
meeting_id: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
return _get_meeting_for_user(meeting_id, current_user, db)
@router.get("/{meeting_id}/status", response_model=MeetingStatusResponse)
def get_meeting_status(
meeting_id: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
return _get_meeting_for_user(meeting_id, current_user, db)
@router.get("/{meeting_id}/audio")
def get_meeting_audio(
meeting_id: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
"""Stream the audio file for a meeting."""
meeting = _get_meeting_for_user(meeting_id, current_user, db)
if not meeting.audio_path or not os.path.exists(meeting.audio_path):
# Try common extensions
for ext in [".webm", ".wav", ".m4a", ".mp3", ".ogg"]:
candidate = os.path.join(AUDIO_STORAGE, f"{meeting_id}{ext}")
if os.path.exists(candidate):
meeting.audio_path = candidate
break
else:
raise HTTPException(status_code=404, detail="Audio file not found")
ext = os.path.splitext(meeting.audio_path)[1].lower()
mime_map = {".webm": "audio/webm", ".wav": "audio/wav", ".mp3": "audio/mpeg",
".m4a": "audio/mp4", ".ogg": "audio/ogg"}
media_type = mime_map.get(ext, "audio/webm")
return FileResponse(meeting.audio_path, media_type=media_type)
@router.get("/{meeting_id}/analyses")
def get_meeting_analyses(
meeting_id: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
meeting = _get_meeting_for_user(meeting_id, current_user, db)
return meeting.analyses
@router.get("/{meeting_id}/analyses/{analysis_type}")
def get_meeting_analysis_by_type(
meeting_id: str,
analysis_type: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
meeting = _get_meeting_for_user(meeting_id, current_user, db)
from app.models.analysis import AnalysisType, Analysis
try:
atype = AnalysisType(analysis_type)
except ValueError:
raise HTTPException(status_code=400,
detail=f"Invalid analysis type '{analysis_type}'. Valid: minutes, sentiment, psychological, communication, summary")
analysis = db.query(Analysis).filter(
Analysis.meeting_id == meeting_id, Analysis.type == atype
).first()
if not analysis:
raise HTTPException(status_code=404, detail=f"Analysis '{analysis_type}' not yet available")
return analysis
@router.put("/{meeting_id}/participants/{speaker_label}", response_model=ParticipantResponse)
def update_participant(
meeting_id: str,
speaker_label: str,
data: ParticipantUpdate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
meeting = _get_meeting_for_user(meeting_id, current_user, db)
participant = db.query(MeetingParticipant).filter(
MeetingParticipant.meeting_id == meeting_id,
MeetingParticipant.speaker_label == speaker_label,
).first()
if not participant:
participant = MeetingParticipant(meeting_id=meeting_id, speaker_label=speaker_label)
db.add(participant)
if data.identified_name is not None:
participant.identified_name = data.identified_name
if data.confidence_score is not None:
participant.confidence_score = data.confidence_score
db.commit()
db.refresh(participant)
return participant
@router.post("/{meeting_id}/participants/{speaker_label}/enroll", response_model=ParticipantResponse)
async def enroll_participant(
meeting_id: str,
speaker_label: str,
name: str = Body(...),
email: str = Body(None),
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
"""Enroll a speaker voice profile and update their name in the transcript."""
import subprocess
import json as _json
import tempfile
meeting = _get_meeting_for_user(meeting_id, current_user, db)
if not meeting.transcription:
raise HTTPException(status_code=400, detail="No transcription available")
if not meeting.audio_path or not os.path.exists(meeting.audio_path):
raise HTTPException(status_code=404, detail="Meeting audio file not found")
segments = meeting.transcription.get("segments", [])
speaker_segments = [s for s in segments if s.get("speaker") == speaker_label]
if not speaker_segments:
raise HTTPException(status_code=404, detail=f"Speaker '{speaker_label}' not found in transcript")
valid_segs = [s for s in speaker_segments if (s.get("end", 0) - s.get("start", 0)) >= 0.5][:12]
tmp_path = None
profile_id = None
try:
if valid_segs:
filter_parts = []
out_parts = []
for i, seg in enumerate(valid_segs):
start = seg["start"]
end = seg["end"]
filter_parts.append(f"[0:a]atrim={start}:{end},asetpts=PTS-STARTPTS[a{i}]")
out_parts.append(f"[a{i}]")
n = len(filter_parts)
filter_str = ";".join(filter_parts) + ";" + "".join(out_parts) + f"concat=n={n}:v=0:a=1[out]"
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
result = subprocess.run(
["ffmpeg", "-y", "-i", meeting.audio_path, "-filter_complex", filter_str,
"-map", "[out]", "-ar", "16000", "-ac", "1", tmp_path],
capture_output=True, timeout=120,
)
audio_source = tmp_path if result.returncode == 0 else meeting.audio_path
else:
audio_source = meeting.audio_path
whisper_url = settings.WHISPER_SERVER_URL
with open(audio_source, "rb") as audio_f:
audio_bytes = audio_f.read()
async with httpx.AsyncClient(timeout=120.0) as http:
resp = await http.post(
f"{whisper_url}/api/v2/enroll",
files={"file": (f"{name.replace(' ', '_')}.wav", audio_bytes, "audio/wav")},
data={
"name": name,
"email": email or "",
"metadata": _json.dumps({"source_meeting": meeting_id, "original_label": speaker_label}),
},
)
if resp.status_code in (200, 201):
profile_data = resp.json()
profile_id = profile_data.get("id")
else:
logger.warning(f"Enrollment API error {resp.status_code}: {resp.text[:200]}")
except HTTPException:
raise
except Exception as e:
logger.warning(f"Enrollment process error: {e}")
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception:
pass
participant = db.query(MeetingParticipant).filter(
MeetingParticipant.meeting_id == meeting.id,
MeetingParticipant.speaker_label == speaker_label,
).first()
if not participant:
participant = MeetingParticipant(meeting_id=meeting.id, speaker_label=speaker_label)
db.add(participant)
participant.identified_name = name
if profile_id:
try:
from uuid import UUID as _UUID
participant.voice_profile_id = _UUID(profile_id)
except Exception:
pass
updated_segments = [
{**seg, "speaker": name} if seg.get("speaker") == speaker_label else seg
for seg in segments
]
meeting.transcription = {**meeting.transcription, "segments": updated_segments}
from sqlalchemy.orm.attributes import flag_modified
flag_modified(meeting, "transcription")
db.commit()
db.refresh(participant)
logger.info(f"Speaker '{speaker_label}' identified as '{name}', profile_id={profile_id}")
return participant
@router.delete("/{meeting_id}", status_code=204)
def delete_meeting(
meeting_id: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
"""Delete a meeting. Only maia.admin users can delete."""
if not is_admin(current_user):
raise HTTPException(status_code=403, detail="Apenas administradores podem excluir reunioes")
meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
db.delete(meeting)
db.commit()

View File

@ -0,0 +1,3 @@
from app.schemas.auth import UserRegister, UserLogin, TokenRefresh, TokenResponse, UserResponse
from app.schemas.meeting import MeetingResponse, MeetingStatusResponse, ParticipantUpdate, ParticipantResponse
from app.schemas.analysis import AnalysisResponse

View File

@ -0,0 +1,17 @@
from pydantic import BaseModel
from typing import Any
from datetime import datetime
from uuid import UUID
from app.models.analysis import AnalysisType
class AnalysisResponse(BaseModel):
id: UUID
meeting_id: UUID
type: AnalysisType
content: Any
model_used: str
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,31 @@
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
from typing import Optional, List
class UserRegister(BaseModel):
email: str
password: str
name: str
class UserLogin(BaseModel):
email: str
password: str
class TokenRefresh(BaseModel):
refresh_token: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: UUID
email: str
name: str
roles: List[str] = []
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,44 @@
from pydantic import BaseModel
from typing import Optional, List, Any
from datetime import datetime
from uuid import UUID
from app.models.meeting import MeetingStatus
class MeetingResponse(BaseModel):
id: UUID
title: Optional[str] = None
date: datetime
duration: Optional[int] = None
status: MeetingStatus
transcription: Optional[Any] = None
user_id: UUID
owner_email: Optional[str] = None
owner_name: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class MeetingStatusResponse(BaseModel):
id: UUID
status: MeetingStatus
title: Optional[str] = None
class Config:
from_attributes = True
class ParticipantUpdate(BaseModel):
identified_name: Optional[str] = None
confidence_score: Optional[float] = None
class ParticipantResponse(BaseModel):
id: UUID
meeting_id: UUID
speaker_label: str
voice_profile_id: Optional[UUID] = None
identified_name: Optional[str] = None
confidence_score: Optional[float] = None
talk_time_seconds: Optional[int] = None
class Config:
from_attributes = True

View File

View File

@ -0,0 +1,84 @@
from sqlalchemy.orm import Session
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.models.user import User
from app.utils.security import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
decode_token,
)
from app.database import get_db
security = HTTPBearer()
def register_user(db: Session, email: str, password: str, name: str) -> User:
existing = db.query(User).filter(User.email == email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user = User(email=email, password_hash=hash_password(password), name=name, roles=[])
db.add(user)
db.commit()
db.refresh(user)
return user
def login_user(db: Session, email: str, password: str) -> dict:
user = db.query(User).filter(User.email == email).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid email or password")
access_token = create_access_token(str(user.id))
refresh_token = create_refresh_token(str(user.id))
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
def refresh_tokens(refresh_token: str) -> dict:
payload = decode_token(refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user_id = payload.get("sub")
access_token = create_access_token(user_id)
new_refresh = create_refresh_token(user_id)
return {"access_token": access_token, "refresh_token": new_refresh, "token_type": "bearer"}
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid or expired access token")
user_id = payload.get("sub")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
def is_admin(user: User) -> bool:
"""Check if user has maia.admin role."""
return "maia.admin" in (user.roles or [])
def get_or_create_sso_user(db: Session, user_info: dict, roles: list = None) -> User:
"""Look up user by email (SSO login). Create or update if found."""
import uuid as _uuid
email = user_info.get("email", "")
name = user_info.get("name", "") or email or "SSO User"
roles = roles or []
user = db.query(User).filter(User.email == email).first()
if user:
user.name = name
# Merge KC roles with existing DB roles (don't downgrade if KC lacks a role)
existing = set(user.roles or [])
incoming = set(roles or [])
user.roles = sorted(existing | incoming)
db.commit()
db.refresh(user)
return user
placeholder_hash = hash_password(str(_uuid.uuid4()))
user = User(email=email, password_hash=placeholder_hash, name=name, roles=roles)
db.add(user)
db.commit()
db.refresh(user)
return user

View File

@ -0,0 +1,88 @@
import os
import subprocess
import glob
import logging
from app.config import settings
logger = logging.getLogger(__name__)
def get_chunks_dir(meeting_id: str) -> str:
path = os.path.join(settings.AUDIO_STORAGE_PATH, "chunks", meeting_id)
os.makedirs(path, exist_ok=True)
return path
def save_chunk(chunk_data: bytes, meeting_id: str, chunk_index: int, ext: str = ".webm") -> str:
chunks_dir = get_chunks_dir(meeting_id)
filename = f"chunk_{chunk_index:06d}{ext}"
path = os.path.join(chunks_dir, filename)
with open(path, "wb") as f:
f.write(chunk_data)
return path
def assemble_chunks(meeting_id: str) -> str:
"""
Assemble all chunks into a single WAV file for Whisper processing.
Timeslice WebM chunks (from a single continuous MediaRecorder session) form a
valid WebM stream when binary-concatenated in order. We concatenate them and
then convert to 16kHz mono WAV with ffmpeg.
Falls back to ffmpeg concat demuxer if binary concat fails.
"""
chunks_dir = get_chunks_dir(meeting_id)
chunk_files = sorted(glob.glob(os.path.join(chunks_dir, "chunk_*")))
if not chunk_files:
raise ValueError(f"No chunks found for meeting {meeting_id}")
output_path = os.path.join(settings.AUDIO_STORAGE_PATH, f"{meeting_id}.wav")
if len(chunk_files) == 1:
result = subprocess.run(
["ffmpeg", "-y", "-i", chunk_files[0],
"-ar", "16000", "-ac", "1", "-sample_fmt", "s16", output_path],
capture_output=True, text=True, timeout=300,
)
if result.returncode != 0:
logger.error(f"ffmpeg single-chunk convert failed: {result.stderr}")
raise RuntimeError(f"Failed to convert audio chunk: {result.stderr}")
else:
# Binary-concatenate timeslice WebM chunks (they are sequential fragments
# of the same MediaRecorder stream — binary concat = valid WebM stream)
raw_concat = os.path.join(chunks_dir, "concat_raw.webm")
with open(raw_concat, "wb") as out:
for cf in chunk_files:
with open(cf, "rb") as inp:
out.write(inp.read())
result = subprocess.run(
["ffmpeg", "-y", "-i", raw_concat,
"-ar", "16000", "-ac", "1", "-sample_fmt", "s16", output_path],
capture_output=True, text=True, timeout=300,
)
if result.returncode != 0:
logger.warning(f"Binary concat failed ({result.stderr[:200]}), trying concat demuxer...")
concat_list = os.path.join(chunks_dir, "concat.txt")
with open(concat_list, "w") as f:
for cf in chunk_files:
f.write(f"file '{cf}'\n")
result2 = subprocess.run(
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_list,
"-ar", "16000", "-ac", "1", "-sample_fmt", "s16", output_path],
capture_output=True, text=True, timeout=300,
)
if result2.returncode != 0:
logger.error(f"Both assembly methods failed: {result2.stderr}")
raise RuntimeError(f"Failed to assemble audio chunks: {result2.stderr}")
logger.info(f"Assembled {len(chunk_files)} chunks -> {output_path}")
return output_path
def cleanup_chunks(meeting_id: str):
"""Remove chunk files after successful assembly."""
import shutil
chunks_dir = get_chunks_dir(meeting_id)
if os.path.exists(chunks_dir):
shutil.rmtree(chunks_dir)

View File

@ -0,0 +1,105 @@
import httpx
import json
import logging
import re
from app.config import settings
logger = logging.getLogger(__name__)
PROMPTS = {
"minutes": (
"Voce e um assistente especializado em reunioes corporativas. "
"Com base na transcricao a seguir, gere uma ata estruturada em JSON contendo: "
"title (string), participants (array de objetos com name e role), "
"agenda (array de strings), decisions (array de strings), "
"action_items (array de objetos com description, responsible e deadline), "
"next_steps (array de strings), open_points (array de strings). "
"Retorne APENAS o JSON valido, sem texto adicional. "
"Transcricao: {transcription}"
),
"sentiment": (
"Analise o tom emocional desta reuniao e retorne um JSON com: "
"overall_sentiment (positive/neutral/negative), "
"timeline (array de objetos com time_range, sentiment e notes), "
"participants_sentiment (array de objetos com speaker, sentiment e details), "
"tension_moments (array de strings), "
"consensus_moments (array de strings), "
"participation_metrics (array de objetos com speaker, talk_percentage e interventions_count). "
"Retorne APENAS o JSON valido. "
"Transcricao: {transcription}"
),
"psychological": (
"Analise os padroes comportamentais dos participantes e retorne um JSON com: "
"participants_profiles (array de objetos com speaker, name, communication_style, "
"leadership_tendencies, disengagement_signs, power_dynamics_role), "
"group_dynamics (string), "
"key_observations (array de strings). "
"Retorne APENAS o JSON valido. "
"Transcricao: {transcription}"
),
"communication": (
"Avalie a comunicacao dos participantes nesta reuniao e retorne um JSON com: "
"overall_score (numero de 1 a 10), "
"dimensions (objeto com clarity, active_listening, assertiveness, meeting_facilitation, "
"cada um com score e notes), "
"strengths (array de objetos com description e example), "
"improvement_areas (array de objetos com description e suggestion), "
"general_feedback (string). "
"Retorne APENAS o JSON valido. "
"Transcricao: {transcription}"
),
"summary": (
"Gere um resumo executivo desta reuniao em JSON com: "
"summary (string), "
"key_points (array de strings), "
"duration_minutes (number), "
"total_participants (number). "
"Retorne APENAS o JSON valido. "
"Transcricao: {transcription}"
),
}
async def run_analysis(analysis_type: str, transcription: dict) -> dict:
"""Run AI analysis on transcription using Ollama"""
transcription_text = json.dumps(transcription, ensure_ascii=False)
prompt_template = PROMPTS.get(analysis_type, PROMPTS["summary"])
prompt = prompt_template.replace("{transcription}", transcription_text[:8000])
logger.info(f"Running {analysis_type} analysis with Ollama model {settings.OLLAMA_MODEL}")
try:
headers = {}
if settings.OLLAMA_API_KEY:
headers["X-API-Key"] = settings.OLLAMA_API_KEY
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(
f"{settings.OLLAMA_URL}/api/generate",
headers=headers,
json={
"model": settings.OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": 0.3},
},
)
response.raise_for_status()
result = response.json()
response_text = result.get("response", "{}")
try:
return json.loads(response_text)
except json.JSONDecodeError:
json_match = re.search(r"\{.*\}", response_text, re.DOTALL)
if json_match:
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
pass
return {"raw_response": response_text, "parse_error": "Could not parse JSON"}
except httpx.ConnectError as e:
logger.error(f"Cannot connect to Ollama at {settings.OLLAMA_URL}: {e}")
return {"error": "Ollama unavailable", "analysis_type": analysis_type}
except Exception as e:
logger.error(f"Ollama analysis error for {analysis_type}: {e}")
return {"error": str(e), "analysis_type": analysis_type}

View File

@ -0,0 +1,39 @@
import os
import aiofiles
from fastapi import UploadFile, HTTPException
from app.config import settings
async def save_audio_file(file: UploadFile, meeting_id: str) -> str:
os.makedirs(settings.AUDIO_STORAGE_PATH, exist_ok=True)
original_name = file.filename or "audio.wav"
ext = os.path.splitext(original_name)[1].lower() or ".wav"
allowed_extensions = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".mp4", ".webm"}
if ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Unsupported audio format '{ext}'. Supported: {', '.join(allowed_extensions)}",
)
filename = f"{meeting_id}{ext}"
file_path = os.path.join(settings.AUDIO_STORAGE_PATH, filename)
max_size = settings.UPLOAD_MAX_SIZE_MB * 1024 * 1024
total_size = 0
async with aiofiles.open(file_path, "wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total_size += len(chunk)
if total_size > max_size:
await f.close()
os.remove(file_path)
raise HTTPException(
status_code=413,
detail=f"File too large. Maximum allowed size is {settings.UPLOAD_MAX_SIZE_MB}MB",
)
await f.write(chunk)
return file_path

View File

@ -0,0 +1,51 @@
from fastapi import WebSocket
from typing import Dict, List
import json
import logging
logger = logging.getLogger(__name__)
class WebSocketManager:
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
async def connect(self, websocket: WebSocket, meeting_id: str):
await websocket.accept()
if meeting_id not in self.active_connections:
self.active_connections[meeting_id] = []
self.active_connections[meeting_id].append(websocket)
logger.info(f"WebSocket connected: meeting={meeting_id}, total={len(self.active_connections[meeting_id])}")
def disconnect(self, websocket: WebSocket, meeting_id: str):
if meeting_id in self.active_connections:
try:
self.active_connections[meeting_id].remove(websocket)
except ValueError:
pass
logger.info(f"WebSocket disconnected: meeting={meeting_id}")
async def send_message(self, meeting_id: str, message: dict):
if meeting_id not in self.active_connections:
return
disconnected = []
for ws in self.active_connections[meeting_id]:
try:
await ws.send_text(json.dumps(message, ensure_ascii=False))
except Exception as e:
logger.warning(f"WebSocket send error for meeting {meeting_id}: {e}")
disconnected.append(ws)
for ws in disconnected:
self.active_connections[meeting_id].remove(ws)
async def broadcast_status(self, meeting_id: str, status: str, data: dict = None):
message = {
"type": "status_update",
"meeting_id": meeting_id,
"status": status,
"data": data or {},
}
await self.send_message(meeting_id, message)
manager = WebSocketManager()

View File

@ -0,0 +1,91 @@
import httpx
import logging
import os
from app.config import settings
logger = logging.getLogger(__name__)
MIME_TYPES = {
".webm": "audio/webm",
".wav": "audio/wav",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".flac": "audio/flac",
}
async def transcribe_audio(audio_path: str) -> dict:
"""
Send audio to Whisper Diarization API.
1) Tries POST /api/v2/transcribe (diarization field: audio) -> returns segments with speaker labels
2) Falls back to POST /transcribe-segments (no diarization) -> assigns SPEAKER_01 to all
"""
logger.info(f"Sending audio to Whisper server: {audio_path}")
try:
with open(audio_path, "rb") as f:
content = f.read()
filename = os.path.basename(audio_path)
ext = os.path.splitext(filename)[1].lower()
mime_type = MIME_TYPES.get(ext, "audio/webm")
async with httpx.AsyncClient(timeout=600.0) as http:
# 1) Try /api/v2/transcribe with diarization (field: audio)
try:
files = {"audio": (filename, content, mime_type)}
data = {"language": "pt"}
resp = await http.post(
f"{settings.WHISPER_SERVER_URL}/api/v2/transcribe",
files=files, data=data, timeout=600.0,
)
if resp.status_code not in (404, 422, 500):
resp.raise_for_status()
result = resp.json()
logger.info(f"Diarized transcription completed for {audio_path} "
f"({result.get('speakers_count', '?')} speakers, "
f"method: {result.get('diarization_method', '?')})")
return result
logger.warning(f"/api/v2/transcribe returned {resp.status_code}, falling back...")
except httpx.HTTPStatusError as e:
if e.response.status_code not in (404, 422, 500):
raise
logger.warning(f"/api/v2/transcribe error {e.response.status_code}, falling back...")
except Exception as e:
logger.warning(f"/api/v2/transcribe exception: {e}, falling back...")
# 2) Fallback: /transcribe-segments (no speaker diarization)
files = {"file": (filename, content, mime_type)}
data = {"language": "pt"}
resp = await http.post(
f"{settings.WHISPER_SERVER_URL}/transcribe-segments",
files=files, data=data, timeout=600.0,
)
resp.raise_for_status()
result = resp.json()
logger.info(f"Transcription via fallback /transcribe-segments completed for {audio_path}")
segments = result.get("segments", [])
normalized = [
{
"start": seg.get("start", 0.0),
"end": seg.get("end", 0.0),
"speaker": "SPEAKER_01",
"text": seg.get("text", "").strip(),
}
for seg in segments
]
return {
"segments": normalized,
"speakers_count": 1,
"audio_duration": segments[-1]["end"] if segments else 0.0,
"language": result.get("language", "pt"),
"full_text": result.get("text", ""),
"diarization_available": False,
"diarization_method": "none",
}
except httpx.ConnectError as e:
logger.error(f"Cannot connect to Whisper server at {settings.WHISPER_SERVER_URL}: {e}")
raise Exception(f"Whisper server unavailable: {str(e)}")
except Exception as e:
logger.error(f"Transcription error: {e}")
raise

View File

View File

@ -0,0 +1,136 @@
import logging
import asyncio
from celery import Celery
from app.config import settings
from app.database import SessionLocal
from app.models.meeting import Meeting, MeetingStatus
from app.models.analysis import Analysis, AnalysisType
from app.services.whisper_service import transcribe_audio
from app.services.ollama_service import run_analysis
logger = logging.getLogger(__name__)
celery_app = Celery(
"meeting_assistant",
broker=settings.REDIS_URL,
backend=settings.REDIS_URL,
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
task_acks_late=True,
worker_prefetch_multiplier=1,
)
def run_async(coro):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
@celery_app.task(bind=True, max_retries=3, default_retry_delay=60)
def process_meeting(self, meeting_id: str):
"""
Full meeting processing pipeline:
1. Save audio (already done at upload)
2. Transcribe via Whisper
3. Analyze via Ollama (minutes, sentiment, psychological, communication, summary)
4. Update status to completed
"""
db = SessionLocal()
meeting = None
try:
meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first()
if not meeting:
logger.error(f"Meeting {meeting_id} not found in DB")
return
# Step 1 - Mark processing
meeting.status = MeetingStatus.processing
db.commit()
logger.info(f"Processing meeting {meeting_id}")
# Step 2 - Transcription
meeting.status = MeetingStatus.transcribing
db.commit()
try:
transcription = run_async(transcribe_audio(meeting.audio_path))
meeting.transcription = transcription
db.commit()
logger.info(f"Transcription done for meeting {meeting_id}")
# Save participants from transcription
try:
from app.models.participant import MeetingParticipant
speaker_labels = {seg.get("speaker") for seg in transcription.get("segments", []) if seg.get("speaker")}
for label in speaker_labels:
existing = db.query(MeetingParticipant).filter(
MeetingParticipant.meeting_id == meeting.id,
MeetingParticipant.speaker_label == label,
).first()
if not existing:
db.add(MeetingParticipant(
meeting_id=meeting.id,
speaker_label=label,
identified_name=None if label.startswith("SPEAKER_") else label,
))
db.commit()
except Exception as e:
logger.warning(f"Failed to save participants: {e}")
except Exception as e:
logger.error(f"Transcription failed for {meeting_id}: {e}")
meeting.status = MeetingStatus.error
db.commit()
return
# Step 3 - AI Analyses
meeting.status = MeetingStatus.analyzing
db.commit()
analysis_types = ["minutes", "sentiment", "psychological", "communication", "summary"]
for analysis_type in analysis_types:
try:
content = run_async(run_analysis(analysis_type, transcription))
existing = (
db.query(Analysis)
.filter(Analysis.meeting_id == meeting_id, Analysis.type == AnalysisType(analysis_type))
.first()
)
if existing:
existing.content = content
existing.model_used = settings.OLLAMA_MODEL
else:
analysis = Analysis(
meeting_id=meeting.id,
type=AnalysisType(analysis_type),
content=content,
model_used=settings.OLLAMA_MODEL,
)
db.add(analysis)
db.commit()
logger.info(f"Analysis '{analysis_type}' done for meeting {meeting_id}")
except Exception as e:
logger.error(f"Analysis '{analysis_type}' failed for {meeting_id}: {e}")
# Step 4 - Mark completed
meeting.status = MeetingStatus.completed
db.commit()
logger.info(f"Meeting {meeting_id} processing COMPLETED")
except Exception as e:
logger.error(f"Unexpected error processing meeting {meeting_id}: {e}")
if meeting:
meeting.status = MeetingStatus.error
db.commit()
raise self.retry(exc=e, countdown=60)
finally:
db.close()

View File

View File

@ -0,0 +1,50 @@
import jwt
from jwt import PyJWKClient
from typing import Optional
JWKS_URI = "https://auth.asaptelecom.com.br/realms/one/protocol/openid-connect/certs"
ISSUER = "https://auth.asaptelecom.com.br/realms/one"
AUDIENCE = "maia-app"
jwks_client = PyJWKClient(JWKS_URI, cache_keys=True)
def validate_keycloak_token(token: str) -> Optional[dict]:
"""
Validate a Keycloak RS256 JWT token.
Returns decoded payload or None if invalid.
"""
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=ISSUER,
options={"verify_aud": False},
)
return payload
except Exception as e:
print(f"[keycloak_auth] Token validation failed: {e}")
return None
def has_role(payload: dict, role: str) -> bool:
"""Check if role is present in realm_access or resource_access."""
realm_roles = payload.get("realm_access", {}).get("roles", [])
if role in realm_roles:
return True
for client_roles in payload.get("resource_access", {}).values():
if role in client_roles.get("roles", []):
return True
return False
def extract_user_info(payload: dict) -> dict:
"""Extract user info from KC token."""
return {
"sub": payload.get("sub"),
"email": payload.get("email", ""),
"name": (payload.get("name") or
f"{payload.get('given_name', '')} {payload.get('family_name', '')}".strip()),
}

View File

@ -0,0 +1,49 @@
import jwt
from datetime import datetime, timedelta
import bcrypt
from typing import Optional
from app.config import settings
def hash_password(password: str) -> str:
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
except Exception:
return False
def create_access_token(user_id: str) -> str:
payload = {
"sub": user_id,
"exp": datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES),
"iat": datetime.utcnow(),
"type": "access",
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
def create_refresh_token(user_id: str) -> str:
payload = {
"sub": user_id,
"exp": datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_EXPIRE_DAYS),
"iat": datetime.utcnow(),
"type": "refresh",
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None

View File

@ -0,0 +1,73 @@
version: '3.8'
services:
postgres:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_USER: meetingassistant
POSTGRES_PASSWORD: meetingassistant
POSTGRES_DB: meeting_assistant
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U meetingassistant"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
api:
build: .
restart: unless-stopped
ports:
- "8080:8000"
env_file:
- .env
environment:
- DATABASE_URL=postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant
- REDIS_URL=redis://redis:6379/0
volumes:
- audio_storage:/opt/Backend/meeting-assistant-api/storage/audio
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
worker:
build: .
restart: unless-stopped
env_file:
- .env
environment:
- DATABASE_URL=postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant
- REDIS_URL=redis://redis:6379/0
volumes:
- audio_storage:/opt/Backend/meeting-assistant-api/storage/audio
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
command: celery -A app.tasks.processing.celery_app worker --loglevel=info --concurrency=2
volumes:
postgres_data:
audio_storage:

16
backend/requirements.txt Normal file
View File

@ -0,0 +1,16 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
alembic==1.12.1
psycopg2-binary==2.9.9
redis==5.0.1
celery==5.3.4
PyJWT==2.8.0
python-multipart==0.0.6
httpx==0.25.2
pydantic==2.5.2
pydantic-settings==2.1.0
aiofiles==23.2.1
python-dotenv==1.0.0
websockets==12.0
cryptography

View File

@ -0,0 +1 @@
# __init__.py

View File

@ -0,0 +1,11 @@
"""Database initialization wrapper."""
import os
import logging
from services.enrollment import init_db as _init_enrollment_db
logger = logging.getLogger(__name__)
def init_db():
"""Initialize all database tables."""
_init_enrollment_db()

67
diarization/main.py Normal file
View File

@ -0,0 +1,67 @@
"""
Whisper Diarization API Enhancement Server
Adds /api/v2 endpoints (diarization + voice enrollment) to the existing Whisper server.
Transparently proxies existing endpoints to the original Whisper server.
Original Whisper server: http://10.100.16.13:5003
This server runs on: port 5060
"""
import logging
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from routers.proxy import router as proxy_router
from routers.v2_transcribe import router as v2_transcribe_router
from routers.v2_speakers import router as v2_speakers_router
from db.database import init_db
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
WHISPER_ORIGIN = os.getenv("WHISPER_ORIGIN", "http://10.100.16.13:5003")
app = FastAPI(
title="Whisper Diarization Enhancement Server",
description=(
"Adds Speaker Diarization + Voice Enrollment on top of the existing Whisper.cpp GPU server. "
"Existing endpoints (/transcribe, /transcribe-segments, /whisper, /transcribe-text, /health) "
"are transparently proxied to the original server."
),
version="2.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup():
logger.info("Whisper Diarization Enhancement Server starting...")
init_db()
logger.info(f"Proxying existing endpoints to: {WHISPER_ORIGIN}")
logger.info("New endpoints available under /api/v2/")
logger.info("Server ready!")
# Proxy existing Whisper endpoints (NEVER MODIFY THESE)
app.include_router(proxy_router, tags=["proxy — original whisper endpoints"])
# New v2 endpoints
app.include_router(v2_transcribe_router, prefix="/api/v2", tags=["v2 — diarization"])
app.include_router(v2_speakers_router, prefix="/api/v2", tags=["v2 — voice enrollment"])
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})

View File

@ -0,0 +1,16 @@
# Whisper Diarization Enhancement Server
fastapi==0.104.1
uvicorn[standard]==0.24.0
httpx==0.25.2
python-multipart==0.0.6
aiofiles==23.2.1
scipy>=1.10.0
numpy>=1.24.0
librosa>=0.10.0
soundfile>=0.12.1
# Optional — heavier ML models (installed separately if available)
# pyannote.audio>=3.1.0
# speechbrain>=0.5.16
# torch>=2.0.0

View File

@ -0,0 +1 @@
# __init__.py files

View File

@ -0,0 +1,82 @@
"""
Proxy router transparently forwards existing Whisper server endpoints.
NEVER modify these routes or their behavior.
"""
import os
import logging
from fastapi import APIRouter, Request, UploadFile, File, Form
from fastapi.responses import Response
import httpx
logger = logging.getLogger(__name__)
WHISPER_ORIGIN = os.getenv("WHISPER_ORIGIN", "http://10.100.16.13:5003")
router = APIRouter()
async def _proxy_get(path: str) -> Response:
"""Forward a GET request to the original Whisper server."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{WHISPER_ORIGIN}{path}")
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
async def _proxy_upload(path: str, file_bytes: bytes, filename: str, extra_fields: dict) -> Response:
"""Forward a multipart file upload to the original Whisper server."""
async with httpx.AsyncClient(timeout=600.0) as client:
files = {"file": (filename, file_bytes, "application/octet-stream")}
data = extra_fields
resp = await client.post(f"{WHISPER_ORIGIN}{path}", files=files, data=data)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
# ── Proxy: GET /health ───────────────────────────────────────────────────────
@router.get("/health", summary="Health — proxied from original Whisper server")
async def health():
return await _proxy_get("/health")
# ── Proxy: POST /transcribe ──────────────────────────────────────────────────
@router.post("/transcribe", summary="Transcribe — proxied from original Whisper server")
async def transcribe(
file: UploadFile = File(...),
language: str = Form("pt"),
):
file_bytes = await file.read()
return await _proxy_upload("/transcribe", file_bytes, file.filename or "audio.wav", {"language": language})
# ── Proxy: POST /transcribe-segments ────────────────────────────────────────
@router.post("/transcribe-segments", summary="Transcribe Segments — proxied from original Whisper server")
async def transcribe_segments(
file: UploadFile = File(...),
language: str = Form("pt"),
):
file_bytes = await file.read()
return await _proxy_upload("/transcribe-segments", file_bytes, file.filename or "audio.wav", {"language": language})
# ── Proxy: POST /transcribe-text ────────────────────────────────────────────
@router.post("/transcribe-text", summary="Transcribe Text — proxied from original Whisper server")
async def transcribe_text(
file: UploadFile = File(...),
):
file_bytes = await file.read()
return await _proxy_upload("/transcribe-text", file_bytes, file.filename or "audio.wav", {})
# ── Proxy: POST /whisper ─────────────────────────────────────────────────────
@router.post("/whisper", summary="Whisper Compat — proxied from original Whisper server")
async def whisper_compat(
file: UploadFile = File(...),
):
file_bytes = await file.read()
return await _proxy_upload("/whisper", file_bytes, file.filename or "audio.wav", {})

View File

@ -0,0 +1,241 @@
"""
Voice Enrollment & Speaker Identification endpoints.
POST /api/v2/enroll enroll a new voice profile
POST /api/v2/enroll/from-meeting enroll from meeting segments
GET /api/v2/speakers list all profiles
GET /api/v2/speakers/{id} get profile details
PUT /api/v2/speakers/{id} update profile
DELETE /api/v2/speakers/{id} delete profile
POST /api/v2/identify identify a speaker from audio
"""
import os
import json
import logging
import tempfile
from typing import List, Optional
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from pydantic import BaseModel
from services.enrollment import (
create_profile,
get_profile,
list_profiles,
update_profile,
delete_profile,
add_embedding,
identify_speaker,
)
from services.diarization import extract_embedding
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ───────────────────────────────────────────────────────────────────
class VoiceProfileResponse(BaseModel):
id: str
name: str
email: Optional[str] = None
metadata: Optional[str] = None
embeddings_count: int
created_at: str
updated_at: str
class UpdateProfileRequest(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
metadata: Optional[str] = None
class IdentifyResponse(BaseModel):
matched: bool
speaker_id: Optional[str] = None
speaker_name: Optional[str] = None
confidence: float
threshold: float
# ── Helper ────────────────────────────────────────────────────────────────────
async def _save_and_embed(file_bytes: bytes, suffix: str) -> object:
"""Save audio bytes to a temp file and extract embedding."""
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(file_bytes)
tmp_path = tmp.name
try:
embedding = extract_embedding(tmp_path)
return embedding
finally:
try:
os.unlink(tmp_path)
except Exception:
pass
# ── POST /enroll ──────────────────────────────────────────────────────────────
@router.post(
"/enroll",
response_model=VoiceProfileResponse,
status_code=201,
summary="Enroll a new voice profile",
description=(
"Submit an audio file (minimum 30 seconds recommended) to enroll a new speaker. "
"Extracts a vocal embedding and saves the profile to the local SQLite database."
),
)
async def enroll(
file: UploadFile = File(..., description="Audio file with the speaker's voice (min 30s recommended)"),
name: str = Form(..., description="Speaker full name"),
email: Optional[str] = Form(None, description="Speaker email (optional)"),
metadata: Optional[str] = Form(None, description="Extra JSON metadata (optional)"),
):
audio_bytes = await file.read()
suffix = os.path.splitext(file.filename or ".wav")[-1].lower() or ".wav"
embedding = await _save_and_embed(audio_bytes, suffix)
profile = create_profile(name=name, email=email, metadata=metadata)
add_embedding(profile["id"], embedding)
profile["embeddings_count"] = 1
return VoiceProfileResponse(**profile)
# ── POST /enroll/from-meeting ─────────────────────────────────────────────────
@router.post(
"/enroll/from-meeting",
response_model=VoiceProfileResponse,
status_code=201,
summary="Enroll speaker from meeting audio segments",
description=(
"Extract voice embedding from specific speaker segments of an already processed meeting. "
"Creates a new profile or adds embeddings to an existing one (matched by name)."
),
)
async def enroll_from_meeting(
meeting_segments: str = Form(
...,
description='JSON array of {speaker_label, audio_file_path}: [{"speaker_label":"SPEAKER_01","audio_file_path":"/path/audio.wav"}]',
),
speaker_name: str = Form(..., description="Name to assign to this speaker"),
speaker_label: str = Form(..., description="Speaker label to extract (e.g. SPEAKER_01)"),
existing_profile_id: Optional[str] = Form(None, description="Existing profile ID to add embeddings to (optional)"),
):
try:
segments = json.loads(meeting_segments)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(status_code=422, detail=f"Invalid meeting_segments JSON: {e}")
# Filter segments matching the requested speaker
target_segments = [s for s in segments if s.get("speaker_label") == speaker_label]
if not target_segments:
raise HTTPException(
status_code=404,
detail=f"No segments found for speaker_label='{speaker_label}'",
)
# Get or create profile
if existing_profile_id:
profile = get_profile(existing_profile_id)
if profile is None:
raise HTTPException(status_code=404, detail=f"Profile '{existing_profile_id}' not found")
else:
profile = create_profile(name=speaker_name, email=None, metadata=None)
# Extract embeddings for each segment
emb_count = 0
for seg in target_segments:
audio_path = seg.get("audio_file_path", "")
if not os.path.isfile(audio_path):
logger.warning(f"Audio file not found: {audio_path}")
continue
try:
embedding = extract_embedding(audio_path)
add_embedding(profile["id"], embedding)
emb_count += 1
except Exception as e:
logger.warning(f"Failed to extract embedding from {audio_path}: {e}")
if emb_count == 0:
raise HTTPException(
status_code=422,
detail="No valid audio segments could be processed. Check that audio_file_path values exist on the server.",
)
return VoiceProfileResponse(**get_profile(profile["id"]))
# ── GET /speakers ─────────────────────────────────────────────────────────────
@router.get(
"/speakers",
response_model=List[VoiceProfileResponse],
summary="List all enrolled voice profiles",
)
async def list_speakers():
return [VoiceProfileResponse(**p) for p in list_profiles()]
# ── GET /speakers/{id} ────────────────────────────────────────────────────────
@router.get(
"/speakers/{profile_id}",
response_model=VoiceProfileResponse,
summary="Get a voice profile by ID",
)
async def get_speaker(profile_id: str):
profile = get_profile(profile_id)
if profile is None:
raise HTTPException(status_code=404, detail="Voice profile not found")
return VoiceProfileResponse(**profile)
# ── PUT /speakers/{id} ────────────────────────────────────────────────────────
@router.put(
"/speakers/{profile_id}",
response_model=VoiceProfileResponse,
summary="Update a voice profile (name, email, metadata — all optional)",
)
async def update_speaker(profile_id: str, data: UpdateProfileRequest):
profile = update_profile(
profile_id,
name=data.name,
email=data.email,
metadata=data.metadata,
)
if profile is None:
raise HTTPException(status_code=404, detail="Voice profile not found")
return VoiceProfileResponse(**profile)
# ── DELETE /speakers/{id} ─────────────────────────────────────────────────────
@router.delete(
"/speakers/{profile_id}",
status_code=204,
summary="Delete a voice profile and all its embeddings",
)
async def delete_speaker(profile_id: str):
if not delete_profile(profile_id):
raise HTTPException(status_code=404, detail="Voice profile not found")
# ── POST /identify ────────────────────────────────────────────────────────────
@router.post(
"/identify",
response_model=IdentifyResponse,
summary="Identify the speaker in an audio clip",
description=(
"Extracts a vocal embedding from the submitted audio and compares it against all "
"enrolled profiles. Returns the best match if confidence >= threshold."
),
)
async def identify(
file: UploadFile = File(..., description="Short audio clip to identify the speaker"),
threshold: Optional[float] = Form(None, description="Confidence threshold (default: 0.75)"),
):
audio_bytes = await file.read()
suffix = os.path.splitext(file.filename or ".wav")[-1].lower() or ".wav"
embedding = await _save_and_embed(audio_bytes, suffix)
result = identify_speaker(embedding, threshold=threshold)
return IdentifyResponse(**result)

View File

@ -0,0 +1,192 @@
"""
POST /api/v2/transcribe Transcription with speaker diarization.
Calls the original Whisper server for transcription (with timestamps),
then runs speaker diarization and aligns the results.
"""
import os
import logging
import tempfile
from typing import List, Optional
import httpx
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from pydantic import BaseModel
from services.diarization import diarize, align_transcription_with_diarization, extract_embedding
logger = logging.getLogger(__name__)
router = APIRouter()
WHISPER_ORIGIN = os.getenv("WHISPER_ORIGIN", "http://10.100.16.13:5003")
# ── Response model ────────────────────────────────────────────────────────────
class TranscriptionSegment(BaseModel):
start: float
end: float
speaker: str
text: str
class DiarizedTranscriptionResponse(BaseModel):
segments: List[TranscriptionSegment]
speakers_count: int
audio_duration: float
language: str
full_text: str
diarization_method: str
# ── Endpoint ──────────────────────────────────────────────────────────────────
@router.post(
"/transcribe",
response_model=DiarizedTranscriptionResponse,
summary="Transcription + Speaker Diarization",
description=(
"Transcribes the audio using the Whisper.cpp GPU server and adds speaker diarization. "
"Returns segments labelled by speaker (SPEAKER_01, SPEAKER_02, …)."
),
)
async def v2_transcribe(
audio: UploadFile = File(..., description="Audio file (WAV, MP3, M4A, OGG, FLAC)"),
language: str = Form("pt", description="Language code (default: pt)"),
num_speakers: Optional[int] = Form(None, description="Expected number of speakers (optional, auto-detected if omitted)"),
):
audio_bytes = await audio.read()
filename = audio.filename or "audio.wav"
# Step 1 — call original Whisper server for timestamps
transcription_segments = await _call_whisper_segments(audio_bytes, filename, language)
# Step 2 — save to temp file for diarization
suffix = os.path.splitext(filename)[-1].lower() or ".wav"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(audio_bytes)
tmp_path = tmp.name
try:
# Step 3 — diarize
diarization_segs, method = diarize(tmp_path, num_speakers=num_speakers)
# Step 4 — align
aligned = align_transcription_with_diarization(transcription_segments, diarization_segs)
# Auto-identify speakers against enrolled profiles
try:
import librosa
import numpy as np
import soundfile as sf
from services.enrollment import identify_speaker
y, sr = librosa.load(tmp_path, sr=16000, mono=True)
speaker_labels = sorted({s["speaker"] for s in aligned})
for speaker_label in speaker_labels:
chunks = []
for dseg in diarization_segs:
if dseg["speaker"] == speaker_label:
s_i = int(dseg["start"] * sr)
e_i = int(dseg["end"] * sr)
if e_i > s_i + int(0.5 * sr):
chunks.append(y[s_i:min(e_i, len(y))])
if not chunks:
continue
speaker_audio = np.concatenate(chunks)
if len(speaker_audio) < sr * 3:
continue
spk_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as spk_tmp:
sf.write(spk_tmp.name, speaker_audio, sr)
spk_path = spk_tmp.name
embedding = extract_embedding(spk_path)
id_result = identify_speaker(embedding)
if id_result["matched"]:
identified_name = id_result["speaker_name"]
logger.info(f"Auto-identified {speaker_label} as '{identified_name}' (conf={id_result['confidence']:.2f})")
for seg in aligned:
if seg["speaker"] == speaker_label:
seg["speaker"] = identified_name
finally:
if spk_path:
try:
os.unlink(spk_path)
except Exception:
pass
except Exception as auto_id_err:
logger.warning(f"Auto-identification failed (non-fatal): {auto_id_err}")
# Step 5 — compute metadata
speakers = sorted({s["speaker"] for s in aligned})
full_text = " ".join(s["text"] for s in aligned if s.get("text"))
# Audio duration from diarization or transcription
if diarization_segs:
audio_duration = max(s["end"] for s in diarization_segs)
elif transcription_segments:
audio_duration = max(s.get("end", 0) for s in transcription_segments)
else:
audio_duration = 0.0
return DiarizedTranscriptionResponse(
segments=[TranscriptionSegment(**s) for s in aligned],
speakers_count=len(speakers),
audio_duration=round(audio_duration, 2),
language=language,
full_text=full_text.strip(),
diarization_method=method,
)
finally:
try:
os.unlink(tmp_path)
except Exception:
pass
# ── Helper: call Whisper for transcription with timestamps ────────────────────
async def _call_whisper_segments(audio_bytes: bytes, filename: str, language: str) -> list:
"""
Calls the original Whisper server at /transcribe-segments and returns
the segments list [{start, end, text}, ...].
"""
try:
async with httpx.AsyncClient(timeout=600.0) as client:
files = {"file": (filename, audio_bytes, "application/octet-stream")}
data = {"language": language}
resp = await client.post(
f"{WHISPER_ORIGIN}/transcribe-segments",
files=files,
data=data,
)
resp.raise_for_status()
result = resp.json()
# Expected: {"text": "...", "language": "...", "segments": [...]}
segments = result.get("segments", [])
if not segments and result.get("text"):
# Fallback: no segments, create a single segment
segments = [{"start": 0.0, "end": 0.0, "text": result["text"]}]
return segments
except httpx.ConnectError as e:
logger.error(f"Cannot connect to Whisper server: {e}")
raise HTTPException(
status_code=503,
detail=f"Whisper server unavailable: {str(e)}",
)
except httpx.HTTPStatusError as e:
logger.error(f"Whisper server error {e.response.status_code}: {e.response.text}")
raise HTTPException(
status_code=502,
detail=f"Whisper server returned {e.response.status_code}",
)
except Exception as e:
logger.error(f"Transcription error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}")

View File

@ -0,0 +1 @@
# __init__.py

View File

@ -0,0 +1,490 @@
"""
Diarization service speaker identification pipeline.
Priority order:
1. pyannote.audio 3.1 best quality, requires HuggingFace token with model access
2. SpeechBrain ECAPA-TDNN good quality (~85%), no license needed, GPU-accelerated
3. Energy + MFCC fallback basic quality (~60%), pure CPU, always available
Speaker embeddings for enrollment:
Same priority: pyannote SpeechBrain MFCC
"""
import os
import io
import logging
import tempfile
import numpy as np
from typing import List, Dict, Any, Optional, Tuple
logger = logging.getLogger(__name__)
# ── Pyannote (primary) ────────────────────────────────────────────────────────
_pyannote_pipeline = None
_pyannote_tried = False
HF_TOKEN = os.getenv("HF_TOKEN", "hf_placeholder_token")
# ── SpeechBrain (secondary) ───────────────────────────────────────────────────
_speechbrain_model = None
_speechbrain_tried = False
SPEECHBRAIN_CACHE = os.getenv("SPEECHBRAIN_CACHE", "/tmp/speechbrain_ecapa")
def _try_load_pyannote():
global _pyannote_pipeline, _pyannote_tried
if _pyannote_tried:
return _pyannote_pipeline
_pyannote_tried = True
try:
from pyannote.audio import Pipeline
logger.info("Loading pyannote speaker-diarization-3.1 ...")
pipeline = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1",
use_auth_token=HF_TOKEN,
)
import torch
if torch.cuda.is_available():
pipeline = pipeline.to(torch.device("cuda"))
_pyannote_pipeline = pipeline
logger.info("pyannote pipeline loaded successfully.")
except Exception as e:
logger.warning(f"pyannote unavailable ({e}), will use energy-based fallback.")
_pyannote_pipeline = None
return _pyannote_pipeline
# ── pyannote embedding (for enrollment) ──────────────────────────────────────
_embedding_model = None
_embedding_tried = False
def _try_load_speechbrain():
global _speechbrain_model, _speechbrain_tried
if _speechbrain_tried:
return _speechbrain_model
_speechbrain_tried = True
try:
import torch
from speechbrain.inference.speaker import EncoderClassifier
logger.info("Loading SpeechBrain ECAPA-TDNN model...")
device = "cuda" if torch.cuda.is_available() else "cpu"
classifier = EncoderClassifier.from_hparams(
source="speechbrain/spkrec-ecapa-voxceleb",
savedir=SPEECHBRAIN_CACHE,
run_opts={"device": device},
)
_speechbrain_model = classifier
logger.info(f"SpeechBrain ECAPA-TDNN loaded on {device}.")
except Exception as e:
logger.warning(f"SpeechBrain unavailable ({e}), will use MFCC fallback.")
_speechbrain_model = None
return _speechbrain_model
def _try_load_embedding_model():
global _embedding_model, _embedding_tried
if _embedding_tried:
return _embedding_model
_embedding_tried = True
try:
from pyannote.audio import Model
from pyannote.audio import Inference
logger.info("Loading pyannote embedding model...")
model = Model.from_pretrained("pyannote/embedding", use_auth_token=HF_TOKEN)
_embedding_model = Inference(model, window="whole")
logger.info("pyannote embedding model loaded.")
except Exception:
sb = _try_load_speechbrain()
if sb is not None:
_embedding_model = ("speechbrain", sb)
logger.info("Using SpeechBrain for embeddings.")
else:
logger.warning("No embedding model available. Using MFCC fallback.")
_embedding_model = None
return _embedding_model
# ── Pyannote diarization ──────────────────────────────────────────────────────
def _diarize_pyannote(audio_path: str) -> List[Dict]:
"""Run pyannote diarization. Returns list of {start, end, speaker}."""
pipeline = _try_load_pyannote()
if pipeline is None:
raise RuntimeError("pyannote not available")
diarization = pipeline(audio_path)
segments = []
for turn, _, speaker in diarization.itertracks(yield_label=True):
segments.append({"start": round(turn.start, 3), "end": round(turn.end, 3), "speaker": speaker})
return segments
# ── Shared VAD: detects speech segments using energy thresholding ─────────────
def _get_speech_segments(audio_path: str, min_silence_sec: float = 0.5) -> Tuple[np.ndarray, int, List[Dict]]:
"""
Energy-based Voice Activity Detection.
Returns (y, sr, segments) where segments = [{start, end}, ...].
"""
import librosa
y, sr = librosa.load(audio_path, sr=16000, mono=True)
frame_len = int(0.025 * sr) # 25 ms
hop_len = int(0.010 * sr) # 10 ms
energy = librosa.feature.rms(y=y, frame_length=frame_len, hop_length=hop_len)[0]
nonzero_energy = energy[energy > 0]
if len(nonzero_energy) == 0:
duration = len(y) / sr
return y, sr, [{"start": 0.0, "end": round(float(duration), 3)}]
threshold = np.percentile(nonzero_energy, 20)
is_speech = energy > threshold
times = librosa.frames_to_time(np.arange(len(is_speech)), sr=sr, hop_length=hop_len)
min_silence_frames = int(min_silence_sec / (hop_len / sr))
segments_raw = []
in_speech = False
seg_start = 0.0
silence_count = 0
for i, (t, sp) in enumerate(zip(times, is_speech)):
if sp:
if not in_speech:
seg_start = t
in_speech = True
silence_count = 0
else:
if in_speech:
silence_count += 1
if silence_count >= min_silence_frames:
seg_end = times[i - silence_count] if i >= silence_count else t
if seg_end - seg_start > 0.3:
segments_raw.append({"start": round(seg_start, 3), "end": round(seg_end, 3)})
in_speech = False
silence_count = 0
if in_speech:
segments_raw.append({"start": round(seg_start, 3), "end": round(float(times[-1]), 3)})
if not segments_raw:
segments_raw = [{"start": 0.0, "end": round(float(len(y) / sr), 3)}]
return y, sr, segments_raw
def _estimate_n_speakers(embeddings: np.ndarray, max_speakers: int = 8) -> int:
"""
Estimate optimal number of speakers using the elbow method on
intra-cluster variance. Tries k=1..max_speakers and picks the k where
adding one more cluster yields < 15% improvement of total variance range.
"""
n = len(embeddings)
if n <= 1:
return 1
max_k = min(max_speakers, n)
if max_k <= 2:
return max_k
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1e-10
normed = embeddings / norms
variances = []
for k in range(1, max_k + 1):
labels = _cosine_cluster(normed, n_clusters=k)
total_var = 0.0
for c in range(k):
mask = labels == c
if mask.sum() > 1:
centroid = normed[mask].mean(axis=0)
total_var += float(np.mean(1 - normed[mask] @ centroid))
variances.append(total_var)
if len(variances) < 2:
return 1
total_range = variances[0] - variances[-1]
if total_range < 1e-6:
return 1
for k_idx in range(1, len(variances)):
improvement = variances[k_idx - 1] - variances[k_idx]
if improvement < 0.15 * total_range:
return k_idx # k = k_idx (1-indexed)
return max_k
# ── SpeechBrain ECAPA-TDNN diarization (secondary) ───────────────────────────
def _diarize_speechbrain(audio_path: str, num_speakers: Optional[int] = None) -> List[Dict]:
"""
SpeechBrain ECAPA-TDNN diarization.
Uses energy-based VAD for segmentation, ECAPA-TDNN 192-dim embeddings,
and cosine k-means clustering. ~85% accuracy vs ~60% for MFCC.
"""
import torch
classifier = _try_load_speechbrain()
if classifier is None:
raise RuntimeError("SpeechBrain not available")
y, sr, segments_raw = _get_speech_segments(audio_path, min_silence_sec=0.5)
if len(segments_raw) == 0:
return []
embeddings = []
for seg in segments_raw:
start_sample = int(seg["start"] * sr)
end_sample = int(seg["end"] * sr)
chunk = y[start_sample:end_sample]
min_samples = int(0.1 * sr) # 100ms minimum
if len(chunk) < min_samples:
embeddings.append(np.zeros(192))
continue
waveform = torch.tensor(chunk).unsqueeze(0).float()
try:
with torch.no_grad():
emb = classifier.encode_batch(waveform)
embeddings.append(emb.squeeze().cpu().numpy())
except Exception as e:
logger.warning(f"ECAPA-TDNN embedding failed for segment: {e}")
embeddings.append(np.zeros(192))
embeddings_arr = np.array(embeddings)
n_speakers = num_speakers if num_speakers else _estimate_n_speakers(embeddings_arr, max_speakers=8)
n_speakers = max(1, min(n_speakers, len(segments_raw)))
labels = _cosine_cluster(embeddings_arr, n_clusters=n_speakers)
for seg, label in zip(segments_raw, labels):
seg["speaker"] = f"SPEAKER_{(label + 1):02d}"
return segments_raw
# ── Energy + MFCC fallback diarization ───────────────────────────────────────
def _diarize_energy(audio_path: str, min_silence_sec: float = 0.5, num_speakers: Optional[int] = None) -> List[Dict]:
"""
Energy-based VAD + MFCC cosine clustering fallback (~60% accuracy).
Used when both pyannote and SpeechBrain are unavailable.
"""
import librosa
y, sr, segments_raw = _get_speech_segments(audio_path, min_silence_sec=min_silence_sec)
if len(segments_raw) == 0:
return []
frame_len = int(0.025 * sr)
def seg_mfcc(s):
start_sample = int(s["start"] * sr)
end_sample = int(s["end"] * sr)
chunk = y[start_sample:end_sample]
if len(chunk) < frame_len:
return np.zeros(20)
mfcc = librosa.feature.mfcc(y=chunk, sr=sr, n_mfcc=20)
return mfcc.mean(axis=1)
features = np.array([seg_mfcc(s) for s in segments_raw])
n_sp = num_speakers if num_speakers else _estimate_n_speakers(features, max_speakers=6)
n_sp = max(1, min(n_sp, len(segments_raw)))
labels = _cosine_cluster(features, n_clusters=n_sp)
for seg, label in zip(segments_raw, labels):
seg["speaker"] = f"SPEAKER_{(label + 1):02d}"
return segments_raw
def _cosine_cluster(features: np.ndarray, n_clusters: int) -> np.ndarray:
"""Greedy cosine-distance clustering (no sklearn dependency)."""
if n_clusters <= 1 or len(features) <= 1:
return np.zeros(len(features), dtype=int)
norms = np.linalg.norm(features, axis=1, keepdims=True)
norms[norms == 0] = 1e-10
normed = features / norms
# Initialize centroids as first n_clusters samples
centroids = normed[:n_clusters].copy()
labels = np.zeros(len(features), dtype=int)
for _ in range(20): # max iterations
# Assign to nearest centroid (highest cosine similarity)
sims = normed @ centroids.T # (N, K)
new_labels = np.argmax(sims, axis=1)
if np.all(new_labels == labels):
break
labels = new_labels
# Update centroids
for k in range(n_clusters):
mask = labels == k
if mask.any():
centroids[k] = normed[mask].mean(axis=0)
norm = np.linalg.norm(centroids[k])
if norm > 0:
centroids[k] /= norm
return labels
# ── Public diarization entry point ───────────────────────────────────────────
def diarize(audio_path: str, num_speakers: Optional[int] = None) -> Tuple[List[Dict], str]:
"""
Returns (segments, method) where:
segments: [{start, end, speaker}, ...]
method: "pyannote" | "speechbrain" | "energy_fallback"
Priority: pyannote SpeechBrain ECAPA-TDNN energy+MFCC
"""
try:
segs = _diarize_pyannote(audio_path)
return segs, "pyannote"
except Exception as e:
logger.warning(f"pyannote diarization failed ({e}), trying SpeechBrain")
try:
segs = _diarize_speechbrain(audio_path, num_speakers=num_speakers)
return segs, "speechbrain"
except Exception as e:
logger.warning(f"SpeechBrain diarization failed ({e}), using MFCC fallback")
segs = _diarize_energy(audio_path, num_speakers=num_speakers)
return segs, "energy_fallback"
# ── Transcription + diarization alignment ────────────────────────────────────
def align_transcription_with_diarization(
transcription_segments: List[Dict],
diarization_segments: List[Dict],
) -> List[Dict]:
"""
For each transcription segment {start, end, text}, find the speaker with
the most time overlap from diarization_segments.
Falls back to temporally closest speaker if no overlap.
Merges consecutive same-speaker segments < 1 second apart.
"""
result = []
for tseg in transcription_segments:
t_start = tseg.get("start", 0.0)
t_end = tseg.get("end", t_start + 0.1)
text = tseg.get("text", "").strip()
if not text:
continue
# Find best speaker by overlap
best_speaker = None
best_overlap = -1.0
best_dist = float("inf")
for dseg in diarization_segments:
d_start = dseg["start"]
d_end = dseg["end"]
speaker = dseg["speaker"]
overlap = max(0.0, min(t_end, d_end) - max(t_start, d_start))
if overlap > best_overlap:
best_overlap = overlap
best_speaker = speaker
# Track closest for fallback
dist = min(abs(t_start - d_start), abs(t_start - d_end),
abs(t_end - d_start), abs(t_end - d_end))
if dist < best_dist:
best_dist = dist
if best_overlap <= 0:
best_speaker = speaker
if best_speaker is None:
best_speaker = "SPEAKER_01"
result.append({
"start": round(t_start, 3),
"end": round(t_end, 3),
"speaker": best_speaker,
"text": text,
})
# Merge consecutive same-speaker segments where gap < 1 second
if not result:
return result
merged = [result[0].copy()]
for seg in result[1:]:
prev = merged[-1]
gap = seg["start"] - prev["end"]
if seg["speaker"] == prev["speaker"] and gap < 1.0:
prev["end"] = seg["end"]
prev["text"] = prev["text"].rstrip() + " " + seg["text"].lstrip()
else:
merged.append(seg.copy())
return merged
# ── Embedding extraction ──────────────────────────────────────────────────────
def extract_embedding(audio_path: str) -> np.ndarray:
"""
Extract speaker embedding from audio file.
Returns 1-D numpy array.
Falls back to MFCC-based embedding if models unavailable.
"""
model = _try_load_embedding_model()
if model is not None:
if isinstance(model, tuple) and model[0] == "speechbrain":
_, classifier = model
try:
import torch
import torchaudio
waveform, sr = torchaudio.load(audio_path)
if sr != 16000:
resampler = torchaudio.transforms.Resample(sr, 16000)
waveform = resampler(waveform)
embedding = classifier.encode_batch(waveform)
return embedding.squeeze().cpu().numpy()
except Exception as e:
logger.warning(f"SpeechBrain embedding failed: {e}")
else:
# pyannote Inference
try:
embedding = model(audio_path)
if hasattr(embedding, 'data'):
return np.array(embedding.data).flatten()
return np.array(embedding).flatten()
except Exception as e:
logger.warning(f"pyannote embedding failed: {e}")
# MFCC fallback
return _mfcc_embedding(audio_path)
def _mfcc_embedding(audio_path: str) -> np.ndarray:
"""Create a simple MFCC-based speaker embedding (128-dim)."""
import librosa
y, sr = librosa.load(audio_path, sr=16000, mono=True)
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=40)
delta = librosa.feature.delta(mfcc)
delta2 = librosa.feature.delta(mfcc, order=2)
features = np.concatenate([
mfcc.mean(axis=1), mfcc.std(axis=1),
delta.mean(axis=1),
delta2.mean(axis=1),
])
norm = np.linalg.norm(features)
if norm > 0:
features = features / norm
return features.astype(np.float32)
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Cosine similarity between two vectors."""
na = np.linalg.norm(a)
nb = np.linalg.norm(b)
if na == 0 or nb == 0:
return 0.0
return float(np.dot(a, b) / (na * nb))

View File

@ -0,0 +1,228 @@
"""
Voice enrollment and speaker identification service.
Uses SQLite at /opt/Backend/whisper-diarization-api/voice_profiles.db
"""
import os
import uuid
import pickle
import logging
import sqlite3
import numpy as np
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any
logger = logging.getLogger(__name__)
DB_PATH = os.getenv("VOICE_DB_PATH", "/opt/Backend/whisper-diarization-api/voice_profiles.db")
CONFIDENCE_THRESHOLD = float(os.getenv("SPEAKER_CONFIDENCE_THRESHOLD", "0.75"))
def _conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ── Schema ───────────────────────────────────────────────────────────────────
def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
with _conn() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS voice_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
metadata TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS voice_embeddings (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES voice_profiles(id) ON DELETE CASCADE,
embedding BLOB NOT NULL,
created_at TEXT NOT NULL
);
""")
logger.info(f"Voice profiles DB initialised at {DB_PATH}")
# ── Helpers ───────────────────────────────────────────────────────────────────
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _serialize(arr: np.ndarray) -> bytes:
return pickle.dumps(arr)
def _deserialize(blob: bytes) -> np.ndarray:
return pickle.loads(blob)
def _profile_row_to_dict(row: sqlite3.Row, embeddings_count: int = 0) -> Dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"email": row["email"],
"metadata": row["metadata"],
"embeddings_count": embeddings_count,
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
# ── CRUD: Voice Profiles ──────────────────────────────────────────────────────
def create_profile(name: str, email: Optional[str], metadata: Optional[str]) -> Dict[str, Any]:
profile_id = str(uuid.uuid4())
now = _now()
with _conn() as conn:
conn.execute(
"INSERT INTO voice_profiles (id, name, email, metadata, created_at, updated_at) VALUES (?,?,?,?,?,?)",
(profile_id, name, email, metadata, now, now),
)
return {
"id": profile_id,
"name": name,
"email": email,
"metadata": metadata,
"embeddings_count": 0,
"created_at": now,
"updated_at": now,
}
def get_profile(profile_id: str) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute("SELECT * FROM voice_profiles WHERE id = ?", (profile_id,)).fetchone()
if row is None:
return None
count = conn.execute(
"SELECT COUNT(*) FROM voice_embeddings WHERE profile_id = ?", (profile_id,)
).fetchone()[0]
return _profile_row_to_dict(row, count)
def list_profiles() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute("SELECT * FROM voice_profiles ORDER BY created_at DESC").fetchall()
result = []
for row in rows:
count = conn.execute(
"SELECT COUNT(*) FROM voice_embeddings WHERE profile_id = ?", (row["id"],)
).fetchone()[0]
result.append(_profile_row_to_dict(row, count))
return result
def update_profile(
profile_id: str,
name: Optional[str] = None,
email: Optional[str] = None,
metadata: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
profile = get_profile(profile_id)
if profile is None:
return None
new_name = name if name is not None else profile["name"]
new_email = email if email is not None else profile["email"]
new_meta = metadata if metadata is not None else profile["metadata"]
now = _now()
with _conn() as conn:
conn.execute(
"UPDATE voice_profiles SET name=?, email=?, metadata=?, updated_at=? WHERE id=?",
(new_name, new_email, new_meta, now, profile_id),
)
return get_profile(profile_id)
def delete_profile(profile_id: str) -> bool:
with _conn() as conn:
cur = conn.execute("DELETE FROM voice_profiles WHERE id = ?", (profile_id,))
return cur.rowcount > 0
# ── Embeddings ────────────────────────────────────────────────────────────────
def add_embedding(profile_id: str, embedding: np.ndarray) -> str:
emb_id = str(uuid.uuid4())
now = _now()
with _conn() as conn:
conn.execute(
"INSERT INTO voice_embeddings (id, profile_id, embedding, created_at) VALUES (?,?,?,?)",
(emb_id, profile_id, _serialize(embedding), now),
)
conn.execute("UPDATE voice_profiles SET updated_at=? WHERE id=?", (now, profile_id))
return emb_id
def get_all_embeddings() -> List[Dict[str, Any]]:
"""Returns all embeddings with profile info."""
with _conn() as conn:
rows = conn.execute("""
SELECT ve.id, ve.profile_id, ve.embedding, vp.name
FROM voice_embeddings ve
JOIN voice_profiles vp ON ve.profile_id = vp.id
""").fetchall()
return [
{
"embedding_id": r["id"],
"profile_id": r["profile_id"],
"speaker_name": r["name"],
"embedding": _deserialize(r["embedding"]),
}
for r in rows
]
# ── Speaker identification ────────────────────────────────────────────────────
def identify_speaker(query_embedding: np.ndarray, threshold: Optional[float] = None) -> Dict[str, Any]:
"""
Compare query_embedding against all registered speakers.
Returns the best match if confidence >= threshold.
"""
from services.diarization import cosine_similarity
if threshold is None:
threshold = CONFIDENCE_THRESHOLD
all_embs = get_all_embeddings()
if not all_embs:
return {"matched": False, "speaker_id": None, "speaker_name": None, "confidence": 0.0, "threshold": threshold}
# Average embeddings per profile
profile_map: Dict[str, Dict[str, Any]] = {}
for item in all_embs:
pid = item["profile_id"]
if pid not in profile_map:
profile_map[pid] = {"speaker_name": item["speaker_name"], "embeddings": []}
profile_map[pid]["embeddings"].append(item["embedding"])
best_profile_id = None
best_score = -1.0
for pid, info in profile_map.items():
# Average cosine similarity against all stored embeddings
scores = [cosine_similarity(query_embedding, e) for e in info["embeddings"]]
avg_score = float(np.mean(scores))
if avg_score > best_score:
best_score = avg_score
best_profile_id = pid
if best_profile_id is None or best_score < threshold:
return {
"matched": False,
"speaker_id": best_profile_id,
"speaker_name": profile_map[best_profile_id]["speaker_name"] if best_profile_id else None,
"confidence": round(best_score, 4),
"threshold": threshold,
}
return {
"matched": True,
"speaker_id": best_profile_id,
"speaker_name": profile_map[best_profile_id]["speaker_name"],
"confidence": round(best_score, 4),
"threshold": threshold,
}

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
frontend/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

102
frontend/deploy.py Normal file
View File

@ -0,0 +1,102 @@
#!/usr/bin/env python3
import paramiko
import os
import tarfile
import io
from pathlib import Path
import time
# Server details
HOST = '5.0.0.181'
USER = 'root'
PASSWORD = 'Ozzyos1bsd3'
REMOTE_PATH = '/opt/Frontend/meeting-assistant-fe/'
try:
print('[1] Connecting to server...')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, username=USER, password=PASSWORD, timeout=30)
print(f' ✓ Connected to {HOST}')
# Stop and remove container
print('[2] Stopping and removing container...')
stdin, stdout, stderr = ssh.exec_command('podman stop meeting-assistant-fe && podman rm meeting-assistant-fe 2>/dev/null || true')
stdout.channel.recv_exit_status()
print(' ✓ Container stopped and removed')
# Create tar archive in memory
print('[3] Creating tar archive of project...')
tar_buffer = io.BytesIO()
with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar:
project_dir = Path('.')
exclude_dirs = {'node_modules', '.git', '.next', 'dist', '.env', '.env.local', '.DS_Store'}
for item in project_dir.rglob('*'):
# Skip excluded directories
if any(excl in item.parts for excl in exclude_dirs):
continue
if item.is_file():
arcname = str(item.relative_to(project_dir))
tar.add(item, arcname=arcname)
tar_buffer.seek(0)
tar_size = len(tar_buffer.getvalue())
print(f' ✓ Archive created ({tar_size / 1024 / 1024:.2f} MB)')
# Upload via SFTP
print('[4] Uploading project to server...')
sftp = ssh.open_sftp()
remote_tar = f'{REMOTE_PATH}project.tar.gz'
sftp.putfo(tar_buffer, remote_tar)
sftp.close()
print(f' ✓ Uploaded to {remote_tar}')
# Extract on server
print('[5] Extracting archive on server...')
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_PATH} && tar -xzf project.tar.gz && rm project.tar.gz')
stdout.channel.recv_exit_status()
print(' ✓ Archive extracted')
# Rebuild image
print('[6] Rebuilding Docker image...')
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_PATH} && podman build -t meeting-assistant-fe . 2>&1')
build_output = stdout.read().decode()
build_err = stderr.read().decode()
exit_code = stdout.channel.recv_exit_status()
if exit_code != 0:
print(f' ⚠️ Build warnings/info:\n{build_output}\n{build_err}')
print(' ✓ Image built')
# Start container
print('[7] Starting new container...')
stdin, stdout, stderr = ssh.exec_command('podman run -d --name meeting-assistant-fe --restart unless-stopped -p 3010:80 meeting-assistant-fe')
container_id = stdout.read().decode().strip()
stdout.channel.recv_exit_status()
print(f' ✓ Container started (ID: {container_id[:12]}...)')
# Wait for container to be ready
time.sleep(2)
# Verify
print('[8] Verifying deployment...')
stdin, stdout, stderr = ssh.exec_command('curl -s -o /dev/null -w "%{http_code}" http://localhost:3010/')
exit_code = stdout.channel.recv_exit_status()
response = stdout.read().decode().strip()
print(f' ✓ Response code: {response}')
if response == '200':
print('\n✅ Deployment successful! PWA is live at http://5.0.0.181:3010/')
else:
print(f'\n⚠️ Unexpected response code: {response}')
# Check container logs
stdin, stdout, stderr = ssh.exec_command('podman logs meeting-assistant-fe --tail 20')
logs = stdout.read().decode()
print(f'Container logs:\n{logs}')
ssh.close()
except Exception as e:
print(f'\n❌ Deployment failed: {e}')
import traceback
traceback.print_exc()

View File

@ -0,0 +1,14 @@
version: '3.8'
services:
meeting-assistant-fe:
build: .
container_name: meeting-assistant-fe
restart: unless-stopped
ports:
- "3000:80"
networks:
- meeting-network
networks:
meeting-network:
driver: bridge

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MAIA — Meeting AI Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

51
frontend/nginx.conf Normal file
View File

@ -0,0 +1,51 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Proxy to backend API (avoids CORS)
location /api/ {
proxy_pass http://5.0.0.181:8090/api/;
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;
}
# WebSocket proxy
location /ws/ {
proxy_pass http://5.0.0.181:8090/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
# Service Worker e manifest NUNCA cachear (browser precisa verificar updates)
location ~* (sw\.js|workbox-.*\.js|registerSW\.js|manifest\.webmanifest)$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
# Cache assets versionados por hash (imutáveis)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache outros estáticos (ícones, fontes)
location ~* \.(png|svg|ico|woff2)$ {
expires 30d;
add_header Cache-Control "public";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}

9958
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
frontend/package.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "meeting-assistant-fe",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"axios": "^1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"keycloak-js": "^26.2.4",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-is": "^19.2.5",
"react-router-dom": "^7.14.1",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4",
"vite-plugin-pwa": "^1.2.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,2 @@
# Icons
Place icon-192.png and icon-512.png here for PWA installation.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

184
frontend/src/App.css Normal file
View File

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

32
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,32 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Home } from '@/pages/Home'
import { Recording } from '@/pages/Recording'
import { UploadPage } from '@/pages/Upload'
import { History } from '@/pages/History'
import { MeetingDetail } from '@/pages/MeetingDetail'
import { Participants } from '@/pages/Participants'
import { Evolution } from '@/pages/Evolution'
import { Settings } from '@/pages/Settings'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/record" element={<ProtectedRoute><Recording /></ProtectedRoute>} />
<Route path="/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
<Route path="/history" element={<ProtectedRoute><History /></ProtectedRoute>} />
<Route path="/meetings/:id" element={<ProtectedRoute><MeetingDetail /></ProtectedRoute>} />
<Route path="/participants" element={<ProtectedRoute><Participants /></ProtectedRoute>} />
<Route path="/evolution" element={<ProtectedRoute><Evolution /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
{/* Keycloak handles auth — redirect legacy login/register paths to home */}
<Route path="/login" element={<Navigate to="/" replace />} />
<Route path="/register" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
)
}

21
frontend/src/api/auth.ts Normal file
View File

@ -0,0 +1,21 @@
import api from './axios'
import type { User } from '@/types'
export const authApi = {
login: (email: string, password: string) =>
api.post<{ access_token: string; refresh_token: string; user: User }>('/auth/login', { email, password }),
register: (name: string, email: string, password: string) =>
api.post<{ access_token: string; refresh_token: string; user: User }>('/auth/register', { name, email, password }),
refresh: (refresh_token: string) =>
api.post<{ access_token: string }>('/auth/refresh', { refresh_token }),
keycloakAuth: async (kcToken: string) => {
const response = await api.post<{ access_token: string; refresh_token: string; token_type: string }>(
'/auth/keycloak',
{ kc_token: kcToken }
)
return response.data
},
}

55
frontend/src/api/axios.ts Normal file
View File

@ -0,0 +1,55 @@
import axios from 'axios'
import keycloak from '@/services/keycloak'
import { useAuthStore } from '@/store/authStore'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retry) {
original._retry = true
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
try {
const { data } = await axios.post('/api/auth/refresh', { refresh_token: refreshToken })
localStorage.setItem('access_token', data.access_token)
original.headers.Authorization = `Bearer ${data.access_token}`
return api(original)
} catch {
// Refresh failed — try re-exchanging via Keycloak
try {
await keycloak.updateToken(10)
if (keycloak.token) {
const { data } = await axios.post('/api/auth/keycloak', { kc_token: keycloak.token })
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
original.headers.Authorization = `Bearer ${data.access_token}`
return api(original)
}
} catch {
// Keycloak session also expired — force re-login
}
useAuthStore.getState().logout()
keycloak.login()
}
} else {
useAuthStore.getState().logout()
keycloak.login()
}
}
return Promise.reject(error)
}
)
export default api

View File

@ -0,0 +1,63 @@
import api from './axios'
import type { Meeting, MeetingAnalysis, Transcript } from '@/types'
export const meetingsApi = {
list: () => api.get<Meeting[]>('/meetings'),
get: (id: string) => api.get<Meeting>(`/meetings/${id}`),
getStatus: (id: string) => api.get<{ status: string; progress: number }>(`/meetings/${id}/status`),
getTranscript: (id: string) => api.get<Transcript>(`/meetings/${id}/transcript`),
getAnalyses: (id: string) => api.get<MeetingAnalysis[]>(`/meetings/${id}/analyses`),
getAnalysis: (id: string, type: string) => api.get<MeetingAnalysis>(`/meetings/${id}/analyses/${type}`),
upload: (file: File, title?: string, onProgress?: (p: number) => void) => {
const form = new FormData()
form.append('file', file)
const params = title ? { title } : {}
return api.post<Meeting>('/meetings/upload', form, {
params,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded * 100) / e.total))
},
})
},
// --- Chunk recording endpoints ---
startMeeting: async (title?: string): Promise<Meeting> => {
const { data } = await api.post<Meeting>('/meetings/start', null, { params: title?.trim() ? { title } : {} })
return data
},
uploadChunk: async (meetingId: string, chunkIndex: number, blob: Blob, mimeType: string): Promise<void> => {
const form = new FormData()
const ext = mimeType.includes('ogg') ? '.ogg' : mimeType.includes('mp4') ? '.mp4' : '.webm'
form.append('file', new File([blob], `chunk_${chunkIndex}${ext}`, { type: mimeType }))
form.append('chunk_index', String(chunkIndex))
await api.post(`/meetings/${meetingId}/chunks`, form)
},
finalizeMeeting: async (meetingId: string, title?: string): Promise<Meeting> => {
const params = title ? { title } : {}
const { data } = await api.post<Meeting>(`/meetings/${meetingId}/finalize`, null, { params })
return data
},
relabelParticipant: (meetingId: string, label: string, name: string) =>
api.put(`/meetings/${meetingId}/participants/${label}`, { name }),
enrollSpeaker: async (meetingId: string, speakerLabel: string, name: string, email?: string): Promise<void> => {
await api.post(`/meetings/${meetingId}/participants/${encodeURIComponent(speakerLabel)}/enroll`, { name, email })
},
delete: (id: string) => api.delete(`/meetings/${id}`),
getAudioUrl: (id: string): string => {
return `/api/meetings/${id}/audio`
},
}

View File

@ -0,0 +1,11 @@
import api from './axios'
import type { Participant } from '@/types'
export const participantsApi = {
list: () => api.get<Participant[]>('/participants'),
create: (name: string, email?: string) =>
api.post<Participant>('/participants', { name, email }),
delete: (id: string) => api.delete(`/participants/${id}`),
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,65 @@
import { NavLink, useNavigate } from 'react-router-dom'
import { Mic, History, Users, TrendingUp, Settings, LogOut } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/utils/cn'
const navItems = [
{ to: '/', icon: Mic, label: 'Início' },
{ to: '/history', icon: History, label: 'Histórico' },
{ to: '/participants', icon: Users, label: 'Participantes' },
{ to: '/evolution', icon: TrendingUp, label: 'Evolução' },
{ to: '/settings', icon: Settings, label: 'Config.' },
]
export function Layout({ children }: { children: React.ReactNode }) {
const navigate = useNavigate()
const { logout, user } = useAuthStore()
function handleLogout() {
logout()
navigate('/login')
}
return (
<div className="flex flex-col min-h-dvh bg-slate-900">
{/* Header */}
<header className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
<div className="flex items-center gap-3">
<img src="/images/logo-asap.png" alt="ASAP Telecom" className="h-7 rounded bg-white px-1.5 py-0.5" />
<span className="text-green-400 font-bold text-lg tracking-wide">MAIA</span>
</div>
<div className="flex items-center gap-3">
{user?.name && (
<span className="text-slate-300 text-sm hidden sm:block truncate max-w-[160px]">
{user.name}
</span>
)}
<button onClick={handleLogout} className="text-slate-400 hover:text-white p-1">
<LogOut size={18} />
</button>
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-auto pb-20">{children}</main>
{/* Bottom Nav */}
<nav className="fixed bottom-0 left-0 right-0 bg-slate-800 border-t border-slate-700 flex justify-around py-2 z-50">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
cn('flex flex-col items-center gap-0.5 px-3 py-1 rounded-lg text-xs transition-colors',
isActive ? 'text-green-400' : 'text-slate-400 hover:text-slate-200')
}
>
<Icon size={20} />
<span>{label}</span>
</NavLink>
))}
</nav>
</div>
)
}

View File

@ -0,0 +1,7 @@
import { useAuthStore } from '@/store/authStore'
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (!isAuthenticated) return null
return <>{children}</>
}

View File

@ -0,0 +1,17 @@
import { Badge } from '@/components/ui/badge'
import type { Meeting } from '@/types'
const map: Record<Meeting['status'], { label: string; variant: 'default' | 'secondary' | 'warning' | 'destructive' | 'info' }> = {
receiving_chunks: { label: 'Gravando', variant: 'info' },
uploading: { label: 'Enviando', variant: 'info' },
processing: { label: 'Processando', variant: 'warning' },
transcribing: { label: 'Transcrevendo', variant: 'warning' },
analyzing: { label: 'Analisando', variant: 'warning' },
completed: { label: 'Concluído', variant: 'default' },
error: { label: 'Erro', variant: 'destructive' },
}
export function StatusBadge({ status }: { status: Meeting['status'] }) {
const { label, variant } = map[status] ?? { label: status, variant: 'secondary' }
return <Badge variant={variant}>{label}</Badge>
}

View File

@ -0,0 +1,19 @@
import { cn } from '@/utils/cn'
import { cva, type VariantProps } from 'class-variance-authority'
const badgeVariants = cva('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold', {
variants: {
variant: {
default: 'bg-green-900 text-green-300',
secondary: 'bg-slate-700 text-slate-300',
destructive: 'bg-red-900 text-red-300',
warning: 'bg-yellow-900 text-yellow-300',
info: 'bg-blue-900 text-blue-300',
},
},
defaultVariants: { variant: 'default' },
})
export function Badge({ className, variant, ...props }: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

View File

@ -0,0 +1,36 @@
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500 disabled:opacity-50 disabled:pointer-events-none select-none',
{
variants: {
variant: {
default: 'bg-green-700 text-white hover:bg-green-600',
destructive: 'bg-red-600 text-white hover:bg-red-500',
outline: 'border border-slate-600 text-slate-200 hover:bg-slate-700',
ghost: 'text-slate-300 hover:bg-slate-700 hover:text-white',
secondary: 'bg-slate-700 text-slate-200 hover:bg-slate-600',
},
size: {
default: 'h-10 px-4 py-2 text-sm',
sm: 'h-8 px-3 text-xs',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} />
)
)
Button.displayName = 'Button'

View File

@ -0,0 +1,17 @@
import { cn } from '@/utils/cn'
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('rounded-xl border border-slate-700 bg-slate-800 p-4', className)} {...props} />
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-3', className)} {...props} />
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-base font-semibold text-white', className)} {...props} />
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('text-slate-300 text-sm', className)} {...props} />
}

View File

@ -0,0 +1,16 @@
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
export const Input = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
'w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder:text-slate-400 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 disabled:opacity-50',
className
)}
{...props}
/>
)
)
Input.displayName = 'Input'

View File

@ -0,0 +1,24 @@
import { useRef, useCallback } from 'react'
export function useWakeLock() {
const wakeLockRef = useRef<WakeLockSentinel | null>(null)
const acquire = useCallback(async () => {
if ('wakeLock' in navigator) {
try {
wakeLockRef.current = await navigator.wakeLock.request('screen')
} catch {
// Not critical — iOS Safari doesn't support this
}
}
}, [])
const release = useCallback(async () => {
if (wakeLockRef.current) {
await wakeLockRef.current.release()
wakeLockRef.current = null
}
}, [])
return { acquire, release }
}

View File

@ -0,0 +1,23 @@
import { useEffect, useRef, useCallback } from 'react'
export function useWebSocket(meetingId: string | null, onMessage: (data: unknown) => void) {
const wsRef = useRef<WebSocket | null>(null)
const connect = useCallback(() => {
if (!meetingId) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/meetings/${meetingId}`)
ws.onmessage = (e) => {
try { onMessage(JSON.parse(e.data)) } catch { /* ignore */ }
}
ws.onclose = () => {
setTimeout(connect, 3000) // auto-reconnect
}
wsRef.current = ws
}, [meetingId, onMessage])
useEffect(() => {
connect()
return () => { wsRef.current?.close() }
}, [connect])
}

28
frontend/src/index.css Normal file
View File

@ -0,0 +1,28 @@
@import "tailwindcss";
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #0f172a;
color: #e2e8f0;
font-family: system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
#root {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }

76
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,76 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
import { useAuthStore } from './store/authStore'
import { authApi } from './api/auth'
const rootElement = document.getElementById('root')!
function render(el: React.ReactElement) {
createRoot(rootElement).render(<React.StrictMode>{el}</React.StrictMode>)
}
function AccessDeniedPage({ forbidden }: { forbidden?: boolean }) {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#020617', color: 'white', padding: '24px' }}>
<img src="/images/logo-asap.png" alt="ASAP Telecom" style={{ height: '48px', background: 'white', borderRadius: '8px', padding: '6px 12px', marginBottom: '20px' }} />
<div style={{ fontSize: '48px' }}>{forbidden ? '🚫' : '🔗'}</div>
<h1 style={{ fontSize: '24px', fontWeight: 'bold', margin: '12px 0 4px' }}>MAIA</h1>
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: '0 0 12px', color: '#94a3b8' }}>
{forbidden ? 'Acesso Negado' : 'Link inválido ou expirado'}
</h2>
<p style={{ color: '#94a3b8', textAlign: 'center', maxWidth: '340px', lineHeight: 1.6 }}>
{forbidden
? 'Seu usuário não possui a permissão maia.app necessária. Entre em contato com o administrador.'
: 'Acesse o MAIA pelo link gerado no portal. O link de acesso direto é necessário para entrar.'}
</p>
</div>
)
}
async function initApp() {
// 1. Ler token da URL (?token=eyJ...)
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
// Limpar token da URL imediatamente (antes de qualquer render)
window.history.replaceState({}, '', window.location.pathname)
try {
const tokens = await authApi.keycloakAuth(urlToken)
// Decodificar payload do token KC para extrair nome/email
const payload = JSON.parse(atob(urlToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
const user = {
id: payload.sub as string,
email: (payload.email as string) || '',
name: (payload.name as string)
|| `${payload.given_name || ''} ${payload.family_name || ''}`.trim()
|| (payload.preferred_username as string)
|| (payload.email as string)
|| 'Usuário',
roles: [
...((payload.realm_access?.roles as string[]) || []),
...Object.values(payload.resource_access || {}).flatMap((c: any) => c.roles || []),
].filter((r: string) => r.startsWith('maia.')),
}
useAuthStore.getState().setAuth(user, tokens.access_token, tokens.refresh_token)
} catch (e: any) {
const status = e?.response?.status
render(<AccessDeniedPage forbidden={status === 403} />)
return
}
} else {
// 2. Sem token na URL — verificar sessão salva no localStorage
const { isAuthenticated } = useAuthStore.getState()
if (!isAuthenticated) {
render(<AccessDeniedPage />)
return
}
}
render(<App />)
}
initApp()

View File

@ -0,0 +1,63 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export function Login() {
const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const { data } = await authApi.login(email, password)
setAuth(data.user, data.access_token, data.refresh_token)
navigate('/')
} catch {
setError('E-mail ou senha inválidos')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-dvh bg-slate-900 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="text-4xl mb-3">🎙</div>
<img src="/images/logo-asap.png" alt="ASAP Telecom" className="h-8 rounded bg-white px-2 py-1 mx-auto mb-3" />
<h1 className="text-2xl font-bold text-white">MAIA</h1>
<p className="text-slate-400 text-sm mt-1">Meeting AI Assistant</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-red-400 text-sm text-center bg-red-950 rounded-lg p-3">{error}</p>}
<div>
<label className="block text-sm text-slate-300 mb-1">E-mail</label>
<Input type="email" placeholder="seu@email.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<div>
<label className="block text-sm text-slate-300 mb-1">Senha</label>
<Input type="password" placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} required />
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? 'Entrando...' : 'Entrar'}
</Button>
</form>
<p className="text-center text-slate-400 text-sm mt-6">
Não tem conta?{' '}
<Link to="/register" className="text-green-400 hover:underline">Cadastrar</Link>
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,63 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export function Register() {
const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const { data } = await authApi.register(name, email, password)
setAuth(data.user, data.access_token, data.refresh_token)
navigate('/')
} catch {
setError('Erro ao criar conta. Tente novamente.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-dvh bg-slate-900 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="text-4xl mb-2">🎙</div>
<h1 className="text-2xl font-bold text-white">Criar conta</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-red-400 text-sm text-center bg-red-950 rounded-lg p-3">{error}</p>}
<div>
<label className="block text-sm text-slate-300 mb-1">Nome</label>
<Input placeholder="Seu nome" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<label className="block text-sm text-slate-300 mb-1">E-mail</label>
<Input type="email" placeholder="seu@email.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<div>
<label className="block text-sm text-slate-300 mb-1">Senha</label>
<Input type="password" placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} />
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? 'Criando...' : 'Criar conta'}
</Button>
</form>
<p className="text-center text-slate-400 text-sm mt-6">
tem conta? <Link to="/login" className="text-green-400 hover:underline">Entrar</Link>
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import { Loader2, TrendingUp } from 'lucide-react'
import { meetingsApi } from '@/api/meetings'
import { useMeetingsStore } from '@/store/meetingsStore'
import { Layout } from '@/components/Layout'
import { Card, CardContent } from '@/components/ui/card'
import type { CommunicationData } from '@/types'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
interface ScorePoint { date: string; score: number; title: string }
export function Evolution() {
const { meetings, fetchMeetings } = useMeetingsStore()
const [scores, setScores] = useState<ScorePoint[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function load() {
await fetchMeetings()
const completed = meetings.filter((m) => m.status === 'completed').slice(0, 20)
const points: ScorePoint[] = []
for (const m of completed) {
try {
const { data } = await meetingsApi.getAnalysis(m.id, 'communication')
const fb = data.content as CommunicationData
const score = fb?.overall_score
if (score !== undefined) {
points.push({ date: new Date(m.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }), score, title: m.title || 'Sem título' })
}
} catch { /* no communication yet */ }
}
setScores(points.reverse())
setLoading(false)
}
load()
}, [])
const avg = scores.length > 0 ? (scores.reduce((a, b) => a + b.score, 0) / scores.length).toFixed(1) : null
const latest = scores[scores.length - 1]?.score
const trend = scores.length >= 2 ? latest - scores[scores.length - 2].score : null
return (
<Layout>
<div className="p-4 max-w-2xl mx-auto space-y-4">
<h1 className="text-xl font-bold text-white">Evolução pessoal</h1>
{loading ? (
<div className="flex justify-center py-12"><Loader2 size={24} className="animate-spin text-slate-400" /></div>
) : scores.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<TrendingUp size={32} className="mx-auto mb-2 opacity-30" />
<p className="text-sm">Nenhuma análise disponível ainda.</p>
<p className="text-xs mt-1">Grave reuniões para ver sua evolução!</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<Card>
<CardContent className="text-center py-4">
<div className="text-3xl font-bold text-white">{latest}<span className="text-base text-slate-400">/10</span></div>
<p className="text-slate-400 text-xs mt-1">Score mais recente</p>
{trend !== null && (
<p className={`text-xs mt-0.5 font-medium ${trend > 0 ? 'text-green-400' : trend < 0 ? 'text-red-400' : 'text-slate-400'}`}>
{trend > 0 ? `↑ +${trend.toFixed(1)}` : trend < 0 ? `${trend.toFixed(1)}` : '→ estável'}
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-3xl font-bold text-white">{avg}<span className="text-base text-slate-400">/10</span></div>
<p className="text-slate-400 text-xs mt-1">Média geral</p>
<p className="text-slate-500 text-xs mt-0.5">{scores.length} reuniões</p>
</CardContent>
</Card>
</div>
<Card>
<CardContent>
<h3 className="text-green-400 text-sm font-semibold mb-3">📈 Score ao longo do tempo</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={scores}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="date" tick={{ fill: '#94a3b8', fontSize: 10 }} />
<YAxis domain={[0, 10]} tick={{ fill: '#94a3b8', fontSize: 10 }} />
<Tooltip
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8 }}
formatter={(v) => [`${v}/10`, 'Score'] as [string, string]}
labelFormatter={(l, payload) => payload?.[0]?.payload?.title ?? l}
/>
<Line type="monotone" dataKey="score" stroke="#4ade80" strokeWidth={2} dot={{ fill: '#4ade80', r: 4 }} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</>
)}
</div>
</Layout>
)
}

View File

@ -0,0 +1,103 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, Trash2, Loader2, User } from 'lucide-react'
import { useMeetingsStore } from '@/store/meetingsStore'
import { meetingsApi } from '@/api/meetings'
import { useAuthStore } from '@/store/authStore'
import { Layout } from '@/components/Layout'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { StatusBadge } from '@/components/StatusBadge'
import { formatDateTimeBR } from '@/utils/format'
export function History() {
const navigate = useNavigate()
const { meetings, loading, fetchMeetings, removeMeeting } = useMeetingsStore()
const authUser = useAuthStore((s) => s.user)
const [search, setSearch] = useState('')
const isAdmin = authUser?.roles?.includes('maia.admin') ?? false
useEffect(() => { fetchMeetings() }, [fetchMeetings])
const filtered = meetings.filter((m) =>
(m.title || '').toLowerCase().includes(search.toLowerCase()) ||
(isAdmin && (m.owner_name || m.owner_email || '').toLowerCase().includes(search.toLowerCase()))
)
async function handleDelete(e: React.MouseEvent, id: string) {
e.stopPropagation()
if (!confirm('Excluir esta reunião?')) return
try {
await meetingsApi.delete(id)
removeMeeting(id)
} catch { /* ignore */ }
}
return (
<Layout>
<div className="p-4 max-w-2xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Histórico</h1>
{isAdmin && (
<span className="text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 px-2 py-0.5 rounded-full">
Admin todas as reuniões
</span>
)}
</div>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<Input
placeholder={isAdmin ? 'Buscar por título ou usuário...' : 'Buscar reunião...'}
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{loading ? (
<div className="flex justify-center py-12"><Loader2 size={24} className="animate-spin text-slate-400" /></div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p>Nenhuma reunião encontrada.</p>
</div>
) : (
<div className="space-y-2">
{filtered.map((m) => (
<Card key={m.id} className="cursor-pointer hover:border-slate-500" onClick={() => navigate(`/meetings/${m.id}`)}>
<CardContent className="flex items-center justify-between p-3">
<div className="flex-1 min-w-0 mr-3">
<p className="text-white text-sm font-medium truncate">{m.title || 'Reunião sem título'}</p>
<p className="text-slate-400 text-xs">{formatDateTimeBR(m.created_at)}</p>
{isAdmin && (m.owner_name || m.owner_email) && (
<p className="text-amber-400/80 text-xs flex items-center gap-1 mt-0.5">
<User size={10} />
{m.owner_name || m.owner_email}
{m.owner_email && m.owner_name && (
<span className="text-slate-500">· {m.owner_email}</span>
)}
</p>
)}
</div>
<div className="flex items-center gap-3">
<StatusBadge status={m.status} />
{isAdmin && (
<button
onClick={(e) => handleDelete(e, m.id)}
className="text-slate-500 hover:text-red-400 p-1"
title="Excluir reunião"
>
<Trash2 size={16} />
</button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</Layout>
)
}

View File

@ -0,0 +1,102 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mic, Upload, Clock, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
import { useMeetingsStore } from '@/store/meetingsStore'
import { useAuthStore } from '@/store/authStore'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
import { Layout } from '@/components/Layout'
import { formatDistanceToNow } from '@/utils/format'
export function Home() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const { meetings, loading, fetchMeetings } = useMeetingsStore()
useEffect(() => { fetchMeetings() }, [fetchMeetings])
const recent = meetings.slice(0, 5)
const processing = meetings.filter((m) => m.status === 'processing' || m.status === 'uploading')
return (
<Layout>
<div className="p-4 space-y-6 max-w-2xl mx-auto">
{/* Greeting */}
<div>
<h1 className="text-xl font-bold text-white">Olá, {user?.name?.split(' ')[0]} 👋</h1>
<p className="text-slate-400 text-sm">O que faremos hoje?</p>
</div>
{/* Actions */}
<div className="grid grid-cols-2 gap-3">
<Button size="lg" onClick={() => navigate('/record')} className="h-20 flex-col gap-2 text-base">
<Mic size={24} />
Gravar reunião
</Button>
<Button size="lg" variant="outline" onClick={() => navigate('/upload')} className="h-20 flex-col gap-2 text-base">
<Upload size={24} />
Enviar áudio
</Button>
</div>
{/* Processing */}
{processing.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-slate-300 mb-2 flex items-center gap-2">
<Loader2 size={14} className="animate-spin text-yellow-400" /> Em processamento
</h2>
<div className="space-y-2">
{processing.map((m) => (
<Card key={m.id} className="cursor-pointer hover:border-slate-500" onClick={() => navigate(`/meetings/${m.id}`)}>
<CardContent className="flex items-center justify-between p-3">
<span className="text-white text-sm font-medium">{m.title || 'Reunião sem título'}</span>
<StatusBadge status={m.status} />
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Recent meetings */}
<div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-slate-300">Reuniões recentes</h2>
<button onClick={() => navigate('/history')} className="text-green-400 text-xs hover:underline">Ver todas</button>
</div>
{loading ? (
<div className="flex justify-center py-8"><Loader2 size={24} className="animate-spin text-slate-400" /></div>
) : recent.length === 0 ? (
<Card>
<CardContent className="text-center py-8 text-slate-400">
<Mic size={32} className="mx-auto mb-2 opacity-30" />
<p>Nenhuma reunião ainda.</p>
<p className="text-xs mt-1">Grave ou envie seu primeiro áudio!</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{recent.map((m) => (
<Card key={m.id} className="cursor-pointer hover:border-slate-500" onClick={() => navigate(`/meetings/${m.id}`)}>
<CardContent className="flex items-center justify-between p-3">
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{m.title || 'Reunião sem título'}</p>
<p className="text-slate-400 text-xs">{formatDistanceToNow(m.created_at)}</p>
</div>
<div className="flex items-center gap-2 ml-2">
{m.status === 'completed' && <CheckCircle size={16} className="text-green-400" />}
{m.status === 'error' && <AlertCircle size={16} className="text-red-400" />}
{(m.status === 'processing' || m.status === 'uploading') && <Clock size={16} className="text-yellow-400" />}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
</Layout>
)
}

View File

@ -0,0 +1,522 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Loader2, Edit2, Check, X, Play, Pause, Volume2 } from 'lucide-react'
import { meetingsApi } from '@/api/meetings'
import api from '@/api/axios'
import { useMeetingsStore } from '@/store/meetingsStore'
import { useWebSocket } from '@/hooks/useWebSocket'
import { Layout } from '@/components/Layout'
import { StatusBadge } from '@/components/StatusBadge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
import type { Meeting, TranscriptSegment, Minutes, SentimentData, PsychologicalData, CommunicationData, SummaryData } from '@/types'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
type Tab = 'transcript' | 'minutes' | 'sentiment' | 'psychological' | 'communication' | 'summary'
const SPEAKER_COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#fb923c', '#a78bfa', '#34d399']
const TERMINAL = ['completed', 'error']
export function MeetingDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { meetings, updateMeeting } = useMeetingsStore()
const [meeting, setMeeting] = useState<Meeting | null>(meetings.find((m) => m.id === id) ?? null)
const [tab, setTab] = useState<Tab>('transcript')
const [minutes, setMinutes] = useState<Minutes | null>(null)
const [sentiment, setSentiment] = useState<SentimentData | null>(null)
const [psychological, setPsychological] = useState<PsychologicalData | null>(null)
const [communication, setCommunication] = useState<CommunicationData | null>(null)
const [summary, setSummary] = useState<SummaryData | null>(null)
const [loadingTab, setLoadingTab] = useState(false)
const [editingLabel, setEditingLabel] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const [audioLoading, setAudioLoading] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [enrollNames, setEnrollNames] = useState<Record<string, string>>({})
const [enrollingLabel, setEnrollingLabel] = useState<string | null>(null)
const [enrollErrors, setEnrollErrors] = useState<Record<string, string>>({})
const [enrollSuccess, setEnrollSuccess] = useState<Record<string, boolean>>({})
const isTerminal = TERMINAL.includes(meeting?.status ?? '')
useEffect(() => {
if (!id) return
meetingsApi.get(id).then(({ data }) => {
setMeeting(data)
updateMeeting(id, data)
if (data.status === 'completed') setTab('transcript')
})
}, [id, updateMeeting])
const onWsMessage = useCallback((data: unknown) => {
const msg = data as { type: string; status?: string; progress?: number }
if (msg.type === 'status_update' && msg.status) {
setMeeting((m) => m ? { ...m, status: msg.status as Meeting['status'], progress: msg.progress } : m)
}
}, [])
useWebSocket(meeting?.status === 'processing' ? (id ?? null) : null, onWsMessage)
useEffect(() => {
if (!id || isTerminal) return
const iv = setInterval(async () => {
try {
const { data } = await meetingsApi.get(id)
setMeeting(data)
updateMeeting(id, data)
if (TERMINAL.includes(data.status)) clearInterval(iv)
} catch { /* ignore */ }
}, 4000)
return () => clearInterval(iv)
}, [id, isTerminal, updateMeeting])
async function loadTab(t: Tab) {
setTab(t)
setLoadingTab(true)
try {
if (t === 'minutes' && !minutes) {
const { data } = await meetingsApi.getAnalysis(id!, 'minutes')
setMinutes(data.content as Minutes)
} else if (t === 'sentiment' && !sentiment) {
const { data } = await meetingsApi.getAnalysis(id!, 'sentiment')
setSentiment(data.content as SentimentData)
} else if (t === 'psychological' && !psychological) {
const { data } = await meetingsApi.getAnalysis(id!, 'psychological')
setPsychological(data.content as PsychologicalData)
} else if (t === 'communication' && !communication) {
const { data } = await meetingsApi.getAnalysis(id!, 'communication')
setCommunication(data.content as CommunicationData)
} else if (t === 'summary' && !summary) {
const { data } = await meetingsApi.getAnalysis(id!, 'summary')
setSummary(data.content as SummaryData)
}
} catch { /* analysis not ready */ } finally {
setLoadingTab(false)
}
}
async function loadAudio() {
if (audioUrl) return
setAudioLoading(true)
try {
const resp = await api.get(`/meetings/${id}/audio`, { responseType: 'blob' })
const url = URL.createObjectURL(resp.data)
setAudioUrl(url)
if (audioRef.current) audioRef.current.src = url
} catch { /* no audio */ } finally {
setAudioLoading(false)
}
}
function togglePlay() {
if (!audioRef.current) return
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false) }
else { audioRef.current.play(); setIsPlaying(true) }
}
async function saveLabel(label: string) {
if (!id || !editName.trim()) return
await meetingsApi.relabelParticipant(id, label, editName.trim())
setMeeting((m) => m ? { ...m } : m)
setEditingLabel(null)
}
function getSpeakerColor(speaker: string): string {
const segs = meeting?.transcription?.segments ?? []
const speakers = [...new Set(segs.map((s) => s.speaker))]
return SPEAKER_COLORS[speakers.indexOf(speaker) % SPEAKER_COLORS.length]
}
const sentimentColor = (s?: string) =>
s === 'positive' ? '#4ade80' : s === 'negative' ? '#f87171' : '#94a3b8'
const tabs: { key: Tab; label: string }[] = [
{ key: 'transcript', label: 'Transcricao' },
{ key: 'summary', label: 'Resumo' },
{ key: 'minutes', label: 'Ata' },
{ key: 'sentiment', label: 'Sentimento' },
{ key: 'psychological', label: 'Perfil' },
{ key: 'communication', label: 'Comunicacao' },
]
const segments: TranscriptSegment[] = meeting?.transcription?.segments ?? []
const unidentifiedSpeakers = [...new Set(segments.filter(s => /^SPEAKER_\d+$/.test(s.speaker)).map(s => s.speaker))].sort()
async function handleEnroll(label: string) {
const name = (enrollNames[label] || '').trim()
if (!name || !id) return
setEnrollingLabel(label)
setEnrollErrors(e => ({ ...e, [label]: '' }))
try {
await meetingsApi.enrollSpeaker(id, label, name)
setEnrollSuccess(s => ({ ...s, [label]: true }))
setMeeting(m => {
if (!m?.transcription?.segments) return m
return {
...m,
transcription: {
...m.transcription,
segments: m.transcription.segments.map((seg: TranscriptSegment) =>
seg.speaker === label ? { ...seg, speaker: name } : seg
),
},
}
})
} catch {
setEnrollErrors(e => ({ ...e, [label]: 'Erro ao salvar. Tente novamente.' }))
} finally {
setEnrollingLabel(null)
}
}
return (
<Layout>
<div className="p-4 max-w-2xl mx-auto space-y-4 pb-8">
<div className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-white"><ArrowLeft size={20} /></button>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold text-white truncate">{meeting?.title || 'Reuniao sem titulo'}</h1>
{meeting && <StatusBadge status={meeting.status} />}
</div>
</div>
{meeting?.status === 'completed' && (
<Card>
<CardContent className="flex items-center gap-3 py-3">
<Volume2 size={16} className="text-slate-400 shrink-0" />
{!audioUrl && !audioLoading && (
<button onClick={loadAudio} className="text-sm text-green-400 hover:text-green-300">
Carregar audio da reuniao
</button>
)}
{audioLoading && <span className="text-slate-400 text-sm flex items-center gap-2"><Loader2 size={14} className="animate-spin" /> Carregando...</span>}
{audioUrl && (
<div className="flex items-center gap-3 flex-1">
<button onClick={togglePlay} className="w-8 h-8 rounded-full bg-green-600 hover:bg-green-500 flex items-center justify-center shrink-0">
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
</button>
<audio
ref={audioRef}
src={audioUrl}
onEnded={() => setIsPlaying(false)}
onPause={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
controls
className="flex-1 h-8"
style={{ accentColor: '#4ade80' }}
/>
</div>
)}
</CardContent>
</Card>
)}
{!isTerminal && meeting?.status !== undefined && (
<Card>
<CardContent className="flex flex-col items-center py-8 gap-3">
<Loader2 size={32} className="animate-spin text-yellow-400" />
<p className="text-slate-300 text-sm font-medium">
{meeting.status === 'receiving_chunks' && 'Recebendo audio...'}
{meeting.status === 'uploading' && 'Enviando para processamento...'}
{meeting.status === 'processing' && 'Iniciando processamento...'}
{meeting.status === 'transcribing' && 'Transcrevendo com Whisper...'}
{meeting.status === 'analyzing' && 'Analisando com IA...'}
</p>
<p className="text-slate-500 text-xs">A pagina atualiza automaticamente</p>
</CardContent>
</Card>
)}
{meeting?.status === 'completed' && (
<>
{unidentifiedSpeakers.length > 0 && (
<Card>
<CardContent className="py-3">
<div className="flex items-center gap-2 mb-3">
<span className="text-lg">🎤</span>
<div>
<p className="text-white text-sm font-semibold">Identificar participantes</p>
<p className="text-slate-400 text-xs">Para reconhecimento automático em reuniões futuras</p>
</div>
</div>
<div className="space-y-2">
{unidentifiedSpeakers.map(label => (
<div key={label} className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400 w-24 shrink-0">{label}</span>
<Input
value={enrollNames[label] || ''}
onChange={e => setEnrollNames(n => ({ ...n, [label]: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && handleEnroll(label)}
placeholder="Nome da pessoa"
className="h-7 text-xs flex-1"
disabled={!!enrollSuccess[label]}
/>
<Button
size="sm"
className="h-7 px-2 text-xs shrink-0"
disabled={enrollingLabel === label || !!enrollSuccess[label]}
onClick={() => handleEnroll(label)}
>
{enrollingLabel === label ? <Loader2 size={12} className="animate-spin" /> : enrollSuccess[label] ? <Check size={12} /> : 'Salvar'}
</Button>
</div>
))}
</div>
{Object.entries(enrollErrors).filter(([, v]) => v).map(([k, v]) => (
<p key={k} className="text-red-400 text-xs mt-1">{k}: {v}</p>
))}
</CardContent>
</Card>
)}
<div className="flex gap-1 overflow-x-auto pb-1">
{tabs.map((t) => (
<button key={t.key} onClick={() => loadTab(t.key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${tab === t.key ? 'bg-green-700 text-white' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-700'}`}>
{t.label}
</button>
))}
</div>
{loadingTab && <div className="flex justify-center py-8"><Loader2 size={20} className="animate-spin text-slate-400" /></div>}
{tab === 'transcript' && !loadingTab && (
<div className="space-y-2">
{segments.length > 0 ? segments.map((seg, i) => (
<div key={i} className="flex gap-3">
<div className="flex flex-col items-center">
<div className="w-2 h-2 rounded-full mt-2 shrink-0" style={{ background: getSpeakerColor(seg.speaker) }} />
<div className="w-px flex-1 bg-slate-700 mt-1" />
</div>
<div className="flex-1 pb-3">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-semibold" style={{ color: getSpeakerColor(seg.speaker) }}>{seg.speaker}</span>
<span className="text-slate-500 text-xs">{Math.floor(seg.start / 60)}:{String(Math.floor(seg.start % 60)).padStart(2, '0')}</span>
{editingLabel === seg.speaker ? (
<div className="flex items-center gap-1 ml-1">
<Input value={editName} onChange={(e) => setEditName(e.target.value)} className="h-5 px-1 text-xs w-24" />
<button onClick={() => saveLabel(seg.speaker)} className="text-green-400"><Check size={12} /></button>
<button onClick={() => setEditingLabel(null)} className="text-red-400"><X size={12} /></button>
</div>
) : (
<button onClick={() => { setEditingLabel(seg.speaker); setEditName(seg.speaker) }} className="text-slate-500 hover:text-slate-300"><Edit2 size={10} /></button>
)}
</div>
<p className="text-slate-200 text-sm">{seg.text}</p>
</div>
</div>
)) : <p className="text-slate-400 text-sm text-center py-8">Transcricao nao disponivel.</p>}
</div>
)}
{tab === 'summary' && !loadingTab && (
<div className="space-y-3">
{summary ? (
<>
{summary.summary && <Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Resumo</h3><p className="text-slate-200 text-sm leading-relaxed">{summary.summary}</p></CardContent></Card>}
{summary.key_points && summary.key_points.length > 0 && (
<Card><CardContent>
<h3 className="text-green-400 text-sm font-semibold mb-2">Pontos-chave</h3>
<ul className="space-y-1">{summary.key_points.map((p, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span className="text-green-400">&#x2022;</span>{p}</li>)}</ul>
</CardContent></Card>
)}
<div className="flex gap-3">
{summary.duration_minutes != null && <Card className="flex-1"><CardContent className="text-center py-3"><p className="text-2xl font-bold text-white">{summary.duration_minutes}'</p><p className="text-slate-400 text-xs">duracao</p></CardContent></Card>}
{summary.total_participants != null && <Card className="flex-1"><CardContent className="text-center py-3"><p className="text-2xl font-bold text-white">{summary.total_participants}</p><p className="text-slate-400 text-xs">participantes</p></CardContent></Card>}
</div>
</>
) : <p className="text-slate-400 text-sm text-center py-8">Resumo nao disponivel.</p>}
</div>
)}
{tab === 'minutes' && !loadingTab && (
<div className="space-y-3">
{minutes ? (
<>
{minutes.agenda && minutes.agenda.length > 0 && (
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Pauta</h3><ul className="space-y-1">{minutes.agenda.map((a, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span className="text-slate-400">{i+1}.</span>{a}</li>)}</ul></CardContent></Card>
)}
{minutes.decisions && minutes.decisions.length > 0 && (
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Decisoes</h3><ul className="space-y-1">{minutes.decisions.map((d, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span>&#x2022;</span>{d}</li>)}</ul></CardContent></Card>
)}
{minutes.action_items && minutes.action_items.length > 0 && (
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Acoes</h3>
<div className="space-y-2">{minutes.action_items.map((a, i) => (
<div key={i} className="text-sm border-l-2 border-slate-700 pl-3">
<p className="text-slate-200">{a.description}</p>
{a.responsible && <p className="text-slate-400 text-xs">&#x2192; {a.responsible}</p>}
{a.deadline && a.deadline !== 'A definir' && <p className="text-slate-500 text-xs">{a.deadline}</p>}
</div>
))}</div>
</CardContent></Card>
)}
{minutes.next_steps && minutes.next_steps.length > 0 && (
<Card><CardContent><h3 className="text-blue-400 text-sm font-semibold mb-2">Proximos passos</h3><ul className="space-y-1">{minutes.next_steps.map((s, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span>&#x2192;</span>{s}</li>)}</ul></CardContent></Card>
)}
{minutes.open_points && minutes.open_points.length > 0 && (
<Card><CardContent><h3 className="text-yellow-400 text-sm font-semibold mb-2">Pontos em aberto</h3><ul className="space-y-1">{minutes.open_points.map((s, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span>&#x2022;</span>{s}</li>)}</ul></CardContent></Card>
)}
</>
) : <p className="text-slate-400 text-sm text-center py-8">Ata nao disponivel.</p>}
</div>
)}
{tab === 'sentiment' && !loadingTab && (
<div className="space-y-3">
{sentiment ? (
<>
{sentiment.overall_sentiment && (
<Card><CardContent className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full" style={{ background: sentimentColor(sentiment.overall_sentiment) }} />
<div>
<p className="text-white text-sm font-medium">Sentimento geral</p>
<p className="text-slate-400 text-xs capitalize">{sentiment.overall_sentiment}</p>
</div>
</CardContent></Card>
)}
{sentiment.participation_metrics && sentiment.participation_metrics.length > 0 && (
<Card><CardContent>
<h3 className="text-green-400 text-sm font-semibold mb-3">Participacao</h3>
<ResponsiveContainer width="100%" height={140}>
<BarChart data={sentiment.participation_metrics}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="speaker" tick={{ fill: '#94a3b8', fontSize: 10 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 10 }} unit="%" />
<Tooltip contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8 }} formatter={(v) => [`${v}%`, 'Participacao']} />
<Bar dataKey="talk_percentage" fill="#4ade80" radius={[4,4,0,0]} />
</BarChart>
</ResponsiveContainer>
</CardContent></Card>
)}
{sentiment.participants_sentiment && sentiment.participants_sentiment.length > 0 && (
<Card><CardContent>
<h3 className="text-green-400 text-sm font-semibold mb-2">Por participante</h3>
<div className="space-y-3">{sentiment.participants_sentiment.map((p, i) => (
<div key={i}>
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 rounded-full" style={{ background: sentimentColor(p.sentiment) }} />
<span className="text-slate-300 text-sm font-medium">{p.speaker}</span>
<span className="text-slate-500 text-xs capitalize">({p.sentiment})</span>
</div>
<p className="text-slate-400 text-xs leading-relaxed pl-4">{p.details}</p>
</div>
))}</div>
</CardContent></Card>
)}
{sentiment.timeline && sentiment.timeline.length > 0 && (
<Card><CardContent>
<h3 className="text-green-400 text-sm font-semibold mb-2">Linha do tempo</h3>
<div className="space-y-2">{sentiment.timeline.map((t, i) => (
<div key={i} className="flex gap-3 text-sm">
<span className="text-slate-500 text-xs whitespace-nowrap shrink-0">{t.time_range}</span>
<div>
<span className="font-medium capitalize" style={{ color: sentimentColor(t.sentiment) }}>{t.sentiment}</span>
{t.notes && <p className="text-slate-400 text-xs mt-0.5">{t.notes}</p>}
</div>
</div>
))}</div>
</CardContent></Card>
)}
{sentiment.tension_moments && sentiment.tension_moments.length > 0 && (
<Card><CardContent><h3 className="text-red-400 text-sm font-semibold mb-2">Momentos de tensao</h3><ul className="space-y-1">{sentiment.tension_moments.map((t, i) => <li key={i} className="text-slate-300 text-sm flex gap-2"><span>&#x2022;</span>{t}</li>)}</ul></CardContent></Card>
)}
{sentiment.consensus_moments && sentiment.consensus_moments.length > 0 && (
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Momentos de consenso</h3><ul className="space-y-1">{sentiment.consensus_moments.map((t, i) => <li key={i} className="text-slate-300 text-sm flex gap-2"><span>&#x2022;</span>{t}</li>)}</ul></CardContent></Card>
)}
</>
) : <p className="text-slate-400 text-sm text-center py-8">Analise de sentimento nao disponivel.</p>}
</div>
)}
{tab === 'psychological' && !loadingTab && (
<div className="space-y-3">
{psychological ? (
<>
{psychological.group_dynamics && (
<Card><CardContent><h3 className="text-purple-400 text-sm font-semibold mb-2">Dinamica do grupo</h3><p className="text-slate-200 text-sm leading-relaxed">{psychological.group_dynamics}</p></CardContent></Card>
)}
{psychological.participants_profiles && psychological.participants_profiles.length > 0 && psychological.participants_profiles.map((p, i) => (
<Card key={i}><CardContent>
<h3 className="text-purple-400 text-sm font-semibold mb-2">{p.name || p.speaker}</h3>
<div className="space-y-2 text-sm">
{p.communication_style && <div><span className="text-slate-400 text-xs">Estilo de comunicacao:</span><p className="text-slate-200">{p.communication_style}</p></div>}
{p.leadership_tendencies && <div><span className="text-slate-400 text-xs">Lideranca:</span><p className="text-slate-200">{p.leadership_tendencies}</p></div>}
{p.power_dynamics_role && <div><span className="text-slate-400 text-xs">Papel na dinamica:</span><p className="text-slate-200">{p.power_dynamics_role}</p></div>}
</div>
</CardContent></Card>
))}
{psychological.key_observations && psychological.key_observations.length > 0 && (
<Card><CardContent><h3 className="text-purple-400 text-sm font-semibold mb-2">Observacoes</h3><ul className="space-y-1">{psychological.key_observations.map((o, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span>&#x2022;</span>{o}</li>)}</ul></CardContent></Card>
)}
</>
) : <p className="text-slate-400 text-sm text-center py-8">Perfil psicologico nao disponivel.</p>}
</div>
)}
{tab === 'communication' && !loadingTab && (
<div className="space-y-3">
{communication ? (
<>
{communication.overall_score != null && (
<Card><CardContent className="text-center py-4">
<div className="text-5xl font-bold text-white mb-1">{communication.overall_score}<span className="text-xl text-slate-400">/10</span></div>
<p className="text-slate-400 text-sm">Score de comunicacao</p>
</CardContent></Card>
)}
{communication.general_feedback && (
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Feedback geral</h3><p className="text-slate-200 text-sm leading-relaxed">{communication.general_feedback}</p></CardContent></Card>
)}
{communication.dimensions && (
<Card><CardContent>
<h3 className="text-green-400 text-sm font-semibold mb-3">Dimensoes</h3>
<div className="space-y-3">{Object.entries(communication.dimensions).map(([dim, val]) => (
<div key={dim}>
<div className="flex justify-between text-xs text-slate-300 mb-1">
<span className="capitalize">{dim.replace(/_/g, ' ')}</span>
<span>{val.score}/10</span>
</div>
<div className="h-1.5 bg-slate-700 rounded-full"><div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${val.score * 10}%` }} /></div>
{val.notes && <p className="text-slate-500 text-xs mt-1">{val.notes}</p>}
</div>
))}</div>
</CardContent></Card>
)}
{communication.strengths && communication.strengths.length > 0 && (
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Pontos fortes</h3>
<div className="space-y-2">{communication.strengths.map((s, i) => (
<div key={i}><p className="text-slate-200 text-sm">{s.description}</p>{s.example && <p className="text-slate-500 text-xs mt-0.5 pl-4">"{s.example}"</p>}</div>
))}</div>
</CardContent></Card>
)}
{communication.improvement_areas && communication.improvement_areas.length > 0 && (
<Card><CardContent><h3 className="text-yellow-400 text-sm font-semibold mb-2">Melhorias</h3>
<div className="space-y-2">{communication.improvement_areas.map((a, i) => (
<div key={i}><p className="text-slate-200 text-sm">{a.description}</p>{a.suggestion && <p className="text-slate-500 text-xs mt-0.5 pl-4">&#x2192; {a.suggestion}</p>}</div>
))}</div>
</CardContent></Card>
)}
</>
) : <p className="text-slate-400 text-sm text-center py-8">Analise de comunicacao nao disponivel.</p>}
</div>
)}
</>
)}
{meeting?.status === 'error' && (
<Card>
<CardContent className="text-center py-8">
<p className="text-red-400 font-medium">Erro ao processar reuniao</p>
{meeting.error_message && <p className="text-slate-400 text-sm mt-1">{meeting.error_message}</p>}
<Button variant="outline" onClick={() => navigate(-1)} className="mt-4">Voltar</Button>
</CardContent>
</Card>
)}
</div>
</Layout>
)
}

View File

@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2, Mic, Loader2 } from 'lucide-react'
import { participantsApi } from '@/api/participants'
import { Layout } from '@/components/Layout'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { Participant } from '@/types'
export function Participants() {
const [participants, setParticipants] = useState<Participant[]>([])
const [loading, setLoading] = useState(true)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [adding, setAdding] = useState(false)
const [showForm, setShowForm] = useState(false)
useEffect(() => {
participantsApi.list().then(({ data }) => setParticipants(data)).finally(() => setLoading(false))
}, [])
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!name.trim()) return
setAdding(true)
try {
const { data } = await participantsApi.create(name.trim(), email || undefined)
setParticipants((p) => [...p, data])
setName('')
setEmail('')
setShowForm(false)
} finally { setAdding(false) }
}
async function handleDelete(id: string) {
if (!confirm('Excluir perfil vocal?')) return
await participantsApi.delete(id)
setParticipants((p) => p.filter((x) => x.id !== id))
}
return (
<Layout>
<div className="p-4 max-w-2xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Participantes</h1>
<Button size="sm" onClick={() => setShowForm((v) => !v)}>
<Plus size={16} /> Adicionar
</Button>
</div>
{showForm && (
<Card>
<CardContent>
<form onSubmit={handleAdd} className="space-y-3">
<Input placeholder="Nome *" value={name} onChange={(e) => setName(e.target.value)} required />
<Input type="email" placeholder="E-mail (opcional)" value={email} onChange={(e) => setEmail(e.target.value)} />
<div className="flex gap-2">
<Button type="submit" disabled={adding} className="flex-1">{adding ? 'Salvando...' : 'Salvar'}</Button>
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
</div>
</form>
</CardContent>
</Card>
)}
{loading ? (
<div className="flex justify-center py-12"><Loader2 size={24} className="animate-spin text-slate-400" /></div>
) : participants.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Mic size={32} className="mx-auto mb-2 opacity-30" />
<p className="text-sm">Nenhum perfil vocal cadastrado.</p>
</div>
) : (
<div className="space-y-2">
{participants.map((p) => (
<Card key={p.id}>
<CardContent className="flex items-center justify-between p-3">
<div>
<p className="text-white text-sm font-medium">{p.name}</p>
{p.email && <p className="text-slate-400 text-xs">{p.email}</p>}
<p className="text-xs mt-0.5">{p.voice_enrolled ? <span className="text-green-400"> Voz cadastrada</span> : <span className="text-slate-500">Voz não cadastrada</span>}</p>
</div>
<button onClick={() => handleDelete(p.id)} className="text-slate-500 hover:text-red-400 p-1"><Trash2 size={16} /></button>
</CardContent>
</Card>
))}
</div>
)}
</div>
</Layout>
)
}

View File

@ -0,0 +1,332 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mic, Square, Loader2, Wifi, WifiOff } from 'lucide-react'
import { meetingsApi } from '@/api/meetings'
import { db } from '@/services/db'
import { useWakeLock } from '@/hooks/useWakeLock'
import { cn } from '@/utils/cn'
type RecordingStatus = 'idle' | 'recording' | 'finalizing' | 'error'
function getSupportedMimeType(): string {
const types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4']
for (const type of types) {
if (MediaRecorder.isTypeSupported(type)) return type
}
return ''
}
const CHUNK_INTERVAL_MS = 30_000
export function Recording() {
const navigate = useNavigate()
const { acquire: acquireWakeLock, release: releaseWakeLock } = useWakeLock()
const [status, setStatus] = useState<RecordingStatus>('idle')
const [title, setTitle] = useState('')
const [elapsed, setElapsed] = useState(0)
const [chunkCount, setChunkCount] = useState(0)
const [uploadedCount, setUploadedCount] = useState(0)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [errorMsg, setErrorMsg] = useState('')
const meetingIdRef = useRef<string | null>(null)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunkIndexRef = useRef(0)
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const retryIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const mimeTypeRef = useRef('')
// VU meter
const [vuLevel, setVuLevel] = useState(0) // 0..1
const audioCtxRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const vuRafRef = useRef<number | null>(null)
const startVU = (stream: MediaStream) => {
const ctx = new AudioContext()
const analyser = ctx.createAnalyser()
analyser.fftSize = 256
ctx.createMediaStreamSource(stream).connect(analyser)
audioCtxRef.current = ctx
analyserRef.current = analyser
const buf = new Uint8Array(analyser.frequencyBinCount)
const tick = () => {
analyser.getByteTimeDomainData(buf)
// RMS
let sum = 0
for (let i = 0; i < buf.length; i++) {
const v = (buf[i] - 128) / 128
sum += v * v
}
setVuLevel(Math.min(1, Math.sqrt(sum / buf.length) * 6))
vuRafRef.current = requestAnimationFrame(tick)
}
vuRafRef.current = requestAnimationFrame(tick)
}
const stopVU = () => {
if (vuRafRef.current) cancelAnimationFrame(vuRafRef.current)
analyserRef.current?.disconnect()
audioCtxRef.current?.close()
audioCtxRef.current = null
analyserRef.current = null
setVuLevel(0)
}
const retryPendingChunks = useCallback(async () => {
if (!navigator.onLine) return
try {
const pending = await db.getPendingChunks()
for (const chunk of pending) {
try {
await db.updateChunkStatus(chunk.id!, 'uploading')
await meetingsApi.uploadChunk(chunk.meetingId, chunk.chunkIndex, chunk.blob, chunk.mimeType)
await db.deleteChunk(chunk.id!)
setUploadedCount(c => c + 1)
} catch {
await db.updateChunkStatus(chunk.id!, 'pending')
}
}
} catch (e) {
console.warn('[Recording] retryPendingChunks error', e)
}
}, [])
useEffect(() => {
const onOnline = () => { setIsOnline(true); retryPendingChunks() }
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [retryPendingChunks])
// On mount: resume any pending chunks from previous sessions
useEffect(() => { retryPendingChunks() }, [retryPendingChunks])
const uploadChunkNow = useCallback(async (
meetingId: string, chunkIndex: number, blob: Blob, mimeType: string, dbId?: number
) => {
try {
await meetingsApi.uploadChunk(meetingId, chunkIndex, blob, mimeType)
if (dbId != null && dbId >= 0) await db.deleteChunk(dbId)
setUploadedCount(c => c + 1)
} catch {
// Keep in IndexedDB for retry - already saved
}
}, [])
const startRecording = async () => {
try {
setErrorMsg('')
// Request audio with quality constraints for transcription
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
}
})
streamRef.current = stream
const mimeType = getSupportedMimeType()
mimeTypeRef.current = mimeType
const meeting = await meetingsApi.startMeeting(title || undefined)
meetingIdRef.current = meeting.id
chunkIndexRef.current = 0
setChunkCount(0)
setUploadedCount(0)
setElapsed(0)
acquireWakeLock()
setStatus('recording')
startVU(stream)
// Use timeslice: single continuous MediaRecorder session, no stop/start gaps
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
recorderRef.current = recorder
recorder.ondataavailable = async (e) => {
if (e.data.size === 0) return
const currentIndex = chunkIndexRef.current++
const meetId = meetingIdRef.current!
setChunkCount(n => n + 1)
const blob = new Blob([e.data], { type: mimeType })
const dbId = await db.saveChunk({
meetingId: meetId, chunkIndex: currentIndex, blob, mimeType,
status: 'pending', createdAt: Date.now()
})
await uploadChunkNow(meetId, currentIndex, blob, mimeType, dbId)
}
// Fire ondataavailable every CHUNK_INTERVAL_MS — no gaps, continuous stream
recorder.start(CHUNK_INTERVAL_MS)
retryIntervalRef.current = setInterval(() => { retryPendingChunks() }, CHUNK_INTERVAL_MS)
elapsedIntervalRef.current = setInterval(() => { setElapsed(s => s + 1) }, 1000)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Erro ao iniciar gravação'
setErrorMsg(msg)
setStatus('error')
}
}
const stopRecording = async () => {
if (!meetingIdRef.current) return
setStatus('finalizing')
if (retryIntervalRef.current) clearInterval(retryIntervalRef.current)
if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current)
stopVU()
// Stop recorder — triggers final ondataavailable then onstop
await new Promise<void>((resolve) => {
const rec = recorderRef.current
if (!rec || rec.state === 'inactive') { resolve(); return }
rec.onstop = () => resolve()
rec.stop()
})
streamRef.current?.getTracks().forEach(t => t.stop())
releaseWakeLock()
// Wait for pending IndexedDB chunks (max 60s)
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const pending = await db.getPendingChunks(meetingIdRef.current!)
if (pending.length === 0) break
await retryPendingChunks()
await new Promise(r => setTimeout(r, 2000))
}
try {
const meeting = await meetingsApi.finalizeMeeting(meetingIdRef.current!, title || undefined)
navigate(`/meetings/${meeting.id}`)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Erro ao finalizar reunião'
setErrorMsg(`${msg}. Os segmentos estão salvos e serão reenviados.`)
setStatus('error')
}
}
const formatTime = (secs: number) => {
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = secs % 60
return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
return (
<div className="min-h-screen bg-slate-950 text-white flex flex-col items-center justify-center p-6 gap-6">
{/* Online indicator */}
<div className={cn(
'flex items-center gap-2 text-sm px-3 py-1 rounded-full',
isOnline ? 'bg-green-900/40 text-green-400' : 'bg-yellow-900/40 text-yellow-400'
)}>
{isOnline ? <Wifi className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
{isOnline ? 'Online' : 'Sem internet — gravando localmente'}
</div>
{/* Title input */}
{status === 'idle' && (
<input
type="text"
placeholder="Título da reunião (opcional)"
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full max-w-sm bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white placeholder-slate-500 text-center focus:outline-none focus:border-green-500"
/>
)}
{/* Timer */}
{status === 'recording' && (
<div className="text-center">
<div className="text-5xl font-mono font-bold tabular-nums">{formatTime(elapsed)}</div>
<div className="text-slate-400 text-sm mt-2">
Segmento {chunkCount + 1} {uploadedCount} enviados
{!isOnline && <span className="text-yellow-400"> aguardando conexão</span>}
</div>
</div>
)}
{/* Idle: start button */}
{status === 'idle' && (
<button
onClick={startRecording}
className="w-24 h-24 rounded-full bg-green-600 hover:bg-green-500 flex items-center justify-center shadow-lg shadow-green-900/50 transition-all active:scale-95"
>
<Mic className="w-10 h-10" />
</button>
)}
{/* Recording: pulsing mic + VU + stop button */}
{status === 'recording' && (
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-red-500 animate-ping opacity-30" />
<div className="w-24 h-24 rounded-full bg-red-600 flex items-center justify-center shadow-lg shadow-red-900/50">
<Mic className="w-10 h-10" />
</div>
</div>
{/* VU meter — 12 bars */}
<div className="flex items-end gap-1 h-8">
{Array.from({ length: 12 }, (_, i) => {
const threshold = (i + 1) / 12
const active = vuLevel >= threshold
const color = i < 8 ? 'bg-green-400' : i < 10 ? 'bg-yellow-400' : 'bg-red-500'
return (
<div
key={i}
className={cn(
'w-2 rounded-sm transition-all duration-75',
active ? color : 'bg-slate-700',
)}
style={{ height: `${50 + i * 4}%` }}
/>
)
})}
</div>
<button
onClick={stopRecording}
className="flex items-center gap-2 px-6 py-3 rounded-xl bg-slate-700 hover:bg-slate-600 transition-all active:scale-95"
>
<Square className="w-5 h-5" />
Finalizar
</button>
</div>
)}
{/* Finalizing */}
{status === 'finalizing' && (
<div className="flex flex-col items-center gap-3 text-slate-300">
<Loader2 className="w-12 h-12 animate-spin text-green-400" />
<p className="font-medium">Finalizando gravação</p>
<p className="text-sm text-slate-500">Enviando segmentos pendentes</p>
</div>
)}
{/* Error */}
{status === 'error' && (
<div className="text-center max-w-sm">
<p className="text-red-400 font-medium mb-2"> {errorMsg}</p>
<button
onClick={() => setStatus('idle')}
className="px-4 py-2 bg-slate-700 rounded-lg text-sm hover:bg-slate-600"
>
Tentar novamente
</button>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,85 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { LogOut, Bell, Info } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { Layout } from '@/components/Layout'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
export function Settings() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [notifGranted, setNotifGranted] = useState(Notification.permission === 'granted')
async function requestNotifications() {
const result = await Notification.requestPermission()
setNotifGranted(result === 'granted')
}
function handleLogout() {
logout()
navigate('/login')
}
return (
<Layout>
<div className="p-4 max-w-md mx-auto space-y-4">
<h1 className="text-xl font-bold text-white">Configurações</h1>
{/* Profile */}
<Card>
<CardContent className="space-y-1">
<p className="text-slate-400 text-xs uppercase tracking-wider font-medium">Conta</p>
<p className="text-white font-medium">{user?.name}</p>
<p className="text-slate-400 text-sm">{user?.email}</p>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardContent className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bell size={18} className="text-slate-400" />
<div>
<p className="text-white text-sm font-medium">Notificações</p>
<p className="text-slate-400 text-xs">{notifGranted ? 'Ativadas' : 'Desativadas'}</p>
</div>
</div>
{!notifGranted && (
<Button size="sm" variant="outline" onClick={requestNotifications}>Ativar</Button>
)}
{notifGranted && <span className="text-green-400 text-sm"></span>}
</CardContent>
</Card>
{/* PWA Install hint */}
<Card>
<CardContent className="flex items-start gap-3">
<Info size={18} className="text-slate-400 shrink-0 mt-0.5" />
<div>
<p className="text-white text-sm font-medium">Instalar como app</p>
<p className="text-slate-400 text-xs mt-0.5">
No Chrome: toque no menu "Adicionar à tela inicial". O app funcionará como nativo, mesmo sem internet.
</p>
</div>
</CardContent>
</Card>
{/* About */}
<Card>
<CardContent className="flex items-center gap-3">
<img src="/images/logo-asap.png" alt="ASAP Telecom" className="h-8 rounded bg-white px-1.5 py-0.5" />
<div>
<p className="text-white text-sm font-medium">MAIA Meeting AI Assistant</p>
<p className="text-slate-400 text-xs">v1.0.0 PWA · ASAP Telecom</p>
</div>
</CardContent>
</Card>
<Button variant="destructive" onClick={handleLogout} className="w-full">
<LogOut size={18} /> Sair da conta
</Button>
</div>
</Layout>
)
}

View File

@ -0,0 +1,115 @@
import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Upload as UploadIcon, FileAudio, X, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Layout } from '@/components/Layout'
import { meetingsApi } from '@/api/meetings'
import { useMeetingsStore } from '@/store/meetingsStore'
export function UploadPage() {
const navigate = useNavigate()
const addMeeting = useMeetingsStore((s) => s.addMeeting)
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState('')
const [progress, setProgress] = useState(0)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [dragging, setDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
function handleFile(f: File) {
if (!f.type.startsWith('audio/') && !f.name.match(/\.(mp3|wav|m4a|ogg|webm|flac|aac)$/i)) {
setError('Formato não suportado. Use MP3, WAV, M4A, OGG, WEBM ou FLAC.')
return
}
setFile(f)
setError('')
}
function onDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
const f = e.dataTransfer.files[0]
if (f) handleFile(f)
}
async function handleUpload() {
if (!file) return
setUploading(true)
setError('')
try {
const { data } = await meetingsApi.upload(file, title || undefined, setProgress)
addMeeting(data)
navigate(`/meetings/${data.id}`)
} catch {
setError('Erro ao enviar o arquivo. Verifique sua conexão e tente novamente.')
setUploading(false)
}
}
return (
<Layout>
<div className="p-4 max-w-md mx-auto space-y-5">
<div>
<h1 className="text-xl font-bold text-white mb-1">Enviar áudio</h1>
<p className="text-slate-400 text-sm">Envie uma gravação feita com seu app nativo (WhatsApp, Voice Recorder, etc.)</p>
</div>
{/* Drop zone */}
<div
onClick={() => inputRef.current?.click()}
onDrop={onDrop}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${dragging ? 'border-green-500 bg-green-950/20' : 'border-slate-600 hover:border-slate-500'}`}
>
{file ? (
<div className="flex items-center justify-center gap-3">
<FileAudio size={24} className="text-green-400" />
<div className="text-left">
<p className="text-white text-sm font-medium">{file.name}</p>
<p className="text-slate-400 text-xs">{(file.size / 1024 / 1024).toFixed(1)} MB</p>
</div>
<button onClick={(e) => { e.stopPropagation(); setFile(null) }} className="text-slate-400 hover:text-white ml-auto">
<X size={18} />
</button>
</div>
) : (
<>
<UploadIcon size={32} className="mx-auto text-slate-400 mb-2" />
<p className="text-slate-300 text-sm">Toque para selecionar ou arraste o arquivo</p>
<p className="text-slate-500 text-xs mt-1">MP3, WAV, M4A, OGG, WEBM, FLAC</p>
</>
)}
<input ref={inputRef} type="file" accept="audio/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f) }} />
</div>
{file && (
<div>
<label className="block text-sm text-slate-300 mb-1">Título (opcional)</label>
<Input placeholder="Ex: Reunião de vendas — 14/04" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
)}
{error && <p className="text-red-400 text-sm bg-red-950 rounded-lg p-3">{error}</p>}
{uploading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm text-slate-300">
<span className="flex items-center gap-2"><Loader2 size={14} className="animate-spin" /> Enviando...</span>
<span>{progress}%</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full" size="lg">
{uploading ? 'Enviando...' : 'Enviar para análise'}
</Button>
</div>
</Layout>
)
}

View File

@ -0,0 +1,69 @@
import { openDB, type IDBPDatabase } from 'idb'
import type { OfflineChunk } from '@/types'
const DB_NAME = 'meeting-assistant'
const DB_VERSION = 2
let _db: IDBPDatabase | null = null
export async function getDB() {
if (!_db) {
_db = await openDB(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
// v1: old 'chunks' store (may exist)
if (oldVersion < 2) {
if (!db.objectStoreNames.contains('pendingChunks')) {
const store = db.createObjectStore('pendingChunks', { keyPath: 'id', autoIncrement: true })
store.createIndex('meetingId', 'meetingId')
store.createIndex('status', 'status')
}
}
},
})
}
return _db
}
export async function saveChunk(chunk: Omit<OfflineChunk, 'id'>): Promise<number> {
try {
const db = await getDB()
return db.add('pendingChunks', chunk) as Promise<number>
} catch (e) {
console.warn('[db] saveChunk failed', e)
return -1
}
}
export async function getPendingChunks(meetingId?: string): Promise<(OfflineChunk & { id: number })[]> {
try {
const db = await getDB()
const all = await db.getAllFromIndex('pendingChunks', 'status', 'pending') as (OfflineChunk & { id: number })[]
if (meetingId) return all.filter(c => c.meetingId === meetingId)
return all
} catch (e) {
console.warn('[db] getPendingChunks failed', e)
return []
}
}
export async function updateChunkStatus(id: number, status: OfflineChunk['status']): Promise<void> {
try {
const db = await getDB()
const chunk = await db.get('pendingChunks', id)
if (chunk) await db.put('pendingChunks', { ...chunk, status })
} catch (e) {
console.warn('[db] updateChunkStatus failed', e)
}
}
export async function deleteChunk(id: number): Promise<void> {
try {
const db = await getDB()
await db.delete('pendingChunks', id)
} catch (e) {
console.warn('[db] deleteChunk failed', e)
}
}
// Namespace export for convenience
export const db = { saveChunk, getPendingChunks, updateChunkStatus, deleteChunk }

View File

@ -0,0 +1,15 @@
import Keycloak from 'keycloak-js'
const keycloak = new Keycloak({
url: 'https://auth.asaptelecom.com.br',
realm: 'one',
clientId: 'maia-app',
})
export default keycloak
export const REQUIRED_ROLE = 'maia.app'
export function hasRole(role: string): boolean {
return keycloak.hasRealmRole(role)
}

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