Compare commits

..

14 Commits

114 changed files with 18179 additions and 0 deletions

View File

@ -0,0 +1,32 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(go mod *)",
"Read(//usr/local/go/bin/**)",
"Read(//home/anthony/go/bin/**)",
"Read(//usr/**)",
"Bash(/home/anthony/go/bin/go mod *)",
"Bash(/home/anthony/go/bin/go build *)",
"Bash(npm install *)",
"Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin/npm install *)",
"Bash(npm run *)",
"Bash(go build *)",
"Bash(npx tsc *)",
"Bash(node_modules/.bin/tsc --noEmit)",
"Bash(/usr/bin/node node_modules/.bin/tsc --noEmit)",
"Bash(fish -c \"which node\")",
"Read(//opt/**)",
"Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin/node node_modules/.bin/tsc --noEmit)",
"Bash(chmod +x /home/anthony/Documents/Projects/Tradarr/build-push.sh)",
"Bash(fish -c \"npm install react-markdown\")",
"Bash(fish -c \"which npm; which pnpm; which bun\")",
"Bash(/usr/bin/npm run *)",
"Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/lib/node_modules/npm/bin/npm run *)",
"WebFetch(domain:docs.ollama.com)",
"WebFetch(domain:github.com)",
"Bash(docker compose *)",
"Bash(sudo docker *)"
]
}
}

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# PostgreSQL
POSTGRES_DB=admin
POSTGRES_USER=admin
POSTGRES_PASSWORD=#Azuw169ytq
# Backend
JWT_SECRET=bK8T5X83JJlTMZc3ZoIoBQbmHybAuEjJ
# 32 bytes en hex (générer avec: openssl rand -hex 32)
ENCRYPTION_KEY=5a6a104d5ad8d2aee3ccf92d9982b7da0d94167a6c1a01057c1328e640bc977e
# Compte admin initial (créé au démarrage si inexistant)
ADMIN_EMAIL=blomios@gmail.com
ADMIN_PASSWORD=#Azuw169ytq
# Port exposé du frontend (défaut: 80)
FRONTEND_PORT=80

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Environment
.env
.env.local
.env.*.local
# Go
backend/tradarr
backend/vendor/
backend/tmp/
# Node / Frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
# Docker volumes (si montés localement)
pgdata/
# IDE
.idea/
.vscode/
*.iml
*.swp
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Scripts de déploiement
build-push.sh

29
Makefile Normal file
View File

@ -0,0 +1,29 @@
.PHONY: up down build logs deps
# Démarrer tous les services
up:
docker-compose up --build
# Arrêter
down:
docker-compose down
# Build uniquement
build:
docker-compose build
# Logs
logs:
docker-compose logs -f
# Télécharger les dépendances Go (à lancer avant le premier build)
deps:
cd backend && go mod tidy
cd frontend && npm install
# Lancer en développement (backend + frontend séparément)
dev-backend:
cd backend && go run ./cmd/server
dev-frontend:
cd frontend && npm run dev

154
README.md
View File

@ -0,0 +1,154 @@
# Tradarr
Agrégateur d'actualités financières avec résumés IA personnalisés par utilisateur.
Tradarr scrape des sources d'information (Bloomberg, StockTwits, Reuters, WatcherGuru), croise les articles avec la watchlist de chaque trader, puis génère via IA un résumé structuré orienté trading. L'utilisateur peut ensuite sélectionner des extraits du résumé et poser des questions à l'IA pour approfondir une analyse.
## Fonctionnalités
- Scraping automatique de plusieurs sources financières (planifiable)
- Résumés IA personnalisés selon la watchlist de chaque utilisateur
- Filtrage intelligent des articles par pertinence (passe 1) avant résumé (passe 2)
- Questions/réponses IA sur des extraits de résumés (rapports)
- Support de plusieurs fournisseurs IA : OpenAI, Anthropic, Gemini, Ollama, Claude Code CLI
- Interface PWA (installable sur mobile)
- Panel d'administration complet
## Installation (production)
### Prérequis
- Docker et Docker Compose installés sur le serveur
- Si tu utilises **Claude Code** comme fournisseur IA : Claude Code CLI installé et authentifié sur la machine hôte (`claude login`)
### 1. Créer le fichier `.env`
```bash
cp .env.example .env
```
Édite `.env` avec tes valeurs :
```env
# PostgreSQL
POSTGRES_DB=tradarr
POSTGRES_USER=tradarr
POSTGRES_PASSWORD=<mot_de_passe_fort>
# Secret JWT pour les tokens de session
JWT_SECRET=<chaîne_aléatoire_longue>
# Clé de chiffrement AES-256 pour les clés API et credentials (32 bytes en hex)
# Générer avec : openssl rand -hex 32
ENCRYPTION_KEY=<32_bytes_en_hex>
# Compte administrateur créé automatiquement au premier démarrage
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=<mot_de_passe_fort>
# Port exposé sur le serveur (défaut : 80)
FRONTEND_PORT=80
```
### 2. Ajuster `docker-compose.prod.yml` si nécessaire
#### Fournisseur Claude Code CLI (optionnel)
Si tu veux utiliser **Claude Code** comme fournisseur IA, le backend doit accéder aux credentials de ta session Claude. La configuration par défaut monte `/home/anthony/.claude` — adapte ce chemin à l'utilisateur qui exécute Docker sur ton serveur :
```yaml
# dans docker-compose.prod.yml, section backend > volumes
volumes:
- /home/<ton_user>/.claude:/root/.claude
```
#### Images Docker
Les images sont référencées par tag dans `docker-compose.prod.yml`. Mets à jour les tags selon la version à déployer :
```yaml
backend:
image: gitea.anthonybouteiller.ovh/blomios/tradarr-backend:v1.0.0
frontend:
image: gitea.anthonybouteiller.ovh/blomios/tradarr-frontend:v1.0.0
scraper:
image: gitea.anthonybouteiller.ovh/blomios/tradarr-scraper:v1.0.0
```
### 3. Démarrer
```bash
docker compose -f docker-compose.prod.yml up -d
```
L'interface est accessible sur `http://<serveur>:<FRONTEND_PORT>`.
---
## Configuration post-démarrage (panel admin)
Connecte-toi avec le compte admin défini dans `.env`, puis accède à **Admin** pour configurer :
### Fournisseurs IA
Ajoute au moins un fournisseur IA et assigne-le aux trois rôles disponibles :
| Rôle | Description |
|------|-------------|
| Résumés | Génération du résumé quotidien (passe 2) |
| Rapports | Réponses aux questions sur les résumés |
| Filtre articles | Sélection des articles pertinents (passe 1) |
Fournisseurs supportés :
| Nom | Clé API requise | Endpoint |
|-----|----------------|----------|
| `openai` | Oui | — |
| `anthropic` | Oui | — |
| `gemini` | Oui | — |
| `ollama` | Non | `http://ollama:11434` (par défaut) |
| `claudecode` | Non | — (utilise le CLI local) |
### Paramètres
| Clé | Description | Défaut |
|-----|-------------|--------|
| `articles_lookback_hours` | Fenêtre de temps pour récupérer les articles | `24` |
| `summary_max_articles` | Nombre max d'articles envoyés au modèle pour le résumé | `50` |
| `filter_batch_size` | Taille des lots pour la passe de filtrage IA | `20` |
| `timezone` | Fuseau horaire affiché dans les résumés | `UTC` |
| `ai_system_prompt` | Prompt système pour la génération des résumés | prompt par défaut |
### Planning
Configure les créneaux de génération automatique des résumés (jours et heures).
### Sources
Active ou désactive les sources de scraping :
- **Bloomberg** — nécessite des credentials (onglet Credentials)
- **StockTwits** — public, aucune configuration requise
- **Reuters** — public
- **WatcherGuru** — public
### Credentials Bloomberg
Renseigne ton login/mot de passe Bloomberg pour activer le scraping de cette source.
---
## Développement local
```bash
# Démarrer tous les services
docker compose up -d
# Rebuild après modification du code
docker compose build backend frontend && docker compose up -d backend frontend
```
Logs :
```bash
docker compose logs -f backend
docker compose logs -f frontend
```

