diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3c153bc..e0eb0a4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,13 @@ "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(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 *)" ] } } diff --git a/backend/internal/ai/ollama.go b/backend/internal/ai/ollama.go index 0b25171..bd7618d 100644 --- a/backend/internal/ai/ollama.go +++ b/backend/internal/ai/ollama.go @@ -7,31 +7,51 @@ import ( "fmt" "io" "net/http" + "time" ) -type ollamaProvider struct { +// 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 { +func newOllama(endpoint, model string) *OllamaProvider { if endpoint == "" { endpoint = "http://ollama:11434" } if model == "" { model = "llama3" } - return &ollamaProvider{ + return &OllamaProvider{ endpoint: endpoint, model: model, client: &http.Client{}, } } -func (p *ollamaProvider) Name() string { return "ollama" } +// NewOllamaManager creates an OllamaProvider for model management (pull/delete/list). +func NewOllamaManager(endpoint string) *OllamaProvider { + return newOllama(endpoint, "") +} -func (p *ollamaProvider) Summarize(ctx context.Context, prompt string, opts GenOptions) (string, error) { + +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 @@ -72,7 +92,19 @@ func (p *ollamaProvider) Summarize(ctx context.Context, prompt string, opts GenO return result.Response, nil } -func (p *ollamaProvider) ListModels(ctx context.Context) ([]string, error) { +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 @@ -85,16 +117,52 @@ func (p *ollamaProvider) ListModels(ctx context.Context) ([]string, error) { raw, _ := io.ReadAll(resp.Body) var result struct { - Models []struct { - Name string `json:"name"` - } `json:"models"` + Models []OllamaModelInfo `json:"models"` } if err := json.Unmarshal(raw, &result); err != nil { return nil, err } - var models []string - for _, m := range result.Models { - models = append(models, m.Name) - } - return models, nil + 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 } diff --git a/backend/internal/ai/pipeline.go b/backend/internal/ai/pipeline.go index a4196ee..5d97170 100644 --- a/backend/internal/ai/pipeline.go +++ b/backend/internal/ai/pipeline.go @@ -50,28 +50,36 @@ func (p *Pipeline) BuildProvider(name, apiKey, endpoint string) (Provider, error return NewProvider(name, apiKey, model, 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) - providerCfg, err := p.repo.GetActiveAIProvider() - if err != nil { - return nil, fmt.Errorf("get active provider: %w", err) - } - if providerCfg == nil { - return nil, fmt.Errorf("no active AI provider configured") - } - apiKey := "" - if providerCfg.APIKeyEncrypted != "" { - apiKey, err = p.enc.Decrypt(providerCfg.APIKeyEncrypted) - if err != nil { - return nil, fmt.Errorf("decrypt API key: %w", err) - } - } - - provider, err := NewProvider(providerCfg.Name, apiKey, providerCfg.Model, providerCfg.Endpoint) + provider, providerCfg, err := p.buildProviderForRole("summary") if err != nil { - return nil, fmt.Errorf("build provider: %w", err) + return nil, err } assets, err := p.repo.GetUserAssets(userID) @@ -105,9 +113,13 @@ func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models. // 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, provider, symbols, articles, maxArticles) + 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] @@ -135,49 +147,84 @@ func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models. return p.repo.CreateSummary(userID, summary, &providerCfg.ID) } -// filterByRelevance demande à l'IA de sélectionner les articles les plus pertinents -// en ne lui envoyant que les titres (prompt très court = rapide). +// 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 { - prompt := buildFilterPrompt(symbols, articles, max) - // Passe 1 : pas de think, contexte réduit (titres seulement = prompt court) - response, err := provider.Summarize(ctx, prompt, GenOptions{Think: false, NumCtx: 8192}) - if err != nil { - fmt.Printf("[pipeline] Passe 1 — échec (%v), repli sur troncature\n", err) - return articles[:max] + batchSizeStr, _ := p.repo.GetSetting("filter_batch_size") + batchSize, _ := strconv.Atoi(batchSizeStr) + if batchSize <= 0 { + batchSize = 20 } - indices := parseIndexArray(response, len(articles)) + 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 { - fmt.Printf("[pipeline] Passe 1 — réponse non parseable, repli sur troncature\n") - return articles[:max] + return nil } filtered := make([]models.Article, 0, len(indices)) for _, i := range indices { - filtered = append(filtered, articles[i]) - if len(filtered) >= max { - break - } + filtered = append(filtered, batch[i]) } return filtered } -func buildFilterPrompt(symbols []string, articles []models.Article, max int) string { +func buildFilterBatchPrompt(symbols []string, batch []models.Article) string { var sb strings.Builder - sb.WriteString("Tu es un assistant de trading financier. ") - sb.WriteString(fmt.Sprintf("Parmi les %d articles ci-dessous, sélectionne les %d plus pertinents pour un trader actif.\n", len(articles), max)) + 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 surveillés (priorité haute) : ") + sb.WriteString("Actifs prioritaires : ") sb.WriteString(strings.Join(symbols, ", ")) sb.WriteString("\n") } - sb.WriteString(fmt.Sprintf("\nRéponds UNIQUEMENT avec un tableau JSON des indices sélectionnés (base 0), exemple : [0, 3, 7, 12]\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 articles { + for i, a := range batch { sb.WriteString(fmt.Sprintf("[%d] %s (%s)\n", i, a.Title, a.SourceName)) } @@ -243,25 +290,9 @@ func (p *Pipeline) GenerateReportAsync(reportID, excerpt, question string, mgr * } func (p *Pipeline) callProviderForReport(ctx context.Context, excerpt, question string) (string, error) { - providerCfg, err := p.repo.GetActiveAIProvider() + provider, _, err := p.buildProviderForRole("report") if err != nil { - return "", fmt.Errorf("get active provider: %w", err) - } - if providerCfg == nil { - return "", fmt.Errorf("no active AI provider configured") - } - - apiKey := "" - if providerCfg.APIKeyEncrypted != "" { - apiKey, err = p.enc.Decrypt(providerCfg.APIKeyEncrypted) - if err != nil { - return "", fmt.Errorf("decrypt API key: %w", err) - } - } - - provider, err := NewProvider(providerCfg.Name, apiKey, providerCfg.Model, providerCfg.Endpoint) - if err != nil { - return "", fmt.Errorf("build provider: %w", err) + return "", err } prompt := fmt.Sprintf( diff --git a/backend/internal/api/handlers/admin.go b/backend/internal/api/handlers/admin.go index e32c1d5..68a1808 100644 --- a/backend/internal/api/handlers/admin.go +++ b/backend/internal/api/handlers/admin.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "fmt" "net/http" @@ -330,6 +331,118 @@ 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) { diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 675a340..a533b7a 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -81,5 +81,14 @@ func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine { 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 } diff --git a/backend/internal/database/migrations/000008_filter_batch.down.sql b/backend/internal/database/migrations/000008_filter_batch.down.sql new file mode 100644 index 0000000..eb8f97a --- /dev/null +++ b/backend/internal/database/migrations/000008_filter_batch.down.sql @@ -0,0 +1 @@ +DELETE FROM settings WHERE key = 'filter_batch_size'; diff --git a/backend/internal/database/migrations/000008_filter_batch.up.sql b/backend/internal/database/migrations/000008_filter_batch.up.sql new file mode 100644 index 0000000..828f0ee --- /dev/null +++ b/backend/internal/database/migrations/000008_filter_batch.up.sql @@ -0,0 +1,2 @@ +INSERT INTO settings (key, value) VALUES ('filter_batch_size', '20') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/internal/models/repository.go b/backend/internal/models/repository.go index b45c61e..b35d860 100644 --- a/backend/internal/models/repository.go +++ b/backend/internal/models/repository.go @@ -590,6 +590,38 @@ func (r *Repository) ListSettings() ([]Setting, error) { 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) { diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index c73e0c6..a80d1cd 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -4,6 +4,12 @@ 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 @@ -32,6 +38,15 @@ export const adminApi = { deleteProvider: (id: string) => api.delete(`/admin/ai-providers/${id}`), listModels: (id: string) => api.get(`/admin/ai-providers/${id}/models`), + // AI Roles + getRoles: () => api.get('/admin/ai-roles'), + updateRole: (role: string, data: AIRoleConfig) => api.put(`/admin/ai-roles/${role}`, data), + + // Ollama model management + listOllamaModels: () => api.get('/admin/ollama/models'), + pullOllamaModel: (name: string) => api.post('/admin/ollama/pull', { name }), + deleteOllamaModel: (name: string) => api.delete(`/admin/ollama/models/${encodeURIComponent(name)}`), + // Sources listSources: () => api.get('/admin/sources'), updateSource: (id: string, enabled: boolean) => api.put(`/admin/sources/${id}`, { enabled }), diff --git a/frontend/src/pages/admin/AIProviders.tsx b/frontend/src/pages/admin/AIProviders.tsx index 8d3dc69..b6871d7 100644 --- a/frontend/src/pages/admin/AIProviders.tsx +++ b/frontend/src/pages/admin/AIProviders.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react' -import { Plus, Trash2, CheckCircle, RefreshCw } from 'lucide-react' -import { adminApi, type AIProvider } from '@/api/admin' +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' @@ -11,182 +11,577 @@ import { Spinner } from '@/components/ui/spinner' const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama'] as const -export function AIProviders() { - const [providers, setProviders] = useState([]) - const [loading, setLoading] = useState(true) - const [showForm, setShowForm] = useState(false) - const [form, setForm] = useState({ name: 'openai', api_key: '', model: '', endpoint: '' }) - const isOllama = form.name === 'ollama' - const [models, setModels] = useState>({}) - const [loadingModels, setLoadingModels] = useState(null) - const [editId, setEditId] = useState(null) - const [saving, setSaving] = useState(false) - const [error, setError] = useState('') +const CUSTOM_MODELS_KEY = 'ollama_custom_models' - useEffect(() => { load() }, []) +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' }] }, +] - async function load() { - setLoading(true) - try { setProviders((await adminApi.listProviders()) ?? []) } finally { setLoading(false) } +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 ( +
+
+
+
+

Télécharger ce modèle ?

+

+ {modelName} sera téléchargé depuis Ollama Hub. + Le téléchargement peut prendre plusieurs minutes. +

+
+ +
+
+ + +
+
+
+ ) +} + +// ── Shared model catalogue ───────────────────────────────────────────────── + +function ModelCatalogue({ + installedNames, + pulling, + onPull, +}: { + installedNames: Set + pulling: string | null + onPull: (name: string) => void +}) { + const [customName, setCustomName] = useState('') + const [pendingModel, setPendingModel] = useState(null) + const [customModels, setCustomModels] = useState(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) } - async function loadModels(id: string) { - setLoadingModels(id) - try { - const m = await adminApi.listModels(id) - setModels(prev => ({ ...prev, [id]: m })) - } catch { /* silently ignore */ } finally { setLoadingModels(null) } + 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) } - async function save() { - setSaving(true); setError('') - try { - if (editId) { - await adminApi.updateProvider(editId, form) - } else { - await adminApi.createProvider(form) + 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 && ( + setPendingModel(null)} + /> + )} + +
+ {/* Free-text input */} +
+

