feat: add claudecode with oauth to AI providers
This commit is contained in:
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
|
||||
```
|
||||
|
||||
@ -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 .
|
||||
|
||||
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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -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('**')
|
||||
? <strong key={i}>{part.slice(2, -2)}</strong>
|
||||
: part
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryContent({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
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>
|
||||
)
|
||||
return <Markdown content={content} />
|
||||
}
|
||||
|
||||
// ── Dashboard ───────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
)}
|
||||
{r.status === 'done' && (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed border-t pt-3">
|
||||
{r.answer}
|
||||
<div className="border-t pt-3">
|
||||
<Markdown content={r.answer} />
|
||||
</div>
|
||||
)}
|
||||
{r.status === 'error' && (
|
||||
|
||||
@ -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<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 (!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<OllamaModelInfo[]> => {
|
||||
try {
|
||||
@ -562,7 +569,7 @@ export function AIProviders() {
|
||||
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
{!isOllamaForm && (
|
||||
{!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 }))} />
|
||||
@ -609,7 +616,7 @@ export function AIProviders() {
|
||||
<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' && <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 className="text-xs text-muted-foreground flex gap-3 mt-0.5">
|
||||
{p.model && <span>Modèle : <strong>{p.model}</strong></span>}
|
||||
|
||||
Reference in New Issue
Block a user