32
backend/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
FROM golang:1.23-bookworm AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o tradarr ./cmd/server
FROM debian:bookworm-slim
# Chromium pour le scraping Bloomberg + Node.js pour Claude Code CLI
RUN apt-get update && apt-get install -y \
chromium \
chromium-driver \
ca-certificates \
fonts-liberation \
libnss3 \
nodejs \
npm \
--no-install-recommends && \
rm -rf /var/lib/apt/lists/* && \
npm install -g @anthropic-ai/claude-code
WORKDIR /app
COPY --from=builder /app/tradarr .
COPY --from=builder /app/internal/database/migrations ./internal/database/migrations
ENV CHROME_PATH=/usr/bin/chromium
EXPOSE 8080
CMD ["./tradarr"]

View File

@ -0,0 +1,90 @@
package main
import (
"fmt"
"log"
"github.com/tradarr/backend/internal/ai"
"github.com/tradarr/backend/internal/api"
"github.com/tradarr/backend/internal/api/handlers"
"github.com/tradarr/backend/internal/auth"
"github.com/tradarr/backend/internal/config"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/database"
"github.com/tradarr/backend/internal/models"
"github.com/tradarr/backend/internal/scheduler"
"github.com/tradarr/backend/internal/scraper"
"github.com/tradarr/backend/internal/scraper/bloomberg"
"github.com/tradarr/backend/internal/scraper/reuters"
"github.com/tradarr/backend/internal/scraper/watcherguru"
"github.com/tradarr/backend/internal/scraper/yahoofinance"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
db, err := database.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("database: %v", err)
}
defer db.Close()
if err := database.RunMigrations(db); err != nil {
log.Fatalf("migrations: %v", err)
}
repo := models.NewRepository(db)
enc := crypto.New(cfg.EncryptionKey)
pipeline := ai.NewPipeline(repo, enc)
if err := ensureAdmin(repo, cfg); err != nil {
log.Printf("ensure admin: %v", err)
}
registry := scraper.NewRegistry(repo)
registry.Register(bloomberg.NewDynamic(repo, enc, cfg.ScraperURL))
registry.Register(yahoofinance.New())
registry.Register(reuters.New())
registry.Register(watcherguru.New())
sched := scheduler.New(registry, pipeline, repo)
if err := sched.Start(); err != nil {
log.Printf("scheduler: %v", err)
}
defer sched.Stop()
h := handlers.New(repo, cfg, enc, registry, pipeline, sched)
r := api.SetupRouter(h, cfg.JWTSecret)
addr := fmt.Sprintf(":%s", cfg.Port)
log.Printf("server listening on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
func ensureAdmin(repo *models.Repository, cfg *config.Config) error {
if cfg.AdminEmail == "" {
return nil
}
existing, err := repo.GetUserByEmail(cfg.AdminEmail)
if err != nil {
return err
}
if existing != nil {
return nil
}
hash, err := auth.HashPassword(cfg.AdminPassword)
if err != nil {
return err
}
_, err = repo.CreateUser(cfg.AdminEmail, hash, models.RoleAdmin)
if err != nil {
return err
}
log.Printf("admin account created: %s", cfg.AdminEmail)
return nil
}

55
backend/go.mod Normal file
View File

@ -0,0 +1,55 @@
module github.com/tradarr/backend
go 1.23
require (
github.com/chromedp/chromedp v0.11.2
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.18.1
github.com/lib/pq v1.10.9
github.com/robfig/cron/v3 v3.0.1
github.com/sashabaranov/go-openai v1.36.1
golang.org/x/crypto v0.32.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.36.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

166
backend/go.sum Normal file
View File

@ -0,0 +1,166 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb h1:noKVm2SsG4v0Yd0lHNtFYc9EUxIVvrr4kJ6hM8wvIYU=
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g=
github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,79 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type anthropicProvider struct {
apiKey string
model string
client *http.Client
}
func newAnthropic(apiKey, model string) *anthropicProvider {
if model == "" {
model = "claude-sonnet-4-6"
}
return &anthropicProvider{
apiKey: apiKey,
model: model,
client: &http.Client{},
}
}
func (p *anthropicProvider) Name() string { return "anthropic" }
func (p *anthropicProvider) Summarize(ctx context.Context, prompt string, _ GenOptions) (string, error) {
body := map[string]interface{}{
"model": p.model,
"max_tokens": 4096,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(b))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", p.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("anthropic API error %d: %s", resp.StatusCode, raw)
}
var result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
if len(result.Content) == 0 {
return "", nil
}
return result.Content[0].Text, nil
}
func (p *anthropicProvider) ListModels(_ context.Context) ([]string, error) {
return []string{
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5-20251001",
}, nil
}

View File

@ -0,0 +1,52 @@
package ai
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
type claudeCodeProvider struct {
model string
}
func newClaudeCode(model string) *claudeCodeProvider {
if model == "" {
model = "claude-sonnet-4-6"
}
return &claudeCodeProvider{model: model}
}
func (p *claudeCodeProvider) Name() string { return "claudecode" }
func (p *claudeCodeProvider) Summarize(ctx context.Context, prompt string, _ GenOptions) (string, error) {
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--model", p.model)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
fmt.Printf("[claudecode] stdout len=%d stderr=%q err=%v\n", stdout.Len(), stderr.String(), err)
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = strings.TrimSpace(stdout.String())
}
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("claude cli: %s", msg)
}
return strings.TrimSpace(stdout.String()), nil
}
func (p *claudeCodeProvider) ListModels(_ context.Context) ([]string, error) {
return []string{
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5-20251001",
}, nil
}

View File

@ -0,0 +1,116 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type geminiProvider struct {
apiKey string
model string
client *http.Client
}
func newGemini(apiKey, model string) *geminiProvider {
if model == "" {
model = "gemini-2.0-flash"
}
return &geminiProvider{
apiKey: apiKey,
model: model,
client: &http.Client{},
}
}
func (p *geminiProvider) Name() string { return "gemini" }
func (p *geminiProvider) Summarize(ctx context.Context, prompt string, _ GenOptions) (string, error) {
url := fmt.Sprintf(
"https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s",
p.model, p.apiKey,
)
body := map[string]interface{}{
"contents": []map[string]interface{}{
{"parts": []map[string]string{{"text": prompt}}},
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("gemini API error %d: %s", resp.StatusCode, raw)
}
var result struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
return "", nil
}
return result.Candidates[0].Content.Parts[0].Text, nil
}
func (p *geminiProvider) ListModels(ctx context.Context) ([]string, error) {
url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models?key=%s", p.apiKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gemini list models error %d: %s", resp.StatusCode, raw)
}
var result struct {
Models []struct {
Name string `json:"name"`
SupportedMethods []string `json:"supportedGenerationMethods"`
} `json:"models"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return nil, err
}
var names []string
for _, m := range result.Models {
for _, method := range m.SupportedMethods {
if method == "generateContent" {
// name is "models/gemini-xxx", strip prefix
id := m.Name
if len(id) > 7 {
id = id[7:] // strip "models/"
}
names = append(names, id)
break
}
}
}
return names, nil
}

View File

@ -0,0 +1,168 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// OllamaModelInfo holds detailed info about an installed Ollama model.
type OllamaModelInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedAt string `json:"modified_at"`
Details struct {
ParameterSize string `json:"parameter_size"`
QuantizationLevel string `json:"quantization_level"`
Family string `json:"family"`
} `json:"details"`
}
// OllamaProvider implements Provider for Ollama and also exposes model management operations.
type OllamaProvider struct {
endpoint string
model string
client *http.Client
}
func newOllama(endpoint, model string) *OllamaProvider {
if endpoint == "" {
endpoint = "http://ollama:11434"
}
if model == "" {
model = "llama3"
}
return &OllamaProvider{
endpoint: endpoint,
model: model,
client: &http.Client{},
}
}
// NewOllamaManager creates an OllamaProvider for model management (pull/delete/list).
func NewOllamaManager(endpoint string) *OllamaProvider {
return newOllama(endpoint, "")
}
func (p *OllamaProvider) Name() string { return "ollama" }
func (p *OllamaProvider) Summarize(ctx context.Context, prompt string, opts GenOptions) (string, error) {
numCtx := 32768
if opts.NumCtx > 0 {
numCtx = opts.NumCtx
}
body := map[string]interface{}{
"model": p.model,
"prompt": prompt,
"stream": false,
"think": opts.Think,
"options": map[string]interface{}{
"num_ctx": numCtx,
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint+"/api/generate", bytes.NewReader(b))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ollama API error %d: %s", resp.StatusCode, raw)
}
var result struct {
Response string `json:"response"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
return result.Response, nil
}
func (p *OllamaProvider) ListModels(ctx context.Context) ([]string, error) {
infos, err := p.ListModelsDetailed(ctx)
if err != nil {
return nil, err
}
names := make([]string, len(infos))
for i, m := range infos {
names[i] = m.Name
}
return names, nil
}
func (p *OllamaProvider) ListModelsDetailed(ctx context.Context) ([]OllamaModelInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.endpoint+"/api/tags", nil)
if err != nil {
return nil, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var result struct {
Models []OllamaModelInfo `json:"models"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return nil, err
}
return result.Models, nil
}
// PullModel pulls (downloads) a model from Ollama Hub. Blocks until complete.
func (p *OllamaProvider) PullModel(ctx context.Context, name string) error {
body, _ := json.Marshal(map[string]interface{}{"name": name, "stream": false})
// Use a long-timeout client since model downloads can take many minutes
client := &http.Client{Timeout: 60 * time.Minute}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint+"/api/pull", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ollama pull error %d: %s", resp.StatusCode, raw)
}
return nil
}
// DeleteModel removes a model from local storage.
func (p *OllamaProvider) DeleteModel(ctx context.Context, name string) error {
body, _ := json.Marshal(map[string]string{"name": name})
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, p.endpoint+"/api/delete", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("ollama delete error %d: %s", resp.StatusCode, raw)
}
return nil
}

View File

@ -0,0 +1,52 @@
package ai
import (
"context"
openai "github.com/sashabaranov/go-openai"
)
type openAIProvider struct {
client *openai.Client
model string
}
func newOpenAI(apiKey, model string) *openAIProvider {
if model == "" {
model = openai.GPT4oMini
}
return &openAIProvider{
client: openai.NewClient(apiKey),
model: model,
}
}
func (p *openAIProvider) Name() string { return "openai" }
func (p *openAIProvider) Summarize(ctx context.Context, prompt string, _ GenOptions) (string, error) {
resp, err := p.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: p.model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleUser, Content: prompt},
},
})
if err != nil {
return "", err
}
if len(resp.Choices) == 0 {
return "", nil
}
return resp.Choices[0].Message.Content, nil
}
func (p *openAIProvider) ListModels(ctx context.Context) ([]string, error) {
resp, err := p.client.ListModels(ctx)
if err != nil {
return nil, err
}
var models []string
for _, m := range resp.Models {
models = append(models, m.ID)
}
return models, nil
}

View File

@ -0,0 +1,329 @@
package ai
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/models"
)
const DefaultSystemPrompt = `Tu es un assistant spécialisé en trading financier. Analyse l'ensemble des actualités suivantes, toutes sources confondues, et crée un résumé global structuré en français, orienté trading.
Structure ton résumé ainsi :
1. **Vue macro** : tendances globales du marché (économie, géopolitique, secteurs)
2. **Actifs surveillés** : pour chaque actif de la watchlist mentionné dans les news :
- Sentiment (haussier/baissier/neutre)
- Faits clés et catalyseurs
- Risques et opportunités
3. **Autres mouvements notables** : actifs hors watchlist à surveiller
4. **Synthèse** : points d'attention prioritaires pour la journée`
type Pipeline struct {
repo *models.Repository
enc *crypto.Encryptor
generating atomic.Bool
}
func NewPipeline(repo *models.Repository, enc *crypto.Encryptor) *Pipeline {
return &Pipeline{repo: repo, enc: enc}
}
func (p *Pipeline) IsGenerating() bool {
return p.generating.Load()
}
func (p *Pipeline) BuildProvider(name, apiKey, endpoint string) (Provider, error) {
return NewProvider(name, apiKey, "", endpoint)
}
// buildProviderForRole resolves and builds the AI provider for a given task role.
func (p *Pipeline) buildProviderForRole(role string) (Provider, *models.AIProvider, error) {
cfg, model, err := p.repo.GetRoleProvider(role)
if err != nil {
return nil, nil, fmt.Errorf("get provider for role %s: %w", role, err)
}
if cfg == nil {
return nil, nil, fmt.Errorf("no AI provider configured for role %s", role)
}
apiKey := ""
if cfg.APIKeyEncrypted != "" {
apiKey, err = p.enc.Decrypt(cfg.APIKeyEncrypted)
if err != nil {
return nil, nil, fmt.Errorf("decrypt API key for role %s: %w", role, err)
}
}
provider, err := NewProvider(cfg.Name, apiKey, model, cfg.Endpoint)
if err != nil {
return nil, nil, fmt.Errorf("build provider for role %s: %w", role, err)
}
return provider, cfg, nil
}
func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models.Summary, error) {
p.generating.Store(true)
defer p.generating.Store(false)
provider, providerCfg, err := p.buildProviderForRole("summary")
if err != nil {
return nil, err
}
assets, err := p.repo.GetUserAssets(userID)
if err != nil {
return nil, fmt.Errorf("get user assets: %w", err)
}
symbols := make([]string, len(assets))
for i, a := range assets {
symbols[i] = a.Symbol
}
hoursStr, _ := p.repo.GetSetting("articles_lookback_hours")
hours, _ := strconv.Atoi(hoursStr)
if hours == 0 {
hours = 24
}
articles, err := p.repo.GetRecentArticles(hours)
if err != nil {
return nil, fmt.Errorf("get articles: %w", err)
}
if len(articles) == 0 {
return nil, fmt.Errorf("no recent articles found")
}
maxStr, _ := p.repo.GetSetting("summary_max_articles")
maxArticles, _ := strconv.Atoi(maxStr)
if maxArticles == 0 {
maxArticles = 50
}
// Passe 1 : filtrage par pertinence — seulement si nettement plus d'articles que le max
if len(articles) > maxArticles*2 {
filterProvider, _, filterErr := p.buildProviderForRole("filter")
if filterErr != nil {
filterProvider = provider // fallback to summary provider
}
fmt.Printf("[pipeline] Passe 1 — filtrage : %d articles → sélection des %d plus pertinents…\n", len(articles), maxArticles)
t1 := time.Now()
articles = p.filterByRelevance(ctx, filterProvider, symbols, articles, maxArticles)
fmt.Printf("[pipeline] Passe 1 — terminée en %s : %d articles retenus\n", time.Since(t1).Round(time.Second), len(articles))
} else if len(articles) > maxArticles {
articles = articles[:maxArticles]
fmt.Printf("[pipeline] troncature directe à %d articles (pas assez d'excédent pour justifier un appel IA)\n", maxArticles)
}
systemPrompt, _ := p.repo.GetSetting("ai_system_prompt")
if systemPrompt == "" {
systemPrompt = DefaultSystemPrompt
}
tz, _ := p.repo.GetSetting("timezone")
// Passe 2 : résumé complet
fmt.Printf("[pipeline] Passe 2 — résumé : génération sur %d articles…\n", len(articles))
t2 := time.Now()
prompt := buildPrompt(systemPrompt, symbols, articles, tz)
// Passe 2 : think activé pour une meilleure qualité d'analyse
summary, err := provider.Summarize(ctx, prompt, GenOptions{Think: true, NumCtx: 32768})
if err != nil {
return nil, fmt.Errorf("AI summarize: %w", err)
}
fmt.Printf("[pipeline] Passe 2 — terminée en %s\n", time.Since(t2).Round(time.Second))
return p.repo.CreateSummary(userID, summary, &providerCfg.ID)
}
// filterByRelevance splits articles into batches and asks the AI to select relevant
// ones from each batch. Results are pooled then truncated to max.
func (p *Pipeline) filterByRelevance(ctx context.Context, provider Provider, symbols []string, articles []models.Article, max int) []models.Article {
batchSizeStr, _ := p.repo.GetSetting("filter_batch_size")
batchSize, _ := strconv.Atoi(batchSizeStr)
if batchSize <= 0 {
batchSize = 20
}
var selected []models.Article
numBatches := (len(articles) + batchSize - 1) / batchSize
for b := 0; b < numBatches; b++ {
start := b * batchSize
end := start + batchSize
if end > len(articles) {
end = len(articles)
}
batch := articles[start:end]
fmt.Printf("[pipeline] Passe 1 — batch %d/%d (%d articles)…\n", b+1, numBatches, len(batch))
t := time.Now()
chosen := p.filterBatch(ctx, provider, symbols, batch)
fmt.Printf("[pipeline] Passe 1 — batch %d/%d terminé en %s : %d retenus\n", b+1, numBatches, time.Since(t).Round(time.Second), len(chosen))
selected = append(selected, chosen...)
// Stop early if we have plenty of candidates
if len(selected) >= max*2 {
fmt.Printf("[pipeline] Passe 1 — suffisamment de candidats (%d), arrêt anticipé\n", len(selected))
break
}
}
if len(selected) <= max {
return selected
}
return selected[:max]
}
// filterBatch asks the AI to return all relevant articles from a single batch.
func (p *Pipeline) filterBatch(ctx context.Context, provider Provider, symbols []string, batch []models.Article) []models.Article {
prompt := buildFilterBatchPrompt(symbols, batch)
response, err := provider.Summarize(ctx, prompt, GenOptions{Think: false, NumCtx: 4096})
if err != nil {
fmt.Printf("[pipeline] filterBatch — échec (%v), conservation du batch entier\n", err)
return batch
}
indices := parseIndexArray(response, len(batch))
if len(indices) == 0 {
return nil
}
filtered := make([]models.Article, 0, len(indices))
for _, i := range indices {
filtered = append(filtered, batch[i])
}
return filtered
}
func buildFilterBatchPrompt(symbols []string, batch []models.Article) string {
var sb strings.Builder
sb.WriteString("Tu es un assistant de trading financier.\n")
sb.WriteString(fmt.Sprintf("Parmi les %d articles ci-dessous, sélectionne TOUS ceux pertinents pour un trader actif.\n", len(batch)))
if len(symbols) > 0 {
sb.WriteString("Actifs prioritaires : ")
sb.WriteString(strings.Join(symbols, ", "))
sb.WriteString("\n")
}
sb.WriteString("\nRéponds UNIQUEMENT avec un tableau JSON des indices retenus (base 0), exemple : [0, 2, 5]\n")
sb.WriteString("Si aucun article n'est pertinent, réponds : []\n")
sb.WriteString("N'ajoute aucun texte avant ou après le tableau JSON.\n\n")
sb.WriteString("Articles :\n")
for i, a := range batch {
sb.WriteString(fmt.Sprintf("[%d] %s (%s)\n", i, a.Title, a.SourceName))
}
return sb.String()
}
var jsonArrayRe = regexp.MustCompile(`\[[\d\s,]+\]`)
func parseIndexArray(response string, maxIndex int) []int {
match := jsonArrayRe.FindString(response)
if match == "" {
return nil
}
match = strings.Trim(match, "[]")
parts := strings.Split(match, ",")
seen := make(map[int]bool)
var indices []int
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil || n < 0 || n >= maxIndex || seen[n] {
continue
}
seen[n] = true
indices = append(indices, n)
}
return indices
}
func (p *Pipeline) GenerateForAll(ctx context.Context) error {
users, err := p.repo.ListUsers()
if err != nil {
return err
}
for _, user := range users {
if _, err := p.GenerateForUser(ctx, user.ID); err != nil {
fmt.Printf("summary for user %s: %v\n", user.Email, err)
}
}
return nil
}
// GenerateReportAsync crée le rapport en DB (status=generating) et lance la génération en arrière-plan.
func (p *Pipeline) GenerateReportAsync(reportID, excerpt, question string, mgr *ReportManager) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
mgr.Register(reportID, cancel)
go func() {
defer cancel()
defer mgr.Remove(reportID)
answer, err := p.callProviderForReport(ctx, excerpt, question)
if err != nil {
if ctx.Err() != nil {
// annulé volontairement — le rapport est supprimé par le handler
return
}
_ = p.repo.UpdateReport(reportID, "error", "", err.Error())
return
}
_ = p.repo.UpdateReport(reportID, "done", answer, "")
}()
}
func (p *Pipeline) callProviderForReport(ctx context.Context, excerpt, question string) (string, error) {
provider, _, err := p.buildProviderForRole("report")
if err != nil {
return "", err
}
prompt := fmt.Sprintf(
"Tu es un assistant financier expert. L'utilisateur a sélectionné les extraits suivants d'un résumé de marché :\n\n%s\n\nQuestion de l'utilisateur : %s\n\nRéponds en français, de façon précise et orientée trading.",
excerpt, question,
)
return provider.Summarize(ctx, prompt, GenOptions{Think: true, NumCtx: 16384})
}
func buildPrompt(systemPrompt string, symbols []string, articles []models.Article, tz string) string {
var sb strings.Builder
sb.WriteString(systemPrompt)
sb.WriteString("\n\n")
if len(symbols) > 0 {
sb.WriteString("Le trader surveille particulièrement ces actifs (sois attentif à toute mention) : ")
sb.WriteString(strings.Join(symbols, ", "))
sb.WriteString(".\n\n")
}
loc, err := time.LoadLocation(tz)
if err != nil || tz == "" {
loc = time.UTC
}
sb.WriteString(fmt.Sprintf("Date d'analyse : %s\n\n", time.Now().In(loc).Format("02/01/2006 15:04")))
sb.WriteString("## Actualités\n\n")
for i, a := range articles {
sb.WriteString(fmt.Sprintf("### [%d] %s\n", i+1, a.Title))
sb.WriteString(fmt.Sprintf("Source : %s\n", a.SourceName))
if a.PublishedAt.Valid {
sb.WriteString(fmt.Sprintf("Date : %s\n", a.PublishedAt.Time.Format("02/01/2006 15:04")))
}
content := a.Content
if len(content) > 1000 {
content = content[:1000] + "..."
}
sb.WriteString(content)
sb.WriteString("\n\n")
}
return sb.String()
}

View File

@ -0,0 +1,35 @@
package ai
import (
"context"
"fmt"
)
// GenOptions permet de contrôler le comportement de génération par appel.
type GenOptions struct {
Think bool // active le mode raisonnement (Qwen3 /think)
NumCtx int // taille du contexte KV (0 = valeur par défaut du provider)
}
type Provider interface {
Name() string
Summarize(ctx context.Context, prompt string, opts GenOptions) (string, error)
ListModels(ctx context.Context) ([]string, error)
}
func NewProvider(name, apiKey, model, endpoint string) (Provider, error) {
switch name {
case "openai":
return newOpenAI(apiKey, model), nil
case "anthropic":
return newAnthropic(apiKey, model), nil
case "gemini":
return newGemini(apiKey, model), nil
case "ollama":
return newOllama(endpoint, model), nil
case "claudecode":
return newClaudeCode(model), nil
default:
return nil, fmt.Errorf("unknown provider: %s", name)
}
}

View File

@ -0,0 +1,37 @@
package ai
import (
"context"
"sync"
)
// ReportManager tracks in-flight report goroutines so they can be cancelled.
type ReportManager struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
}
func NewReportManager() *ReportManager {
return &ReportManager{cancels: make(map[string]context.CancelFunc)}
}
func (m *ReportManager) Register(id string, cancel context.CancelFunc) {
m.mu.Lock()
defer m.mu.Unlock()
m.cancels[id] = cancel
}
func (m *ReportManager) Cancel(id string) {
m.mu.Lock()
defer m.mu.Unlock()
if cancel, ok := m.cancels[id]; ok {
cancel()
delete(m.cancels, id)
}
}
func (m *ReportManager) Remove(id string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.cancels, id)
}

View File

@ -0,0 +1,520 @@
package handlers
import (
"context"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/ai"
"github.com/tradarr/backend/internal/httputil"
"github.com/tradarr/backend/internal/models"
)
// ── Credentials ────────────────────────────────────────────────────────────
type credentialsRequest struct {
SourceID string `json:"source_id" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password"`
}
func (h *Handler) GetCredentials(c *gin.Context) {
sources, err := h.repo.ListSources()
if err != nil {
httputil.InternalError(c, err)
return
}
type credResponse struct {
SourceID string `json:"source_id"`
SourceName string `json:"source_name"`
Username string `json:"username"`
HasPassword bool `json:"has_password"`
}
var result []credResponse
for _, src := range sources {
if src.Type != "bloomberg" {
continue
}
cred, _ := h.repo.GetCredentials(src.ID)
r := credResponse{SourceID: src.ID, SourceName: src.Name}
if cred != nil {
r.Username = cred.Username
r.HasPassword = cred.PasswordEncrypted != ""
}
result = append(result, r)
}
httputil.OK(c, result)
}
func (h *Handler) UpsertCredentials(c *gin.Context) {
var req credentialsRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
encPwd := ""
if req.Password != "" {
var err error
encPwd, err = h.enc.Encrypt(req.Password)
if err != nil {
httputil.InternalError(c, err)
return
}
}
if err := h.repo.UpsertCredentials(req.SourceID, req.Username, encPwd); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
// ── AI Providers ───────────────────────────────────────────────────────────
type aiProviderRequest struct {
Name string `json:"name" binding:"required"`
APIKey string `json:"api_key"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
}
func (h *Handler) ListAIProviders(c *gin.Context) {
providers, err := h.repo.ListAIProviders()
if err != nil {
httputil.InternalError(c, err)
return
}
// Ne pas exposer les clés chiffrées — juste indiquer si elle existe
type safeProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
IsActive bool `json:"is_active"`
HasKey bool `json:"has_key"`
}
var result []safeProvider
for _, p := range providers {
result = append(result, safeProvider{
ID: p.ID,
Name: p.Name,
Model: p.Model,
Endpoint: p.Endpoint,
IsActive: p.IsActive,
HasKey: p.APIKeyEncrypted != "",
})
}
httputil.OK(c, result)
}
func (h *Handler) CreateAIProvider(c *gin.Context) {
var req aiProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
encKey := ""
if req.APIKey != "" {
var err error
encKey, err = h.enc.Encrypt(req.APIKey)
if err != nil {
httputil.InternalError(c, err)
return
}
}
p, err := h.repo.CreateAIProvider(req.Name, encKey, req.Model, req.Endpoint)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.Created(c, p)
}
func (h *Handler) UpdateAIProvider(c *gin.Context) {
id := c.Param("id")
var req aiProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
existing, err := h.repo.GetAIProviderByID(id)
if err != nil || existing == nil {
httputil.NotFound(c)
return
}
encKey := existing.APIKeyEncrypted
if req.APIKey != "" {
encKey, err = h.enc.Encrypt(req.APIKey)
if err != nil {
httputil.InternalError(c, err)
return
}
}
if err := h.repo.UpdateAIProvider(id, encKey, req.Model, req.Endpoint); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
func (h *Handler) SetActiveAIProvider(c *gin.Context) {
id := c.Param("id")
if err := h.repo.SetActiveAIProvider(id); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
func (h *Handler) DeleteAIProvider(c *gin.Context) {
if err := h.repo.DeleteAIProvider(c.Param("id")); err != nil {
httputil.InternalError(c, err)
return
}
httputil.NoContent(c)
}
func (h *Handler) ProbeAIModels(c *gin.Context) {
var req aiProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
provider, err := h.pipeline.BuildProvider(req.Name, req.APIKey, req.Endpoint)
if err != nil {
httputil.InternalError(c, err)
return
}
models, err := provider.ListModels(c.Request.Context())
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, models)
}
func (h *Handler) ListAIModels(c *gin.Context) {
id := c.Param("id")
p, err := h.repo.GetAIProviderByID(id)
if err != nil || p == nil {
httputil.NotFound(c)
return
}
apiKey := ""
if p.APIKeyEncrypted != "" {
apiKey, err = h.enc.Decrypt(p.APIKeyEncrypted)
if err != nil {
httputil.InternalError(c, err)
return
}
}
provider, err := h.pipeline.BuildProvider(p.Name, apiKey, p.Endpoint)
if err != nil {
httputil.InternalError(c, err)
return
}
models, err := provider.ListModels(c.Request.Context())
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, models)
}
// ── Sources ────────────────────────────────────────────────────────────────
func (h *Handler) ListSources(c *gin.Context) {
sources, err := h.repo.ListSources()
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, sources)
}
type updateSourceRequest struct {
Enabled bool `json:"enabled"`
}
func (h *Handler) UpdateSource(c *gin.Context) {
var req updateSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
if err := h.repo.UpdateSource(c.Param("id"), req.Enabled); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
// ── Scrape Jobs ────────────────────────────────────────────────────────────
func (h *Handler) ListScrapeJobs(c *gin.Context) {
jobs, err := h.repo.ListScrapeJobs(100)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, jobs)
}
func (h *Handler) TriggerScrapeJob(c *gin.Context) {
type triggerRequest struct {
SourceID string `json:"source_id" binding:"required"`
}
var req triggerRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
go func() {
if err := h.registry.Run(req.SourceID); err != nil {
fmt.Printf("scrape job error: %v\n", err)
}
}()
c.JSON(http.StatusAccepted, gin.H{"ok": true, "message": "job started"})
}
// ── Settings ───────────────────────────────────────────────────────────────
func (h *Handler) ListSettings(c *gin.Context) {
settings, err := h.repo.ListSettings()
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, settings)
}
type updateSettingsRequest struct {
Settings []models.Setting `json:"settings"`
}
func (h *Handler) UpdateSettings(c *gin.Context) {
var req updateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
for _, s := range req.Settings {
if err := h.repo.SetSetting(s.Key, s.Value); err != nil {
httputil.InternalError(c, err)
return
}
}
httputil.OK(c, gin.H{"ok": true})
}
// ── Schedule ───────────────────────────────────────────────────────────────
func (h *Handler) GetSchedule(c *gin.Context) {
slots, err := h.repo.ListScheduleSlots()
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, slots)
}
type scheduleRequest struct {
Slots []struct {
DayOfWeek int `json:"day_of_week"`
Hour int `json:"hour"`
Minute int `json:"minute"`
} `json:"slots"`
}
func (h *Handler) UpdateSchedule(c *gin.Context) {
var req scheduleRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
slots := make([]models.ScheduleSlot, len(req.Slots))
for i, s := range req.Slots {
slots[i] = models.ScheduleSlot{DayOfWeek: s.DayOfWeek, Hour: s.Hour, Minute: s.Minute}
}
if err := h.repo.ReplaceSchedule(slots); err != nil {
httputil.InternalError(c, err)
return
}
if err := h.scheduler.Reload(); err != nil {
fmt.Printf("schedule reload: %v\n", err)
}
httputil.OK(c, gin.H{"ok": true})
}
func (h *Handler) GetDefaultSystemPrompt(c *gin.Context) {
httputil.OK(c, gin.H{"prompt": ai.DefaultSystemPrompt})
}
// ── AI Roles ───────────────────────────────────────────────────────────────
func (h *Handler) GetAIRoles(c *gin.Context) {
roles := []string{"summary", "report", "filter"}
resp := gin.H{}
for _, role := range roles {
providerID, _ := h.repo.GetSetting("ai_role_" + role + "_provider")
model, _ := h.repo.GetSetting("ai_role_" + role + "_model")
resp[role] = gin.H{"provider_id": providerID, "model": model}
}
httputil.OK(c, resp)
}
type updateAIRoleRequest struct {
ProviderID string `json:"provider_id"`
Model string `json:"model"`
}
func (h *Handler) UpdateAIRole(c *gin.Context) {
role := c.Param("role")
if role != "summary" && role != "report" && role != "filter" {
httputil.BadRequest(c, fmt.Errorf("invalid role: must be summary, report, or filter"))
return
}
var req updateAIRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
if err := h.repo.SetRoleProvider(role, req.ProviderID, req.Model); err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"ok": true})
}
// ── Ollama Model Management ────────────────────────────────────────────────
func (h *Handler) getOllamaManager() (*ai.OllamaProvider, error) {
providers, err := h.repo.ListAIProviders()
if err != nil {
return nil, err
}
endpoint := ""
for _, p := range providers {
if p.Name == "ollama" {
endpoint = p.Endpoint
break
}
}
if endpoint == "" {
endpoint = "http://ollama:11434"
}
return ai.NewOllamaManager(endpoint), nil
}
func (h *Handler) ListOllamaModels(c *gin.Context) {
mgr, err := h.getOllamaManager()
if err != nil {
httputil.InternalError(c, err)
return
}
models, err := mgr.ListModelsDetailed(c.Request.Context())
if err != nil {
httputil.InternalError(c, err)
return
}
if models == nil {
models = []ai.OllamaModelInfo{}
}
httputil.OK(c, models)
}
type pullModelRequest struct {
Name string `json:"name" binding:"required"`
}
func (h *Handler) PullOllamaModel(c *gin.Context) {
var req pullModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
mgr, err := h.getOllamaManager()
if err != nil {
httputil.InternalError(c, err)
return
}
go func() {
if err := mgr.PullModel(context.Background(), req.Name); err != nil {
fmt.Printf("[ollama] pull %s failed: %v\n", req.Name, err)
} else {
fmt.Printf("[ollama] pull %s completed\n", req.Name)
}
}()
c.JSON(202, gin.H{"ok": true, "message": "pull started"})
}
func (h *Handler) DeleteOllamaModel(c *gin.Context) {
name := c.Param("name")
mgr, err := h.getOllamaManager()
if err != nil {
httputil.InternalError(c, err)
return
}
if err := mgr.DeleteModel(c.Request.Context(), name); err != nil {
httputil.InternalError(c, err)
return
}
httputil.NoContent(c)
}
// ── Admin Users ────────────────────────────────────────────────────────────
func (h *Handler) ListUsers(c *gin.Context) {
users, err := h.repo.ListUsers()
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, users)
}
type updateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required"`
}
func (h *Handler) UpdateAdminUser(c *gin.Context) {
id := c.Param("id")
var req updateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
if req.Role != "admin" && req.Role != "user" {
httputil.BadRequest(c, fmt.Errorf("role must be admin or user"))
return
}
user, err := h.repo.UpdateUser(id, req.Email, models.Role(req.Role))
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, user)
}
func (h *Handler) DeleteAdminUser(c *gin.Context) {
id := c.Param("id")
// Empêcher la suppression du dernier admin
user, err := h.repo.GetUserByID(id)
if err != nil || user == nil {
httputil.NotFound(c)
return
}
if user.Role == "admin" {
count, _ := h.repo.CountAdmins()
if count <= 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete the last admin"})
return
}
}
if err := h.repo.DeleteUser(id); err != nil {
httputil.InternalError(c, err)
return
}
httputil.NoContent(c)
}

View File

@ -0,0 +1,36 @@
package handlers
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/httputil"
)
func (h *Handler) ListArticles(c *gin.Context) {
symbol := c.Query("symbol")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit > 100 {
limit = 100
}
articles, err := h.repo.ListArticles(symbol, limit, offset)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, articles)
}
func (h *Handler) GetArticle(c *gin.Context) {
article, err := h.repo.GetArticleByID(c.Param("id"))
if err != nil {
httputil.InternalError(c, err)
return
}
if article == nil {
httputil.NotFound(c)
return
}
httputil.OK(c, article)
}

View File

@ -0,0 +1,64 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/auth"
"github.com/tradarr/backend/internal/httputil"
"github.com/tradarr/backend/internal/models"
)
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type registerRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
user, err := h.repo.GetUserByEmail(req.Email)
if err != nil || user == nil || !auth.CheckPassword(user.PasswordHash, req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
token, err := auth.GenerateToken(user.ID, user.Email, string(user.Role), h.cfg.JWTSecret)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, gin.H{"token": token, "user": user})
}
func (h *Handler) Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
existing, _ := h.repo.GetUserByEmail(req.Email)
if existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
httputil.InternalError(c, err)
return
}
user, err := h.repo.CreateUser(req.Email, hash, models.RoleUser)
if err != nil {
httputil.InternalError(c, err)
return
}
token, _ := auth.GenerateToken(user.ID, user.Email, string(user.Role), h.cfg.JWTSecret)
httputil.Created(c, gin.H{"token": token, "user": user})
}

View File

@ -0,0 +1,39 @@
package handlers
import (
"github.com/tradarr/backend/internal/ai"
"github.com/tradarr/backend/internal/config"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/models"
"github.com/tradarr/backend/internal/scheduler"
"github.com/tradarr/backend/internal/scraper"
)
type Handler struct {
repo *models.Repository
cfg *config.Config
enc *crypto.Encryptor
registry *scraper.Registry
pipeline *ai.Pipeline
scheduler *scheduler.Scheduler
reportManager *ai.ReportManager
}
func New(
repo *models.Repository,
cfg *config.Config,
enc *crypto.Encryptor,
registry *scraper.Registry,
pipeline *ai.Pipeline,
sched *scheduler.Scheduler,
) *Handler {
return &Handler{
repo: repo,
cfg: cfg,
enc: enc,
registry: registry,
pipeline: pipeline,
scheduler: sched,
reportManager: ai.NewReportManager(),
}
}

View File

@ -0,0 +1,84 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/httputil"
)
type reportRequest struct {
SummaryID string `json:"summary_id"`
Excerpts []string `json:"excerpts" binding:"required,min=1"`
Question string `json:"question" binding:"required"`
}
func (h *Handler) CreateReport(c *gin.Context) {
userID := c.GetString("userID")
var req reportRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
var summaryID *string
if req.SummaryID != "" {
summaryID = &req.SummaryID
}
// Joindre les extraits avec un séparateur visuel
excerpt := buildExcerptContext(req.Excerpts)
// Créer le rapport en DB avec status=generating, retourner immédiatement
report, err := h.repo.CreatePendingReport(userID, summaryID, excerpt, req.Question)
if err != nil {
httputil.InternalError(c, err)
return
}
// Lancer la génération en arrière-plan
h.pipeline.GenerateReportAsync(report.ID, excerpt, req.Question, h.reportManager)
c.JSON(http.StatusCreated, report)
}
func (h *Handler) ListReports(c *gin.Context) {
userID := c.GetString("userID")
reports, err := h.repo.ListReports(userID)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, reports)
}
func (h *Handler) DeleteReport(c *gin.Context) {
userID := c.GetString("userID")
id := c.Param("id")
// Annuler la goroutine si elle tourne encore
h.reportManager.Cancel(id)
if err := h.repo.DeleteReport(id, userID); err != nil {
httputil.InternalError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) GetGeneratingStatus(c *gin.Context) {
httputil.OK(c, gin.H{"generating": h.pipeline.IsGenerating()})
}
func buildExcerptContext(excerpts []string) string {
if len(excerpts) == 1 {
return excerpts[0]
}
var sb strings.Builder
for i, e := range excerpts {
if i > 0 {
sb.WriteString("\n\n---\n\n")
}
sb.WriteString(e)
}
return sb.String()
}

View File

@ -0,0 +1,33 @@
package handlers
import (
"context"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/httputil"
)
func (h *Handler) ListSummaries(c *gin.Context) {
userID := c.GetString("userID")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
summaries, err := h.repo.ListSummaries(userID, limit)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, summaries)
}
func (h *Handler) GenerateSummary(c *gin.Context) {
userID := c.GetString("userID")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
summary, err := h.pipeline.GenerateForUser(ctx, userID)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.Created(c, summary)
}

View File

@ -0,0 +1,56 @@
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/httputil"
)
func (h *Handler) GetMe(c *gin.Context) {
userID := c.GetString("userID")
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil {
httputil.NotFound(c)
return
}
httputil.OK(c, user)
}
func (h *Handler) GetMyAssets(c *gin.Context) {
userID := c.GetString("userID")
assets, err := h.repo.GetUserAssets(userID)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.OK(c, assets)
}
type addAssetRequest struct {
Symbol string `json:"symbol" binding:"required"`
Name string `json:"name"`
}
func (h *Handler) AddMyAsset(c *gin.Context) {
userID := c.GetString("userID")
var req addAssetRequest
if err := c.ShouldBindJSON(&req); err != nil {
httputil.BadRequest(c, err)
return
}
asset, err := h.repo.AddUserAsset(userID, req.Symbol, req.Name)
if err != nil {
httputil.InternalError(c, err)
return
}
httputil.Created(c, asset)
}
func (h *Handler) RemoveMyAsset(c *gin.Context) {
userID := c.GetString("userID")
symbol := c.Param("symbol")
if err := h.repo.RemoveUserAsset(userID, symbol); err != nil {
httputil.InternalError(c, err)
return
}
httputil.NoContent(c)
}

View File

@ -0,0 +1,95 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/tradarr/backend/internal/api/handlers"
"github.com/tradarr/backend/internal/auth"
)
func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{
SkipPaths: []string{"/api/summaries/status"},
}))
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
api := r.Group("/api")
// Auth public
api.POST("/auth/login", h.Login)
api.POST("/auth/register", h.Register)
// Routes authentifiées
authed := api.Group("/")
authed.Use(auth.Middleware(jwtSecret))
authed.GET("/me", h.GetMe)
authed.GET("/me/assets", h.GetMyAssets)
authed.POST("/me/assets", h.AddMyAsset)
authed.DELETE("/me/assets/:symbol", h.RemoveMyAsset)
authed.GET("/articles", h.ListArticles)
authed.GET("/articles/:id", h.GetArticle)
authed.GET("/summaries", h.ListSummaries)
authed.GET("/summaries/status", h.GetGeneratingStatus)
authed.POST("/summaries/generate", h.GenerateSummary)
authed.GET("/reports", h.ListReports)
authed.POST("/reports", h.CreateReport)
authed.DELETE("/reports/:id", h.DeleteReport)
// Admin
admin := authed.Group("/admin")
admin.Use(auth.AdminOnly())
admin.GET("/credentials", h.GetCredentials)
admin.PUT("/credentials", h.UpsertCredentials)
admin.GET("/ai-providers", h.ListAIProviders)
admin.POST("/ai-providers", h.CreateAIProvider)
admin.PUT("/ai-providers/:id", h.UpdateAIProvider)
admin.POST("/ai-providers/:id/activate", h.SetActiveAIProvider)
admin.DELETE("/ai-providers/:id", h.DeleteAIProvider)
admin.GET("/ai-providers/:id/models", h.ListAIModels)
admin.POST("/ai-providers/probe-models", h.ProbeAIModels)
admin.GET("/sources", h.ListSources)
admin.PUT("/sources/:id", h.UpdateSource)
admin.GET("/scrape-jobs", h.ListScrapeJobs)
admin.POST("/scrape-jobs/trigger", h.TriggerScrapeJob)
admin.GET("/settings", h.ListSettings)
admin.PUT("/settings", h.UpdateSettings)
admin.GET("/settings/default-prompt", h.GetDefaultSystemPrompt)
admin.GET("/schedule", h.GetSchedule)
admin.PUT("/schedule", h.UpdateSchedule)
admin.GET("/users", h.ListUsers)
admin.PUT("/users/:id", h.UpdateAdminUser)
admin.DELETE("/users/:id", h.DeleteAdminUser)
// AI roles (per-task model assignment)
admin.GET("/ai-roles", h.GetAIRoles)
admin.PUT("/ai-roles/:role", h.UpdateAIRole)
// Ollama model management
admin.GET("/ollama/models", h.ListOllamaModels)
admin.POST("/ollama/pull", h.PullOllamaModel)
admin.DELETE("/ollama/models/:name", h.DeleteOllamaModel)
return r
}

View File

@ -0,0 +1,46 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userID, email, role, secret string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(tokenStr, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}

View File

@ -0,0 +1,37 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func Middleware(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := ValidateToken(strings.TrimPrefix(header, "Bearer "), secret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set("userID", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Next()
}
}
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetString("role") != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
c.Next()
}
}

View File

@ -0,0 +1,12 @@
package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

View File

@ -0,0 +1,58 @@
package config
import (
"encoding/hex"
"fmt"
"os"
)
type Config struct {
DatabaseURL string
JWTSecret string
EncryptionKey []byte
Port string
ScraperURL string
AdminEmail string
AdminPassword string
}
func Load() (*Config, error) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
encHex := os.Getenv("ENCRYPTION_KEY")
if encHex == "" {
return nil, fmt.Errorf("ENCRYPTION_KEY is required")
}
encKey, err := hex.DecodeString(encHex)
if err != nil || len(encKey) != 32 {
return nil, fmt.Errorf("ENCRYPTION_KEY must be a valid 32-byte hex string")
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
scraperURL := os.Getenv("SCRAPER_URL")
if scraperURL == "" {
scraperURL = "http://scraper:3001"
}
return &Config{
DatabaseURL: dbURL,
JWTSecret: jwtSecret,
EncryptionKey: encKey,
Port: port,
ScraperURL: scraperURL,
AdminEmail: os.Getenv("ADMIN_EMAIL"),
AdminPassword: os.Getenv("ADMIN_PASSWORD"),
}, nil
}

View File

@ -0,0 +1,59 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)
type Encryptor struct {
key []byte
}
func New(key []byte) *Encryptor {
return &Encryptor{key: key}
}
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (e *Encryptor) Decrypt(encoded string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(data) < gcm.NonceSize() {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}

View File

@ -0,0 +1,43 @@
package database
import (
"database/sql"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/lib/pq"
)
func Connect(databaseURL string) (*sql.DB, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
return db, nil
}
func RunMigrations(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return fmt.Errorf("migration driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance(
"file://internal/database/migrations",
"postgres",
driver,
)
if err != nil {
return fmt.Errorf("migrate init: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}

View File

@ -0,0 +1,10 @@
DROP TABLE IF EXISTS settings CASCADE;
DROP TABLE IF EXISTS summaries CASCADE;
DROP TABLE IF EXISTS ai_providers CASCADE;
DROP TABLE IF EXISTS scrape_jobs CASCADE;
DROP TABLE IF EXISTS scrape_credentials CASCADE;
DROP TABLE IF EXISTS article_symbols CASCADE;
DROP TABLE IF EXISTS articles CASCADE;
DROP TABLE IF EXISTS sources CASCADE;
DROP TABLE IF EXISTS user_assets CASCADE;
DROP TABLE IF EXISTS users CASCADE;

View File

@ -0,0 +1,106 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
symbol VARCHAR(20) NOT NULL,
name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, symbol)
);
CREATE TABLE sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('bloomberg', 'stocktwits', 'reuters', 'watcherguru')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL UNIQUE,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE article_symbols (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
symbol VARCHAR(20) NOT NULL,
UNIQUE (article_id, symbol)
);
CREATE TABLE scrape_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES sources(id) ON DELETE CASCADE UNIQUE,
username TEXT NOT NULL DEFAULT '',
password_encrypted TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE scrape_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'done', 'error')),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
articles_found INT NOT NULL DEFAULT 0,
error_msg TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (name IN ('openai', 'anthropic', 'gemini', 'ollama')),
api_key_encrypted TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
endpoint TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE summaries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
ai_provider_id UUID REFERENCES ai_providers(id) ON DELETE SET NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
-- Index pour les performances
CREATE INDEX idx_articles_source_id ON articles(source_id);
CREATE INDEX idx_articles_published_at ON articles(published_at DESC);
CREATE INDEX idx_article_symbols_symbol ON article_symbols(symbol);
CREATE INDEX idx_summaries_user_id ON summaries(user_id);
CREATE INDEX idx_summaries_generated_at ON summaries(generated_at DESC);
CREATE INDEX idx_scrape_jobs_status ON scrape_jobs(status);
CREATE INDEX idx_user_assets_user_id ON user_assets(user_id);
-- Sources initiales
INSERT INTO sources (name, type, enabled) VALUES
('Bloomberg', 'bloomberg', TRUE),
('Yahoo Finance', 'stocktwits', TRUE);
-- Paramètres par défaut
INSERT INTO settings (key, value) VALUES
('scrape_interval_minutes', '60'),
('articles_lookback_hours', '24'),
('summary_max_articles', '50');

View File

@ -0,0 +1 @@
DELETE FROM settings WHERE key = 'ai_system_prompt';

View File

@ -0,0 +1,13 @@
INSERT INTO settings (key, value) VALUES (
'ai_system_prompt',
'Tu es un assistant spécialisé en trading financier. Analyse l''ensemble des actualités suivantes, toutes sources confondues, et crée un résumé global structuré en français, orienté trading.
Structure ton résumé ainsi :
1. **Vue macro** : tendances globales du marché (économie, géopolitique, secteurs)
2. **Actifs surveillés** : pour chaque actif de la watchlist mentionné dans les news :
- Sentiment (haussier/baissier/neutre)
- Faits clés et catalyseurs
- Risques et opportunités
3. **Autres mouvements notables** : actifs hors watchlist à surveiller
4. **Synthèse** : points d''attention prioritaires pour la journée'
) ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1 @@
DELETE FROM sources WHERE type IN ('reuters', 'watcherguru');

View File

@ -0,0 +1,4 @@
INSERT INTO sources (name, type, enabled) VALUES
('Reuters', 'reuters', true),
('Watcher.Guru', 'watcherguru', true)
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS scrape_schedules;

View File

@ -0,0 +1,17 @@
CREATE TABLE scrape_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
hour SMALLINT NOT NULL CHECK (hour BETWEEN 0 AND 23),
minute SMALLINT NOT NULL DEFAULT 0 CHECK (minute BETWEEN 0 AND 59),
UNIQUE (day_of_week, hour, minute)
);
-- Planning par défaut : lun-ven à 6h et 15h, week-end à 6h uniquement
INSERT INTO scrape_schedules (day_of_week, hour, minute) VALUES
(1, 6, 0), (1, 15, 0),
(2, 6, 0), (2, 15, 0),
(3, 6, 0), (3, 15, 0),
(4, 6, 0), (4, 15, 0),
(5, 6, 0), (5, 15, 0),
(6, 6, 0),
(0, 6, 0);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS reports;

View File

@ -0,0 +1,9 @@
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
summary_id UUID REFERENCES summaries(id) ON DELETE SET NULL,
context_excerpt TEXT NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@ -0,0 +1,4 @@
ALTER TABLE reports
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS error_msg,
ALTER COLUMN answer DROP DEFAULT;

View File

@ -0,0 +1,4 @@
ALTER TABLE reports
ALTER COLUMN answer SET DEFAULT '',
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'done',
ADD COLUMN error_msg TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1 @@
DELETE FROM settings WHERE key = 'timezone';

View File

@ -0,0 +1,2 @@
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Paris')
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1 @@
DELETE FROM settings WHERE key = 'filter_batch_size';

View File

@ -0,0 +1,2 @@
INSERT INTO settings (key, value) VALUES ('filter_batch_size', '20')
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,3 @@
ALTER TABLE ai_providers DROP CONSTRAINT IF EXISTS ai_providers_name_check;
ALTER TABLE ai_providers ADD CONSTRAINT ai_providers_name_check
CHECK (name IN ('openai', 'anthropic', 'gemini', 'ollama'));

View File

@ -0,0 +1,3 @@
ALTER TABLE ai_providers DROP CONSTRAINT IF EXISTS ai_providers_name_check;
ALTER TABLE ai_providers ADD CONSTRAINT ai_providers_name_check
CHECK (name IN ('openai', 'anthropic', 'gemini', 'ollama', 'claudecode'));

View File

@ -0,0 +1,48 @@
package httputil
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
)
// nilToEmpty converts nil slices to empty slices so JSON serializes as [] not null
func nilToEmpty(data interface{}) interface{} {
if data == nil {
return data
}
v := reflect.ValueOf(data)
if v.Kind() == reflect.Slice && v.IsNil() {
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
}
return data
}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{"data": nilToEmpty(data)})
}
func Created(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, gin.H{"data": nilToEmpty(data)})
}
func NoContent(c *gin.Context) {
c.Status(http.StatusNoContent)
}
func BadRequest(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
func Unauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
}
func NotFound(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
}
func InternalError(c *gin.Context, err error) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}

View File

@ -0,0 +1,118 @@
package models
import (
"database/sql"
"time"
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Role Role `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserAsset struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type Source struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
type Article struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
SourceName string `json:"source_name,omitempty"`
Title string `json:"title"`
Content string `json:"content"`
URL string `json:"url"`
PublishedAt sql.NullTime `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
Symbols []string `json:"symbols,omitempty"`
}
type ArticleSymbol struct {
ID string `json:"id"`
ArticleID string `json:"article_id"`
Symbol string `json:"symbol"`
}
type ScrapeCredential struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
Username string `json:"username"`
PasswordEncrypted string `json:"-"`
UpdatedAt time.Time `json:"updated_at"`
}
type ScrapeJob struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
SourceName string `json:"source_name,omitempty"`
Status string `json:"status"`
StartedAt sql.NullTime `json:"started_at"`
FinishedAt sql.NullTime `json:"finished_at"`
ArticlesFound int `json:"articles_found"`
ErrorMsg string `json:"error_msg"`
CreatedAt time.Time `json:"created_at"`
}
type AIProvider struct {
ID string `json:"id"`
Name string `json:"name"`
APIKeyEncrypted string `json:"-"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
type Summary struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Content string `json:"content"`
AIProviderID *string `json:"ai_provider_id"`
GeneratedAt time.Time `json:"generated_at"`
}
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
}
type ScheduleSlot struct {
ID string `json:"id"`
DayOfWeek int `json:"day_of_week"` // 0=dimanche, 1=lundi ... 6=samedi
Hour int `json:"hour"`
Minute int `json:"minute"`
}
type Report struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SummaryID *string `json:"summary_id"`
ContextExcerpt string `json:"context_excerpt"`
Question string `json:"question"`
Answer string `json:"answer"`
Status string `json:"status"` // generating | done | error
ErrorMsg string `json:"error_msg"`
CreatedAt time.Time `json:"created_at"`
}

View File

@ -0,0 +1,668 @@
package models
import (
"database/sql"
"fmt"
"strings"
"time"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
// ── Users ──────────────────────────────────────────────────────────────────
func (r *Repository) CreateUser(email, passwordHash string, role Role) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
INSERT INTO users (email, password_hash, role)
VALUES ($1, $2, $3)
RETURNING id, email, password_hash, role, created_at, updated_at`,
email, passwordHash, role,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
return u, err
}
func (r *Repository) GetUserByEmail(email string) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users WHERE email = $1`, email,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
func (r *Repository) GetUserByID(id string) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
func (r *Repository) ListUsers() ([]User, error) {
rows, err := r.db.Query(`
SELECT id, email, password_hash, role, created_at, updated_at
FROM users ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}
func (r *Repository) UpdateUser(id, email string, role Role) (*User, error) {
u := &User{}
err := r.db.QueryRow(`
UPDATE users SET email=$1, role=$2, updated_at=NOW()
WHERE id=$3
RETURNING id, email, password_hash, role, created_at, updated_at`,
email, role, id,
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, &u.UpdatedAt)
return u, err
}
func (r *Repository) DeleteUser(id string) error {
_, err := r.db.Exec(`DELETE FROM users WHERE id=$1`, id)
return err
}
func (r *Repository) CountAdmins() (int, error) {
var count int
err := r.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&count)
return count, err
}
// ── User Assets ────────────────────────────────────────────────────────────
func (r *Repository) GetUserAssets(userID string) ([]UserAsset, error) {
rows, err := r.db.Query(`
SELECT id, user_id, symbol, name, created_at
FROM user_assets WHERE user_id=$1 ORDER BY symbol`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var assets []UserAsset
for rows.Next() {
var a UserAsset
if err := rows.Scan(&a.ID, &a.UserID, &a.Symbol, &a.Name, &a.CreatedAt); err != nil {
return nil, err
}
assets = append(assets, a)
}
return assets, nil
}
func (r *Repository) AddUserAsset(userID, symbol, name string) (*UserAsset, error) {
a := &UserAsset{}
err := r.db.QueryRow(`
INSERT INTO user_assets (user_id, symbol, name)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, symbol) DO UPDATE SET name=EXCLUDED.name
RETURNING id, user_id, symbol, name, created_at`,
userID, strings.ToUpper(symbol), name,
).Scan(&a.ID, &a.UserID, &a.Symbol, &a.Name, &a.CreatedAt)
return a, err
}
func (r *Repository) RemoveUserAsset(userID, symbol string) error {
_, err := r.db.Exec(`
DELETE FROM user_assets WHERE user_id=$1 AND symbol=$2`,
userID, strings.ToUpper(symbol))
return err
}
func (r *Repository) GetAllWatchedSymbols() ([]string, error) {
rows, err := r.db.Query(`SELECT DISTINCT symbol FROM user_assets ORDER BY symbol`)
if err != nil {
return nil, err
}
defer rows.Close()
var symbols []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
symbols = append(symbols, s)
}
return symbols, nil
}
// ── Sources ────────────────────────────────────────────────────────────────
func (r *Repository) ListSources() ([]Source, error) {
rows, err := r.db.Query(`SELECT id, name, type, enabled, created_at FROM sources ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var sources []Source
for rows.Next() {
var s Source
if err := rows.Scan(&s.ID, &s.Name, &s.Type, &s.Enabled, &s.CreatedAt); err != nil {
return nil, err
}
sources = append(sources, s)
}
return sources, nil
}
func (r *Repository) GetSourceByType(sourceType string) (*Source, error) {
s := &Source{}
err := r.db.QueryRow(`
SELECT id, name, type, enabled, created_at FROM sources WHERE type=$1`, sourceType,
).Scan(&s.ID, &s.Name, &s.Type, &s.Enabled, &s.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return s, err
}
func (r *Repository) UpdateSource(id string, enabled bool) error {
_, err := r.db.Exec(`UPDATE sources SET enabled=$1 WHERE id=$2`, enabled, id)
return err
}
// ── Articles ───────────────────────────────────────────────────────────────
// InsertArticleIfNew insère l'article uniquement s'il n'existe pas déjà (par URL).
// Retourne (article, true, nil) si inséré, (nil, false, nil) si déjà présent.
func (r *Repository) InsertArticleIfNew(sourceID, title, content, url string, publishedAt *time.Time) (*Article, bool, error) {
var pa sql.NullTime
if publishedAt != nil {
pa = sql.NullTime{Time: *publishedAt, Valid: true}
}
a := &Article{}
err := r.db.QueryRow(`
INSERT INTO articles (source_id, title, content, url, published_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (url) DO NOTHING
RETURNING id, source_id, title, content, url, published_at, created_at`,
sourceID, title, content, url, pa,
).Scan(&a.ID, &a.SourceID, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt)
if err == sql.ErrNoRows {
return nil, false, nil // déjà présent
}
if err != nil {
return nil, false, err
}
return a, true, nil
}
func (r *Repository) AddArticleSymbol(articleID, symbol string) error {
_, err := r.db.Exec(`
INSERT INTO article_symbols (article_id, symbol)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
articleID, strings.ToUpper(symbol))
return err
}
func (r *Repository) ListArticles(symbol string, limit, offset int) ([]Article, error) {
query := `
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id`
args := []interface{}{}
if symbol != "" {
query += `
JOIN article_symbols asy ON asy.article_id = a.id AND asy.symbol = $1`
args = append(args, strings.ToUpper(symbol))
}
query += ` ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
func (r *Repository) GetArticleByID(id string) (*Article, error) {
a := &Article{}
err := r.db.QueryRow(`
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a JOIN sources s ON s.id=a.source_id
WHERE a.id=$1`, id,
).Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return a, err
}
func (r *Repository) GetRecentArticles(hours int) ([]Article, error) {
rows, err := r.db.Query(`
SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id
WHERE COALESCE(a.published_at, a.created_at) > NOW() - ($1 * INTERVAL '1 hour')
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`, hours)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
func (r *Repository) GetRecentArticlesForSymbols(symbols []string, hours int) ([]Article, error) {
if len(symbols) == 0 {
return nil, nil
}
placeholders := make([]string, len(symbols))
args := []interface{}{hours}
for i, s := range symbols {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, strings.ToUpper(s))
}
query := fmt.Sprintf(`
SELECT DISTINCT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at
FROM articles a
JOIN sources s ON s.id = a.source_id
JOIN article_symbols asy ON asy.article_id = a.id
WHERE asy.symbol IN (%s)
AND a.created_at > NOW() - ($1 * INTERVAL '1 hour')
ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`,
strings.Join(placeholders, ","))
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var articles []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.SourceID, &a.SourceName, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt); err != nil {
return nil, err
}
articles = append(articles, a)
}
return articles, nil
}
// ── Scrape Credentials ─────────────────────────────────────────────────────
func (r *Repository) GetCredentials(sourceID string) (*ScrapeCredential, error) {
c := &ScrapeCredential{}
err := r.db.QueryRow(`
SELECT id, source_id, username, password_encrypted, updated_at
FROM scrape_credentials WHERE source_id=$1`, sourceID,
).Scan(&c.ID, &c.SourceID, &c.Username, &c.PasswordEncrypted, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return c, err
}
func (r *Repository) UpsertCredentials(sourceID, username, passwordEncrypted string) error {
_, err := r.db.Exec(`
INSERT INTO scrape_credentials (source_id, username, password_encrypted)
VALUES ($1, $2, $3)
ON CONFLICT (source_id) DO UPDATE
SET username=EXCLUDED.username, password_encrypted=EXCLUDED.password_encrypted, updated_at=NOW()`,
sourceID, username, passwordEncrypted)
return err
}
// ── Scrape Jobs ────────────────────────────────────────────────────────────
func (r *Repository) CreateScrapeJob(sourceID string) (*ScrapeJob, error) {
j := &ScrapeJob{}
err := r.db.QueryRow(`
INSERT INTO scrape_jobs (source_id) VALUES ($1)
RETURNING id, source_id, status, started_at, finished_at, articles_found, error_msg, created_at`,
sourceID,
).Scan(&j.ID, &j.SourceID, &j.Status, &j.StartedAt, &j.FinishedAt, &j.ArticlesFound, &j.ErrorMsg, &j.CreatedAt)
return j, err
}
func (r *Repository) UpdateScrapeJob(id, status string, articlesFound int, errMsg string) error {
var finishedAt *time.Time
if status == "done" || status == "error" {
now := time.Now()
finishedAt = &now
}
_, err := r.db.Exec(`
UPDATE scrape_jobs
SET status=$1, articles_found=$2, error_msg=$3, finished_at=$4,
started_at=CASE WHEN status='pending' THEN NOW() ELSE started_at END
WHERE id=$5`,
status, articlesFound, errMsg, finishedAt, id)
return err
}
func (r *Repository) ListScrapeJobs(limit int) ([]ScrapeJob, error) {
rows, err := r.db.Query(`
SELECT j.id, j.source_id, s.name, j.status, j.started_at, j.finished_at,
j.articles_found, j.error_msg, j.created_at
FROM scrape_jobs j JOIN sources s ON s.id=j.source_id
ORDER BY j.created_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var jobs []ScrapeJob
for rows.Next() {
var j ScrapeJob
if err := rows.Scan(&j.ID, &j.SourceID, &j.SourceName, &j.Status, &j.StartedAt,
&j.FinishedAt, &j.ArticlesFound, &j.ErrorMsg, &j.CreatedAt); err != nil {
return nil, err
}
jobs = append(jobs, j)
}
return jobs, nil
}
// ── AI Providers ───────────────────────────────────────────────────────────
func (r *Repository) ListAIProviders() ([]AIProvider, error) {
rows, err := r.db.Query(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var providers []AIProvider
for rows.Next() {
var p AIProvider
if err := rows.Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt); err != nil {
return nil, err
}
providers = append(providers, p)
}
return providers, nil
}
func (r *Repository) GetAIProviderByID(id string) (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers WHERE id=$1`, id,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (r *Repository) GetActiveAIProvider() (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
SELECT id, name, api_key_encrypted, model, endpoint, is_active, created_at
FROM ai_providers WHERE is_active=TRUE LIMIT 1`,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (r *Repository) CreateAIProvider(name, apiKeyEncrypted, model, endpoint string) (*AIProvider, error) {
p := &AIProvider{}
err := r.db.QueryRow(`
INSERT INTO ai_providers (name, api_key_encrypted, model, endpoint)
VALUES ($1, $2, $3, $4)
RETURNING id, name, api_key_encrypted, model, endpoint, is_active, created_at`,
name, apiKeyEncrypted, model, endpoint,
).Scan(&p.ID, &p.Name, &p.APIKeyEncrypted, &p.Model, &p.Endpoint, &p.IsActive, &p.CreatedAt)
return p, err
}
func (r *Repository) UpdateAIProvider(id, apiKeyEncrypted, model, endpoint string) error {
_, err := r.db.Exec(`
UPDATE ai_providers SET api_key_encrypted=$1, model=$2, endpoint=$3 WHERE id=$4`,
apiKeyEncrypted, model, endpoint, id)
return err
}
func (r *Repository) SetActiveAIProvider(id string) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE ai_providers SET is_active=FALSE`); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE ai_providers SET is_active=TRUE WHERE id=$1`, id); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) DeleteAIProvider(id string) error {
_, err := r.db.Exec(`DELETE FROM ai_providers WHERE id=$1`, id)
return err
}
// ── Summaries ──────────────────────────────────────────────────────────────
func (r *Repository) CreateSummary(userID, content string, providerID *string) (*Summary, error) {
s := &Summary{}
err := r.db.QueryRow(`
INSERT INTO summaries (user_id, content, ai_provider_id)
VALUES ($1, $2, $3)
RETURNING id, user_id, content, ai_provider_id, generated_at`,
userID, content, providerID,
).Scan(&s.ID, &s.UserID, &s.Content, &s.AIProviderID, &s.GeneratedAt)
return s, err
}
func (r *Repository) ListSummaries(userID string, limit int) ([]Summary, error) {
rows, err := r.db.Query(`
SELECT id, user_id, content, ai_provider_id, generated_at
FROM summaries WHERE user_id=$1
ORDER BY generated_at DESC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []Summary
for rows.Next() {
var s Summary
if err := rows.Scan(&s.ID, &s.UserID, &s.Content, &s.AIProviderID, &s.GeneratedAt); err != nil {
return nil, err
}
summaries = append(summaries, s)
}
return summaries, nil
}
// ── Settings ───────────────────────────────────────────────────────────────
func (r *Repository) GetSetting(key string) (string, error) {
var value string
err := r.db.QueryRow(`SELECT value FROM settings WHERE key=$1`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
func (r *Repository) SetSetting(key, value string) error {
_, err := r.db.Exec(`
INSERT INTO settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value`,
key, value)
return err
}
// ── Schedule ───────────────────────────────────────────────────────────────
func (r *Repository) ListScheduleSlots() ([]ScheduleSlot, error) {
rows, err := r.db.Query(`
SELECT id, day_of_week, hour, minute FROM scrape_schedules
ORDER BY day_of_week, hour, minute`)
if err != nil {
return nil, err
}
defer rows.Close()
var slots []ScheduleSlot
for rows.Next() {
var s ScheduleSlot
if err := rows.Scan(&s.ID, &s.DayOfWeek, &s.Hour, &s.Minute); err != nil {
return nil, err
}
slots = append(slots, s)
}
return slots, nil
}
func (r *Repository) ReplaceSchedule(slots []ScheduleSlot) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM scrape_schedules`); err != nil {
return err
}
for _, s := range slots {
if _, err := tx.Exec(
`INSERT INTO scrape_schedules (day_of_week, hour, minute) VALUES ($1, $2, $3)
ON CONFLICT (day_of_week, hour, minute) DO NOTHING`,
s.DayOfWeek, s.Hour, s.Minute,
); err != nil {
return err
}
}
return tx.Commit()
}
// ── Settings ───────────────────────────────────────────────────────────────
func (r *Repository) ListSettings() ([]Setting, error) {
rows, err := r.db.Query(`SELECT key, value FROM settings ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
var settings []Setting
for rows.Next() {
var s Setting
if err := rows.Scan(&s.Key, &s.Value); err != nil {
return nil, err
}
settings = append(settings, s)
}
return settings, nil
}
// ── AI Role Providers ──────────────────────────────────────────────────────
// GetRoleProvider returns the configured provider and model for a given role (summary/report/filter).
// Falls back to the active provider if no role-specific provider is set.
func (r *Repository) GetRoleProvider(role string) (*AIProvider, string, error) {
providerID, _ := r.GetSetting("ai_role_" + role + "_provider")
model, _ := r.GetSetting("ai_role_" + role + "_model")
if providerID != "" {
p, err := r.GetAIProviderByID(providerID)
if err == nil && p != nil {
if model == "" {
model = p.Model
}
return p, model, nil
}
}
// Fallback to active provider
p, err := r.GetActiveAIProvider()
if p != nil && model == "" {
model = p.Model
}
return p, model, err
}
func (r *Repository) SetRoleProvider(role, providerID, model string) error {
if err := r.SetSetting("ai_role_"+role+"_provider", providerID); err != nil {
return err
}
return r.SetSetting("ai_role_"+role+"_model", model)
}
// ── Reports ────────────────────────────────────────────────────────────────
func (r *Repository) CreatePendingReport(userID string, summaryID *string, excerpt, question string) (*Report, error) {
rep := &Report{}
err := r.db.QueryRow(`
INSERT INTO reports (user_id, summary_id, context_excerpt, question, answer, status)
VALUES ($1, $2, $3, $4, '', 'generating')
RETURNING id, user_id, summary_id, context_excerpt, question, answer, status, error_msg, created_at`,
userID, summaryID, excerpt, question,
).Scan(&rep.ID, &rep.UserID, &rep.SummaryID, &rep.ContextExcerpt, &rep.Question, &rep.Answer, &rep.Status, &rep.ErrorMsg, &rep.CreatedAt)
return rep, err
}
func (r *Repository) UpdateReport(id, status, answer, errorMsg string) error {
_, err := r.db.Exec(`
UPDATE reports SET status=$1, answer=$2, error_msg=$3 WHERE id=$4`,
status, answer, errorMsg, id)
return err
}
func (r *Repository) ListReports(userID string) ([]Report, error) {
rows, err := r.db.Query(`
SELECT id, user_id, summary_id, context_excerpt, question, answer, status, error_msg, created_at
FROM reports WHERE user_id=$1
ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var reports []Report
for rows.Next() {
var rep Report
if err := rows.Scan(&rep.ID, &rep.UserID, &rep.SummaryID, &rep.ContextExcerpt, &rep.Question, &rep.Answer, &rep.Status, &rep.ErrorMsg, &rep.CreatedAt); err != nil {
return nil, err
}
reports = append(reports, rep)
}
return reports, nil
}
func (r *Repository) DeleteReport(id, userID string) error {
_, err := r.db.Exec(`DELETE FROM reports WHERE id=$1 AND user_id=$2`, id, userID)
return err
}

View File

@ -0,0 +1,90 @@
package scheduler
import (
"context"
"fmt"
"github.com/robfig/cron/v3"
"github.com/tradarr/backend/internal/ai"
"github.com/tradarr/backend/internal/models"
"github.com/tradarr/backend/internal/scraper"
)
type Scheduler struct {
cron *cron.Cron
registry *scraper.Registry
pipeline *ai.Pipeline
repo *models.Repository
entryIDs []cron.EntryID
}
func New(registry *scraper.Registry, pipeline *ai.Pipeline, repo *models.Repository) *Scheduler {
return &Scheduler{
cron: cron.New(),
registry: registry,
pipeline: pipeline,
repo: repo,
}
}
func (s *Scheduler) Start() error {
if err := s.loadSchedule(); err != nil {
return err
}
s.cron.Start()
return nil
}
func (s *Scheduler) Stop() {
s.cron.Stop()
}
func (s *Scheduler) Reload() error {
for _, id := range s.entryIDs {
s.cron.Remove(id)
}
s.entryIDs = nil
return s.loadSchedule()
}
func (s *Scheduler) loadSchedule() error {
slots, err := s.repo.ListScheduleSlots()
if err != nil {
return fmt.Errorf("load schedule: %w", err)
}
if len(slots) == 0 {
fmt.Println("scheduler: no schedule configured, scraping disabled")
return nil
}
tz, _ := s.repo.GetSetting("timezone")
if tz == "" {
tz = "UTC"
}
for _, slot := range slots {
// TZ= prefix permet à robfig/cron d'interpréter les heures dans le fuseau configuré
spec := fmt.Sprintf("TZ=%s %d %d * * %d", tz, slot.Minute, slot.Hour, slot.DayOfWeek)
id, err := s.cron.AddFunc(spec, s.run)
if err != nil {
fmt.Printf("scheduler: invalid cron spec %q: %v\n", spec, err)
continue
}
s.entryIDs = append(s.entryIDs, id)
}
fmt.Printf("scheduler: %d time slots loaded (timezone: %s)\n", len(s.entryIDs), tz)
return nil
}
func (s *Scheduler) run() {
fmt.Println("scheduler: starting scraping cycle")
if err := s.registry.RunAll(); err != nil {
fmt.Printf("scheduler scrape error: %v\n", err)
return
}
fmt.Println("scheduler: starting AI summaries")
if err := s.pipeline.GenerateForAll(context.Background()); err != nil {
fmt.Printf("scheduler summary error: %v\n", err)
}
}

View File

@ -0,0 +1,94 @@
package bloomberg
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/tradarr/backend/internal/scraper"
)
type Bloomberg struct {
scraperURL string
client *http.Client
}
func New(scraperURL string) *Bloomberg {
if scraperURL == "" {
scraperURL = "http://scraper:3001"
}
return &Bloomberg{
scraperURL: scraperURL,
client: &http.Client{Timeout: 10 * time.Minute},
}
}
func (b *Bloomberg) Name() string { return "bloomberg" }
type scraperRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type scraperArticle struct {
Title string `json:"title"`
URL string `json:"url"`
}
type scraperResponse struct {
Articles []scraperArticle `json:"articles"`
Error string `json:"error,omitempty"`
}
func (b *Bloomberg) ScrapeWithCredentials(ctx context.Context, username, password string, symbols []string) ([]scraper.Article, error) {
payload, _ := json.Marshal(scraperRequest{Username: username, Password: password})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.scraperURL+"/bloomberg/scrape", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("scraper service unreachable: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("scraper service HTTP %d: %s", resp.StatusCode, body)
}
var result scraperResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse scraper response: %w", err)
}
if result.Error != "" {
return nil, fmt.Errorf("bloomberg: %s", result.Error)
}
now := time.Now()
var articles []scraper.Article
for _, a := range result.Articles {
title := strings.TrimSpace(a.Title)
url := a.URL
if title == "" || url == "" {
continue
}
syms := scraper.DetectSymbols(title, symbols)
articles = append(articles, scraper.Article{
Title: title,
Content: title,
URL: url,
PublishedAt: &now,
Symbols: syms,
})
}
fmt.Printf("bloomberg: %d articles fetched\n", len(articles))
return articles, nil
}

View File

@ -0,0 +1,48 @@
package bloomberg
import (
"context"
"fmt"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/models"
"github.com/tradarr/backend/internal/scraper"
)
type DynamicBloomberg struct {
repo *models.Repository
enc *crypto.Encryptor
scraperURL string
}
func NewDynamic(repo *models.Repository, enc *crypto.Encryptor, scraperURL string) *DynamicBloomberg {
return &DynamicBloomberg{repo: repo, enc: enc, scraperURL: scraperURL}
}
func (d *DynamicBloomberg) Name() string { return "bloomberg" }
func (d *DynamicBloomberg) Scrape(ctx context.Context, symbols []string) ([]scraper.Article, error) {
source, err := d.repo.GetSourceByType("bloomberg")
if err != nil || source == nil {
return nil, fmt.Errorf("bloomberg source not found")
}
cred, err := d.repo.GetCredentials(source.ID)
if err != nil {
return nil, fmt.Errorf("get bloomberg credentials: %w", err)
}
if cred == nil || cred.Username == "" {
return nil, fmt.Errorf("bloomberg credentials not configured — configure them in the admin panel")
}
password := ""
if cred.PasswordEncrypted != "" {
password, err = d.enc.Decrypt(cred.PasswordEncrypted)
if err != nil {
return nil, fmt.Errorf("decrypt bloomberg password: %w", err)
}
}
b := New(d.scraperURL)
return b.ScrapeWithCredentials(ctx, cred.Username, password, symbols)
}

View File

@ -0,0 +1,106 @@
package scraper
import (
"context"
"fmt"
"time"
"github.com/tradarr/backend/internal/models"
)
type Registry struct {
scrapers map[string]Scraper
repo *models.Repository
}
func NewRegistry(repo *models.Repository) *Registry {
return &Registry{
scrapers: map[string]Scraper{},
repo: repo,
}
}
func (r *Registry) Register(s Scraper) {
r.scrapers[s.Name()] = s
}
// Run exécute le scraper associé à sourceID et persiste les articles
func (r *Registry) Run(sourceID string) error {
sources, err := r.repo.ListSources()
if err != nil {
return err
}
var source *models.Source
for i := range sources {
if sources[i].ID == sourceID {
source = &sources[i]
break
}
}
if source == nil {
return fmt.Errorf("source %s not found", sourceID)
}
scrpr, ok := r.scrapers[source.Type]
if !ok {
return fmt.Errorf("no scraper for type %s", source.Type)
}
// Créer le job
job, err := r.repo.CreateScrapeJob(sourceID)
if err != nil {
return err
}
if err := r.repo.UpdateScrapeJob(job.ID, "running", 0, ""); err != nil {
return err
}
// Récupérer les symboles surveillés
symbols, err := r.repo.GetAllWatchedSymbols()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
articles, scrapeErr := scrpr.Scrape(ctx, symbols)
if scrapeErr != nil {
_ = r.repo.UpdateScrapeJob(job.ID, "error", 0, scrapeErr.Error())
return scrapeErr
}
// Persister uniquement les nouveaux articles
count := 0
for _, a := range articles {
saved, isNew, err := r.repo.InsertArticleIfNew(sourceID, a.Title, a.Content, a.URL, a.PublishedAt)
if err != nil || !isNew {
continue
}
count++
for _, sym := range a.Symbols {
_ = r.repo.AddArticleSymbol(saved.ID, sym)
}
}
return r.repo.UpdateScrapeJob(job.ID, "done", count, "")
}
// RunAll exécute tous les scrapers activés
func (r *Registry) RunAll() error {
sources, err := r.repo.ListSources()
if err != nil {
return err
}
for _, src := range sources {
if !src.Enabled {
continue
}
if err := r.Run(src.ID); err != nil {
fmt.Printf("scraper %s error: %v\n", src.Name, err)
}
}
return nil
}

View File

@ -0,0 +1,129 @@
package reuters
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/tradarr/backend/internal/scraper"
)
// Reuters RSS est bloqué par Cloudflare. On utilise des flux RSS financiers
// publics fiables à la place : MarketWatch, CNBC, Seeking Alpha.
var feeds = []struct {
name string
url string
}{
{"MarketWatch Top Stories", "https://feeds.content.dowjones.io/public/rss/mw_topstories"},
{"MarketWatch Markets", "https://feeds.content.dowjones.io/public/rss/mw_marketpulse"},
{"CNBC Top News", "https://search.cnbc.com/rs/search/combinedcombined/rss/topNews"},
{"CNBC Finance", "https://search.cnbc.com/rs/search/combinedcombined/rss/topNews?tag=Finance"},
}
type Reuters struct {
client *http.Client
}
func New() *Reuters {
return &Reuters{client: &http.Client{Timeout: 15 * time.Second}}
}
func (r *Reuters) Name() string { return "reuters" }
type rssFeed struct {
Channel struct {
Items []struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
} `xml:"item"`
} `xml:"channel"`
}
func (r *Reuters) Scrape(ctx context.Context, _ []string) ([]scraper.Article, error) {
var articles []scraper.Article
seen := make(map[string]bool)
for i, feed := range feeds {
if i > 0 {
select {
case <-ctx.Done():
return articles, ctx.Err()
case <-time.After(300 * time.Millisecond):
}
}
items, err := r.fetchFeed(ctx, feed.url)
if err != nil {
fmt.Printf("reuters/financial %s: %v\n", feed.name, err)
continue
}
for _, a := range items {
if !seen[a.URL] {
seen[a.URL] = true
articles = append(articles, a)
}
}
fmt.Printf("reuters/financial %s: %d articles\n", feed.name, len(items))
}
return articles, nil
}
func (r *Reuters) fetchFeed(ctx context.Context, feedURL string) ([]scraper.Article, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Tradarr/1.0)")
req.Header.Set("Accept", "application/rss+xml, application/xml, text/xml")
resp, err := r.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
}
var feed rssFeed
if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil {
return nil, fmt.Errorf("parse RSS: %w", err)
}
var articles []scraper.Article
for _, item := range feed.Channel.Items {
title := strings.TrimSpace(item.Title)
link := strings.TrimSpace(item.Link)
if title == "" || link == "" {
continue
}
var publishedAt *time.Time
for _, f := range []string{time.RFC1123Z, time.RFC1123, "Mon, 02 Jan 2006 15:04:05 -0700"} {
if t, err := time.Parse(f, item.PubDate); err == nil {
publishedAt = &t
break
}
}
content := strings.TrimSpace(item.Description)
if content == "" {
content = title
}
articles = append(articles, scraper.Article{
Title: title,
Content: content,
URL: link,
PublishedAt: publishedAt,
})
}
return articles, nil
}

View File

@ -0,0 +1,75 @@
package scraper
import (
"context"
"time"
"github.com/tradarr/backend/internal/models"
)
type Article struct {
Title string
Content string
URL string
PublishedAt *time.Time
Symbols []string
}
type Scraper interface {
Name() string
Scrape(ctx context.Context, symbols []string) ([]Article, error)
}
// detectSymbols extrait les symboles mentionnés dans un texte
func DetectSymbols(text string, watchlist []string) []string {
found := map[string]bool{}
for _, s := range watchlist {
// Recherche du symbole en majuscules dans le texte
if containsWord(text, s) {
found[s] = true
}
}
result := make([]string, 0, len(found))
for s := range found {
result = append(result, s)
}
return result
}
func containsWord(text, word string) bool {
upper := []byte(text)
w := []byte(word)
for i := 0; i <= len(upper)-len(w); i++ {
match := true
for j := range w {
c := upper[i+j]
if c >= 'a' && c <= 'z' {
c -= 32
}
if c != w[j] {
match = false
break
}
}
if match {
// Vérifier que c'est un mot entier
before := i == 0 || !isAlphaNum(upper[i-1])
after := i+len(w) >= len(upper) || !isAlphaNum(upper[i+len(w)])
if before && after {
return true
}
}
}
return false
}
func isAlphaNum(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// ScraperResult est le résultat d'un job de scraping
type ScraperResult struct {
Source *models.Source
Articles []Article
Err error
}

View File

@ -0,0 +1,128 @@
package stocktwits
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/tradarr/backend/internal/scraper"
)
const apiBase = "https://api.stocktwits.com/api/2"
type StockTwits struct {
client *http.Client
}
func New() *StockTwits {
return &StockTwits{
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *StockTwits) Name() string { return "stocktwits" }
type apiResponse struct {
Response struct {
Status int `json:"status"`
Error string `json:"error,omitempty"`
} `json:"response"`
Messages []struct {
ID int `json:"id"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
User struct {
Username string `json:"username"`
} `json:"user"`
Entities struct {
Sentiment *struct {
Basic string `json:"basic"`
} `json:"sentiment"`
} `json:"entities"`
} `json:"messages"`
}
func (s *StockTwits) Scrape(ctx context.Context, symbols []string) ([]scraper.Article, error) {
var articles []scraper.Article
for i, symbol := range symbols {
// Délai entre les requêtes pour éviter le rate limiting
if i > 0 {
select {
case <-ctx.Done():
return articles, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
msgs, err := s.fetchSymbol(ctx, symbol)
if err != nil {
fmt.Printf("stocktwits %s: %v\n", symbol, err)
continue
}
articles = append(articles, msgs...)
}
return articles, nil
}
func (s *StockTwits) fetchSymbol(ctx context.Context, symbol string) ([]scraper.Article, error) {
url := fmt.Sprintf("%s/streams/symbol/%s.json", apiBase, symbol)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("rate limited by StockTwits for %s", symbol)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("StockTwits returned HTTP %d for %s: %s", resp.StatusCode, symbol, string(body))
}
var data apiResponse
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("parse response for %s: %w", symbol, err)
}
// L'API StockTwits retourne un status dans le body même en HTTP 200
if data.Response.Status != 0 && data.Response.Status != 200 {
return nil, fmt.Errorf("StockTwits API error %d for %s: %s", data.Response.Status, symbol, data.Response.Error)
}
var articles []scraper.Article
for _, msg := range data.Messages {
if msg.Body == "" {
continue
}
sentiment := ""
if msg.Entities.Sentiment != nil {
sentiment = " [" + msg.Entities.Sentiment.Basic + "]"
}
title := fmt.Sprintf("$%s — @%s%s", symbol, msg.User.Username, sentiment)
publishedAt, _ := time.Parse(time.RFC3339, msg.CreatedAt)
msgURL := fmt.Sprintf("https://stocktwits.com/%s/message/%d", msg.User.Username, msg.ID)
articles = append(articles, scraper.Article{
Title: title,
Content: msg.Body,
URL: msgURL,
PublishedAt: &publishedAt,
Symbols: []string{symbol},
})
}
fmt.Printf("stocktwits %s: %d messages fetched\n", symbol, len(articles))
return articles, nil
}

View File

@ -0,0 +1,200 @@
package watcherguru
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
"golang.org/x/net/html"
"github.com/tradarr/backend/internal/scraper"
)
const baseURL = "https://watcher.guru"
type WatcherGuru struct {
client *http.Client
}
func New() *WatcherGuru {
return &WatcherGuru{client: &http.Client{Timeout: 15 * time.Second}}
}
func (w *WatcherGuru) Name() string { return "watcherguru" }
type rssFeed struct {
Channel struct {
Items []struct {
Title string `xml:"title"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
Desc string `xml:"description"`
} `xml:"item"`
} `xml:"channel"`
}
func (w *WatcherGuru) Scrape(ctx context.Context, _ []string) ([]scraper.Article, error) {
// Try RSS feeds first
for _, feedURL := range []string{
baseURL + "/feed/",
baseURL + "/news/feed/",
} {
articles, err := w.fetchRSS(ctx, feedURL)
if err == nil && len(articles) > 0 {
fmt.Printf("watcherguru rss: %d articles\n", len(articles))
return articles, nil
}
}
// Fallback: HTML scraping
articles, err := w.scrapeHTML(ctx)
if err != nil {
return nil, fmt.Errorf("watcherguru: %w", err)
}
fmt.Printf("watcherguru html: %d articles\n", len(articles))
return articles, nil
}
func (w *WatcherGuru) fetchRSS(ctx context.Context, feedURL string) ([]scraper.Article, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Tradarr/1.0)")
resp, err := w.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var feed rssFeed
if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil {
return nil, fmt.Errorf("parse RSS: %w", err)
}
var articles []scraper.Article
for _, item := range feed.Channel.Items {
title := strings.TrimSpace(item.Title)
link := strings.TrimSpace(item.Link)
if title == "" || link == "" {
continue
}
var publishedAt *time.Time
for _, f := range []string{time.RFC1123Z, time.RFC1123, "Mon, 02 Jan 2006 15:04:05 -0700"} {
if t, err := time.Parse(f, item.PubDate); err == nil {
publishedAt = &t
break
}
}
content := strings.TrimSpace(item.Desc)
if content == "" {
content = title
}
articles = append(articles, scraper.Article{
Title: title,
Content: content,
URL: link,
PublishedAt: publishedAt,
})
}
return articles, nil
}
func (w *WatcherGuru) scrapeHTML(ctx context.Context) ([]scraper.Article, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/news/", nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := w.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
}
doc, err := html.Parse(resp.Body)
if err != nil {
return nil, fmt.Errorf("parse HTML: %w", err)
}
var articles []scraper.Article
seen := make(map[string]bool)
now := time.Now()
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && (n.Data == "a" || n.Data == "article") {
if n.Data == "a" {
href := attrVal(n, "href")
if href == "" || seen[href] {
walk(n.FirstChild)
return
}
// Collect links that look like news articles
if strings.Contains(href, "/news/") || strings.Contains(href, "watcher.guru") {
text := strings.TrimSpace(nodeText(n))
if len(text) > 20 {
url := href
if !strings.HasPrefix(url, "http") {
url = baseURL + url
}
if !seen[url] {
seen[url] = true
articles = append(articles, scraper.Article{
Title: text,
Content: text,
URL: url,
PublishedAt: &now,
})
}
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
if len(articles) > 40 {
articles = articles[:40]
}
return articles, nil
}
func attrVal(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
func nodeText(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
var sb strings.Builder
for c := n.FirstChild; c != nil; c = c.NextSibling {
sb.WriteString(nodeText(c))
}
return sb.String()
}

View File

@ -0,0 +1,131 @@
package yahoofinance
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/tradarr/backend/internal/scraper"
)
type YahooFinance struct {
client *http.Client
}
func New() *YahooFinance {
return &YahooFinance{
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (y *YahooFinance) Name() string { return "stocktwits" } // garde le même type en DB
type rssFeed struct {
Channel struct {
Items []struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
} `xml:"item"`
} `xml:"channel"`
}
func (y *YahooFinance) Scrape(ctx context.Context, symbols []string) ([]scraper.Article, error) {
var articles []scraper.Article
for i, symbol := range symbols {
if i > 0 {
select {
case <-ctx.Done():
return articles, ctx.Err()
case <-time.After(300 * time.Millisecond):
}
}
items, err := y.fetchSymbol(ctx, symbol)
if err != nil {
fmt.Printf("yahoofinance %s: %v\n", symbol, err)
continue
}
articles = append(articles, items...)
fmt.Printf("yahoofinance %s: %d articles fetched\n", symbol, len(items))
}
return articles, nil
}
func (y *YahooFinance) fetchSymbol(ctx context.Context, symbol string) ([]scraper.Article, error) {
url := fmt.Sprintf(
"https://feeds.finance.yahoo.com/rss/2.0/headline?s=%s&region=US&lang=en-US",
symbol,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Tradarr/1.0)")
req.Header.Set("Accept", "application/rss+xml, application/xml, text/xml")
resp, err := y.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var feed rssFeed
if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil {
return nil, fmt.Errorf("parse RSS: %w", err)
}
const maxPerSymbol = 5
var articles []scraper.Article
for _, item := range feed.Channel.Items {
if len(articles) >= maxPerSymbol {
break
}
title := strings.TrimSpace(item.Title)
link := strings.TrimSpace(item.Link)
if title == "" || link == "" {
continue
}
var publishedAt *time.Time
if item.PubDate != "" {
formats := []string{
time.RFC1123Z,
time.RFC1123,
"Mon, 02 Jan 2006 15:04:05 -0700",
}
for _, f := range formats {
if t, err := time.Parse(f, item.PubDate); err == nil {
publishedAt = &t
break
}
}
}
content := strings.TrimSpace(item.Description)
if content == "" {
content = title
}
articles = append(articles, scraper.Article{
Title: title,
Content: content,
URL: link,
PublishedAt: publishedAt,
Symbols: []string{symbol},
})
}
return articles, nil
}

60
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,60 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-tradarr}
POSTGRES_USER: ${POSTGRES_USER:-tradarr}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tradarr}"]
interval: 10s
timeout: 5s
retries: 5
scraper:
image: gitea.anthonybouteiller.ovh/blomios/tradarr-scraper:v1.0.0
restart: unless-stopped
expose:
- "3001"
backend:
image: gitea.anthonybouteiller.ovh/blomios/tradarr-backend:v1.0.0
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
scraper:
condition: service_started
environment:
DATABASE_URL: "host=postgres port=5432 user=${POSTGRES_USER:-tradarr} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB:-tradarr} sslmode=disable"
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY must be 32 bytes hex}
PORT: "8080"
SCRAPER_URL: "http://scraper:3001"
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@tradarr.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
volumes:
- /home/anthony/.claude:/root/.claude
expose:
- "8080"
ollama:
image: ollama/ollama
restart: unless-stopped
volumes:
- ollama_data:/root/.ollama
frontend:
image: gitea.anthonybouteiller.ovh/blomios/tradarr-frontend:v1.0.0
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-80}:80"
volumes:
pgdata:
ollama_data:

66
docker-compose.yml Normal file
View File

@ -0,0 +1,66 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-tradarr}
POSTGRES_USER: ${POSTGRES_USER:-tradarr}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tradarr}"]
interval: 10s
timeout: 5s
retries: 5
scraper:
build:
context: ./scraper-service
dockerfile: Dockerfile
restart: unless-stopped
expose:
- "3001"
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
scraper:
condition: service_started
environment:
DATABASE_URL: "host=postgres port=5432 user=${POSTGRES_USER:-tradarr} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB:-tradarr} sslmode=disable"
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY must be 32 bytes hex}
PORT: "8080"
SCRAPER_URL: "http://scraper:3001"
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@tradarr.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
volumes:
- /home/anthony/.claude:/root/.claude
expose:
- "8080"
ollama:
image: ollama/ollama
restart: unless-stopped
volumes:
- ollama_data:/root/.ollama
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-80}:80"
volumes:
pgdata:
ollama_data:

24
frontend/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM node:22 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends librsvg2-bin && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
# Generate PNG icons from SVG source
RUN mkdir -p public/icons && \
rsvg-convert -w 192 -h 192 public/icon-source.svg -o public/icons/icon-192.png && \
rsvg-convert -w 512 -h 512 public/icon-source.svg -o public/icons/icon-512.png
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;"]

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="fr">
<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" />
<meta name="theme-color" content="#0f172a" />
<meta name="description" content="Agrégateur de news financières avec résumés IA" />
<title>Tradarr</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
frontend/nginx.conf Normal file
View File

@ -0,0 +1,43 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Résolveur DNS Docker — résolution à la requête, pas au démarrage
resolver 127.0.0.11 valid=10s ipv6=off;
# Proxy API vers backend
location /api/ {
set $backend http://backend:8080;
proxy_pass $backend;
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_read_timeout 3600s;
proxy_connect_timeout 10s;
proxy_send_timeout 3600s;
}
# Service Worker — ne pas mettre en cache
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# Assets statiques avec cache long
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# SPA — renvoyer index.html pour toutes les routes React
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

9997
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
frontend/package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "tradarr",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.5",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"postcss": "^8.5.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vite-plugin-pwa": "^0.21.1"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
<polyline points="16 7 22 7 22 13"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Background -->
<rect width="512" height="512" rx="96" fill="#0f172a"/>
<!-- Chart line scaled to center safe zone (~360px) -->
<g transform="translate(76, 76) scale(15)">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"
fill="none" stroke="#3b82f6" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="16 7 22 7 22 13"
fill="none" stroke="#3b82f6" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 570 B

74
frontend/src/api/admin.ts Normal file
View File

@ -0,0 +1,74 @@
import { api } from './client'
export interface AIProvider {
id: string; name: string; model: string; endpoint: string
is_active: boolean; has_key: boolean
}
export interface AIRoleConfig { provider_id: string; model: string }
export interface AIRoles { summary: AIRoleConfig; report: AIRoleConfig; filter: AIRoleConfig }
export interface OllamaModelInfo {
name: string; size: number; modified_at: string
details: { parameter_size: string; quantization_level: string; family: string }
}
export interface Source { id: string; name: string; type: string; enabled: boolean }
export interface ScrapeJob {
id: string; source_id: string; source_name: string; status: string
started_at: { Time: string; Valid: boolean } | null
finished_at: { Time: string; Valid: boolean } | null
articles_found: number; error_msg: string; created_at: string
}
export interface Setting { key: string; value: string }
export interface ScheduleSlot { id?: string; day_of_week: number; hour: number; minute: number }
export interface AdminUser { id: string; email: string; role: string; created_at: string }
export interface Credential { source_id: string; source_name: string; username: string; has_password: boolean }
export const adminApi = {
// Credentials
getCredentials: () => api.get<Credential[]>('/admin/credentials'),
updateCredentials: (data: { source_id: string; username: string; password?: string }) =>
api.put<void>('/admin/credentials', data),
// AI Providers
listProviders: () => api.get<AIProvider[]>('/admin/ai-providers'),
createProvider: (data: { name: string; api_key?: string; model?: string; endpoint?: string }) =>
api.post<AIProvider>('/admin/ai-providers', data),
updateProvider: (id: string, data: { name: string; api_key?: string; model?: string; endpoint?: string }) =>
api.put<void>(`/admin/ai-providers/${id}`, data),
activateProvider: (id: string) => api.post<void>(`/admin/ai-providers/${id}/activate`),
deleteProvider: (id: string) => api.delete<void>(`/admin/ai-providers/${id}`),
listModels: (id: string) => api.get<string[]>(`/admin/ai-providers/${id}/models`),
probeModels: (data: { name: string; api_key?: string; endpoint?: string }) =>
api.post<string[]>('/admin/ai-providers/probe-models', data),
// AI Roles
getRoles: () => api.get<AIRoles>('/admin/ai-roles'),
updateRole: (role: string, data: AIRoleConfig) => api.put<void>(`/admin/ai-roles/${role}`, data),
// Ollama model management
listOllamaModels: () => api.get<OllamaModelInfo[]>('/admin/ollama/models'),
pullOllamaModel: (name: string) => api.post<void>('/admin/ollama/pull', { name }),
deleteOllamaModel: (name: string) => api.delete<void>(`/admin/ollama/models/${encodeURIComponent(name)}`),
// Sources
listSources: () => api.get<Source[]>('/admin/sources'),
updateSource: (id: string, enabled: boolean) => api.put<void>(`/admin/sources/${id}`, { enabled }),
// Jobs
listJobs: () => api.get<ScrapeJob[]>('/admin/scrape-jobs'),
triggerJob: (source_id: string) => api.post<void>('/admin/scrape-jobs/trigger', { source_id }),
// Settings
listSettings: () => api.get<Setting[]>('/admin/settings'),
updateSettings: (settings: Setting[]) => api.put<void>('/admin/settings', { settings }),
getDefaultPrompt: () => api.get<{ prompt: string }>('/admin/settings/default-prompt'),
// Schedule
getSchedule: () => api.get<ScheduleSlot[]>('/admin/schedule'),
updateSchedule: (slots: ScheduleSlot[]) => api.put<void>('/admin/schedule', { slots }),
// Users
listUsers: () => api.get<AdminUser[]>('/admin/users'),
updateUser: (id: string, email: string, role: string) =>
api.put<AdminUser>(`/admin/users/${id}`, { email, role }),
deleteUser: (id: string) => api.delete<void>(`/admin/users/${id}`),
}

View File

@ -0,0 +1,25 @@
import { api } from './client'
export interface Article {
id: string
source_id: string
source_name: string
title: string
content: string
url: string
published_at: { Time: string; Valid: boolean } | null
created_at: string
symbols?: string[]
}
export const articlesApi = {
list: (params?: { symbol?: string; limit?: number; offset?: number }) => {
const q = new URLSearchParams()
if (params?.symbol) q.set('symbol', params.symbol)
if (params?.limit) q.set('limit', String(params.limit))
if (params?.offset) q.set('offset', String(params.offset))
const qs = q.toString()
return api.get<Article[]>(`/articles${qs ? `?${qs}` : ''}`)
},
get: (id: string) => api.get<Article>(`/articles/${id}`),
}

View File

@ -0,0 +1,9 @@
import { api } from './client'
export interface Asset { id: string; user_id: string; symbol: string; name: string; created_at: string }
export const assetsApi = {
list: () => api.get<Asset[]>('/me/assets'),
add: (symbol: string, name: string) => api.post<Asset>('/me/assets', { symbol, name }),
remove: (symbol: string) => api.delete<void>(`/me/assets/${symbol}`),
}

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

@ -0,0 +1,12 @@
import { api } from './client'
export interface User { id: string; email: string; role: 'admin' | 'user' }
interface AuthResponse { token: string; user: User }
export const authApi = {
login: (email: string, password: string) =>
api.post<AuthResponse>('/auth/login', { email, password }),
register: (email: string, password: string) =>
api.post<AuthResponse>('/auth/register', { email, password }),
me: () => api.get<User>('/me'),
}

View File

@ -0,0 +1,39 @@
const BASE = '/api'
function getToken() {
return localStorage.getItem('token')
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
})
if (res.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
throw new Error('Session expirée')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
if (res.status === 204) return undefined as T
const json = await res.json()
return json.data !== undefined ? json.data : json
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}

View File

@ -0,0 +1,20 @@
import { api } from './client'
export interface Report {
id: string
user_id: string
summary_id: string | null
context_excerpt: string
question: string
answer: string
status: 'generating' | 'done' | 'error'
error_msg: string
created_at: string
}
export const reportsApi = {
list: () => api.get<Report[]>('/reports'),
create: (data: { summary_id?: string; excerpts: string[]; question: string }) =>
api.post<Report>('/reports', data),
delete: (id: string) => api.delete<void>(`/reports/${id}`),
}

View File

@ -0,0 +1,15 @@
import { api } from './client'
export interface Summary {
id: string
user_id: string
content: string
ai_provider_id: string | null
generated_at: string
}
export const summariesApi = {
list: (limit = 10) => api.get<Summary[]>(`/summaries?limit=${limit}`),
generate: () => api.post<Summary>('/summaries/generate'),
status: () => api.get<{ generating: boolean }>('/summaries/status'),
}

View File

@ -0,0 +1,26 @@
import { Outlet, Navigate } from 'react-router-dom'
import { useAuth } from '@/lib/auth'
import { Sidebar } from './Sidebar'
import { MobileNav } from './MobileNav'
export function AppLayout() {
const { token } = useAuth()
if (!token) return <Navigate to="/login" replace />
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar desktop */}
<div className="hidden md:flex">
<Sidebar />
</div>
{/* Main content */}
<main className="flex-1 overflow-y-auto pb-16 md:pb-0">
<Outlet />
</main>
{/* Bottom nav mobile */}
<MobileNav />
</div>
)
}