Nom personnalisé

+
+ setCustomName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && submitCustom()} + disabled={pulling !== null} + /> + +
+
+ + {/* Custom models section */} + {customEntries.length > 0 && ( +
+

Personnalisés

+
+ {customEntries.map(fullName => { + const installed = installedNames.has(fullName) + const isPulling = pulling === fullName + return + })} +
+
+ )} + + {/* Hardcoded catalogue */} +
+ {KNOWN_OLLAMA_MODELS.map(family => ( +
+

{family.family}

+
+ {family.tags.map(({ tag, size }) => { + const fullName = `${family.name}:${tag}` + const installed = installedNames.has(fullName) + const isPulling = pulling === fullName + return + })} +
+
+ ))} +
+
+ + ) +} + +function ModelTag({ fullName, size, installed, isPulling, pulling, onRequest }: { + fullName: string; size: string + installed: boolean; isPulling: boolean; pulling: string | null + onRequest: (name: string) => void +}) { + return ( + + ) +} - async function activate(id: string) { - await adminApi.activateProvider(id) - await load() - } +// ── Ollama model picker (inside provider form) ───────────────────────────── - async function remove(id: string) { - if (!confirm('Supprimer ce fournisseur ?')) return - await adminApi.deleteProvider(id) - await load() - } +function OllamaModelPicker({ value, installedModels, onSelect, onRefresh }: { + value: string + installedModels: OllamaModelInfo[] + onSelect: (name: string) => void + onRefresh: () => Promise +}) { + const [showCatalogue, setShowCatalogue] = useState(installedModels.length === 0) + const [pulling, setPulling] = useState(null) + const installedNames = new Set(installedModels.map(m => m.name)) - function startEdit(p: AIProvider) { - setEditId(p.id) - setForm({ name: p.name, api_key: '', model: p.model, endpoint: p.endpoint }) - setShowForm(true) + async function pull(fullName: string) { + setPulling(fullName) + try { + await adminApi.pullOllamaModel(fullName) + await new Promise(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 ( -
-
-
-

