diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1218e3f..e910d59 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,14 @@ "Bash(/home/anthony/go/bin/go build *)", "Bash(npm install *)", "Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin/npm install *)", - "Bash(npm run *)" + "Bash(npm run *)", + "Bash(go build *)", + "Bash(npx tsc *)", + "Bash(node_modules/.bin/tsc --noEmit)", + "Bash(/usr/bin/node node_modules/.bin/tsc --noEmit)", + "Bash(fish -c \"which node\")", + "Read(//opt/**)", + "Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin/node node_modules/.bin/tsc --noEmit)" ] } } diff --git a/backend/internal/ai/pipeline.go b/backend/internal/ai/pipeline.go index c1da501..d8356ce 100644 --- a/backend/internal/ai/pipeline.go +++ b/backend/internal/ai/pipeline.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "sync/atomic" "time" "github.com/tradarr/backend/internal/crypto" @@ -24,14 +25,19 @@ Structure ton résumé ainsi : 4. **Synthèse** : points d'attention prioritaires pour la journée` type Pipeline struct { - repo *models.Repository - enc *crypto.Encryptor + repo *models.Repository + enc *crypto.Encryptor + generating atomic.Bool } func NewPipeline(repo *models.Repository, enc *crypto.Encryptor) *Pipeline { return &Pipeline{repo: repo, enc: enc} } +func (p *Pipeline) IsGenerating() bool { + return p.generating.Load() +} + func (p *Pipeline) BuildProvider(name, apiKey, endpoint string) (Provider, error) { provider, err := p.repo.GetActiveAIProvider() if err != nil { @@ -45,6 +51,8 @@ func (p *Pipeline) BuildProvider(name, apiKey, endpoint string) (Provider, error } 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) @@ -97,9 +105,10 @@ func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models. // Passe 1 : filtrage par pertinence sur les titres si trop d'articles if len(articles) > maxArticles { - fmt.Printf("pipeline: %d articles → filtering to %d via AI\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() articles = p.filterByRelevance(ctx, provider, symbols, articles, maxArticles) - fmt.Printf("pipeline: %d articles retained after filtering\n", len(articles)) + fmt.Printf("[pipeline] Passe 1 — terminée en %s : %d articles retenus\n", time.Since(t1).Round(time.Second), len(articles)) } systemPrompt, _ := p.repo.GetSetting("ai_system_prompt") @@ -108,11 +117,14 @@ func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models. } // Passe 2 : résumé complet + fmt.Printf("[pipeline] Passe 2 — résumé : génération sur %d articles…\n", len(articles)) + t2 := time.Now() prompt := buildPrompt(systemPrompt, symbols, articles) summary, err := provider.Summarize(ctx, prompt) if err != nil { return nil, fmt.Errorf("AI summarize: %w", err) } + fmt.Printf("[pipeline] Passe 2 — terminée en %s\n", time.Since(t2).Round(time.Second)) return p.repo.CreateSummary(userID, summary, &providerCfg.ID) } @@ -123,13 +135,13 @@ func (p *Pipeline) filterByRelevance(ctx context.Context, provider Provider, sym prompt := buildFilterPrompt(symbols, articles, max) response, err := provider.Summarize(ctx, prompt) if err != nil { - fmt.Printf("pipeline: filter AI call failed (%v), falling back to truncation\n", err) + fmt.Printf("[pipeline] Passe 1 — échec (%v), repli sur troncature\n", err) return articles[:max] } indices := parseIndexArray(response, len(articles)) if len(indices) == 0 { - fmt.Printf("pipeline: could not parse filter response, falling back to truncation\n") + fmt.Printf("[pipeline] Passe 1 — réponse non parseable, repli sur troncature\n") return articles[:max] } @@ -201,6 +213,58 @@ func (p *Pipeline) GenerateForAll(ctx context.Context) error { return nil } +// GenerateReportAsync crée le rapport en DB (status=generating) et lance la génération en arrière-plan. +func (p *Pipeline) GenerateReportAsync(reportID, excerpt, question string, mgr *ReportManager) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + mgr.Register(reportID, cancel) + + go func() { + defer cancel() + defer mgr.Remove(reportID) + + answer, err := p.callProviderForReport(ctx, excerpt, question) + if err != nil { + if ctx.Err() != nil { + // annulé volontairement — le rapport est supprimé par le handler + return + } + _ = p.repo.UpdateReport(reportID, "error", "", err.Error()) + return + } + _ = p.repo.UpdateReport(reportID, "done", answer, "") + }() +} + +func (p *Pipeline) callProviderForReport(ctx context.Context, excerpt, question string) (string, error) { + providerCfg, err := p.repo.GetActiveAIProvider() + 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) + } + + prompt := fmt.Sprintf( + "Tu es un assistant financier expert. L'utilisateur a sélectionné les extraits suivants d'un résumé de marché :\n\n%s\n\nQuestion de l'utilisateur : %s\n\nRéponds en français, de façon précise et orientée trading.", + excerpt, question, + ) + + return provider.Summarize(ctx, prompt) +} + func buildPrompt(systemPrompt string, symbols []string, articles []models.Article) string { var sb strings.Builder sb.WriteString(systemPrompt) diff --git a/backend/internal/ai/report_manager.go b/backend/internal/ai/report_manager.go new file mode 100644 index 0000000..f333eb7 --- /dev/null +++ b/backend/internal/ai/report_manager.go @@ -0,0 +1,37 @@ +package ai + +import ( + "context" + "sync" +) + +// ReportManager tracks in-flight report goroutines so they can be cancelled. +type ReportManager struct { + mu sync.Mutex + cancels map[string]context.CancelFunc +} + +func NewReportManager() *ReportManager { + return &ReportManager{cancels: make(map[string]context.CancelFunc)} +} + +func (m *ReportManager) Register(id string, cancel context.CancelFunc) { + m.mu.Lock() + defer m.mu.Unlock() + m.cancels[id] = cancel +} + +func (m *ReportManager) Cancel(id string) { + m.mu.Lock() + defer m.mu.Unlock() + if cancel, ok := m.cancels[id]; ok { + cancel() + delete(m.cancels, id) + } +} + +func (m *ReportManager) Remove(id string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.cancels, id) +} diff --git a/backend/internal/api/handlers/handler.go b/backend/internal/api/handlers/handler.go index 1035187..9dfaa37 100644 --- a/backend/internal/api/handlers/handler.go +++ b/backend/internal/api/handlers/handler.go @@ -10,12 +10,13 @@ import ( ) type Handler struct { - repo *models.Repository - cfg *config.Config - enc *crypto.Encryptor - registry *scraper.Registry - pipeline *ai.Pipeline - scheduler *scheduler.Scheduler + repo *models.Repository + cfg *config.Config + enc *crypto.Encryptor + registry *scraper.Registry + pipeline *ai.Pipeline + scheduler *scheduler.Scheduler + reportManager *ai.ReportManager } func New( @@ -27,11 +28,12 @@ func New( sched *scheduler.Scheduler, ) *Handler { return &Handler{ - repo: repo, - cfg: cfg, - enc: enc, - registry: registry, - pipeline: pipeline, - scheduler: sched, + repo: repo, + cfg: cfg, + enc: enc, + registry: registry, + pipeline: pipeline, + scheduler: sched, + reportManager: ai.NewReportManager(), } } diff --git a/backend/internal/api/handlers/reports.go b/backend/internal/api/handlers/reports.go new file mode 100644 index 0000000..75378bd --- /dev/null +++ b/backend/internal/api/handlers/reports.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tradarr/backend/internal/httputil" +) + +type reportRequest struct { + SummaryID string `json:"summary_id"` + Excerpts []string `json:"excerpts" binding:"required,min=1"` + Question string `json:"question" binding:"required"` +} + +func (h *Handler) CreateReport(c *gin.Context) { + userID := c.GetString("userID") + var req reportRequest + if err := c.ShouldBindJSON(&req); err != nil { + httputil.BadRequest(c, err) + return + } + + var summaryID *string + if req.SummaryID != "" { + summaryID = &req.SummaryID + } + + // Joindre les extraits avec un séparateur visuel + excerpt := buildExcerptContext(req.Excerpts) + + // Créer le rapport en DB avec status=generating, retourner immédiatement + report, err := h.repo.CreatePendingReport(userID, summaryID, excerpt, req.Question) + if err != nil { + httputil.InternalError(c, err) + return + } + + // Lancer la génération en arrière-plan + h.pipeline.GenerateReportAsync(report.ID, excerpt, req.Question, h.reportManager) + + c.JSON(http.StatusCreated, report) +} + +func (h *Handler) ListReports(c *gin.Context) { + userID := c.GetString("userID") + reports, err := h.repo.ListReports(userID) + if err != nil { + httputil.InternalError(c, err) + return + } + httputil.OK(c, reports) +} + +func (h *Handler) DeleteReport(c *gin.Context) { + userID := c.GetString("userID") + id := c.Param("id") + // Annuler la goroutine si elle tourne encore + h.reportManager.Cancel(id) + if err := h.repo.DeleteReport(id, userID); err != nil { + httputil.InternalError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *Handler) GetGeneratingStatus(c *gin.Context) { + httputil.OK(c, gin.H{"generating": h.pipeline.IsGenerating()}) +} + +func buildExcerptContext(excerpts []string) string { + if len(excerpts) == 1 { + return excerpts[0] + } + var sb strings.Builder + for i, e := range excerpts { + if i > 0 { + sb.WriteString("\n\n---\n\n") + } + sb.WriteString(e) + } + return sb.String() +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index a2adb7b..675a340 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -7,7 +7,11 @@ import ( ) func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine { - r := gin.Default() + r := gin.New() + r.Use(gin.Recovery()) + r.Use(gin.LoggerWithConfig(gin.LoggerConfig{ + SkipPaths: []string{"/api/summaries/status"}, + })) r.Use(func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") @@ -39,8 +43,13 @@ func SetupRouter(h *handlers.Handler, jwtSecret string) *gin.Engine { authed.GET("/articles/:id", h.GetArticle) authed.GET("/summaries", h.ListSummaries) + authed.GET("/summaries/status", h.GetGeneratingStatus) authed.POST("/summaries/generate", h.GenerateSummary) + authed.GET("/reports", h.ListReports) + authed.POST("/reports", h.CreateReport) + authed.DELETE("/reports/:id", h.DeleteReport) + // Admin admin := authed.Group("/admin") admin.Use(auth.AdminOnly()) diff --git a/backend/internal/database/migrations/000005_reports.down.sql b/backend/internal/database/migrations/000005_reports.down.sql new file mode 100644 index 0000000..8e3fa8a --- /dev/null +++ b/backend/internal/database/migrations/000005_reports.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS reports; diff --git a/backend/internal/database/migrations/000005_reports.up.sql b/backend/internal/database/migrations/000005_reports.up.sql new file mode 100644 index 0000000..3eb79d1 --- /dev/null +++ b/backend/internal/database/migrations/000005_reports.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + summary_id UUID REFERENCES summaries(id) ON DELETE SET NULL, + context_excerpt TEXT NOT NULL, + question TEXT NOT NULL, + answer TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/internal/database/migrations/000006_reports_status.down.sql b/backend/internal/database/migrations/000006_reports_status.down.sql new file mode 100644 index 0000000..dfb2171 --- /dev/null +++ b/backend/internal/database/migrations/000006_reports_status.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE reports + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS error_msg, + ALTER COLUMN answer DROP DEFAULT; diff --git a/backend/internal/database/migrations/000006_reports_status.up.sql b/backend/internal/database/migrations/000006_reports_status.up.sql new file mode 100644 index 0000000..93829f0 --- /dev/null +++ b/backend/internal/database/migrations/000006_reports_status.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE reports + ALTER COLUMN answer SET DEFAULT '', + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'done', + ADD COLUMN error_msg TEXT NOT NULL DEFAULT ''; diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index f6b4bc7..2f86af6 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -104,3 +104,15 @@ type ScheduleSlot struct { Hour int `json:"hour"` Minute int `json:"minute"` } + +type Report struct { + ID string `json:"id"` + UserID string `json:"user_id"` + SummaryID *string `json:"summary_id"` + ContextExcerpt string `json:"context_excerpt"` + Question string `json:"question"` + Answer string `json:"answer"` + Status string `json:"status"` // generating | done | error + ErrorMsg string `json:"error_msg"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/models/repository.go b/backend/internal/models/repository.go index 5af29a5..b45c61e 100644 --- a/backend/internal/models/repository.go +++ b/backend/internal/models/repository.go @@ -187,20 +187,28 @@ func (r *Repository) UpdateSource(id string, enabled bool) error { // ── Articles ─────────────────────────────────────────────────────────────── -func (r *Repository) UpsertArticle(sourceID, title, content, url string, publishedAt *time.Time) (*Article, error) { - a := &Article{} +// InsertArticleIfNew insère l'article uniquement s'il n'existe pas déjà (par URL). +// Retourne (article, true, nil) si inséré, (nil, false, nil) si déjà présent. +func (r *Repository) InsertArticleIfNew(sourceID, title, content, url string, publishedAt *time.Time) (*Article, bool, error) { var pa sql.NullTime if publishedAt != nil { pa = sql.NullTime{Time: *publishedAt, Valid: true} } + a := &Article{} err := r.db.QueryRow(` INSERT INTO articles (source_id, title, content, url, published_at) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (url) DO UPDATE SET title=EXCLUDED.title, content=EXCLUDED.content + ON CONFLICT (url) DO NOTHING RETURNING id, source_id, title, content, url, published_at, created_at`, sourceID, title, content, url, pa, ).Scan(&a.ID, &a.SourceID, &a.Title, &a.Content, &a.URL, &a.PublishedAt, &a.CreatedAt) - return a, err + if err == sql.ErrNoRows { + return nil, false, nil // déjà présent + } + if err != nil { + return nil, false, err + } + return a, true, nil } func (r *Repository) AddArticleSymbol(articleID, symbol string) error { @@ -260,7 +268,7 @@ func (r *Repository) GetRecentArticles(hours int) ([]Article, error) { SELECT a.id, a.source_id, s.name, a.title, a.content, a.url, a.published_at, a.created_at FROM articles a JOIN sources s ON s.id = a.source_id - WHERE a.created_at > NOW() - ($1 * INTERVAL '1 hour') + WHERE COALESCE(a.published_at, a.created_at) > NOW() - ($1 * INTERVAL '1 hour') ORDER BY a.published_at DESC NULLS LAST, a.created_at DESC`, hours) if err != nil { return nil, err @@ -581,3 +589,48 @@ func (r *Repository) ListSettings() ([]Setting, error) { } return settings, nil } + +// ── Reports ──────────────────────────────────────────────────────────────── + +func (r *Repository) CreatePendingReport(userID string, summaryID *string, excerpt, question string) (*Report, error) { + rep := &Report{} + err := r.db.QueryRow(` + INSERT INTO reports (user_id, summary_id, context_excerpt, question, answer, status) + VALUES ($1, $2, $3, $4, '', 'generating') + RETURNING id, user_id, summary_id, context_excerpt, question, answer, status, error_msg, created_at`, + userID, summaryID, excerpt, question, + ).Scan(&rep.ID, &rep.UserID, &rep.SummaryID, &rep.ContextExcerpt, &rep.Question, &rep.Answer, &rep.Status, &rep.ErrorMsg, &rep.CreatedAt) + return rep, err +} + +func (r *Repository) UpdateReport(id, status, answer, errorMsg string) error { + _, err := r.db.Exec(` + UPDATE reports SET status=$1, answer=$2, error_msg=$3 WHERE id=$4`, + status, answer, errorMsg, id) + return err +} + +func (r *Repository) ListReports(userID string) ([]Report, error) { + rows, err := r.db.Query(` + SELECT id, user_id, summary_id, context_excerpt, question, answer, status, error_msg, created_at + FROM reports WHERE user_id=$1 + ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var reports []Report + for rows.Next() { + var rep Report + if err := rows.Scan(&rep.ID, &rep.UserID, &rep.SummaryID, &rep.ContextExcerpt, &rep.Question, &rep.Answer, &rep.Status, &rep.ErrorMsg, &rep.CreatedAt); err != nil { + return nil, err + } + reports = append(reports, rep) + } + return reports, nil +} + +func (r *Repository) DeleteReport(id, userID string) error { + _, err := r.db.Exec(`DELETE FROM reports WHERE id=$1 AND user_id=$2`, id, userID) + return err +} diff --git a/backend/internal/scraper/registry.go b/backend/internal/scraper/registry.go index 6c7b30c..fe47991 100644 --- a/backend/internal/scraper/registry.go +++ b/backend/internal/scraper/registry.go @@ -72,11 +72,11 @@ func (r *Registry) Run(sourceID string) error { return scrapeErr } - // Persister les articles + // Persister uniquement les nouveaux articles count := 0 for _, a := range articles { - saved, err := r.repo.UpsertArticle(sourceID, a.Title, a.Content, a.URL, a.PublishedAt) - if err != nil { + saved, isNew, err := r.repo.InsertArticleIfNew(sourceID, a.Title, a.Content, a.URL, a.PublishedAt) + if err != nil || !isNew { continue } count++ diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts new file mode 100644 index 0000000..382ad31 --- /dev/null +++ b/frontend/src/api/reports.ts @@ -0,0 +1,20 @@ +import { api } from './client' + +export interface Report { + id: string + user_id: string + summary_id: string | null + context_excerpt: string + question: string + answer: string + status: 'generating' | 'done' | 'error' + error_msg: string + created_at: string +} + +export const reportsApi = { + list: () => api.get('/reports'), + create: (data: { summary_id?: string; excerpts: string[]; question: string }) => + api.post('/reports', data), + delete: (id: string) => api.delete(`/reports/${id}`), +} diff --git a/frontend/src/api/summaries.ts b/frontend/src/api/summaries.ts index 207183a..a8a28d6 100644 --- a/frontend/src/api/summaries.ts +++ b/frontend/src/api/summaries.ts @@ -11,4 +11,5 @@ export interface Summary { export const summariesApi = { list: (limit = 10) => api.get(`/summaries?limit=${limit}`), generate: () => api.post('/summaries/generate'), + status: () => api.get<{ generating: boolean }>('/summaries/status'), } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 87af5e2..066431d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom' -import { LayoutDashboard, Newspaper, Star, Settings, Key, Cpu, Database, ClipboardList, Users, LogOut, TrendingUp, CalendarDays } from 'lucide-react' +import { LayoutDashboard, Newspaper, Star, Settings, Key, Cpu, Database, ClipboardList, Users, LogOut, TrendingUp, CalendarDays, FileText } from 'lucide-react' import { useAuth } from '@/lib/auth' import { cn } from '@/lib/cn' @@ -7,6 +7,7 @@ const navItems = [ { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/feed', icon: Newspaper, label: 'Actualités' }, { to: '/watchlist', icon: Star, label: 'Watchlist' }, + { to: '/reports', icon: FileText, label: 'Rapports' }, ] const adminItems = [ diff --git a/frontend/src/lib/router.tsx b/frontend/src/lib/router.tsx index f76bf69..a96c28d 100644 --- a/frontend/src/lib/router.tsx +++ b/frontend/src/lib/router.tsx @@ -12,6 +12,7 @@ import { Jobs } from '@/pages/admin/Jobs' import { AdminUsers } from '@/pages/admin/AdminUsers' import { AdminSettings } from '@/pages/admin/AdminSettings' import { Schedule } from '@/pages/admin/Schedule' +import { Reports } from '@/pages/Reports' export const router = createBrowserRouter([ { path: '/login', element: }, @@ -21,6 +22,7 @@ export const router = createBrowserRouter([ { path: '/', element: }, { path: '/feed', element: }, { path: '/watchlist', element: }, + { path: '/reports', element: }, { path: '/admin', element: , diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b806944..f612114 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect } from 'react' -import { TrendingUp, Clock, Sparkles } from 'lucide-react' +import { useState, useEffect, useRef, useCallback } from 'react' +import { TrendingUp, Clock, Sparkles, MessageSquarePlus, Loader2, Plus, X, Send } from 'lucide-react' import { summariesApi, type Summary } from '@/api/summaries' +import { reportsApi } from '@/api/reports' import { assetsApi, type Asset } from '@/api/assets' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -8,10 +9,127 @@ import { Badge } from '@/components/ui/badge' import { Spinner } from '@/components/ui/spinner' import { useAuth } from '@/lib/auth' +// ── Text-selection floating button ───────────────────────────────────────── + +function useTextSelection(containerRef: React.RefObject) { + const [selection, setSelection] = useState<{ text: string; x: number; y: number } | null>(null) + + useEffect(() => { + function onMouseUp() { + const sel = window.getSelection() + const text = sel?.toString().trim() + if (!text || !containerRef.current) { setSelection(null); return } + const range = sel!.getRangeAt(0) + const rect = range.getBoundingClientRect() + const containerRect = containerRef.current.getBoundingClientRect() + setSelection({ + text, + x: rect.left - containerRect.left + rect.width / 2, + y: rect.top - containerRect.top - 8, + }) + } + function onMouseDown(e: MouseEvent) { + if (!(e.target as Element).closest('[data-context-action]')) setSelection(null) + } + document.addEventListener('mouseup', onMouseUp) + document.addEventListener('mousedown', onMouseDown) + return () => { + document.removeEventListener('mouseup', onMouseUp) + document.removeEventListener('mousedown', onMouseDown) + } + }, [containerRef]) + + return selection +} + +// ── Context panel (extraits + question) ──────────────────────────────────── + +function ContextPanel({ + excerpts, + onRemove, + onClear, + onSubmit, +}: { + excerpts: string[] + onRemove: (i: number) => void + onClear: () => void + onSubmit: (question: string) => Promise +}) { + const [question, setQuestion] = useState('') + const [submitting, setSubmitting] = useState(false) + const [submitted, setSubmitted] = useState(false) + + async function submit() { + if (!question.trim() || submitting) return + setSubmitting(true) + try { + await onSubmit(question) + setSubmitted(true) + setQuestion('') + setTimeout(() => setSubmitted(false), 2000) + } finally { + setSubmitting(false) + } + } + + return ( +
+ {/* Header */} +
+ + + Contexte ({excerpts.length} extrait{excerpts.length > 1 ? 's' : ''}) + + +
+ + {/* Extraits */} +
+ {excerpts.map((e, i) => ( +
+
+ « {e} » +
+ +
+ ))} +
+ + {/* Question */} +
+