View File

@ -0,0 +1,34 @@
import { NavLink } from 'react-router-dom'
import { LayoutDashboard, Newspaper, Star, Settings, FileText } from 'lucide-react'
import { cn } from '@/lib/cn'
const items = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/feed', icon: Newspaper, label: 'Actus' },
{ to: '/watchlist', icon: Star, label: 'Watchlist' },
{ to: '/reports', icon: FileText, label: 'Rapports' },
{ to: '/admin', icon: Settings, label: 'Admin' },
]
export function MobileNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 flex border-t bg-card md:hidden">
{items.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex flex-1 flex-col items-center gap-1 py-3 text-xs transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground'
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
)
}

View File

@ -0,0 +1,87 @@
import { NavLink } from 'react-router-dom'
import { LayoutDashboard, Newspaper, Star, Settings, Key, Cpu, Database, ClipboardList, Users, LogOut, TrendingUp, CalendarDays, FileText } from 'lucide-react'
import { useAuth } from '@/lib/auth'
import { cn } from '@/lib/cn'
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/feed', icon: Newspaper, label: 'Actualités' },
{ to: '/watchlist', icon: Star, label: 'Watchlist' },
{ to: '/reports', icon: FileText, label: 'Rapports' },
]
const adminItems = [
{ to: '/admin/ai', icon: Cpu, label: 'Fournisseurs IA' },
{ to: '/admin/credentials', icon: Key, label: 'Identifiants' },
{ to: '/admin/sources', icon: Database, label: 'Sources' },
{ to: '/admin/jobs', icon: ClipboardList, label: 'Jobs' },
{ to: '/admin/users', icon: Users, label: 'Utilisateurs' },
{ to: '/admin/schedule', icon: CalendarDays, label: 'Planning' },
{ to: '/admin/settings', icon: Settings, label: 'Paramètres' },
]
function NavItem({ to, icon: Icon, label }: { to: string; icon: React.ElementType; label: string }) {
return (
<NavLink
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
)
}
export function Sidebar() {
const { user, logout, isAdmin } = useAuth()
return (
<aside className="flex h-screen w-60 flex-col border-r bg-card px-3 py-4">
{/* Logo */}
<div className="mb-6 flex items-center gap-2 px-3">
<TrendingUp className="h-6 w-6 text-primary" />
<span className="text-lg font-bold">Tradarr</span>
</div>
{/* Navigation principale */}
<nav className="flex flex-col gap-1">
{navItems.map(item => <NavItem key={item.to} {...item} />)}
</nav>
{/* Section admin */}
{isAdmin && (
<>
<div className="mt-6 mb-2 px-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Administration</p>
</div>
<nav className="flex flex-col gap-1">
{adminItems.map(item => <NavItem key={item.to} {...item} />)}
</nav>
</>
)}
{/* User + logout */}
<div className="mt-auto border-t pt-4">
<div className="px-3 mb-2">
<p className="text-sm font-medium truncate">{user?.email}</p>
<p className="text-xs text-muted-foreground capitalize">{user?.role}</p>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<LogOut className="h-4 w-4" />
Déconnexion
</button>
</div>
</aside>
)
}