Fournisseurs IA

-

Configurez les fournisseurs IA et sélectionnez le modèle actif

-
- -
- - {showForm && ( - - {editId ? 'Modifier' : 'Nouveau fournisseur'} - -
-
- - -
- {!isOllama && ( -
- - setForm(f => ({ ...f, api_key: e.target.value }))} /> -
- )} -
- - setForm(f => ({ ...f, model: e.target.value }))} /> -
- {isOllama && ( -
- - -
- )} -
- {error &&

{error}

} -
- - -
-
-
- )} - - {loading ? ( -
- ) : ( -
- {providers.map(p => ( - - -
-
- {p.name} - {p.is_active && Actif} - {!p.has_key && p.name !== 'ollama' && Sans clé} -
-
- {p.model && Modèle : {p.model}} - {p.endpoint && Endpoint : {p.endpoint}} -
- {/* Dropdown modèles disponibles */} - {models[p.id] && models[p.id].length > 0 && ( -
- - -
- )} -
-
- - - {!p.is_active && ( - - )} - -
-
-
- ))} - {providers.length === 0 && ( - Aucun fournisseur configuré - )} +
+ + + {showCatalogue && ( +
+
)}
) } + +// ── Role Assignment ──────────────────────────────────────────────────────── + +const ROLE_LABELS: Record = { + 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 +}) { + 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 ( +
+
+ +

