From f2bb88f04097eba74b104f35d364858e7b13459b Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 21 Apr 2026 16:27:58 +0200 Subject: [PATCH] feat: add claudecode with oauth to AI providers --- README.md | 154 ++++++++++++++++++ backend/Dockerfile | 7 +- backend/internal/ai/claudecode.go | 52 ++++++ backend/internal/ai/provider.go | 2 + .../000009_claudecode_provider.down.sql | 3 + .../000009_claudecode_provider.up.sql | 3 + docker-compose.prod.yml | 2 + docker-compose.yml | 2 + frontend/src/components/ui/markdown.tsx | 28 ++++ frontend/src/pages/Dashboard.tsx | 26 +-- frontend/src/pages/Reports.tsx | 5 +- frontend/src/pages/admin/AIProviders.tsx | 15 +- 12 files changed, 267 insertions(+), 32 deletions(-) create mode 100644 backend/internal/ai/claudecode.go create mode 100644 backend/internal/database/migrations/000009_claudecode_provider.down.sql create mode 100644 backend/internal/database/migrations/000009_claudecode_provider.up.sql create mode 100644 frontend/src/components/ui/markdown.tsx diff --git a/README.md b/README.md index e69de29..80a5fc0 100644 --- a/README.md +++ b/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= + +# Secret JWT pour les tokens de session +JWT_SECRET= + +# 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= + +# 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//.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://:`. + +--- + +## 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 +``` diff --git a/backend/Dockerfile b/backend/Dockerfile index 734de0c..8b9b374 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,15 +9,18 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o tradarr ./cmd/server FROM debian:bookworm-slim -# Chromium pour le scraping Bloomberg +# 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/* + rm -rf /var/lib/apt/lists/* && \ + npm install -g @anthropic-ai/claude-code WORKDIR /app COPY --from=builder /app/tradarr . diff --git a/backend/internal/ai/claudecode.go b/backend/internal/ai/claudecode.go new file mode 100644 index 0000000..98a6c76 --- /dev/null +++ b/backend/internal/ai/claudecode.go @@ -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 +} diff --git a/backend/internal/ai/provider.go b/backend/internal/ai/provider.go index 0869a02..d1158e4 100644 --- a/backend/internal/ai/provider.go +++ b/backend/internal/ai/provider.go @@ -27,6 +27,8 @@ func NewProvider(name, apiKey, model, endpoint string) (Provider, error) { 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) } diff --git a/backend/internal/database/migrations/000009_claudecode_provider.down.sql b/backend/internal/database/migrations/000009_claudecode_provider.down.sql new file mode 100644 index 0000000..8da5897 --- /dev/null +++ b/backend/internal/database/migrations/000009_claudecode_provider.down.sql @@ -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')); diff --git a/backend/internal/database/migrations/000009_claudecode_provider.up.sql b/backend/internal/database/migrations/000009_claudecode_provider.up.sql new file mode 100644 index 0000000..5964e32 --- /dev/null +++ b/backend/internal/database/migrations/000009_claudecode_provider.up.sql @@ -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')); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 27045dc..2dfc9f3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -36,6 +36,8 @@ services: 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" diff --git a/docker-compose.yml b/docker-compose.yml index 5d55079..6d32ccf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,8 @@ services: 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" diff --git a/frontend/src/components/ui/markdown.tsx b/frontend/src/components/ui/markdown.tsx new file mode 100644 index 0000000..4205da4 --- /dev/null +++ b/frontend/src/components/ui/markdown.tsx @@ -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('**') + ? {part.slice(2, -2)} + : part + ) +} + +export function Markdown({ content, className }: { content: string; className?: string }) { + const lines = content.split('\n') + return ( +
+ {lines.map((line, i) => { + if (line.startsWith('##### ')) return
{renderInline(line.slice(6))}
+ if (line.startsWith('#### ')) return

{renderInline(line.slice(5))}

+ if (line.startsWith('### ')) return

{renderInline(line.slice(4))}

+ if (line.startsWith('## ')) return

{renderInline(line.slice(3))}

+ if (line.startsWith('# ')) return

{renderInline(line.slice(2))}

+ if (line.startsWith('- ') || line.startsWith('* ')) return
  • {renderInline(line.slice(2))}
  • + if (line.trim() === '') return
    + return

    {renderInline(line)}

    + })} +
    + ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c49ab17..0281078 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,6 +4,7 @@ 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' @@ -135,31 +136,8 @@ function ContextPanel({ // ── Summary content renderer ──────────────────────────────────────────────── -function renderInline(text: string): React.ReactNode { - const parts = text.split(/(\*\*[^*]+\*\*)/g) - return parts.map((part, i) => - part.startsWith('**') && part.endsWith('**') - ? {part.slice(2, -2)} - : part - ) -} - function SummaryContent({ content }: { content: string }) { - const lines = content.split('\n') - return ( -
    - {lines.map((line, i) => { - if (line.startsWith('##### ')) return
    {renderInline(line.slice(6))}
    - if (line.startsWith('#### ')) return

    {renderInline(line.slice(5))}

    - if (line.startsWith('### ')) return

    {renderInline(line.slice(4))}

    - if (line.startsWith('## ')) return

    {renderInline(line.slice(3))}

    - if (line.startsWith('# ')) return

    {renderInline(line.slice(2))}

    - if (line.startsWith('- ') || line.startsWith('* ')) return
  • {renderInline(line.slice(2))}
  • - if (line.trim() === '') return
    - return

    {renderInline(line)}

    - })} -
    - ) + return } // ── Dashboard ─────────────────────────────────────────────────────────────── diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 8db867a..0e3d7fd 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -2,6 +2,7 @@ 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' @@ -108,8 +109,8 @@ export function Reports() {
    )} {r.status === 'done' && ( -
    - {r.answer} +
    +
    )} {r.status === 'error' && ( diff --git a/frontend/src/pages/admin/AIProviders.tsx b/frontend/src/pages/admin/AIProviders.tsx index fb45f29..c73347d 100644 --- a/frontend/src/pages/admin/AIProviders.tsx +++ b/frontend/src/pages/admin/AIProviders.tsx @@ -9,7 +9,7 @@ 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'] as const +const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama', 'claudecode'] as const const CUSTOM_MODELS_KEY = 'ollama_custom_models' @@ -223,9 +223,15 @@ function CloudModelPicker({ value, providerName, apiKey, endpoint, onChange }: { const [models, setModels] = useState([]) 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 (!apiKey) { setLoadError('Renseigne la clé API d\'abord'); return } + 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 }) @@ -456,6 +462,7 @@ export function AIProviders() { const [error, setError] = useState('') const isOllamaForm = form.name === 'ollama' + const isClaudeCodeForm = form.name === 'claudecode' const loadOllamaModels = useCallback(async (): Promise => { try { @@ -562,7 +569,7 @@ export function AIProviders() { {PROVIDER_NAMES.map(n => )}
    - {!isOllamaForm && ( + {!isOllamaForm && !isClaudeCodeForm && (
    setForm(f => ({ ...f, api_key: e.target.value }))} /> @@ -609,7 +616,7 @@ export function AIProviders() {
    {p.name} {p.is_active && Défaut} - {!p.has_key && p.name !== 'ollama' && Sans clé} + {!p.has_key && p.name !== 'ollama' && p.name !== 'claudecode' && Sans clé}
    {p.model && Modèle : {p.model}}