Compare commits
14 Commits
cc2e94ab88
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9780311cd0 | |||
| f2bb88f040 | |||
| 985768f400 | |||
| 2761282c0b | |||
| b7269601eb | |||
| b2a7c1a2e3 | |||
| 6274b4a0b8 | |||
| 351dd3b608 | |||
| 8a0edc3d59 | |||
| 71513ea62c | |||
| 7ef93276e1 | |||
| eb1fb5ca78 | |||
| 93668273ff | |||
| f9b6d35c49 |
32
.claude/settings.local.json
Normal file
32
.claude/settings.local.json
Normal 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
16
.env.example
Normal 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
33
.gitignore
vendored
Normal 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
29
Makefile
Normal 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
154
README.md
@ -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
32
backend/Dockerfile
Normal 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"]
|
||||||
90
backend/cmd/server/main.go
Normal file
90
backend/cmd/server/main.go
Normal 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
55
backend/go.mod
Normal 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
166
backend/go.sum
Normal 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=
|
||||||
79
backend/internal/ai/anthropic.go
Normal file
79
backend/internal/ai/anthropic.go
Normal 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
|
||||||
|
}
|
||||||
52
backend/internal/ai/claudecode.go
Normal file
52
backend/internal/ai/claudecode.go
Normal 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
|
||||||
|
}
|
||||||
116
backend/internal/ai/gemini.go
Normal file
116
backend/internal/ai/gemini.go
Normal 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
|
||||||
|
}
|
||||||
168
backend/internal/ai/ollama.go
Normal file
168
backend/internal/ai/ollama.go
Normal 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
|
||||||
|
}
|
||||||
52
backend/internal/ai/openai.go
Normal file
52
backend/internal/ai/openai.go
Normal 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
|
||||||
|
}
|
||||||
329
backend/internal/ai/pipeline.go
Normal file
329
backend/internal/ai/pipeline.go
Normal 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()
|
||||||
|
}
|
||||||
35
backend/internal/ai/provider.go
Normal file
35
backend/internal/ai/provider.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/internal/ai/report_manager.go
Normal file
37
backend/internal/ai/report_manager.go
Normal 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)
|
||||||
|
}
|
||||||
520
backend/internal/api/handlers/admin.go
Normal file
520
backend/internal/api/handlers/admin.go
Normal 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)
|
||||||
|
}
|
||||||
36
backend/internal/api/handlers/articles.go
Normal file
36
backend/internal/api/handlers/articles.go
Normal 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)
|
||||||
|
}
|
||||||
64
backend/internal/api/handlers/auth.go
Normal file
64
backend/internal/api/handlers/auth.go
Normal 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})
|
||||||
|
}
|
||||||
39
backend/internal/api/handlers/handler.go
Normal file
39
backend/internal/api/handlers/handler.go
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
84
backend/internal/api/handlers/reports.go
Normal file
84
backend/internal/api/handlers/reports.go
Normal 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()
|
||||||
|
}
|
||||||
33
backend/internal/api/handlers/summaries.go
Normal file
33
backend/internal/api/handlers/summaries.go
Normal 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)
|
||||||
|
}
|
||||||
56
backend/internal/api/handlers/user.go
Normal file
56
backend/internal/api/handlers/user.go
Normal 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)
|
||||||
|
}
|
||||||
95
backend/internal/api/router.go
Normal file
95
backend/internal/api/router.go
Normal 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
|
||||||
|
}
|
||||||
46
backend/internal/auth/jwt.go
Normal file
46
backend/internal/auth/jwt.go
Normal 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
|
||||||
|
}
|
||||||
37
backend/internal/auth/middleware.go
Normal file
37
backend/internal/auth/middleware.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/internal/auth/passwords.go
Normal file
12
backend/internal/auth/passwords.go
Normal 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
|
||||||
|
}
|
||||||
58
backend/internal/config/config.go
Normal file
58
backend/internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
59
backend/internal/crypto/aes.go
Normal file
59
backend/internal/crypto/aes.go
Normal 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
|
||||||
|
}
|
||||||
43
backend/internal/database/db.go
Normal file
43
backend/internal/database/db.go
Normal 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
|
||||||
|
}
|
||||||
10
backend/internal/database/migrations/000001_init.down.sql
Normal file
10
backend/internal/database/migrations/000001_init.down.sql
Normal 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;
|
||||||
106
backend/internal/database/migrations/000001_init.up.sql
Normal file
106
backend/internal/database/migrations/000001_init.up.sql
Normal 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');
|
||||||
@ -0,0 +1 @@
|
|||||||
|
DELETE FROM settings WHERE key = 'ai_system_prompt';
|
||||||
13
backend/internal/database/migrations/000002_ai_prompt.up.sql
Normal file
13
backend/internal/database/migrations/000002_ai_prompt.up.sql
Normal 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;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
DELETE FROM sources WHERE type IN ('reuters', 'watcherguru');
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
INSERT INTO sources (name, type, enabled) VALUES
|
||||||
|
('Reuters', 'reuters', true),
|
||||||
|
('Watcher.Guru', 'watcherguru', true)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS scrape_schedules;
|
||||||
17
backend/internal/database/migrations/000004_schedule.up.sql
Normal file
17
backend/internal/database/migrations/000004_schedule.up.sql
Normal 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);
|
||||||
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS reports;
|
||||||
@ -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()
|
||||||
|
);
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE reports
|
||||||
|
DROP COLUMN IF EXISTS status,
|
||||||
|
DROP COLUMN IF EXISTS error_msg,
|
||||||
|
ALTER COLUMN answer DROP DEFAULT;
|
||||||
@ -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 '';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
DELETE FROM settings WHERE key = 'timezone';
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Paris')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
DELETE FROM settings WHERE key = 'filter_batch_size';
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
INSERT INTO settings (key, value) VALUES ('filter_batch_size', '20')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@ -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'));
|
||||||
@ -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'));
|
||||||
48
backend/internal/httputil/response.go
Normal file
48
backend/internal/httputil/response.go
Normal 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()})
|
||||||
|
}
|
||||||
118
backend/internal/models/models.go
Normal file
118
backend/internal/models/models.go
Normal 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"`
|
||||||
|
}
|
||||||
668
backend/internal/models/repository.go
Normal file
668
backend/internal/models/repository.go
Normal 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
|
||||||
|
}
|
||||||
90
backend/internal/scheduler/scheduler.go
Normal file
90
backend/internal/scheduler/scheduler.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
backend/internal/scraper/bloomberg/bloomberg.go
Normal file
94
backend/internal/scraper/bloomberg/bloomberg.go
Normal 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
|
||||||
|
}
|
||||||
48
backend/internal/scraper/bloomberg/dynamic.go
Normal file
48
backend/internal/scraper/bloomberg/dynamic.go
Normal 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)
|
||||||
|
}
|
||||||
106
backend/internal/scraper/registry.go
Normal file
106
backend/internal/scraper/registry.go
Normal 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
|
||||||
|
}
|
||||||
129
backend/internal/scraper/reuters/reuters.go
Normal file
129
backend/internal/scraper/reuters/reuters.go
Normal 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
|
||||||
|
}
|
||||||
75
backend/internal/scraper/scraper.go
Normal file
75
backend/internal/scraper/scraper.go
Normal 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
|
||||||
|
}
|
||||||
128
backend/internal/scraper/stocktwits/stocktwits.go
Normal file
128
backend/internal/scraper/stocktwits/stocktwits.go
Normal 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
|
||||||
|
}
|
||||||
200
backend/internal/scraper/watcherguru/watcherguru.go
Normal file
200
backend/internal/scraper/watcherguru/watcherguru.go
Normal 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()
|
||||||
|
}
|
||||||
131
backend/internal/scraper/yahoofinance/yahoofinance.go
Normal file
131
backend/internal/scraper/yahoofinance/yahoofinance.go
Normal 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®ion=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
60
docker-compose.prod.yml
Normal 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
66
docker-compose.yml
Normal 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
24
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
43
frontend/nginx.conf
Normal 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
9997
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal 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 |
13
frontend/public/icon-source.svg
Normal file
13
frontend/public/icon-source.svg
Normal 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
74
frontend/src/api/admin.ts
Normal 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}`),
|
||||||
|
}
|
||||||
25
frontend/src/api/articles.ts
Normal file
25
frontend/src/api/articles.ts
Normal 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}`),
|
||||||
|
}
|
||||||
9
frontend/src/api/assets.ts
Normal file
9
frontend/src/api/assets.ts
Normal 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
12
frontend/src/api/auth.ts
Normal 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'),
|
||||||
|
}
|
||||||
39
frontend/src/api/client.ts
Normal file
39
frontend/src/api/client.ts
Normal 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' }),
|
||||||
|
}
|
||||||
20
frontend/src/api/reports.ts
Normal file
20
frontend/src/api/reports.ts
Normal 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}`),
|
||||||
|
}
|
||||||
15
frontend/src/api/summaries.ts
Normal file
15
frontend/src/api/summaries.ts
Normal 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'),
|
||||||
|
}
|
||||||
26
frontend/src/components/layout/AppLayout.tsx
Normal file
26
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
frontend/src/components/layout/MobileNav.tsx
Normal file
34
frontend/src/components/layout/MobileNav.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
frontend/src/components/layout/Sidebar.tsx
Normal file
87
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/badge.tsx
Normal file
26
frontend/src/components/ui/badge.tsx
Normal 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} />
|
||||||
|
}
|
||||||
35
frontend/src/components/ui/button.tsx
Normal file
35
frontend/src/components/ui/button.tsx
Normal 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'
|
||||||
37
frontend/src/components/ui/card.tsx
Normal file
37
frontend/src/components/ui/card.tsx
Normal 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'
|
||||||
17
frontend/src/components/ui/input.tsx
Normal file
17
frontend/src/components/ui/input.tsx
Normal 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'
|
||||||
13
frontend/src/components/ui/label.tsx
Normal file
13
frontend/src/components/ui/label.tsx
Normal 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'
|
||||||
28
frontend/src/components/ui/markdown.tsx
Normal file
28
frontend/src/components/ui/markdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/select.tsx
Normal file
22
frontend/src/components/ui/select.tsx
Normal 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'
|
||||||
7
frontend/src/components/ui/spinner.tsx
Normal file
7
frontend/src/components/ui/spinner.tsx
Normal 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
44
frontend/src/index.css
Normal 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
51
frontend/src/lib/auth.tsx
Normal 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
6
frontend/src/lib/cn.ts
Normal 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))
|
||||||
|
}
|
||||||
42
frontend/src/lib/router.tsx
Normal file
42
frontend/src/lib/router.tsx
Normal 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
14
frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
349
frontend/src/pages/Dashboard.tsx
Normal file
349
frontend/src/pages/Dashboard.tsx
Normal 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">
|
||||||
|
« {e} »
|
||||||
|
</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
116
frontend/src/pages/Feed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
frontend/src/pages/Login.tsx
Normal file
63
frontend/src/pages/Login.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
frontend/src/pages/Reports.tsx
Normal file
128
frontend/src/pages/Reports.tsx
Normal 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">
|
||||||
|
« {excerpt} »
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
frontend/src/pages/Watchlist.tsx
Normal file
117
frontend/src/pages/Watchlist.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
652
frontend/src/pages/admin/AIProviders.tsx
Normal file
652
frontend/src/pages/admin/AIProviders.tsx
Normal 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
Reference in New Issue
Block a user