{desc}

+ +
+ +
+ ) +} + +// ── Ollama model manager card ────────────────────────────────────────────── + +function OllamaModelsCard({ installedModels, onRefresh, onDelete }: { + installedModels: OllamaModelInfo[] + onRefresh: () => Promise + onDelete: (name: string) => Promise +}) { + const [showCatalogue, setShowCatalogue] = useState(false) + const [pulling, setPulling] = useState(null) + const [deleting, setDeleting] = useState(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(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 ( + + +
+ + Modèles Ollama + + +
+
+ +
+

+ Installés ({installedModels.length}) +

+ {installedModels.length === 0 ? ( +

Aucun modèle installé

+ ) : ( +
+ {installedModels.map(m => ( +
+
+ {m.name} + + {formatSize(m.size)} + {m.details.parameter_size && <> · {m.details.parameter_size}} + {m.details.quantization_level && <> · {m.details.quantization_level}} + +
+ +
+ ))} +
+ )} +
+ {showCatalogue && ( +
+

Catalogue

+ +
+ )} +
+
+ ) +} + +// ── Main Page ────────────────────────────────────────────────────────────── + +export function AIProviders() { + const [providers, setProviders] = useState([]) + const [roles, setRoles] = useState(null) + const [ollamaModels, setOllamaModels] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ name: 'openai', api_key: '', model: '', endpoint: '' }) + const [editId, setEditId] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const isOllamaForm = form.name === 'ollama' + + const loadOllamaModels = useCallback(async (): Promise => { + 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 ( +
+
+

Fournisseurs IA

+

Configurez les fournisseurs et assignez un modèle à chaque tâche

+
+ + {loading ? ( +
+ ) : ( + <> + {/* Role assignments */} + {roles && providers.length > 0 && ( + + Assignation des tâches + + {(['summary', 'report', 'filter'] as const).map(role => ( +
+ saveRole(role, pid)} /> +
+ ))} +
+
+ )} + + {/* Providers list */} + + +
+ Fournisseurs configurés + +
+
+ + {showForm && ( +
+

{editId ? 'Modifier le fournisseur' : 'Nouveau fournisseur'}

+
+
+ + +
+ {!isOllamaForm && ( +
+ + setForm(f => ({ ...f, api_key: e.target.value }))} /> +
+ )} + {isOllamaForm && ( +
+ + setForm(f => ({ ...f, endpoint: e.target.value }))} /> +
+ )} +
+
+ + {isOllamaForm ? ( + setForm(f => ({ ...f, model: m }))} + onRefresh={loadOllamaModels} /> + ) : ( + setForm(f => ({ ...f, model: e.target.value }))} /> + )} +
+ {error &&

{error}

} +
+ + +
+
+ )} + + {providers.length === 0 && !showForm && ( +

Aucun fournisseur configuré

+ )} + + {providers.map(p => ( +
+
+
+ {p.name} + {p.is_active && Défaut} + {!p.has_key && p.name !== 'ollama' && Sans clé} +
+
+ {p.model && Modèle : {p.model}} + {p.endpoint && {p.endpoint}} +
+
+
+ + {!p.is_active && ( + + )} + +
+
+ ))} +
+
+ + {/* Ollama model manager — always shown so user can install even without provider configured */} + + + )} +
+ ) +} diff --git a/frontend/src/pages/admin/AdminSettings.tsx b/frontend/src/pages/admin/AdminSettings.tsx index 2053c1d..e200a88 100644 --- a/frontend/src/pages/admin/AdminSettings.tsx +++ b/frontend/src/pages/admin/AdminSettings.tsx @@ -8,7 +8,8 @@ import { Label } from '@/components/ui/label' import { Spinner } from '@/components/ui/spinner' const NUMERIC_SETTINGS: Record = { - summary_max_articles: { label: 'Articles max par résumé', description: 'Nombre maximum d\'articles envoyés à l\'IA' }, + summary_max_articles: { label: 'Articles max par résumé', description: 'Nombre maximum d\'articles envoyés à l\'IA pour la passe 2 (résumé)' }, + filter_batch_size: { label: 'Taille des batches (filtre)', description: 'Nombre d\'articles par appel IA lors de la passe 1 (filtrage). Réduire pour des réponses plus rapides.' }, } const COMMON_TIMEZONES = [