feat: add download features to llm models
This commit is contained in:
@ -20,7 +20,13 @@
|
|||||||
"Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin/node node_modules/.bin/tsc --noEmit)",
|
"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(chmod +x /home/anthony/Documents/Projects/Tradarr/build-push.sh)",
|
||||||
"Bash(fish -c \"npm install react-markdown\")",
|
"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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,31 +7,51 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"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
|
endpoint string
|
||||||
model string
|
model string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func newOllama(endpoint, model string) *ollamaProvider {
|
func newOllama(endpoint, model string) *OllamaProvider {
|
||||||
if endpoint == "" {
|
if endpoint == "" {
|
||||||
endpoint = "http://ollama:11434"
|
endpoint = "http://ollama:11434"
|
||||||
}
|
}
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "llama3"
|
model = "llama3"
|
||||||
}
|
}
|
||||||
return &ollamaProvider{
|
return &OllamaProvider{
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
client: &http.Client{},
|
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
|
numCtx := 32768
|
||||||
if opts.NumCtx > 0 {
|
if opts.NumCtx > 0 {
|
||||||
numCtx = opts.NumCtx
|
numCtx = opts.NumCtx
|
||||||
@ -72,7 +92,19 @@ func (p *ollamaProvider) Summarize(ctx context.Context, prompt string, opts GenO
|
|||||||
return result.Response, nil
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.endpoint+"/api/tags", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -85,16 +117,52 @@ func (p *ollamaProvider) ListModels(ctx context.Context) ([]string, error) {
|
|||||||
|
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
var result struct {
|
var result struct {
|
||||||
Models []struct {
|
Models []OllamaModelInfo `json:"models"`
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"models"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &result); err != nil {
|
if err := json.Unmarshal(raw, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var models []string
|
return result.Models, nil
|
||||||
for _, m := range result.Models {
|
|
||||||
models = append(models, m.Name)
|
|
||||||
}
|
}
|
||||||
return 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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,28 +50,36 @@ func (p *Pipeline) BuildProvider(name, apiKey, endpoint string) (Provider, error
|
|||||||
return NewProvider(name, apiKey, model, endpoint)
|
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) {
|
func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models.Summary, error) {
|
||||||
p.generating.Store(true)
|
p.generating.Store(true)
|
||||||
defer p.generating.Store(false)
|
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 := ""
|
provider, providerCfg, err := p.buildProviderForRole("summary")
|
||||||
if providerCfg.APIKeyEncrypted != "" {
|
|
||||||
apiKey, err = p.enc.Decrypt(providerCfg.APIKeyEncrypted)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decrypt API key: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
provider, err := NewProvider(providerCfg.Name, apiKey, providerCfg.Model, providerCfg.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build provider: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assets, err := p.repo.GetUserAssets(userID)
|
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
|
// Passe 1 : filtrage par pertinence — seulement si nettement plus d'articles que le max
|
||||||
if len(articles) > maxArticles*2 {
|
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)
|
fmt.Printf("[pipeline] Passe 1 — filtrage : %d articles → sélection des %d plus pertinents…\n", len(articles), maxArticles)
|
||||||
t1 := time.Now()
|
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))
|
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 {
|
} else if len(articles) > maxArticles {
|
||||||
articles = 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)
|
return p.repo.CreateSummary(userID, summary, &providerCfg.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterByRelevance demande à l'IA de sélectionner les articles les plus pertinents
|
// filterByRelevance splits articles into batches and asks the AI to select relevant
|
||||||
// en ne lui envoyant que les titres (prompt très court = rapide).
|
// 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 {
|
func (p *Pipeline) filterByRelevance(ctx context.Context, provider Provider, symbols []string, articles []models.Article, max int) []models.Article {
|
||||||
prompt := buildFilterPrompt(symbols, articles, max)
|
batchSizeStr, _ := p.repo.GetSetting("filter_batch_size")
|
||||||
// Passe 1 : pas de think, contexte réduit (titres seulement = prompt court)
|
batchSize, _ := strconv.Atoi(batchSizeStr)
|
||||||
response, err := provider.Summarize(ctx, prompt, GenOptions{Think: false, NumCtx: 8192})
|
if batchSize <= 0 {
|
||||||
if err != nil {
|
batchSize = 20
|
||||||
fmt.Printf("[pipeline] Passe 1 — échec (%v), repli sur troncature\n", err)
|
|
||||||
return articles[:max]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if len(indices) == 0 {
|
||||||
fmt.Printf("[pipeline] Passe 1 — réponse non parseable, repli sur troncature\n")
|
return nil
|
||||||
return articles[:max]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered := make([]models.Article, 0, len(indices))
|
filtered := make([]models.Article, 0, len(indices))
|
||||||
for _, i := range indices {
|
for _, i := range indices {
|
||||||
filtered = append(filtered, articles[i])
|
filtered = append(filtered, batch[i])
|
||||||
if len(filtered) >= max {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildFilterPrompt(symbols []string, articles []models.Article, max int) string {
|
func buildFilterBatchPrompt(symbols []string, batch []models.Article) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("Tu es un assistant de trading financier. ")
|
sb.WriteString("Tu es un assistant de trading financier.\n")
|
||||||
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(fmt.Sprintf("Parmi les %d articles ci-dessous, sélectionne TOUS ceux pertinents pour un trader actif.\n", len(batch)))
|
||||||
|
|
||||||
if len(symbols) > 0 {
|
if len(symbols) > 0 {
|
||||||
sb.WriteString("Actifs surveillés (priorité haute) : ")
|
sb.WriteString("Actifs prioritaires : ")
|
||||||
sb.WriteString(strings.Join(symbols, ", "))
|
sb.WriteString(strings.Join(symbols, ", "))
|
||||||
sb.WriteString("\n")
|
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("N'ajoute aucun texte avant ou après le tableau JSON.\n\n")
|
||||||
sb.WriteString("Articles :\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))
|
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) {
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("get active provider: %w", err)
|
return "", 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt := fmt.Sprintf(
|
prompt := fmt.Sprintf(
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@ -330,6 +331,118 @@ func (h *Handler) GetDefaultSystemPrompt(c *gin.Context) {
|
|||||||
httputil.OK(c, gin.H{"prompt": ai.DefaultSystemPrompt})
|
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 ────────────────────────────────────────────────────────────
|
// ── Admin Users ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) ListUsers(c *gin.Context) {
|
func (h *Handler) ListUsers(c *gin.Context) {
|
||||||
|
|||||||
@ -81,5 +81,14 @@ func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine {
|
|||||||
admin.PUT("/users/:id", h.UpdateAdminUser)
|
admin.PUT("/users/:id", h.UpdateAdminUser)
|
||||||
admin.DELETE("/users/:id", h.DeleteAdminUser)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -590,6 +590,38 @@ func (r *Repository) ListSettings() ([]Setting, error) {
|
|||||||
return settings, nil
|
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 ────────────────────────────────────────────────────────────────
|
// ── Reports ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (r *Repository) CreatePendingReport(userID string, summaryID *string, excerpt, question string) (*Report, error) {
|
func (r *Repository) CreatePendingReport(userID string, summaryID *string, excerpt, question string) (*Report, error) {
|
||||||
|
|||||||
@ -4,6 +4,12 @@ export interface AIProvider {
|
|||||||
id: string; name: string; model: string; endpoint: string
|
id: string; name: string; model: string; endpoint: string
|
||||||
is_active: boolean; has_key: boolean
|
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 Source { id: string; name: string; type: string; enabled: boolean }
|
||||||
export interface ScrapeJob {
|
export interface ScrapeJob {
|
||||||
id: string; source_id: string; source_name: string; status: string
|
id: string; source_id: string; source_name: string; status: string
|
||||||
@ -32,6 +38,15 @@ export const adminApi = {
|
|||||||
deleteProvider: (id: string) => api.delete<void>(`/admin/ai-providers/${id}`),
|
deleteProvider: (id: string) => api.delete<void>(`/admin/ai-providers/${id}`),
|
||||||
listModels: (id: string) => api.get<string[]>(`/admin/ai-providers/${id}/models`),
|
listModels: (id: string) => api.get<string[]>(`/admin/ai-providers/${id}/models`),
|
||||||
|
|
||||||
|
// 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
|
// Sources
|
||||||
listSources: () => api.get<Source[]>('/admin/sources'),
|
listSources: () => api.get<Source[]>('/admin/sources'),
|
||||||
updateSource: (id: string, enabled: boolean) => api.put<void>(`/admin/sources/${id}`, { enabled }),
|
updateSource: (id: string, enabled: boolean) => api.put<void>(`/admin/sources/${id}`, { enabled }),
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Plus, Trash2, CheckCircle, RefreshCw } from 'lucide-react'
|
import { Plus, Trash2, Star, Download, HardDrive, Cpu, ChevronDown, ChevronUp, X } from 'lucide-react'
|
||||||
import { adminApi, type AIProvider } from '@/api/admin'
|
import { adminApi, type AIProvider, type AIRoles, type OllamaModelInfo } from '@/api/admin'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
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
|
const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama'] as const
|
||||||
|
|
||||||
export function AIProviders() {
|
const CUSTOM_MODELS_KEY = 'ollama_custom_models'
|
||||||
const [providers, setProviders] = useState<AIProvider[]>([])
|
|
||||||
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<Record<string, string[]>>({})
|
|
||||||
const [loadingModels, setLoadingModels] = useState<string | null>(null)
|
|
||||||
const [editId, setEditId] = useState<string | null>(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
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() {
|
function formatSize(bytes: number): string {
|
||||||
setLoading(true)
|
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`
|
||||||
try { setProviders((await adminApi.listProviders()) ?? []) } finally { setLoading(false) }
|
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`
|
||||||
|
return `${bytes} B`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModels(id: string) {
|
function loadCustomModels(): string[] {
|
||||||
setLoadingModels(id)
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 {
|
try {
|
||||||
const m = await adminApi.listModels(id)
|
await adminApi.pullOllamaModel(fullName)
|
||||||
setModels(prev => ({ ...prev, [id]: m }))
|
await new Promise<void>(resolve => {
|
||||||
} catch { /* silently ignore */ } finally { setLoadingModels(null) }
|
const iv = setInterval(async () => {
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
setSaving(true); setError('')
|
|
||||||
try {
|
try {
|
||||||
if (editId) {
|
const updated = await onRefresh()
|
||||||
await adminApi.updateProvider(editId, form)
|
if (updated.some(m => m.name === fullName)) { clearInterval(iv); onSelect(fullName); setShowCatalogue(false); resolve() }
|
||||||
} else {
|
} catch { /* ignore */ }
|
||||||
await adminApi.createProvider(form)
|
}, 4000)
|
||||||
}
|
setTimeout(() => { clearInterval(iv); resolve() }, 1800000)
|
||||||
setShowForm(false); setEditId(null)
|
})
|
||||||
setForm({ name: 'openai', api_key: '', model: '', endpoint: '' })
|
} finally { setPulling(null) }
|
||||||
await load()
|
|
||||||
} catch (e) { setError(e instanceof Error ? e.message : 'Erreur') } finally { setSaving(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function activate(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<Select value={value} onChange={e => onSelect(e.target.value)}>
|
||||||
<div>
|
<option value="">— Choisir un modèle installé —</option>
|
||||||
<h1 className="text-2xl font-bold">Fournisseurs IA</h1>
|
{installedModels.map(m => (
|
||||||
<p className="text-muted-foreground text-sm">Configurez les fournisseurs IA et sélectionnez le modèle actif</p>
|
<option key={m.name} value={m.name}>
|
||||||
</div>
|
{m.name}{m.details.parameter_size ? ` (${m.details.parameter_size})` : ''}
|
||||||
<Button onClick={() => { setShowForm(true); setEditId(null) }}>
|
</option>
|
||||||
<Plus className="h-4 w-4" /> Ajouter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader><CardTitle>{editId ? 'Modifier' : 'Nouveau fournisseur'}</CardTitle></CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Fournisseur</Label>
|
|
||||||
<Select
|
|
||||||
value={form.name}
|
|
||||||
onChange={e => {
|
|
||||||
const name = e.target.value
|
|
||||||
setForm(f => ({
|
|
||||||
...f,
|
|
||||||
name,
|
|
||||||
endpoint: name === 'ollama' ? 'http://ollama:11434' : '',
|
|
||||||
api_key: '',
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
disabled={!!editId}
|
|
||||||
>
|
|
||||||
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{!isOllama && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Clé API {editId && <span className="text-muted-foreground">(laisser vide pour conserver)</span>}</Label>
|
|
||||||
<Input type="password" placeholder="sk-..." value={form.api_key} onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Modèle</Label>
|
|
||||||
<Input placeholder="gpt-4o-mini, claude-sonnet-4-6…" value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
{isOllama && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Endpoint Ollama</Label>
|
|
||||||
<Input value="http://ollama:11434" readOnly className="opacity-60 cursor-not-allowed" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={save} disabled={saving}>{saving ? <Spinner className="h-4 w-4" /> : 'Enregistrer'}</Button>
|
|
||||||
<Button variant="outline" onClick={() => { setShowForm(false); setEditId(null) }}>Annuler</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex justify-center py-12"><Spinner /></div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{providers.map(p => (
|
|
||||||
<Card key={p.id} className={p.is_active ? 'border-primary/50' : ''}>
|
|
||||||
<CardContent className="flex flex-wrap items-center gap-4 py-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="font-semibold capitalize">{p.name}</span>
|
|
||||||
{p.is_active && <Badge variant="default">Actif</Badge>}
|
|
||||||
{!p.has_key && p.name !== 'ollama' && <Badge variant="outline" className="text-yellow-500 border-yellow-500">Sans clé</Badge>}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground mt-1 flex gap-4 flex-wrap">
|
|
||||||
{p.model && <span>Modèle : <strong>{p.model}</strong></span>}
|
|
||||||
{p.endpoint && <span>Endpoint : {p.endpoint}</span>}
|
|
||||||
</div>
|
|
||||||
{/* Dropdown modèles disponibles */}
|
|
||||||
{models[p.id] && models[p.id].length > 0 && (
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
<Label className="text-xs">Choisir un modèle :</Label>
|
|
||||||
<Select
|
|
||||||
className="w-full max-w-xs"
|
|
||||||
value={p.model}
|
|
||||||
onChange={async e => {
|
|
||||||
await adminApi.updateProvider(p.id, { name: p.name, model: e.target.value, endpoint: p.endpoint })
|
|
||||||
await load()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{models[p.id].map(m => <option key={m} value={m}>{m}</option>)}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => loadModels(p.id)} disabled={loadingModels === p.id}>
|
|
||||||
{loadingModels === p.id ? <Spinner className="h-3 w-3" /> : <RefreshCw className="h-3 w-3" />}
|
|
||||||
Modèles
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => startEdit(p)}>Modifier</Button>
|
|
||||||
{!p.is_active && (
|
|
||||||
<Button size="sm" onClick={() => activate(p.id)}>
|
|
||||||
<CheckCircle className="h-3 w-3" /> Activer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="destructive" size="sm" onClick={() => remove(p.id)}>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
{providers.length === 0 && (
|
</Select>
|
||||||
<Card><CardContent className="py-8 text-center text-muted-foreground">Aucun fournisseur configuré</CardContent></Card>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 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 && (
|
||||||
|
<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} />
|
||||||
|
) : (
|
||||||
|
<Input placeholder="gpt-4o-mini, claude-sonnet-4-6…" value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
|
||||||
|
)}
|
||||||
|
</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' && <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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
|
|
||||||
const NUMERIC_SETTINGS: Record<string, { label: string; description: string }> = {
|
const NUMERIC_SETTINGS: Record<string, { label: string; description: string }> = {
|
||||||
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 = [
|
const COMMON_TIMEZONES = [
|
||||||
|
|||||||
Reference in New Issue
Block a user