View File

@ -0,0 +1,26 @@
import { type HTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/cn'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
bullish: 'border-transparent bg-bullish/20 text-bullish',
bearish: 'border-transparent bg-bearish/20 text-bearish',
},
},
defaultVariants: { variant: 'default' },
}
)
export interface BadgeProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

View File

@ -0,0 +1,35 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
)
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
)
Button.displayName = 'Button'

View File

@ -0,0 +1,37 @@
import { forwardRef, type HTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} />
)
)
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'

View File

@ -0,0 +1,17 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
))
Input.displayName = 'Input'

View File

@ -0,0 +1,13 @@
import { forwardRef, type LabelHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
export const Label = forwardRef<HTMLLabelElement, LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
)
)
Label.displayName = 'Label'

View File

@ -0,0 +1,28 @@
import React from 'react'
function renderInline(text: string): React.ReactNode {
const parts = text.split(/(\*\*[^*]+\*\*)/g)
return parts.map((part, i) =>
part.startsWith('**') && part.endsWith('**')
? <strong key={i}>{part.slice(2, -2)}</strong>
: part
)
}
export function Markdown({ content, className }: { content: string; className?: string }) {
const lines = content.split('\n')
return (
<div className={`space-y-1 text-sm leading-relaxed select-text ${className ?? ''}`}>
{lines.map((line, i) => {
if (line.startsWith('##### ')) return <h5 key={i} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mt-3">{renderInline(line.slice(6))}</h5>
if (line.startsWith('#### ')) return <h4 key={i} className="text-sm font-semibold mt-3">{renderInline(line.slice(5))}</h4>
if (line.startsWith('### ')) return <h3 key={i} className="text-sm font-semibold text-primary mt-4">{renderInline(line.slice(4))}</h3>
if (line.startsWith('## ')) return <h2 key={i} className="text-base font-bold mt-5 first:mt-0 border-b pb-1">{renderInline(line.slice(3))}</h2>
if (line.startsWith('# ')) return <h1 key={i} className="text-lg font-bold mt-5 first:mt-0">{renderInline(line.slice(2))}</h1>
if (line.startsWith('- ') || line.startsWith('* ')) return <li key={i} className="ml-4 text-muted-foreground list-disc">{renderInline(line.slice(2))}</li>
if (line.trim() === '') return <div key={i} className="h-2" />
return <p key={i} className="text-muted-foreground">{renderInline(line)}</p>
})}
</div>
)
}

