feat: add claudecode with oauth to AI providers

This commit is contained in:
2026-04-21 16:27:58 +02:00
parent 985768f400
commit f2bb88f040
12 changed files with 267 additions and 32 deletions

154
README.md
View File

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

View File

@ -9,15 +9,18 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o tradarr ./cmd/server
FROM debian:bookworm-slim 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 \ RUN apt-get update && apt-get install -y \
chromium \ chromium \
chromium-driver \ chromium-driver \
ca-certificates \ ca-certificates \
fonts-liberation \ fonts-liberation \
libnss3 \ libnss3 \
nodejs \
npm \
--no-install-recommends && \ --no-install-recommends && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/* && \
npm install -g @anthropic-ai/claude-code
WORKDIR /app WORKDIR /app
COPY --from=builder /app/tradarr . COPY --from=builder /app/tradarr .

View File

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

View File

@ -27,6 +27,8 @@ func NewProvider(name, apiKey, model, endpoint string) (Provider, error) {
return newGemini(apiKey, model), nil return newGemini(apiKey, model), nil
case "ollama": case "ollama":
return newOllama(endpoint, model), nil return newOllama(endpoint, model), nil
case "claudecode":
return newClaudeCode(model), nil
default: default:
return nil, fmt.Errorf("unknown provider: %s", name) return nil, fmt.Errorf("unknown provider: %s", name)
} }

View File

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

View File

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

View File

@ -36,6 +36,8 @@ services:
SCRAPER_URL: "http://scraper:3001" SCRAPER_URL: "http://scraper:3001"
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@tradarr.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@tradarr.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
volumes:
- /home/anthony/.claude:/root/.claude
expose: expose:
- "8080" - "8080"

View File

@ -40,6 +40,8 @@ services:
SCRAPER_URL: "http://scraper:3001" SCRAPER_URL: "http://scraper:3001"
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@tradarr.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@tradarr.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
volumes:
- /home/anthony/.claude:/root/.claude
expose: expose:
- "8080" - "8080"

View File

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

View File

@ -4,6 +4,7 @@ import { summariesApi, type Summary } from '@/api/summaries'
import { reportsApi } from '@/api/reports' import { reportsApi } from '@/api/reports'
import { assetsApi, type Asset } from '@/api/assets' import { assetsApi, type Asset } from '@/api/assets'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Markdown } from '@/components/ui/markdown'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
@ -135,31 +136,8 @@ function ContextPanel({
// ── Summary content renderer ──────────────────────────────────────────────── // ── Summary content renderer ────────────────────────────────────────────────
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
)
}
function SummaryContent({ content }: { content: string }) { function SummaryContent({ content }: { content: string }) {
const lines = content.split('\n') return <Markdown content={content} />
return (
<div className="space-y-1 text-sm leading-relaxed select-text">
{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>
)
} }
// ── Dashboard ─────────────────────────────────────────────────────────────── // ── Dashboard ───────────────────────────────────────────────────────────────

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle } from 'lucide-react' import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle } from 'lucide-react'
import { reportsApi, type Report } from '@/api/reports' import { reportsApi, type Report } from '@/api/reports'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Markdown } from '@/components/ui/markdown'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -108,8 +109,8 @@ export function Reports() {
</div> </div>
)} )}
{r.status === 'done' && ( {r.status === 'done' && (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed border-t pt-3"> <div className="border-t pt-3">
{r.answer} <Markdown content={r.answer} />
</div> </div>
)} )}
{r.status === 'error' && ( {r.status === 'error' && (

View File

@ -9,7 +9,7 @@ import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner' 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' const CUSTOM_MODELS_KEY = 'ollama_custom_models'
@ -223,9 +223,15 @@ function CloudModelPicker({ value, providerName, apiKey, endpoint, onChange }: {
const [models, setModels] = useState<string[]>([]) const [models, setModels] = useState<string[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState('') const [loadError, setLoadError] = useState('')
const noKeyNeeded = providerName === 'claudecode'
useEffect(() => {
if (noKeyNeeded) loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerName])
async function loadModels() { 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('') setLoading(true); setLoadError('')
try { try {
const list = await adminApi.probeModels({ name: providerName, api_key: apiKey, endpoint }) const list = await adminApi.probeModels({ name: providerName, api_key: apiKey, endpoint })
@ -456,6 +462,7 @@ export function AIProviders() {
const [error, setError] = useState('') const [error, setError] = useState('')
const isOllamaForm = form.name === 'ollama' const isOllamaForm = form.name === 'ollama'
const isClaudeCodeForm = form.name === 'claudecode'
const loadOllamaModels = useCallback(async (): Promise<OllamaModelInfo[]> => { const loadOllamaModels = useCallback(async (): Promise<OllamaModelInfo[]> => {
try { try {
@ -562,7 +569,7 @@ export function AIProviders() {
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)} {PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
</Select> </Select>
</div> </div>
{!isOllamaForm && ( {!isOllamaForm && !isClaudeCodeForm && (
<div className="space-y-1"> <div className="space-y-1">
<Label>Clé API {editId && <span className="text-muted-foreground text-xs">(vide = conserver)</span>}</Label> <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 }))} /> <Input type="password" placeholder="sk-…" value={form.api_key} onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))} />
@ -609,7 +616,7 @@ export function AIProviders() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold capitalize text-sm">{p.name}</span> <span className="font-semibold capitalize text-sm">{p.name}</span>
{p.is_active && <Badge variant="default" className="text-xs">Défaut</Badge>} {p.is_active && <Badge variant="default" className="text-xs">Défaut</Badge>}
{!p.has_key && p.name !== 'ollama' && <Badge variant="outline" className="text-yellow-500 border-yellow-500 text-xs">Sans clé</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>
<div className="text-xs text-muted-foreground flex gap-3 mt-0.5"> <div className="text-xs text-muted-foreground flex gap-3 mt-0.5">
{p.model && <span>Modèle : <strong>{p.model}</strong></span>} {p.model && <span>Modèle : <strong>{p.model}</strong></span>}