View File

@ -0,0 +1,22 @@
import { forwardRef, type SelectHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
import { ChevronDown } from 'lucide-react'
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({ className, children, ...props }, ref) => (
<div className="relative">
<select
ref={ref}
className={cn(
'flex h-9 w-full appearance-none rounded-md border border-input bg-transparent px-3 py-1 pr-8 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
))
Select.displayName = 'Select'

View File

@ -0,0 +1,7 @@
import { cn } from '@/lib/cn'
export function Spinner({ className }: { className?: string }) {
return (
<div className={cn('h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent', className)} />
)
}

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

@ -0,0 +1,44 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222 47% 6%;
--foreground: 210 40% 95%;
--card: 222 47% 9%;
--card-foreground: 210 40% 95%;
--border: 217 33% 18%;
--input: 217 33% 18%;
--ring: 221 83% 53%;
--primary: 221 83% 53%;
--primary-foreground: 210 40% 98%;
--secondary: 217 33% 18%;
--secondary-foreground: 210 40% 95%;
--muted: 217 33% 14%;
--muted-foreground: 215 20% 55%;
--accent: 217 33% 18%;
--accent-foreground: 210 40% 95%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--radius: 0.5rem;
}
}
@layer base {
* { @apply border-border; }
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Scrollbar style */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { @apply bg-muted; }
::-webkit-scrollbar-thumb { @apply bg-border rounded-full; }
@layer utilities {
.scrollbar-none { scrollbar-width: none; }
.scrollbar-none::-webkit-scrollbar { display: none; }
}

51
frontend/src/lib/auth.tsx Normal file
View File

@ -0,0 +1,51 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
interface User {
id: string
email: string
role: 'admin' | 'user'
}
interface AuthCtx {
user: User | null
token: string | null
login: (token: string, user: User) => void
logout: () => void
isAdmin: boolean
}
const AuthContext = createContext<AuthCtx | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'))
const [user, setUser] = useState<User | null>(() => {
const u = localStorage.getItem('user')
return u ? JSON.parse(u) : null
})
function login(t: string, u: User) {
localStorage.setItem('token', t)
localStorage.setItem('user', JSON.stringify(u))
setToken(t)
setUser(u)
}
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('user')
setToken(null)
setUser(null)
}
return (
<AuthContext.Provider value={{ user, token, login, logout, isAdmin: user?.role === 'admin' }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

6
frontend/src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,42 @@
import { createBrowserRouter } from 'react-router-dom'
import { AppLayout } from '@/components/layout/AppLayout'
import { Login } from '@/pages/Login'
import { Dashboard } from '@/pages/Dashboard'
import { Feed } from '@/pages/Feed'
import { Watchlist } from '@/pages/Watchlist'
import { AdminLayout } from '@/pages/admin/AdminLayout'
import { AIProviders } from '@/pages/admin/AIProviders'
import { Credentials } from '@/pages/admin/Credentials'
import { Sources } from '@/pages/admin/Sources'
import { Jobs } from '@/pages/admin/Jobs'
import { AdminUsers } from '@/pages/admin/AdminUsers'
import { AdminSettings } from '@/pages/admin/AdminSettings'
import { Schedule } from '@/pages/admin/Schedule'
import { Reports } from '@/pages/Reports'
export const router = createBrowserRouter([
{ path: '/login', element: <Login /> },
{
element: <AppLayout />,
children: [
{ path: '/', element: <Dashboard /> },
{ path: '/feed', element: <Feed /> },
{ path: '/watchlist', element: <Watchlist /> },
{ path: '/reports', element: <Reports /> },
{
path: '/admin',
element: <AdminLayout />,
children: [
{ index: true, element: <AIProviders /> },
{ path: 'ai', element: <AIProviders /> },
{ path: 'credentials', element: <Credentials /> },
{ path: 'sources', element: <Sources /> },
{ path: 'jobs', element: <Jobs /> },
{ path: 'users', element: <AdminUsers /> },
{ path: 'settings', element: <AdminSettings /> },
{ path: 'schedule', element: <Schedule /> },
],
},
],
},
])

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

@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { AuthProvider } from '@/lib/auth'
import { router } from '@/lib/router'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</StrictMode>
)

View File

@ -0,0 +1,349 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { TrendingUp, Clock, Sparkles, MessageSquarePlus, Loader2, Plus, X, Send, ChevronDown, ChevronUp } from 'lucide-react'
import { summariesApi, type Summary } from '@/api/summaries'
import { reportsApi } from '@/api/reports'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Markdown } from '@/components/ui/markdown'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { useAuth } from '@/lib/auth'
// ── Text-selection floating button ─────────────────────────────────────────
function useTextSelection(containerRef: React.RefObject<HTMLElement>) {
const [selection, setSelection] = useState<{ text: string; x: number; y: number } | null>(null)
useEffect(() => {
function readSelection() {
const sel = window.getSelection()
const text = sel?.toString().trim()
if (!text || !containerRef.current) { setSelection(null); return }
const range = sel!.getRangeAt(0)
const rect = range.getBoundingClientRect()
const containerRect = containerRef.current.getBoundingClientRect()
setSelection({
text,
x: rect.left - containerRect.left + rect.width / 2,
y: rect.top - containerRect.top - 8,
})
}
function onMouseUp() { readSelection() }
// On mobile, selectionchange fires after the user lifts their finger
function onSelectionChange() {
const sel = window.getSelection()
if (!sel?.toString().trim()) setSelection(null)
else readSelection()
}
function onPointerDown(e: PointerEvent) {
if (!(e.target as Element).closest('[data-context-action]')) setSelection(null)
}
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('pointerdown', onPointerDown)
}
}, [containerRef])
return selection
}
// ── Context panel (extraits + question) ────────────────────────────────────
function ContextPanel({
excerpts,
onRemove,
onClear,
onSubmit,
}: {
excerpts: string[]
onRemove: (i: number) => void
onClear: () => void
onSubmit: (question: string) => Promise<void>
}) {
const [question, setQuestion] = useState('')
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [showExcerpts, setShowExcerpts] = useState(false)
async function submit() {
if (!question.trim() || submitting) return
setSubmitting(true)
try {
await onSubmit(question)
setSubmitted(true)
setQuestion('')
setTimeout(() => setSubmitted(false), 2000)
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed bottom-16 left-0 right-0 z-40 flex flex-col bg-card border-t shadow-2xl overflow-hidden md:bottom-4 md:left-auto md:right-4 md:w-96 md:rounded-xl md:border">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-primary/5">
<button
className="text-sm font-semibold flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => setShowExcerpts(v => !v)}
>
<MessageSquarePlus className="h-4 w-4 text-primary" />
Contexte ({excerpts.length} extrait{excerpts.length > 1 ? 's' : ''})
<span className="md:hidden">{showExcerpts ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}</span>
</button>
<button onClick={onClear} className="text-xs text-muted-foreground hover:text-destructive transition-colors">
Tout effacer
</button>
</div>
{/* Extraits — toujours visible sur desktop, togglable sur mobile */}
<div className={`flex-1 overflow-y-auto px-4 py-3 space-y-2 min-h-0 max-h-[25vh] ${showExcerpts ? 'block' : 'hidden'} md:block`}>
{excerpts.map((e, i) => (
<div key={i} className="flex items-start gap-2 group">
<div className="flex-1 rounded bg-muted/60 px-2 py-1.5 text-xs text-muted-foreground italic line-clamp-3">
«&nbsp;{e}&nbsp;»
</div>
<button
onClick={() => onRemove(i)}
className="mt-1 shrink-0 text-muted-foreground hover:text-destructive md:opacity-0 md:group-hover:opacity-100 transition-opacity"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
{/* Question */}
<div className="border-t px-4 py-3 space-y-2">
<textarea
className="w-full rounded border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring resize-none"
rows={2}
placeholder="Votre question…"
value={question}
onChange={e => setQuestion(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submit() }}
/>
<Button size="sm" className="w-full" onClick={submit} disabled={!question.trim() || submitting || submitted}>
{submitting
? <><Loader2 className="h-3 w-3 animate-spin" /> Envoi</>
: submitted
? 'Envoyé !'
: <><Send className="h-3 w-3" /> Envoyer (Ctrl+Entrée)</>}
</Button>
</div>
</div>
)
}
// ── Summary content renderer ────────────────────────────────────────────────
function SummaryContent({ content }: { content: string }) {
return <Markdown content={content} />
}
// ── Dashboard ───────────────────────────────────────────────────────────────
export function Dashboard() {
const { user } = useAuth()
const [summaries, setSummaries] = useState<Summary[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [current, setCurrent] = useState<Summary | null>(null)
const [excerpts, setExcerpts] = useState<string[]>([])
const summaryRef = useRef<HTMLDivElement>(null)
const textSel = useTextSelection(summaryRef as React.RefObject<HTMLElement>)
useEffect(() => { load() }, [])
// Poll generating status every 5s
useEffect(() => {
const interval = setInterval(async () => {
try {
const status = await summariesApi.status()
const wasGenerating = generating
setGenerating(status.generating)
if (wasGenerating && !status.generating) load()
} catch { /* ignore */ }
}, 5000)
return () => clearInterval(interval)
}, [generating])
async function load() {
setLoading(true)
try {
const [s, a] = await Promise.all([summariesApi.list(5), assetsApi.list()])
setSummaries(s ?? [])
setAssets(a ?? [])
setCurrent(s?.[0] ?? null)
} finally { setLoading(false) }
}
async function generate() {
setGenerating(true)
try {
const s = await summariesApi.generate()
setSummaries(prev => [s, ...prev])
setCurrent(s)
} catch (e) {
alert(e instanceof Error ? e.message : 'Erreur lors de la génération')
} finally { setGenerating(false) }
}
const addExcerpt = useCallback(() => {
if (!textSel) return
setExcerpts(prev => [...prev, textSel.text])
window.getSelection()?.removeAllRanges()
}, [textSel])
async function submitQuestion(question: string) {
await reportsApi.create({
summary_id: current?.id,
excerpts,
question,
})
setExcerpts([])
}
return (
<div className="p-4 md:p-6 space-y-6">
{/* Generating banner */}
{generating && (
<div className="flex items-center gap-3 rounded-md bg-primary/10 border border-primary/20 px-4 py-3 text-sm text-primary">
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
Génération d'un résumé IA en cours — cela peut prendre plusieurs minutes…
</div>
)}
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Bonjour 👋</h1>
<p className="text-muted-foreground text-sm">{user?.email}</p>
</div>
<Button onClick={generate} disabled={generating || assets.length === 0}>
{generating ? <><Spinner className="h-4 w-4" /> Génération…</> : <><Sparkles className="h-4 w-4" /> Générer un résumé</>}
</Button>
</div>
{assets.length === 0 && !loading && (
<Card className="border-dashed">
<CardContent className="py-8 text-center">
<TrendingUp className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
<p className="font-medium">Votre watchlist est vide</p>
<p className="text-sm text-muted-foreground mt-1">Ajoutez des symboles dans la section Watchlist pour obtenir des résumés personnalisés</p>
</CardContent>
</Card>
)}
{assets.length > 0 && (
<div className="flex flex-wrap gap-2">
{assets.map(a => (
<Badge key={a.id} variant="secondary" className="font-mono">{a.symbol}</Badge>
))}
</div>
)}
{/* Résumé actuel */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-4">
{loading ? (
<div className="flex justify-center py-20"><Spinner /></div>
) : current ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> Résumé IA
</CardTitle>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(current.generated_at).toLocaleString('fr-FR')}
</div>
</div>
</CardHeader>
<CardContent>
<div ref={summaryRef} className="relative">
<SummaryContent content={current.content} />
{/* Desktop: floating button above selection */}
{textSel && (
<button
data-context-action
onClick={addExcerpt}
className="absolute z-10 hidden md:flex items-center gap-1 rounded-full bg-primary text-primary-foreground text-xs px-2 py-1 shadow-lg hover:bg-primary/90 transition-colors"
style={{ left: textSel.x, top: textSel.y, transform: 'translate(-50%, -100%)' }}
>
<Plus className="h-3 w-3" />
Ajouter au contexte
</button>
)}
</div>
{/* Mobile: fixed bottom bar — avoids conflict with native selection menu */}
{textSel && (
<div className="md:hidden fixed bottom-16 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none">
<button
data-context-action
onClick={addExcerpt}
className="pointer-events-auto flex items-center gap-2 rounded-full bg-primary text-primary-foreground text-sm px-4 py-2.5 shadow-xl"
>
<Plus className="h-4 w-4" />
Ajouter au contexte
</button>
</div>
)}
<p className="text-xs text-muted-foreground mt-4 italic">
Sélectionnez du texte pour l'ajouter au contexte, puis posez une question dans le panneau qui apparaît.
</p>
</CardContent>
</Card>
) : (
<Card className="border-dashed">
<CardContent className="py-12 text-center text-muted-foreground">
<Sparkles className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p>Aucun résumé disponible</p>
<p className="text-xs mt-1">Cliquez sur "Générer un résumé" pour commencer</p>
</CardContent>
</Card>
)}
</div>
{/* Historique résumés */}
{summaries.length > 1 && (
<div className="space-y-3">
<h2 className="font-semibold text-sm text-muted-foreground uppercase tracking-wider">Historique</h2>
{summaries.slice(1).map(s => (
<Card
key={s.id}
className={`cursor-pointer transition-colors hover:border-primary/50 ${current?.id === s.id ? 'border-primary/50' : ''}`}
onClick={() => setCurrent(s)}
>
<CardContent className="py-3">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
{new Date(s.generated_at).toLocaleString('fr-FR')}
</div>
<p className="text-sm line-clamp-3">{s.content.slice(0, 120)}</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Panneau contexte flottant */}
{excerpts.length > 0 && (
<ContextPanel
excerpts={excerpts}
onRemove={i => setExcerpts(prev => prev.filter((_, idx) => idx !== i))}
onClear={() => setExcerpts([])}
onSubmit={submitQuestion}
/>
)}
</div>
)
}

116
frontend/src/pages/Feed.tsx Normal file
View File

@ -0,0 +1,116 @@
import { useState, useEffect } from 'react'
import { ExternalLink, RefreshCw, Search } from 'lucide-react'
import { articlesApi, type Article } from '@/api/articles'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
function fmtDate(a: Article) {
const d = a.published_at?.Valid ? new Date(a.published_at.Time) : new Date(a.created_at)
const now = Date.now()
const diff = now - d.getTime()
if (diff < 3600000) return `Il y a ${Math.floor(diff / 60000)} min`
if (diff < 86400000) return `Il y a ${Math.floor(diff / 3600000)} h`
return d.toLocaleDateString('fr-FR')
}
export function Feed() {
const [articles, setArticles] = useState<Article[]>([])
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filterSymbol, setFilterSymbol] = useState('')
const [offset, setOffset] = useState(0)
const limit = 30
useEffect(() => {
assetsApi.list().then(a => setAssets(a ?? []))
}, [])
useEffect(() => { load(0) }, [filterSymbol])
async function load(newOffset = 0) {
setLoading(true)
try {
const data = await articlesApi.list({ symbol: filterSymbol || undefined, limit, offset: newOffset })
if (newOffset === 0) setArticles(data ?? [])
else setArticles(prev => [...prev, ...(data ?? [])])
setOffset(newOffset + limit)
} finally { setLoading(false) }
}
const filtered = search
? articles.filter(a => a.title.toLowerCase().includes(search.toLowerCase()) || a.source_name?.toLowerCase().includes(search.toLowerCase()))
: articles
return (
<div className="p-4 md:p-6 space-y-4">
<div className="flex flex-wrap items-center gap-4">
<h1 className="text-2xl font-bold flex-1">Actualités</h1>
<Button variant="outline" size="icon" onClick={() => load(0)}><RefreshCw className="h-4 w-4" /></Button>
</div>
{/* Filtres */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-8" placeholder="Rechercher…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Select value={filterSymbol} onChange={e => setFilterSymbol(e.target.value)} className="w-40">
<option value="">Tous les symboles</option>
{assets.map(a => <option key={a.id} value={a.symbol}>{a.symbol}</option>)}
</Select>
</div>
{loading && articles.length === 0 ? (
<div className="flex justify-center py-20"><Spinner /></div>
) : (
<>
<div className="space-y-3">
{filtered.map(a => (
<Card key={a.id} className="hover:border-border/80 transition-colors">
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<Badge variant="outline" className="text-xs">{a.source_name}</Badge>
<span className="text-xs text-muted-foreground">{fmtDate(a)}</span>
</div>
<h3 className="font-medium leading-snug mb-2">{a.title}</h3>
{a.content && (
<p className="text-sm text-muted-foreground line-clamp-2">{a.content}</p>
)}
</div>
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</CardContent>
</Card>
))}
{filtered.length === 0 && (
<Card><CardContent className="py-12 text-center text-muted-foreground">Aucun article</CardContent></Card>
)}
</div>
{filtered.length >= limit && (
<div className="flex justify-center pt-2">
<Button variant="outline" onClick={() => load(offset)} disabled={loading}>
{loading ? <Spinner className="h-4 w-4" /> : 'Charger plus'}
</Button>
</div>
)}
</>
)}
</div>
)
}

View File

@ -0,0 +1,63 @@
import { useState, type FormEvent } from 'react'
import { Navigate } from 'react-router-dom'
import { TrendingUp } from 'lucide-react'
import { useAuth } from '@/lib/auth'
import { authApi } from '@/api/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export function Login() {
const { token, login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (token) return <Navigate to="/" replace />
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const { token: t, user } = await authApi.login(email, password)
login(t, user)
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur de connexion')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<TrendingUp className="h-10 w-10 text-primary" />
</div>
<CardTitle className="text-2xl">Tradarr</CardTitle>
<p className="text-sm text-muted-foreground">Votre assistant trading IA</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
</div>
<div className="space-y-1">
<Label htmlFor="password">Mot de passe</Label>
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Connexion…' : 'Se connecter'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,128 @@
import { useState, useEffect, useCallback } from 'react'
import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle } from 'lucide-react'
import { reportsApi, type Report } from '@/api/reports'
import { Card, CardContent } from '@/components/ui/card'
import { Markdown } from '@/components/ui/markdown'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Badge } from '@/components/ui/badge'
function StatusBadge({ status }: { status: Report['status'] }) {
if (status === 'generating') return (
<Badge variant="secondary" className="gap-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" /> En cours
</Badge>
)
if (status === 'error') return (
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" /> Erreur
</Badge>
)
return null
}
export function Reports() {
const [reports, setReports] = useState<Report[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
try { setReports((await reportsApi.list()) ?? []) }
finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
// Poll toutes les 3s tant qu'il y a des rapports en cours
useEffect(() => {
const hasGenerating = reports.some(r => r.status === 'generating')
if (!hasGenerating) return
const interval = setInterval(load, 3000)
return () => clearInterval(interval)
}, [reports, load])
async function remove(id: string) {
await reportsApi.delete(id)
setReports(prev => prev.filter(r => r.id !== id))
}
if (loading) return <div className="flex justify-center py-20"><Spinner /></div>
return (
<div className="p-4 md:p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Rapports IA</h1>
<p className="text-muted-foreground text-sm">
Vos questions posées sur des extraits de résumés, avec les réponses de l'IA.
</p>
</div>
{reports.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 text-center text-muted-foreground">
<FileText className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p>Aucun rapport enregistré</p>
<p className="text-xs mt-1">Sélectionnez du texte dans un résumé et posez une question à l'IA.</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{reports.map(r => (
<Card key={r.id} className={r.status === 'generating' ? 'border-primary/30' : ''}>
<CardContent className="pt-4 pb-4 space-y-3">
{/* Meta */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(r.created_at).toLocaleString('fr-FR')}
</div>
<StatusBadge status={r.status} />
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => remove(r.id)}
title={r.status === 'generating' ? 'Annuler' : 'Supprimer'}
>
{r.status === 'generating'
? <XCircle className="h-3 w-3" />
: <Trash2 className="h-3 w-3" />}
</Button>
</div>
{/* Extraits de contexte */}
{r.context_excerpt.split('\n\n---\n\n').map((excerpt, i) => (
<div key={i} className="rounded bg-muted/60 px-3 py-2 text-xs text-muted-foreground italic">
«&nbsp;{excerpt}&nbsp;»
</div>
))}
{/* Question */}
<p className="text-sm font-medium">{r.question}</p>
{/* Réponse */}
{r.status === 'generating' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
L'IA génère la réponse…
</div>
)}
{r.status === 'done' && (
<div className="border-t pt-3">
<Markdown content={r.answer} />
</div>
)}
{r.status === 'error' && (
<div className="text-sm text-destructive border-t pt-3">
Erreur : {r.error_msg || 'Une erreur est survenue lors de la génération.'}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,117 @@
import { useState, useEffect, type FormEvent } from 'react'
import { Plus, Trash2, TrendingUp } from 'lucide-react'
import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
export function Watchlist() {
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [symbol, setSymbol] = useState('')
const [name, setName] = useState('')
const [adding, setAdding] = useState(false)
const [removing, setRemoving] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
try { setAssets(await assetsApi.list() ?? []) } finally { setLoading(false) }
}
async function add(e: FormEvent) {
e.preventDefault()
if (!symbol) return
setAdding(true); setError('')
try {
await assetsApi.add(symbol.toUpperCase(), name)
setSymbol(''); setName('')
await load()
} catch (err) { setError(err instanceof Error ? err.message : 'Erreur') } finally { setAdding(false) }
}
async function remove(sym: string) {
setRemoving(sym)
await assetsApi.remove(sym)
await load()
setRemoving(null)
}
return (
<div className="p-4 md:p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Watchlist</h1>
<p className="text-muted-foreground text-sm">Les symboles suivis seront utilisés pour personnaliser vos résumés IA</p>
</div>
{/* Formulaire d'ajout */}
<Card>
<CardHeader><CardTitle className="text-base">Ajouter un actif</CardTitle></CardHeader>
<CardContent>
<form onSubmit={add} className="flex flex-wrap gap-3 items-end">
<div className="space-y-1 flex-1 min-w-32">
<Label>Symbole</Label>
<Input
placeholder="AAPL, TSLA, BTC…"
value={symbol}
onChange={e => setSymbol(e.target.value.toUpperCase())}
className="font-mono"
required
/>
</div>
<div className="space-y-1 flex-1 min-w-40">
<Label>Nom <span className="text-muted-foreground">(optionnel)</span></Label>
<Input
placeholder="Apple Inc."
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<Button type="submit" disabled={adding || !symbol}>
{adding ? <Spinner className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
Ajouter
</Button>
</form>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
{/* Liste */}
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : assets.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<TrendingUp className="h-10 w-10 mx-auto text-muted-foreground mb-3 opacity-50" />
<p className="text-muted-foreground">Aucun actif dans votre watchlist</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map(a => (
<Card key={a.id} className="flex items-center justify-between p-4">
<div className="flex items-center gap-3 min-w-0">
<Badge variant="secondary" className="font-mono shrink-0">{a.symbol}</Badge>
{a.name && <span className="text-sm text-muted-foreground truncate">{a.name}</span>}
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => remove(a.symbol)}
disabled={removing === a.symbol}
>
{removing === a.symbol ? <Spinner className="h-3 w-3" /> : <Trash2 className="h-3 w-3" />}
</Button>
</Card>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,652 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Star, Download, HardDrive, Cpu, ChevronDown, ChevronUp, X } from 'lucide-react'
import { adminApi, type AIProvider, type AIRoles, type OllamaModelInfo } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama', 'claudecode'] as const
const CUSTOM_MODELS_KEY = 'ollama_custom_models'
const KNOWN_OLLAMA_MODELS: { name: string; family: string; tags: { tag: string; size: string }[] }[] = [
{ name: 'qwen3', family: 'Qwen3', tags: [{ tag: '0.6b', size: '~0.4 GB' }, { tag: '1.7b', size: '~1.1 GB' }, { tag: '4b', size: '~2.6 GB' }, { tag: '8b', size: '~5.2 GB' }, { tag: '14b', size: '~9.3 GB' }, { tag: '32b', size: '~20 GB' }] },
{ name: 'qwen2.5', family: 'Qwen2.5', tags: [{ tag: '0.5b', size: '~0.4 GB' }, { tag: '1.5b', size: '~1 GB' }, { tag: '3b', size: '~2 GB' }, { tag: '7b', size: '~4.7 GB' }, { tag: '14b', size: '~9 GB' }, { tag: '32b', size: '~20 GB' }] },
{ name: 'llama3.2', family: 'Llama 3.2', tags: [{ tag: '1b', size: '~1.3 GB' }, { tag: '3b', size: '~2 GB' }] },
{ name: 'llama3.1', family: 'Llama 3.1', tags: [{ tag: '8b', size: '~4.9 GB' }, { tag: '70b', size: '~40 GB' }] },
{ name: 'gemma3', family: 'Gemma 3', tags: [{ tag: '1b', size: '~0.8 GB' }, { tag: '4b', size: '~3.3 GB' }, { tag: '12b', size: '~8 GB' }, { tag: '27b', size: '~17 GB' }] },
{ name: 'mistral', family: 'Mistral', tags: [{ tag: '7b', size: '~4.1 GB' }] },
{ name: 'phi4', family: 'Phi-4', tags: [{ tag: 'latest', size: '~9 GB' }] },
{ name: 'phi4-mini', family: 'Phi-4 Mini', tags: [{ tag: 'latest', size: '~2.5 GB' }] },
{ name: 'deepseek-r1', family: 'DeepSeek-R1', tags: [{ tag: '1.5b', size: '~1.1 GB' }, { tag: '7b', size: '~4.7 GB' }, { tag: '8b', size: '~4.9 GB' }, { tag: '14b', size: '~9 GB' }, { tag: '32b', size: '~20 GB' }] },
]
function formatSize(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`
return `${bytes} B`
}
function loadCustomModels(): string[] {
try { return JSON.parse(localStorage.getItem(CUSTOM_MODELS_KEY) ?? '[]') } catch { return [] }
}
function saveCustomModels(models: string[]) {
localStorage.setItem(CUSTOM_MODELS_KEY, JSON.stringify(models))
}
// ── Confirmation dialog ────────────────────────────────────────────────────
function ConfirmDownloadDialog({ modelName, onConfirm, onCancel }: {
modelName: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border rounded-xl shadow-2xl p-6 w-full max-w-sm mx-4 space-y-4">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold">Télécharger ce modèle ?</p>
<p className="text-sm text-muted-foreground mt-1">
<span className="font-mono text-foreground">{modelName}</span> sera téléchargé depuis Ollama Hub.
Le téléchargement peut prendre plusieurs minutes.
</p>
</div>
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground ml-3 shrink-0">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onCancel}>Annuler</Button>
<Button size="sm" onClick={onConfirm}>
<Download className="h-3 w-3 mr-1" /> Télécharger
</Button>
</div>
</div>
</div>
)
}
// ── Shared model catalogue ─────────────────────────────────────────────────
function ModelCatalogue({
installedNames,
pulling,
onPull,
}: {
installedNames: Set<string>
pulling: string | null
onPull: (name: string) => void
}) {
const [customName, setCustomName] = useState('')
const [pendingModel, setPendingModel] = useState<string | null>(null)
const [customModels, setCustomModels] = useState<string[]>(loadCustomModels)
// All model names in the hardcoded catalogue
const knownNames = new Set(
KNOWN_OLLAMA_MODELS.flatMap(f => f.tags.map(t => `${f.name}:${t.tag}`))
)
function requestPull(name: string) {
setPendingModel(name)
}
function confirmPull() {
if (!pendingModel) return
// If not in hardcoded catalogue, add to custom list
if (!knownNames.has(pendingModel)) {
const updated = [...new Set([...customModels, pendingModel])]
setCustomModels(updated)
saveCustomModels(updated)
}
onPull(pendingModel)
setPendingModel(null)
}
function submitCustom() {
const name = customName.trim()
if (!name) return
const fullName = name.includes(':') ? name : `${name}:latest`
setCustomName('')
requestPull(fullName)
}
// Families to display: hardcoded + custom entries
const customEntries = customModels.filter(m => !knownNames.has(m))
return (
<>
{pendingModel && (
<ConfirmDownloadDialog
modelName={pendingModel}
onConfirm={confirmPull}
onCancel={() => setPendingModel(null)}
/>
)}
<div className="space-y-4">
{/* Free-text input */}
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1.5">Nom personnalisé</p>
<div className="flex gap-2">
<Input
className="h-8 text-sm font-mono"
placeholder="ex: llama3.2:3b, mymodel:latest…"
value={customName}
onChange={e => setCustomName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submitCustom()}
disabled={pulling !== null}
/>
<Button size="sm" className="h-8 shrink-0" onClick={submitCustom}
disabled={!customName.trim() || pulling !== null}>
<Download className="h-3 w-3" /> Installer
</Button>
</div>
</div>
{/* Custom models section */}
{customEntries.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1.5">Personnalisés</p>
<div className="flex flex-wrap gap-1.5">
{customEntries.map(fullName => {
const installed = installedNames.has(fullName)
const isPulling = pulling === fullName
return <ModelTag key={fullName} fullName={fullName} size="?" installed={installed} isPulling={isPulling} pulling={pulling} onRequest={requestPull} />
})}
</div>
</div>
)}
{/* Hardcoded catalogue */}
<div className="space-y-3">
{KNOWN_OLLAMA_MODELS.map(family => (
<div key={family.name}>
<p className="text-xs font-semibold text-muted-foreground mb-1.5">{family.family}</p>
<div className="flex flex-wrap gap-1.5">
{family.tags.map(({ tag, size }) => {
const fullName = `${family.name}:${tag}`
const installed = installedNames.has(fullName)
const isPulling = pulling === fullName
return <ModelTag key={fullName} fullName={fullName} size={size} installed={installed} isPulling={isPulling} pulling={pulling} onRequest={requestPull} />
})}
</div>
</div>
))}
</div>
</div>
</>
)
}
function ModelTag({ fullName, size, installed, isPulling, pulling, onRequest }: {
fullName: string; size: string
installed: boolean; isPulling: boolean; pulling: string | null
onRequest: (name: string) => void
}) {
return (
<button
type="button"
disabled={installed || pulling !== null}
onClick={() => !installed && !isPulling && onRequest(fullName)}
className={[
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors',
installed
? 'border-primary/40 bg-primary/5 text-primary cursor-default'
: isPulling
? 'border-border bg-muted text-muted-foreground cursor-wait'
: pulling !== null
? 'border-border bg-background text-muted-foreground opacity-50 cursor-not-allowed'
: 'border-border bg-background hover:border-primary/60 hover:bg-primary/5 cursor-pointer',
].join(' ')}
>
{isPulling
? <><Spinner className="h-2.5 w-2.5" /> Téléchargement</>
: installed
? <><span className="text-[10px]"></span> {fullName}</>
: <><Download className="h-2.5 w-2.5" /> {fullName} {size !== '?' && <span className="text-muted-foreground">{size}</span>}</>
}
</button>
)
}
// ── Cloud model picker (OpenAI / Anthropic / Gemini / …) ──────────────────
function CloudModelPicker({ value, providerName, apiKey, endpoint, onChange }: {
value: string; providerName: string; apiKey: string; endpoint: string
onChange: (model: string) => void
}) {
const [models, setModels] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState('')
const noKeyNeeded = providerName === 'claudecode'
useEffect(() => {
if (noKeyNeeded) loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerName])
async function loadModels() {
if (!noKeyNeeded && !apiKey) { setLoadError('Renseigne la clé API d\'abord'); return }
setLoading(true); setLoadError('')
try {
const list = await adminApi.probeModels({ name: providerName, api_key: apiKey, endpoint })
setModels(list ?? [])
if ((list ?? []).length === 0) setLoadError('Aucun modèle trouvé')
} catch (e) {
setLoadError(e instanceof Error ? e.message : 'Erreur')
} finally { setLoading(false) }
}
return (
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
placeholder="gpt-4o-mini, claude-sonnet-4-6…"
value={value}
onChange={e => onChange(e.target.value)}
list="cloud-models-list"
className="flex-1"
/>
<Button type="button" variant="outline" size="sm" className="shrink-0" onClick={loadModels} disabled={loading}>
{loading ? <Spinner className="h-3 w-3" /> : 'Charger'}
</Button>
</div>
{models.length > 0 && (
<datalist id="cloud-models-list">
{models.map(m => <option key={m} value={m} />)}
</datalist>
)}
{models.length > 0 && (
<Select value={value} onChange={e => onChange(e.target.value)}>
<option value=""> Sélectionner un modèle </option>
{models.map(m => <option key={m} value={m}>{m}</option>)}
</Select>
)}
{loadError && <p className="text-xs text-destructive">{loadError}</p>}
</div>
)
}
// ── Ollama model picker (inside provider form) ─────────────────────────────
function OllamaModelPicker({ value, installedModels, onSelect, onRefresh }: {
value: string
installedModels: OllamaModelInfo[]
onSelect: (name: string) => void
onRefresh: () => Promise<OllamaModelInfo[]>
}) {
const [showCatalogue, setShowCatalogue] = useState(installedModels.length === 0)
const [pulling, setPulling] = useState<string | null>(null)
const installedNames = new Set(installedModels.map(m => m.name))
async function pull(fullName: string) {
setPulling(fullName)
try {
await adminApi.pullOllamaModel(fullName)
await new Promise<void>(resolve => {
const iv = setInterval(async () => {
try {
const updated = await onRefresh()
if (updated.some(m => m.name === fullName)) { clearInterval(iv); onSelect(fullName); setShowCatalogue(false); resolve() }
} catch { /* ignore */ }
}, 4000)
setTimeout(() => { clearInterval(iv); resolve() }, 1800000)
})
} finally { setPulling(null) }
}
return (
<div className="space-y-2">
<Select value={value} onChange={e => onSelect(e.target.value)}>
<option value=""> Choisir un modèle installé </option>
{installedModels.map(m => (
<option key={m.name} value={m.name}>
{m.name}{m.details.parameter_size ? ` (${m.details.parameter_size})` : ''}
</option>
))}
</Select>
<button type="button" className="flex items-center gap-1 text-xs text-primary hover:underline"
onClick={() => setShowCatalogue(v => !v)}>
{showCatalogue ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{showCatalogue ? 'Masquer le catalogue' : 'Installer un nouveau modèle'}
</button>
{showCatalogue && (
<div className="rounded-lg border bg-muted/30 p-3 max-h-72 overflow-y-auto">
<ModelCatalogue installedNames={installedNames} pulling={pulling} onPull={pull} />
</div>
)}
</div>
)
}
// ── Role Assignment ────────────────────────────────────────────────────────
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
summary: { label: 'Résumés', desc: 'Génération du résumé quotidien (passe 2)' },
report: { label: 'Rapports', desc: 'Réponses aux questions sur les résumés' },
filter: { label: 'Filtre articles', desc: 'Sélection des articles pertinents (passe 1)' },
}
function RoleCard({ role, providers, currentProviderID, onSave }: {
role: string; providers: AIProvider[]; currentProviderID: string
onSave: (providerID: string) => Promise<void>
}) {
const [providerID, setProviderID] = useState(currentProviderID)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => { setProviderID(currentProviderID) }, [currentProviderID])
const { label, desc } = ROLE_LABELS[role]
async function save() {
setSaving(true)
try { await onSave(providerID); setSaved(true); setTimeout(() => setSaved(false), 2000) }
finally { setSaving(false) }
}
return (
<div className="grid gap-3 sm:grid-cols-[1fr_auto] items-end">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</Label>
<p className="text-xs text-muted-foreground mb-2">{desc}</p>
<Select value={providerID} onChange={e => setProviderID(e.target.value)}>
<option value=""> Fournisseur par défaut </option>
{providers.map(p => <option key={p.id} value={p.id}>{p.name}{p.model ? `${p.model}` : ''}</option>)}
</Select>
</div>
<Button size="sm" onClick={save} disabled={saving} className="whitespace-nowrap">
{saving ? <Spinner className="h-3 w-3" /> : saved ? '✓ Sauvegardé' : 'Appliquer'}
</Button>
</div>
)
}
// ── Ollama model manager card ──────────────────────────────────────────────
function OllamaModelsCard({ installedModels, onRefresh, onDelete }: {
installedModels: OllamaModelInfo[]
onRefresh: () => Promise<OllamaModelInfo[]>
onDelete: (name: string) => Promise<void>
}) {
const [showCatalogue, setShowCatalogue] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [deleting, setDeleting] = useState<string | null>(null)
const installedNames = new Set(installedModels.map(m => m.name))
async function pull(fullName: string) {
setPulling(fullName)
try {
await adminApi.pullOllamaModel(fullName)
await new Promise<void>(resolve => {
const iv = setInterval(async () => {
try {
const updated = await onRefresh()
if (updated.some(m => m.name === fullName)) { clearInterval(iv); resolve() }
} catch { /* ignore */ }
}, 4000)
setTimeout(() => { clearInterval(iv); resolve() }, 1800000)
})
} finally { setPulling(null) }
}
async function remove(name: string) {
setDeleting(name)
try { await onDelete(name) } finally { setDeleting(null) }
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<HardDrive className="h-4 w-4" /> Modèles Ollama
</CardTitle>
<Button variant="outline" size="sm" onClick={() => setShowCatalogue(v => !v)}>
{showCatalogue ? <><ChevronUp className="h-3.5 w-3.5" /> Masquer</> : <><Download className="h-3.5 w-3.5" /> Installer</>}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2 flex items-center gap-1.5">
<Cpu className="h-3.5 w-3.5" /> Installés ({installedModels.length})
</h3>
{installedModels.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Aucun modèle installé</p>
) : (
<div className="space-y-1.5">
{installedModels.map(m => (
<div key={m.name} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
<div>
<span className="font-mono text-sm font-medium">{m.name}</span>
<span className="ml-3 text-xs text-muted-foreground">
{formatSize(m.size)}
{m.details.parameter_size && <> · {m.details.parameter_size}</>}
{m.details.quantization_level && <> · {m.details.quantization_level}</>}
</span>
</div>
<Button variant="ghost" size="sm"
className="text-destructive hover:text-destructive h-7 w-7 p-0"
onClick={() => remove(m.name)} disabled={deleting === m.name}>
{deleting === m.name ? <Spinner className="h-3 w-3" /> : <Trash2 className="h-3 w-3" />}
</Button>
</div>
))}
</div>
)}
</div>
{showCatalogue && (
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-sm font-semibold mb-3">Catalogue</p>
<ModelCatalogue installedNames={installedNames} pulling={pulling} onPull={pull} />
</div>
)}
</CardContent>
</Card>
)
}
// ── Main Page ──────────────────────────────────────────────────────────────
export function AIProviders() {
const [providers, setProviders] = useState<AIProvider[]>([])
const [roles, setRoles] = useState<AIRoles | null>(null)
const [ollamaModels, setOllamaModels] = useState<OllamaModelInfo[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ name: 'openai', api_key: '', model: '', endpoint: '' })
const [editId, setEditId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const isOllamaForm = form.name === 'ollama'
const isClaudeCodeForm = form.name === 'claudecode'
const loadOllamaModels = useCallback(async (): Promise<OllamaModelInfo[]> => {
try {
console.log('[ollama] fetching installed models…')
const m = await adminApi.listOllamaModels()
const list = m ?? []
console.log(`[ollama] ${list.length} model(s):`, list.map(x => x.name))
setOllamaModels(list)
return list
} catch (e) {
console.error('[ollama] error:', e)
return []
}
}, [])
const load = useCallback(async () => {
setLoading(true)
try {
const [p, r] = await Promise.all([adminApi.listProviders(), adminApi.getRoles()])
setProviders(p ?? [])
setRoles(r)
await loadOllamaModels()
} finally { setLoading(false) }
}, [loadOllamaModels])
useEffect(() => { load() }, [load])
async function save() {
setSaving(true); setError('')
try {
if (editId) { await adminApi.updateProvider(editId, form) }
else { await adminApi.createProvider(form) }
setShowForm(false); setEditId(null)
setForm({ name: 'openai', api_key: '', model: '', endpoint: '' })
await load()
} catch (e) { setError(e instanceof Error ? e.message : 'Erreur') }
finally { setSaving(false) }
}
async function setDefault(id: string) { await adminApi.activateProvider(id); await load() }
async function remove(id: string) {
if (!confirm('Supprimer ce fournisseur ?')) return
await adminApi.deleteProvider(id); await load()
}
function startEdit(p: AIProvider) {
setEditId(p.id); setForm({ name: p.name, api_key: '', model: p.model, endpoint: p.endpoint }); setShowForm(true)
}
async function saveRole(role: string, providerID: string) {
await adminApi.updateRole(role, { provider_id: providerID, model: '' }); await load()
}
async function deleteOllamaModel(name: string) {
await adminApi.deleteOllamaModel(name); await loadOllamaModels()
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Fournisseurs IA</h1>
<p className="text-muted-foreground text-sm">Configurez les fournisseurs et assignez un modèle à chaque tâche</p>
</div>
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : (
<>
{/* Role assignments */}
{roles && providers.length > 0 && (
<Card>
<CardHeader><CardTitle className="text-base">Assignation des tâches</CardTitle></CardHeader>
<CardContent className="space-y-5 divide-y">
{(['summary', 'report', 'filter'] as const).map(role => (
<div key={role} className={role !== 'summary' ? 'pt-5' : ''}>
<RoleCard role={role} providers={providers}
currentProviderID={roles[role].provider_id}
onSave={pid => saveRole(role, pid)} />
</div>
))}
</CardContent>
</Card>
)}
{/* Providers list */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Fournisseurs configurés</CardTitle>
<Button size="sm" onClick={() => { setShowForm(true); setEditId(null); setForm({ name: 'openai', api_key: '', model: '', endpoint: '' }) }}>
<Plus className="h-4 w-4" /> Ajouter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{showForm && (
<div className="rounded-lg border p-4 space-y-4 bg-muted/30">
<p className="font-medium text-sm">{editId ? 'Modifier le fournisseur' : 'Nouveau fournisseur'}</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label>Fournisseur</Label>
<Select value={form.name} disabled={!!editId}
onChange={e => {
const name = e.target.value
setForm(f => ({ ...f, name, model: '', api_key: '', endpoint: name === 'ollama' ? 'http://ollama:11434' : '' }))
}}>
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
</Select>
</div>
{!isOllamaForm && !isClaudeCodeForm && (
<div className="space-y-1">
<Label>Clé API {editId && <span className="text-muted-foreground text-xs">(vide = conserver)</span>}</Label>
<Input type="password" placeholder="sk-…" value={form.api_key} onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))} />
</div>
)}
{isOllamaForm && (
<div className="space-y-1">
<Label>Endpoint</Label>
<Input value={form.endpoint || 'http://ollama:11434'} onChange={e => setForm(f => ({ ...f, endpoint: e.target.value }))} />
</div>
)}
</div>
<div className="space-y-1">
<Label>Modèle par défaut</Label>
{isOllamaForm ? (
<OllamaModelPicker value={form.model} installedModels={ollamaModels}
onSelect={m => setForm(f => ({ ...f, model: m }))}
onRefresh={loadOllamaModels} />
) : (
<CloudModelPicker
value={form.model}
providerName={form.name}
apiKey={form.api_key}
endpoint={form.endpoint}
onChange={m => setForm(f => ({ ...f, model: m }))}
/>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={save} disabled={saving}>{saving ? <Spinner className="h-4 w-4" /> : 'Enregistrer'}</Button>
<Button size="sm" variant="outline" onClick={() => { setShowForm(false); setEditId(null) }}>Annuler</Button>
</div>
</div>
)}
{providers.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground text-center py-4">Aucun fournisseur configuré</p>
)}
{providers.map(p => (
<div key={p.id} className={`flex flex-wrap items-center gap-3 rounded-md border px-3 py-2.5 ${p.is_active ? 'border-primary/50 bg-primary/5' : ''}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold capitalize text-sm">{p.name}</span>
{p.is_active && <Badge variant="default" className="text-xs">Défaut</Badge>}
{!p.has_key && p.name !== 'ollama' && p.name !== 'claudecode' && <Badge variant="outline" className="text-yellow-500 border-yellow-500 text-xs">Sans clé</Badge>}
</div>
<div className="text-xs text-muted-foreground flex gap-3 mt-0.5">
{p.model && <span>Modèle : <strong>{p.model}</strong></span>}
{p.endpoint && <span>{p.endpoint}</span>}
</div>
</div>
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => startEdit(p)}>Modifier</Button>
{!p.is_active && (
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => setDefault(p.id)}>
<Star className="h-3 w-3 mr-1" /> Défaut
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive hover:text-destructive" onClick={() => remove(p.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
{/* Ollama model manager — always shown so user can install even without provider configured */}
<OllamaModelsCard
installedModels={ollamaModels}
onRefresh={loadOllamaModels}
onDelete={deleteOllamaModel}
/>
</>
)}
</div>
)
}

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