Remove legacy React/Firebase files, update project metadata
- Delete original src/ directory (React components, Firebase, Gemini service) - Delete root package.json, package-lock.json, node_modules/ - Delete vite.config.ts, tsconfig.json, index.html (old React config) - Delete firebase-applet-config.json, firebase-blueprint.json, firestore.rules - Delete metadata.json, README.md - Update .gitignore for new project structure (backend/target, frontend/dist) - Rewrite CLAUDE.md to document the new Rust/SolidJS/Postgres architecture Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
1f9f7f39d7
commit
8dc4900c47
@ -1,121 +1,138 @@
|
||||
# Documentation Technique et Fonctionnelle : AI Weekly Synth
|
||||
|
||||
## Objectif de l'application
|
||||
**AI Weekly Synth** est une application web permettant de générer automatiquement des synthèses d'actualités personnalisées et thématisées. Conçue pour la veille technologique ou sectorielle (par défaut centrée sur l'Intelligence Artificielle), elle utilise l'IA générative (Google Gemini) pour rechercher, filtrer, résumer et catégoriser les actualités récentes. L'application est multi-utilisateurs, garantissant à chacun un espace de veille privé et sur-mesure.
|
||||
|
||||
## Fonctionnalités offertes
|
||||
* **Authentification sécurisée** : Connexion via compte Google (SSO) garantissant la protection des données personnelles.
|
||||
* **Génération de synthèses par IA** : Recherche sur le web et création de résumés structurés basés sur les paramètres de l'utilisateur.
|
||||
* **Envoi par email** : Envoi direct de la synthèse via l'API Gmail (OAuth popup pour obtenir un access token).
|
||||
* **Gestion des sources personnalisées** : Ajout d'URLs spécifiques (blogs, sites d'actualité) avec import/export CSV et import en masse.
|
||||
* **Configuration sur-mesure (Paramètres)** :
|
||||
* Choix du thème de veille (ex: "Intelligence Artificielle", "Cybersécurité", "Économie").
|
||||
* Définition de la fenêtre de recherche (ex: les 7 derniers jours).
|
||||
* Personnalisation des catégories de la synthèse (jusqu'à 20 catégories).
|
||||
* Choix du modèle d'IA (Gemini 3.1 Pro, 3.0 Flash, 3.1 Flash Lite, 2.5 Flash).
|
||||
* Modification du "prompt" de comportement de l'agent de recherche.
|
||||
* Export/import des paramètres en JSON.
|
||||
* **Historique et consultation** : Sauvegarde de toutes les synthèses générées pour une consultation ultérieure, avec liens directs vers les articles sources et suppression avec double confirmation.
|
||||
|
||||
## Structure du projet
|
||||
# AI Weekly Synth
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.tsx # Point d'entrée React
|
||||
├── App.tsx # Router, Layout, ProtectedRoute, Login
|
||||
├── index.css # Import Tailwind uniquement
|
||||
├── firebase.ts # Init Firebase, helpers auth, gestion erreurs Firestore
|
||||
├── types.ts # Interfaces (NewsItem, SynthesisData, AppSettings, etc.) + DEFAULT_SETTINGS
|
||||
├── components/
|
||||
│ └── AuthContext.tsx # Provider React Context pour l'authentification
|
||||
├── pages/
|
||||
│ ├── Home.tsx # Dashboard : liste des synthèses avec apercu et suppression
|
||||
│ ├── GenerateSynthesis.tsx # Declenchement de la generation IA
|
||||
│ ├── SynthesisDetail.tsx # Lecture detaillee + envoi email + suppression
|
||||
│ ├── Sources.tsx # CRUD sources personnalisees (unitaire, CSV, masse)
|
||||
│ └── Settings.tsx # Parametres utilisateur (theme, categories, modele IA, etc.)
|
||||
└── services/
|
||||
└── geminiService.ts # Logique IA : generation 2 passes + validation/scraping URLs
|
||||
```
|
||||
|
||||
## Architecture de la solution
|
||||
L'application repose sur une architecture **Serverless (BaaS - Backend as a Service)**. Il n'y a pas de serveur backend intermediaire.
|
||||
|
||||
* **Frontend** : Application Single Page (SPA) en React 19 qui gère l'interface, le routage (`react-router-dom` v7) et l'état de l'application.
|
||||
* **Backend / Base de données** : Firebase Firestore (NoSQL) pour stocker les paramètres, les sources et les synthèses. Les requêtes sont faites directement depuis le client React.
|
||||
* **Sécurité des données** : Les `firestore.rules` garantissent le cloisonnement des données (Multi-tenant). Chaque document possède un champ `authorUid` ou `userId` vérifié à chaque requête (`isDocOwner()`).
|
||||
* **Intelligence Artificielle** : Le SDK `@google/genai` est appelé directement depuis le frontend. L'outil `googleSearch` est activé pour le "Grounding" (recherche web en temps réel).
|
||||
|
||||
### Pipeline de generation (geminiService.ts)
|
||||
|
||||
La generation s'effectue en **2 passes** :
|
||||
|
||||
1. **Passe 1 - Recherche** : Gemini avec `googleSearch` grounding produit des news brutes par categorie (JSON structure via `responseSchema`). Les categories sont dynamiques, basees sur les `settings.categories` de l'utilisateur (cles `category_0`, `category_1`, etc.).
|
||||
|
||||
2. **Validation/Scraping des URLs** : Chaque URL retournee est verifiee via 3 proxies CORS en cascade (`allorigins` -> `codetabs` -> `corsproxy.io`). Les articles sont filtres si : URL invalide/404, contenu d'erreur (soft 404), ou date de publication trop ancienne (via meta tags, JSON-LD, balise `<time>`). Le contenu textuel est extrait (max 4000 caracteres).
|
||||
|
||||
3. **Passe 2 - Reecriture** : Gemini recrit les titres et resumes en se basant sur le contenu scrape reel des articles, garantissant la fidelite des resumes.
|
||||
|
||||
### Collections Firestore
|
||||
## Overview
|
||||
AI Weekly Synth is a self-hosted web application that generates AI-powered weekly news syntheses. Users configure their topics, categories, and preferred LLM provider, then the app searches the web, validates sources, and produces structured summaries.
|
||||
|
||||
| Collection | Document ID | Champs cles | Acces |
|
||||
|---|---|---|---|
|
||||
| `syntheses` | auto-generated | `week`, `sections[]` (ou champs legacy), `authorUid`, `createdAt` | Owner uniquement |
|
||||
| `sources` | auto-generated | `title`, `url`, `authorUid`, `createdAt` | Owner uniquement |
|
||||
| `settings` | `{userId}` | `theme`, `categories[]`, `maxAgeDays`, `maxItemsPerCategory`, `aiModel`, `searchAgentBehavior` | Owner uniquement |
|
||||
## Architecture
|
||||
- **Backend**: Rust (Axum) — `backend/`
|
||||
- **Frontend**: SolidJS + Tailwind CSS v4 — `frontend/`
|
||||
- **Database**: PostgreSQL (via sqlx with compile-time checked queries)
|
||||
- **Deployment**: Docker only (`docker-compose.yml`)
|
||||
|
||||
### Routes
|
||||
|
||||
| Path | Composant | Description |
|
||||
|---|---|---|
|
||||
| `/login` | `Login` (dans App.tsx) | Connexion Google |
|
||||
| `/` | `Home` | Dashboard des syntheses |
|
||||
| `/generate` | `GenerateSynthesis` | Lancement de la generation |
|
||||
| `/synthesis/:id` | `SynthesisDetail` | Vue detaillee d'une synthese |
|
||||
| `/sources` | `Sources` | Gestion des sources personnalisees |
|
||||
| `/settings` | `Settings` | Parametres de generation |
|
||||
|
||||
## Choix technologiques
|
||||
* **Framework UI** : React 19 avec Vite 6 (plugin `@vitejs/plugin-react`).
|
||||
* **Langage** : TypeScript 5.8 avec typage strict (`src/types.ts` centralise les interfaces).
|
||||
* **Styling** : Tailwind CSS v4 (via `@tailwindcss/vite` plugin, import unique dans `index.css`).
|
||||
* **Icônes** : `lucide-react` pour les icônes SVG.
|
||||
* **Base de données & Auth** : Firebase 12 (Firestore + Authentication avec Google provider).
|
||||
* **IA** : API Google Gemini (`@google/genai`) avec génération structurée (`responseSchema` + `Type` enum) et grounding (`googleSearch`).
|
||||
* **Utilitaires** : `date-fns` pour le formatage des dates en français.
|
||||
|
||||
## Maintenance et Evolution
|
||||
|
||||
### Points d'attention pour les developpeurs
|
||||
## Project Structure
|
||||
```
|
||||
ai_synth/
|
||||
├── backend/ Rust/Axum backend
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs Entry point, CLI (serve, create-admin)
|
||||
│ │ ├── router.rs All API routes + middleware stack
|
||||
│ │ ├── handlers/ HTTP handlers (auth, settings, sources, syntheses, admin, etc.)
|
||||
│ │ ├── services/ Business logic (auth, email, encryption, scraper, LLM providers, synthesis pipeline)
|
||||
│ │ ├── db/ Database queries (sqlx)
|
||||
│ │ ├── models/ Data types + validation
|
||||
│ │ ├── middleware/ Auth session extraction, CSRF check
|
||||
│ │ └── util/ Token generation, hashing
|
||||
│ ├── migrations/ SQL migrations (9 files)
|
||||
│ ├── tests/ Integration tests (require Postgres)
|
||||
│ ├── Cargo.toml
|
||||
│ └── Dockerfile Multi-stage build
|
||||
├── frontend/ SolidJS frontend
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx Router, layouts, route guards
|
||||
│ │ ├── pages/ Login, Register, Home, Settings, Sources, GenerateSynthesis, SynthesisDetail, admin/*
|
||||
│ │ ├── components/ Navbar, Layout, AdminLayout, Turnstile, ApiKeyManager, ui/*
|
||||
│ │ ├── api/ API clients (auth, settings, sources, syntheses, admin, config, apiKeys)
|
||||
│ │ ├── contexts/ AuthContext (session-based)
|
||||
│ │ ├── i18n/ French translations (i18n-ready for future languages)
|
||||
│ │ └── utils/ SSE client, date formatting, provider info
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts SolidJS + Tailwind + dev proxy
|
||||
├── docs/ Analysis reports + implementation plans
|
||||
├── docker-compose.yml App + Postgres
|
||||
├── .env.example All required env vars documented
|
||||
└── CLAUDE.md This file
|
||||
```
|
||||
|
||||
1. **Rate Limiting**
|
||||
* Le `RateLimiter` dans `geminiService.ts` est configure a 29 requetes/minute. Il est in-memory et par-onglet : plusieurs onglets ouverts peuvent depasser le quota. Tout nouvel appel Gemini doit passer par `await geminiRateLimiter.acquire()`.
|
||||
## Key Features
|
||||
- **Authentication**: Email + magic link (passwordless), Cloudflare Turnstile captcha, 30-day session cookies
|
||||
- **LLM Providers**: Google Gemini, OpenAI, Anthropic — users bring their own API keys
|
||||
- **Generation Pipeline**: 2-pass (search with web grounding → scrape/validate URLs → rewrite summaries), adaptive per provider
|
||||
- **Admin Module**: Provider/model curation, rate limit config, user management
|
||||
- **Security**: AES-256-GCM encryption for API keys at rest, SSRF prevention in scraper, CSRF via X-Requested-With, HttpOnly/SameSite cookies
|
||||
- **Export**: Email via Resend, PDF, Markdown
|
||||
- **Real-time**: SSE for generation progress streaming
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Docker (production-like)
|
||||
```bash
|
||||
cp .env.example .env # Fill in values
|
||||
docker compose up
|
||||
```
|
||||
|
||||
2. **Generation Structuree (JSON Schema)**
|
||||
* Le schema de reponse est genere dynamiquement a partir de `settings.categories`. Si vous modifiez la structure des donnees (ex: ajout d'un champ `imageUrl` par article), vous **devez** mettre a jour : `newsItemSchema` dans `geminiService.ts` ET l'interface `NewsItem` dans `types.ts`.
|
||||
### Development
|
||||
```bash
|
||||
# Backend (requires Postgres running)
|
||||
cd backend && cargo run -- serve
|
||||
|
||||
3. **Donnees legacy (SynthesisData)**
|
||||
* `SynthesisData` contient des champs legacy optionnels (`majorAnnouncements`, `financialSector`, etc.) ET le nouveau format `sections[]`. Le code de `Home.tsx` et `SynthesisDetail.tsx` gere les deux formats via des fallbacks. Les nouvelles syntheses utilisent uniquement `sections[]`.
|
||||
# Frontend (proxies /api to backend)
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
4. **Cle API Gemini**
|
||||
* La cle `GEMINI_API_KEY` est injectee dans le bundle client via `vite.config.ts` (`process.env.GEMINI_API_KEY`). Elle est donc exposee dans le JavaScript du navigateur. Pour la production, il faudrait passer par un backend ou une Cloud Function.
|
||||
### CLI
|
||||
```bash
|
||||
# Create first admin user
|
||||
cd backend && cargo run -- create-admin admin@example.com
|
||||
```
|
||||
|
||||
5. **Proxies CORS**
|
||||
* `fetchHtmlContent` utilise 3 proxies tiers en cascade pour le scraping. Ces services sont non garantis et peuvent etre indisponibles. Si le scraping echoue, l'article est conserve avec un `scrapedContent` vide.
|
||||
## Testing
|
||||
```bash
|
||||
# Backend unit tests (no Postgres needed)
|
||||
cd backend && cargo test --lib
|
||||
|
||||
6. **Regles de securite Firestore (`firestore.rules`)**
|
||||
* Les regles sont strictes avec validation de champs (types, tailles, formats). Si vous ajoutez une nouvelle collection ou un nouveau champ, vous devez mettre a jour les regles ET les fonctions de validation (`isValidSynthesis`, `isValidSettings`, `isValidSource`).
|
||||
# Backend integration tests (requires Postgres)
|
||||
TEST_DATABASE_URL=postgres://user:pass@localhost:5432/postgres cargo test
|
||||
|
||||
7. **Envoi email Gmail**
|
||||
* L'envoi utilise l'API Gmail directement depuis le client (`SynthesisDetail.tsx`). `getGmailAccessToken()` dans `firebase.ts` re-declenche un `signInWithPopup` a chaque envoi pour obtenir le scope `gmail.send`.
|
||||
# Frontend unit tests
|
||||
cd frontend && npx vitest run
|
||||
|
||||
### Dependances inutilisees dans package.json
|
||||
* `motion`, `clsx`, `tailwind-merge` : importees mais non utilisees dans le code source actuel.
|
||||
* `express`, `@types/express`, `dotenv` : suggerent un composant serveur prevu ou supprime, aucun code serveur present.
|
||||
# Frontend type check
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
### Evolutions possibles
|
||||
* **Securiser la cle API** : Deplacer l'appel Gemini vers une Cloud Function Firebase.
|
||||
* **Generation automatique (CRON)** : Cloud Function + Cloud Scheduler pour generer automatiquement chaque semaine.
|
||||
* **Export PDF/Markdown** : Bouton de telechargement sur la vue de detail.
|
||||
* **Error Boundary React** : Aucun error boundary n'est en place, un crash dans un composant fait tomber toute l'app.
|
||||
* **Nettoyage des donnees legacy** : Supprimer les champs `majorAnnouncements`, `financialSector`, etc. de `SynthesisData` une fois les anciennes syntheses migrees.
|
||||
## API Endpoints
|
||||
|
||||
### Public
|
||||
- `POST /api/v1/auth/register` — create account + magic link
|
||||
- `POST /api/v1/auth/login` — request magic link
|
||||
- `GET /api/v1/auth/verify` — verify token (email click)
|
||||
- `POST /api/v1/auth/verify` — verify token (frontend API)
|
||||
- `GET /api/v1/health` — health check
|
||||
|
||||
### Authenticated
|
||||
- `GET/PUT /api/v1/settings` — user settings
|
||||
- `GET/POST/DELETE /api/v1/sources` — sources CRUD + bulk/CSV import/export
|
||||
- `GET/DELETE /api/v1/syntheses/:id` — syntheses CRUD
|
||||
- `POST /api/v1/syntheses/generate` — trigger async generation
|
||||
- `GET /api/v1/syntheses/generate/:job_id/progress` — SSE progress stream
|
||||
- `POST /api/v1/syntheses/:id/send-email` — email synthesis
|
||||
- `GET /api/v1/syntheses/:id/export/markdown` — Markdown download
|
||||
- `GET /api/v1/syntheses/:id/export/pdf` — PDF download
|
||||
- `GET/POST/DELETE /api/v1/user/api-keys` — LLM API key management
|
||||
- `GET /api/v1/config/providers` — available providers/models
|
||||
|
||||
### Admin Only
|
||||
- `GET/POST/PUT/DELETE /api/v1/admin/providers` — provider/model config
|
||||
- `GET/PUT /api/v1/admin/rate-limits` — rate limit config
|
||||
- `GET /api/v1/admin/users` — user list
|
||||
- `PUT /api/v1/admin/users/:id/role` — role management
|
||||
|
||||
## Database (9 migrations)
|
||||
Tables: `users`, `sessions`, `magic_link_tokens`, `user_settings`, `sources`, `syntheses`, `admin_providers`, `admin_rate_limits`, `user_api_keys`, `audit_log`
|
||||
|
||||
## Environment Variables
|
||||
See `.env.example` for the complete list. Key ones:
|
||||
- `DATABASE_URL` — Postgres connection string
|
||||
- `MASTER_ENCRYPTION_KEY` — 64 hex chars for AES-256-GCM
|
||||
- `SESSION_SECRET` — at least 64 chars
|
||||
- `RESEND_API_KEY` — for email sending
|
||||
- `TURNSTILE_SECRET_KEY` / `TURNSTILE_SITE_KEY` — captcha
|
||||
- `APP_URL` — public URL (for CORS, magic links, cookies)
|
||||
|
||||
## Design Decisions
|
||||
- Idiomatic Rust (learning project) — no unwrap() in production code
|
||||
- Users bring their own LLM API keys (encrypted at rest)
|
||||
- Admin curates available providers/models, users select from the list
|
||||
- Single-tenant self-hosted (one instance per deployment)
|
||||
- i18n-ready (French only for now, all strings in `frontend/src/i18n/fr.ts`)
|
||||
- Adaptive generation pipeline: skips scrape+rewrite when native web grounding is sufficient
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/633f0b39-1bef-446a-acd0-a427cc7be39b
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"projectId": "gen-lang-client-0861214696",
|
||||
"appId": "1:917289332389:web:41fac112ae9d57f3a4a74c",
|
||||
"apiKey": "AIzaSyDQcaVDyGSjndMGiJ0vMEhLn2m92nfBRJs",
|
||||
"authDomain": "gen-lang-client-0861214696.firebaseapp.com",
|
||||
"firestoreDatabaseId": "ai-studio-633f0b39-1bef-446a-acd0-a427cc7be39b",
|
||||
"storageBucket": "gen-lang-client-0861214696.firebasestorage.app",
|
||||
"messagingSenderId": "917289332389",
|
||||
"measurementId": ""
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
{
|
||||
"entities": {
|
||||
"NewsItem": {
|
||||
"title": "News Item",
|
||||
"description": "A single news item with title, url, and summary.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"summary": { "type": "string" }
|
||||
},
|
||||
"required": ["title", "url", "summary"]
|
||||
},
|
||||
"Synthesis": {
|
||||
"title": "Synthesis",
|
||||
"description": "Weekly AI news synthesis.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"week": { "type": "string", "description": "ISO week string, e.g., 2026-W12" },
|
||||
"createdAt": { "type": "timestamp" },
|
||||
"authorUid": { "type": "string" },
|
||||
"majorAnnouncements": { "type": "array", "items": { "$ref": "#/entities/NewsItem" } },
|
||||
"financialSector": { "type": "array", "items": { "$ref": "#/entities/NewsItem" } },
|
||||
"otherEnterprises": { "type": "array", "items": { "$ref": "#/entities/NewsItem" } },
|
||||
"publicSector": { "type": "array", "items": { "$ref": "#/entities/NewsItem" } },
|
||||
"generalPublic": { "type": "array", "items": { "$ref": "#/entities/NewsItem" } },
|
||||
"sections": { "type": "array", "items": { "type": "object" } }
|
||||
},
|
||||
"required": ["week", "createdAt", "authorUid"]
|
||||
},
|
||||
"Settings": {
|
||||
"title": "Settings",
|
||||
"description": "User specific settings for synthesis generation.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme": { "type": "string" },
|
||||
"maxAgeDays": { "type": "number" },
|
||||
"categories": { "type": "array", "items": { "type": "string" } },
|
||||
"maxItemsPerCategory": { "type": "number" }
|
||||
},
|
||||
"required": ["theme", "maxAgeDays", "categories", "maxItemsPerCategory"]
|
||||
},
|
||||
"Source": {
|
||||
"title": "Source",
|
||||
"description": "A custom news source.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"authorUid": { "type": "string" },
|
||||
"createdAt": { "type": "timestamp" }
|
||||
},
|
||||
"required": ["title", "url", "authorUid", "createdAt"]
|
||||
}
|
||||
},
|
||||
"firestore": {
|
||||
"/syntheses/{synthesisId}": {
|
||||
"schema": { "$ref": "#/entities/Synthesis" },
|
||||
"description": "Stores weekly syntheses."
|
||||
},
|
||||
"/sources/{sourceId}": {
|
||||
"schema": { "$ref": "#/entities/Source" },
|
||||
"description": "Stores custom sources per user."
|
||||
},
|
||||
"/settings/{userId}": {
|
||||
"schema": { "$ref": "#/entities/Settings" },
|
||||
"description": "Stores user settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,113 +0,0 @@
|
||||
rules_version = '2';
|
||||
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
// ===============================================================
|
||||
// Helper Functions
|
||||
// ===============================================================
|
||||
function isAuthenticated() {
|
||||
return request.auth != null;
|
||||
}
|
||||
|
||||
function isOwner(userId) {
|
||||
return isAuthenticated() && request.auth.uid == userId;
|
||||
}
|
||||
|
||||
function isDocOwner() {
|
||||
return isAuthenticated() && request.auth.uid == resource.data.authorUid;
|
||||
}
|
||||
|
||||
function uidUnchanged() {
|
||||
return !('authorUid' in request.resource.data) ||
|
||||
request.resource.data.authorUid == request.auth.uid;
|
||||
}
|
||||
|
||||
function uidNotModified() {
|
||||
return !('authorUid' in request.resource.data) ||
|
||||
request.resource.data.authorUid == resource.data.authorUid;
|
||||
}
|
||||
|
||||
function hasRequiredFields(fields) {
|
||||
return request.resource.data.keys().hasAll(fields);
|
||||
}
|
||||
|
||||
function isValidUrl(url) {
|
||||
return url is string &&
|
||||
(url.matches("^https://.*") || url.matches("^http://.*"));
|
||||
}
|
||||
|
||||
function isValidNewsItem(item) {
|
||||
return item is map &&
|
||||
item.keys().hasAll(['title', 'url', 'summary']) &&
|
||||
item.title is string && item.title.size() > 0 && item.title.size() < 500 &&
|
||||
item.url is string && item.url.size() < 1000 &&
|
||||
item.summary is string && item.summary.size() > 0 && item.summary.size() < 2000;
|
||||
}
|
||||
|
||||
function isValidNewsArray(arr) {
|
||||
return arr is list && arr.size() <= 50; // Max 50 items per section
|
||||
}
|
||||
|
||||
function isValidSynthesis(data) {
|
||||
return hasRequiredFields(['week', 'createdAt', 'authorUid']) &&
|
||||
data.week is string && data.week.size() > 0 && data.week.size() < 20 &&
|
||||
data.createdAt is timestamp &&
|
||||
data.authorUid is string && data.authorUid.size() > 0 &&
|
||||
(!('majorAnnouncements' in data) || isValidNewsArray(data.majorAnnouncements)) &&
|
||||
(!('financialSector' in data) || isValidNewsArray(data.financialSector)) &&
|
||||
(!('otherEnterprises' in data) || isValidNewsArray(data.otherEnterprises)) &&
|
||||
(!('publicSector' in data) || isValidNewsArray(data.publicSector)) &&
|
||||
(!('generalPublic' in data) || isValidNewsArray(data.generalPublic)) &&
|
||||
(!('sections' in data) || data.sections is list);
|
||||
}
|
||||
|
||||
function isValidSettings(data) {
|
||||
return hasRequiredFields(['theme', 'maxAgeDays', 'categories', 'maxItemsPerCategory']) &&
|
||||
data.theme is string && data.theme.size() > 0 && data.theme.size() < 200 &&
|
||||
data.maxAgeDays is number && data.maxAgeDays > 0 && data.maxAgeDays <= 365 &&
|
||||
data.categories is list && data.categories.size() > 0 && data.categories.size() <= 20 &&
|
||||
data.maxItemsPerCategory is number && data.maxItemsPerCategory > 0 && data.maxItemsPerCategory <= 50;
|
||||
}
|
||||
|
||||
function isValidSource(data) {
|
||||
return hasRequiredFields(['title', 'url', 'authorUid', 'createdAt']) &&
|
||||
data.title is string && data.title.size() > 0 && data.title.size() < 200 &&
|
||||
data.url is string && isValidUrl(data.url) && data.url.size() < 1000 &&
|
||||
data.authorUid is string && data.authorUid.size() > 0 &&
|
||||
data.createdAt is timestamp;
|
||||
}
|
||||
|
||||
match /syntheses/{synthesisId} {
|
||||
allow read: if isAuthenticated() && isDocOwner();
|
||||
allow create: if isAuthenticated() &&
|
||||
isValidSynthesis(request.resource.data) &&
|
||||
uidUnchanged();
|
||||
allow update: if isAuthenticated() &&
|
||||
isDocOwner() &&
|
||||
isValidSynthesis(request.resource.data) &&
|
||||
uidNotModified() &&
|
||||
request.resource.data.createdAt == resource.data.createdAt;
|
||||
allow delete: if isAuthenticated() && isDocOwner();
|
||||
}
|
||||
|
||||
match /sources/{sourceId} {
|
||||
allow read: if isAuthenticated() && isDocOwner();
|
||||
allow create: if isAuthenticated() &&
|
||||
isValidSource(request.resource.data) &&
|
||||
uidUnchanged();
|
||||
allow update: if isAuthenticated() &&
|
||||
isDocOwner() &&
|
||||
isValidSource(request.resource.data) &&
|
||||
uidNotModified() &&
|
||||
request.resource.data.createdAt == resource.data.createdAt;
|
||||
allow delete: if isAuthenticated() && isDocOwner();
|
||||
}
|
||||
|
||||
match /settings/{userId} {
|
||||
allow read: if isAuthenticated() && request.auth.uid == userId;
|
||||
allow write: if isAuthenticated() && request.auth.uid == userId &&
|
||||
isValidSettings(request.resource.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "AI Weekly Synth",
|
||||
"description": "Synthèse hebdomadaire automatique des actualités de l'Intelligence Artificielle.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"firebase": "^12.11.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Link } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './components/AuthContext';
|
||||
import Home from './pages/Home';
|
||||
import GenerateSynthesis from './pages/GenerateSynthesis';
|
||||
import SynthesisDetail from './pages/SynthesisDetail';
|
||||
import Sources from './pages/Sources';
|
||||
import Settings from './pages/Settings';
|
||||
import { LogOut, BrainCircuit, Settings as SettingsIcon } from 'lucide-react';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="flex justify-center items-center h-screen"><div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div></div>;
|
||||
if (!user) return <Navigate to="/login" />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function Login() {
|
||||
const { user, signIn } = useAuth();
|
||||
if (user) return <Navigate to="/" />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<BrainCircuit className="mx-auto h-12 w-12 text-indigo-600" />
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
AI Weekly Synth
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Votre synthèse hebdomadaire des actualités IA
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 space-y-6">
|
||||
<button
|
||||
onClick={signIn}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Se connecter avec Google
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<BrainCircuit className="h-8 w-8 text-indigo-600" />
|
||||
<Link to="/" className="ml-2 text-xl font-bold text-gray-900">AI Weekly Synth</Link>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<Link to="/" className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
Synthèses
|
||||
</Link>
|
||||
<Link to="/sources" className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
Sources personnalisées
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{user && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">{user.email}</span>
|
||||
<Link to="/settings" className="text-gray-400 hover:text-gray-600 p-2 rounded-full hover:bg-gray-100 transition-colors" title="Paramètres">
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Home />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/sources" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Sources />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/generate" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<GenerateSynthesis />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/synthesis/:id" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SynthesisDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { User, onAuthStateChanged } from 'firebase/auth';
|
||||
import { auth, signInWithGoogle, logout } from '../firebase';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
signIn: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
|
||||
setUser(currentUser);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, signIn: signInWithGoogle, signOut: logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@ -1,89 +0,0 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, GoogleAuthProvider, signInWithPopup, signOut } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
import firebaseConfig from '../firebase-applet-config.json';
|
||||
|
||||
const app = initializeApp(firebaseConfig);
|
||||
export const db = getFirestore(app, firebaseConfig.firestoreDatabaseId);
|
||||
export const auth = getAuth(app);
|
||||
|
||||
export const signInWithGoogle = async () => {
|
||||
const provider = new GoogleAuthProvider();
|
||||
try {
|
||||
await signInWithPopup(auth, provider);
|
||||
} catch (error) {
|
||||
console.error("Error signing in with Google", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getGmailAccessToken = async (): Promise<string | null> => {
|
||||
const provider = new GoogleAuthProvider();
|
||||
provider.addScope('https://www.googleapis.com/auth/gmail.send');
|
||||
try {
|
||||
const result = await signInWithPopup(auth, provider);
|
||||
const credential = GoogleAuthProvider.credentialFromResult(result);
|
||||
return credential?.accessToken || null;
|
||||
} catch (error) {
|
||||
console.error("Error getting Gmail access token", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
try {
|
||||
await signOut(auth);
|
||||
} catch (error) {
|
||||
console.error("Error signing out", error);
|
||||
}
|
||||
};
|
||||
|
||||
export enum OperationType {
|
||||
CREATE = 'create',
|
||||
UPDATE = 'update',
|
||||
DELETE = 'delete',
|
||||
LIST = 'list',
|
||||
GET = 'get',
|
||||
WRITE = 'write',
|
||||
}
|
||||
|
||||
export interface FirestoreErrorInfo {
|
||||
error: string;
|
||||
operationType: OperationType;
|
||||
path: string | null;
|
||||
authInfo: {
|
||||
userId?: string;
|
||||
email?: string | null;
|
||||
emailVerified?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
tenantId?: string | null;
|
||||
providerInfo: {
|
||||
providerId: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
photoUrl: string | null;
|
||||
}[];
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFirestoreError(error: unknown, operationType: OperationType, path: string | null) {
|
||||
const errInfo: FirestoreErrorInfo = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
authInfo: {
|
||||
userId: auth.currentUser?.uid,
|
||||
email: auth.currentUser?.email,
|
||||
emailVerified: auth.currentUser?.emailVerified,
|
||||
isAnonymous: auth.currentUser?.isAnonymous,
|
||||
tenantId: auth.currentUser?.tenantId,
|
||||
providerInfo: auth.currentUser?.providerData.map(provider => ({
|
||||
providerId: provider.providerId,
|
||||
displayName: provider.displayName,
|
||||
email: provider.email,
|
||||
photoUrl: provider.photoURL
|
||||
})) || []
|
||||
},
|
||||
operationType,
|
||||
path
|
||||
}
|
||||
console.error('Firestore Error: ', JSON.stringify(errInfo));
|
||||
throw new Error(JSON.stringify(errInfo));
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@ -1,10 +0,0 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@ -1,175 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { collection, addDoc, Timestamp, getDocs, query, where, doc, getDoc } from 'firebase/firestore';
|
||||
import { db, handleFirestoreError, OperationType } from '../firebase';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { generateWeeklySynthesis } from '../services/geminiService';
|
||||
import { SynthesisData, AppSettings, DEFAULT_SETTINGS } from '../types';
|
||||
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
function getIsoWeekString(date: Date) {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${weekNo.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function GenerateSynthesis() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||
|
||||
// Fetch settings on mount
|
||||
React.useEffect(() => {
|
||||
if (!user) return;
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const settingsDoc = await getDoc(doc(db, 'settings', user.uid));
|
||||
if (settingsDoc.exists()) {
|
||||
setSettings(settingsDoc.data() as AppSettings);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Could not fetch settings, using defaults", err);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
}, [user]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
// 0. Fetch custom sources
|
||||
const q = query(collection(db, 'sources'), where('authorUid', '==', user.uid));
|
||||
const snapshot = await getDocs(q);
|
||||
const customSources = snapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return { title: data.title, url: data.url };
|
||||
});
|
||||
|
||||
// 1. Generate synthesis using Gemini
|
||||
const synthesisData: SynthesisData = await generateWeeklySynthesis(customSources, settings);
|
||||
|
||||
// 2. Save to Firestore
|
||||
const weekString = getIsoWeekString(new Date());
|
||||
|
||||
const docData = {
|
||||
...synthesisData,
|
||||
week: weekString,
|
||||
createdAt: Timestamp.now(),
|
||||
authorUid: user.uid,
|
||||
};
|
||||
|
||||
const docRef = await addDoc(collection(db, 'syntheses'), docData);
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate(`/synthesis/${docRef.id}`);
|
||||
}, 1500);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
|
||||
let errorMessage = err.message || "Une erreur est survenue lors de la génération.";
|
||||
|
||||
// Try to parse JSON error message from Gemini API
|
||||
try {
|
||||
if (err.message && err.message.startsWith('{')) {
|
||||
const parsed = JSON.parse(err.message);
|
||||
if (parsed.error && parsed.error.message) {
|
||||
errorMessage = parsed.error.message;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
if (errorMessage.includes('429') || errorMessage.includes('quota') || errorMessage.includes('RESOURCE_EXHAUSTED')) {
|
||||
errorMessage = "Quota d'utilisation de l'API Gemini dépassé. Veuillez vérifier votre plan et vos détails de facturation sur Google AI Studio.";
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
|
||||
// We don't use handleFirestoreError here unless it's specifically a firestore error,
|
||||
// but addDoc will throw if rules fail.
|
||||
if (err.code && err.code.startsWith('permission-denied')) {
|
||||
handleFirestoreError(err, OperationType.CREATE, 'syntheses');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="bg-white shadow sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Générer la Synthèse Hebdomadaire
|
||||
</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>
|
||||
Cette action va lancer l'analyse des actualités des <strong>{settings.maxAgeDays} derniers jours</strong> sur le thème <strong>"{settings.theme}"</strong> via l'IA.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Note : La génération peut prendre jusqu'à une minute.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mt-4 bg-green-50 border-l-4 border-green-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700">Synthèse générée avec succès ! Redirection...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || success}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" />
|
||||
Génération en cours...
|
||||
</>
|
||||
) : (
|
||||
'Lancer la génération'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,181 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { collection, query, orderBy, onSnapshot, Timestamp, where, deleteDoc, doc, getDoc } from 'firebase/firestore';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { db, handleFirestoreError, OperationType } from '../firebase';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { SynthesisDocument } from '../types';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { FileText, Plus, Trash2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
const { user } = useAuth();
|
||||
const [syntheses, setSyntheses] = useState<SynthesisDocument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [maxItems, setMaxItems] = useState<number>(4);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Fetch settings to get maxItemsPerCategory
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const settingsDoc = await getDoc(doc(db, 'settings', user.uid));
|
||||
if (settingsDoc.exists()) {
|
||||
const data = settingsDoc.data();
|
||||
if (data.maxItemsPerCategory) {
|
||||
setMaxItems(data.maxItemsPerCategory);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Could not fetch settings", err);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
|
||||
const q = query(
|
||||
collection(db, 'syntheses'),
|
||||
where('authorUid', '==', user.uid)
|
||||
);
|
||||
|
||||
const unsubscribe = onSnapshot(q, (snapshot) => {
|
||||
const docs = snapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data()
|
||||
})) as SynthesisDocument[];
|
||||
// Sort client-side to avoid requiring a composite index
|
||||
docs.sort((a, b) => b.createdAt.toMillis() - a.createdAt.toMillis());
|
||||
setSyntheses(docs);
|
||||
setLoading(false);
|
||||
}, (error) => {
|
||||
handleFirestoreError(error, OperationType.LIST, 'syntheses');
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [user]);
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (deletingId === id) {
|
||||
// Confirm deletion
|
||||
try {
|
||||
await deleteDoc(doc(db, 'syntheses', id));
|
||||
setDeletingId(null);
|
||||
} catch (err) {
|
||||
handleFirestoreError(err, OperationType.DELETE, `syntheses/${id}`);
|
||||
setDeletingId(null);
|
||||
}
|
||||
} else {
|
||||
// Show confirmation state
|
||||
setDeletingId(id);
|
||||
// Auto-cancel after 3 seconds
|
||||
setTimeout(() => {
|
||||
setDeletingId((current) => current === id ? null : current);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Synthèses d'Actualités par IA</h1>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Retrouvez ici toutes vos synthèses hebdomadaires générées automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/generate"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Nouvelle Synthèse
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{syntheses.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<FileText className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune synthèse</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Commencez par générer votre première synthèse hebdomadaire.</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/generate"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Générer
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{syntheses.map((synth) => (
|
||||
<Link
|
||||
key={synth.id}
|
||||
to={`/synthesis/${synth.id}`}
|
||||
className="block bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md hover:border-indigo-300 transition-all duration-200 overflow-hidden"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
Semaine {synth.week.split('-W')[1]}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{format(synth.createdAt.toDate(), 'dd MMM yyyy', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Synthèse de la semaine
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 space-y-1.5">
|
||||
{(() => {
|
||||
const items = synth.sections?.[0]?.items || synth.majorAnnouncements || [];
|
||||
if (items.length === 0) {
|
||||
return <p>Aucune annonce majeure cette semaine.</p>;
|
||||
}
|
||||
return items.slice(0, maxItems).map((item, idx) => (
|
||||
<p key={idx} className="line-clamp-2">
|
||||
• {item.title}
|
||||
</p>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-6 py-3 border-t border-gray-100 flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Lire la synthèse →
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, synth.id)}
|
||||
className={`inline-flex items-center p-1.5 rounded-md transition-colors ${
|
||||
deletingId === synth.id
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||
: 'text-gray-400 hover:text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
title={deletingId === synth.id ? "Cliquer à nouveau pour confirmer" : "Supprimer"}
|
||||
>
|
||||
{deletingId === synth.id ? (
|
||||
<span className="text-xs font-medium mr-1">Confirmer</span>
|
||||
) : null}
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,308 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { doc, getDoc, setDoc } from 'firebase/firestore';
|
||||
import { db, handleFirestoreError, OperationType } from '../firebase';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { AppSettings, DEFAULT_SETTINGS } from '../types';
|
||||
import { Save, Plus, Trash2, Settings as SettingsIcon, Download, Upload } from 'lucide-react';
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const docRef = doc(db, 'settings', user.uid);
|
||||
const docSnap = await getDoc(docRef);
|
||||
if (docSnap.exists()) {
|
||||
setSettings(docSnap.data() as AppSettings);
|
||||
}
|
||||
} catch (err) {
|
||||
handleFirestoreError(err, OperationType.GET, `settings/${user.uid}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!user) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
// Filter out empty categories
|
||||
const cleanedSettings = {
|
||||
...settings,
|
||||
categories: settings.categories.filter(c => c.trim() !== '')
|
||||
};
|
||||
|
||||
if (cleanedSettings.categories.length === 0) {
|
||||
cleanedSettings.categories = ['Général']; // Fallback
|
||||
}
|
||||
|
||||
await setDoc(doc(db, 'settings', user.uid), cleanedSettings);
|
||||
setSettings(cleanedSettings);
|
||||
setMessage({ type: 'success', text: 'Paramètres enregistrés avec succès.' });
|
||||
} catch (err) {
|
||||
handleFirestoreError(err, OperationType.WRITE, `settings/${user.uid}`);
|
||||
setMessage({ type: 'error', text: 'Erreur lors de l\'enregistrement des paramètres.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = (index: number, value: string) => {
|
||||
const newCategories = [...settings.categories];
|
||||
newCategories[index] = value;
|
||||
setSettings({ ...settings, categories: newCategories });
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
if (settings.categories.length >= 20) return;
|
||||
setSettings({ ...settings, categories: [...settings.categories, 'Nouvelle catégorie'] });
|
||||
};
|
||||
|
||||
const removeCategory = (index: number) => {
|
||||
if (settings.categories.length <= 1) return;
|
||||
const newCategories = settings.categories.filter((_, i) => i !== index);
|
||||
setSettings({ ...settings, categories: newCategories });
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(settings, null, 2));
|
||||
const downloadAnchorNode = document.createElement('a');
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute("download", "settings.json");
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
};
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const newSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||
setSettings(newSettings);
|
||||
setMessage({ type: 'success', text: 'Configuration importée avec succès. N\'oubliez pas d\'enregistrer.' });
|
||||
} else {
|
||||
throw new Error('Format invalide');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setMessage({ type: 'error', text: 'Erreur lors de l\'importation du fichier JSON.' });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<SettingsIcon className="h-8 w-8 text-indigo-600 mr-3" />
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Paramètres de génération</h1>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exporter
|
||||
</button>
|
||||
<label className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Importer
|
||||
<input type="file" className="hidden" accept=".json" onChange={handleImport} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-6 p-4 rounded-md ${message.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
|
||||
<div className="px-4 py-5 sm:p-6 space-y-6">
|
||||
|
||||
<div>
|
||||
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
|
||||
Thème de la recherche
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="theme"
|
||||
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
|
||||
value={settings.theme}
|
||||
onChange={(e) => setSettings({ ...settings, theme: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Le sujet principal pour la recherche d'actualités (ex: Intelligence Artificielle, Cybersécurité, etc.).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="maxAgeDays" className="block text-sm font-medium text-gray-700">
|
||||
Ancienneté maximum (jours)
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="number"
|
||||
id="maxAgeDays"
|
||||
min="1"
|
||||
max="365"
|
||||
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
|
||||
value={settings.maxAgeDays}
|
||||
onChange={(e) => setSettings({ ...settings, maxAgeDays: parseInt(e.target.value) || 7 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="maxItemsPerCategory" className="block text-sm font-medium text-gray-700">
|
||||
Actualités max par catégorie
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="number"
|
||||
id="maxItemsPerCategory"
|
||||
min="1"
|
||||
max="20"
|
||||
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
|
||||
value={settings.maxItemsPerCategory}
|
||||
onChange={(e) => setSettings({ ...settings, maxItemsPerCategory: parseInt(e.target.value) || 4 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="searchAgentBehavior" className="block text-sm font-medium text-gray-700">
|
||||
Comportement de l'agent de recherche
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<textarea
|
||||
id="searchAgentBehavior"
|
||||
rows={3}
|
||||
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
|
||||
value={settings.searchAgentBehavior || ''}
|
||||
onChange={(e) => setSettings({ ...settings, searchAgentBehavior: e.target.value })}
|
||||
placeholder="Tu peux également utiliser d'autres sources pertinentes trouvées via la recherche Google."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Personnalisez les instructions données à l'IA concernant sa méthode de recherche.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="aiModel" className="block text-sm font-medium text-gray-700">
|
||||
Modèle d'IA
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<select
|
||||
id="aiModel"
|
||||
name="aiModel"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md border"
|
||||
value={settings.aiModel || 'gemini-3.1-pro-preview'}
|
||||
onChange={(e) => setSettings({ ...settings, aiModel: e.target.value })}
|
||||
>
|
||||
<option value="gemini-3.1-pro-preview">Gemini 3.1 Pro (conseillé)</option>
|
||||
<option value="gemini-3-flash-preview">Gemini 3.0 Flash</option>
|
||||
<option value="gemini-3.1-flash-lite-preview">Gemini 3.1 Flash Lite</option>
|
||||
<option value="gemini-2.5-flash">Gemini 2.5 Flash</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Choisissez le modèle d'IA utilisé pour générer les synthèses. Le modèle Pro est conseillé pour des résultats plus pertinents et une meilleure analyse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Catégories d'actualité
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCategory}
|
||||
disabled={settings.categories.length >= 20}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{settings.categories.map((category, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="text-gray-500 font-medium w-6">{index + 1}.</span>
|
||||
<input
|
||||
type="text"
|
||||
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
|
||||
value={category}
|
||||
onChange={(e) => handleCategoryChange(index, e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCategory(index)}
|
||||
disabled={settings.categories.length <= 1}
|
||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Supprimer cette catégorie"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Enregistrer les paramètres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,372 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { collection, query, where, onSnapshot, addDoc, deleteDoc, doc, Timestamp } from 'firebase/firestore';
|
||||
import { db, handleFirestoreError, OperationType } from '../firebase';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { SourceDocument } from '../types';
|
||||
import { Trash2, Plus, Link as LinkIcon, Download, Upload } from 'lucide-react';
|
||||
|
||||
export default function Sources() {
|
||||
const { user } = useAuth();
|
||||
const [sources, setSources] = useState<SourceDocument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [bulkText, setBulkText] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const q = query(
|
||||
collection(db, 'sources'),
|
||||
where('authorUid', '==', user.uid)
|
||||
);
|
||||
|
||||
const unsubscribe = onSnapshot(q, (snapshot) => {
|
||||
const docs = snapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data()
|
||||
})) as SourceDocument[];
|
||||
docs.sort((a, b) => b.createdAt.toMillis() - a.createdAt.toMillis());
|
||||
setSources(docs);
|
||||
setLoading(false);
|
||||
}, (error) => {
|
||||
handleFirestoreError(error, OperationType.LIST, 'sources');
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [user]);
|
||||
|
||||
const handleAddSource = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user || !newTitle.trim() || !newUrl.trim()) return;
|
||||
|
||||
let formattedUrl = newUrl.trim();
|
||||
if (!formattedUrl.startsWith('http://') && !formattedUrl.startsWith('https://')) {
|
||||
formattedUrl = 'https://' + formattedUrl;
|
||||
}
|
||||
|
||||
setAdding(true);
|
||||
try {
|
||||
await addDoc(collection(db, 'sources'), {
|
||||
title: newTitle.trim(),
|
||||
url: formattedUrl,
|
||||
authorUid: user.uid,
|
||||
createdAt: Timestamp.now()
|
||||
});
|
||||
setNewTitle('');
|
||||
setNewUrl('');
|
||||
} catch (error) {
|
||||
handleFirestoreError(error, OperationType.CREATE, 'sources');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteDoc(doc(db, 'sources', id));
|
||||
} catch (error) {
|
||||
handleFirestoreError(error, OperationType.DELETE, `sources/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const header = "Titre,URL\n";
|
||||
const csvContent = sources.map(s => `"${s.title.replace(/"/g, '""')}","${s.url.replace(/"/g, '""')}"`).join("\n");
|
||||
const dataStr = "data:text/csv;charset=utf-8," + encodeURIComponent(header + csvContent);
|
||||
const downloadAnchorNode = document.createElement('a');
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute("download", "sources.csv");
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
};
|
||||
|
||||
const handleImportCSV = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !user) return;
|
||||
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
|
||||
// Skip header if it exists
|
||||
const startIndex = lines[0].toLowerCase().includes('titre') || lines[0].toLowerCase().includes('title') ? 1 : 0;
|
||||
|
||||
const validSources: { title: string, url: string }[] = [];
|
||||
|
||||
for (let i = startIndex; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const separator = line.includes(';') ? ';' : ',';
|
||||
|
||||
let inQuotes = false;
|
||||
let currentVal = '';
|
||||
const values = [];
|
||||
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const char = line[j];
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === separator && !inQuotes) {
|
||||
values.push(currentVal);
|
||||
currentVal = '';
|
||||
} else {
|
||||
currentVal += char;
|
||||
}
|
||||
}
|
||||
values.push(currentVal);
|
||||
|
||||
if (values.length >= 2) {
|
||||
const title = values[0].trim().replace(/^"|"$/g, '').replace(/""/g, '"');
|
||||
let url = values.slice(1).join(separator).trim().replace(/^"|"$/g, '').replace(/""/g, '"');
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
if (title && url) {
|
||||
validSources.push({ title, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validSources.length === 0) {
|
||||
setImportError("Aucune source valide trouvée dans le fichier CSV.");
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(validSources.map(source =>
|
||||
addDoc(collection(db, 'sources'), {
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
authorUid: user.uid,
|
||||
createdAt: Timestamp.now()
|
||||
})
|
||||
));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setImportError("Erreur lors de l'importation du fichier CSV.");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleBulkImport = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user || !bulkText.trim()) return;
|
||||
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
|
||||
const lines = bulkText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
const validSources: { title: string, url: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(';');
|
||||
if (parts.length >= 2) {
|
||||
const title = parts[0].trim();
|
||||
let url = parts.slice(1).join(';').trim();
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
if (title && url) {
|
||||
validSources.push({ title, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validSources.length === 0) {
|
||||
setImportError("Aucune source valide trouvée. Vérifiez le format (Nom;URL).");
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(validSources.map(source =>
|
||||
addDoc(collection(db, 'sources'), {
|
||||
title: source.title,
|
||||
url: source.url,
|
||||
authorUid: user.uid,
|
||||
createdAt: Timestamp.now()
|
||||
})
|
||||
));
|
||||
setBulkText('');
|
||||
} catch (error) {
|
||||
handleFirestoreError(error, OperationType.CREATE, 'sources');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Sources Personnalisées</h1>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Ajoutez des sites web ou des blogs que l'IA devra obligatoirement consulter lors de la génération de vos synthèses.
|
||||
Ces sources s'ajoutent aux sources par défaut.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow sm:rounded-lg mb-8">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Ajouter une source</h3>
|
||||
<form onSubmit={handleAddSource} className="space-y-4 sm:flex sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="title" className="sr-only">Titre</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
|
||||
placeholder="Nom de la source (ex: Blog de Yann LeCun)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="url" className="sr-only">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
id="url"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
|
||||
placeholder="https://..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adding}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Ajouter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow sm:rounded-lg mb-8">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Import / Export CSV</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Sauvegardez vos sources ou importez-en de nouvelles depuis un fichier CSV.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exporter en CSV
|
||||
</button>
|
||||
<label className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Importer depuis un CSV
|
||||
<input type="file" className="hidden" accept=".csv" onChange={handleImportCSV} disabled={importing} />
|
||||
</label>
|
||||
</div>
|
||||
{importError && (
|
||||
<p className="mt-2 text-sm text-red-600">{importError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow sm:rounded-lg mb-8">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Import en masse</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Ajoutez plusieurs sources d'un coup. Une source par ligne, au format : <strong>Nom de la source;URL</strong>
|
||||
</p>
|
||||
<form onSubmit={handleBulkImport} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="bulk" className="sr-only">Liste des sources</label>
|
||||
<textarea
|
||||
id="bulk"
|
||||
rows={5}
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
|
||||
placeholder="Blog IA;https://blog.ia.com News Tech;https://tech.news.fr"
|
||||
/>
|
||||
</div>
|
||||
{importError && (
|
||||
<p className="text-sm text-red-600">{importError}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={importing || !bulkText.trim()}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{importing ? 'Importation...' : 'Importer les sources'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{sources.length === 0 ? (
|
||||
<li className="px-4 py-8 text-center text-gray-500">
|
||||
Aucune source personnalisée pour le moment.
|
||||
</li>
|
||||
) : (
|
||||
sources.map((source) => (
|
||||
<li key={source.id}>
|
||||
<div className="px-4 py-4 flex items-center sm:px-6">
|
||||
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="truncate">
|
||||
<div className="flex text-sm">
|
||||
<p className="font-medium text-indigo-600 truncate">{source.title}</p>
|
||||
</div>
|
||||
<div className="mt-2 flex">
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<LinkIcon className="flex-shrink-0 mr-1.5 h-4 w-4 text-gray-400" />
|
||||
<a href={source.url} target="_blank" rel="noopener noreferrer" className="truncate hover:underline">
|
||||
{source.url}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleDelete(source.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,307 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { doc, onSnapshot, deleteDoc } from 'firebase/firestore';
|
||||
import { db, handleFirestoreError, OperationType, getGmailAccessToken } from '../firebase';
|
||||
import { SynthesisDocument, NewsItem } from '../types';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { ArrowLeft, ExternalLink, Mail, Send, Trash2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const Section: React.FC<{ title: string; items: NewsItem[] }> = ({ title, items }) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 border-b pb-2">{title}</h2>
|
||||
<div className="space-y-6">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="bg-white rounded-lg shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold text-indigo-700 mb-2">
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer" className="hover:underline flex items-center gap-2">
|
||||
{item.title}
|
||||
<ExternalLink className="h-4 w-4 text-gray-400" />
|
||||
</a>
|
||||
</h3>
|
||||
<p className="text-gray-700 leading-relaxed text-sm">
|
||||
{item.summary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SynthesisDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [synthesis, setSynthesis] = useState<SynthesisDocument | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('olivier.abrivard@desjardins.com');
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [sendingEmail, setSendingEmail] = useState(false);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const docRef = doc(db, 'syntheses', id);
|
||||
const unsubscribe = onSnapshot(docRef, (snapshot) => {
|
||||
if (snapshot.exists()) {
|
||||
setSynthesis({ id: snapshot.id, ...snapshot.data() } as SynthesisDocument);
|
||||
} else {
|
||||
setError("Synthèse introuvable.");
|
||||
}
|
||||
setLoading(false);
|
||||
}, (err) => {
|
||||
handleFirestoreError(err, OperationType.GET, `syntheses/${id}`);
|
||||
setError("Erreur lors du chargement de la synthèse.");
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [id]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!synthesis || !id) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteDoc(doc(db, 'syntheses', id));
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
handleFirestoreError(err, OperationType.DELETE, `syntheses/${id}`);
|
||||
setError("Erreur lors de la suppression.");
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatEmailBody = (synth: SynthesisDocument) => {
|
||||
let body = `Synthèse de la Semaine ${synth.week.split('-W')[1]}\n`;
|
||||
body += `Générée le ${format(synth.createdAt.toDate(), 'dd MMMM yyyy', { locale: fr })}\n\n`;
|
||||
|
||||
const addSection = (title: string, items: NewsItem[]) => {
|
||||
if (!items || items.length === 0) return;
|
||||
body += `--- ${title} ---\n\n`;
|
||||
items.forEach(item => {
|
||||
body += `${item.title}\n`;
|
||||
body += `${item.url}\n`;
|
||||
body += `${item.summary}\n\n`;
|
||||
});
|
||||
};
|
||||
|
||||
if (synth.sections && synth.sections.length > 0) {
|
||||
synth.sections.forEach(section => {
|
||||
addSection(section.title, section.items);
|
||||
});
|
||||
} else {
|
||||
addSection("Annonces majeures / importantes", synth.majorAnnouncements || []);
|
||||
addSection("Entreprises des secteurs financiers", synth.financialSector || []);
|
||||
addSection("Grandes entreprises des autres secteurs", synth.otherEnterprises || []);
|
||||
addSection("Secteurs publics (Défense, Éducation, etc.)", synth.publicSector || []);
|
||||
addSection("Grand public / Particuliers", synth.generalPublic || []);
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!synthesis || !email) return;
|
||||
|
||||
setSendingEmail(true);
|
||||
setEmailError(null);
|
||||
setCopySuccess(false);
|
||||
|
||||
try {
|
||||
const accessToken = await getGmailAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new Error("Impossible d'obtenir l'autorisation de Gmail.");
|
||||
}
|
||||
|
||||
const subject = `Synthèse IA - Semaine ${synthesis.week.split('-W')[1]}`;
|
||||
const bodyText = formatEmailBody(synthesis);
|
||||
|
||||
const emailLines = [
|
||||
`To: ${email}`,
|
||||
`Subject: =?utf-8?B?${btoa(unescape(encodeURIComponent(subject)))}?=`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'MIME-Version: 1.0',
|
||||
'',
|
||||
bodyText
|
||||
];
|
||||
const emailContent = emailLines.join('\r\n');
|
||||
|
||||
const utf8Bytes = new TextEncoder().encode(emailContent);
|
||||
let binaryString = "";
|
||||
for (let i = 0; i < utf8Bytes.length; i++) {
|
||||
binaryString += String.fromCharCode(utf8Bytes[i]);
|
||||
}
|
||||
const base64EncodedEmail = btoa(binaryString)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
raw: base64EncodedEmail
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`Erreur Gmail: ${errorData.error?.message || 'Erreur inconnue'}`);
|
||||
}
|
||||
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 5000);
|
||||
} catch (err) {
|
||||
console.error("Erreur lors de l'envoi de l'email:", err);
|
||||
setEmailError(err instanceof Error ? err.message : "Erreur lors de l'envoi de l'email");
|
||||
} finally {
|
||||
setSendingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !synthesis) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-12 text-center">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<Link to="/" className="text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
← Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Link to="/" className="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Retour aux synthèses
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="inline-flex items-center text-sm font-medium text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center text-red-800">
|
||||
<AlertTriangle className="h-5 w-5 mr-2 flex-shrink-0" />
|
||||
<p className="text-sm">Êtes-vous sûr de vouloir supprimer cette synthèse définitivement ?</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
) : null}
|
||||
Confirmer la suppression
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
Synthèse de la Semaine {synthesis.week.split('-W')[1]}
|
||||
</h1>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
|
||||
Générée le {format(synthesis.createdAt.toDate(), 'dd MMMM yyyy', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="flex-1 w-full">
|
||||
<label htmlFor="email" className="sr-only">Adresse email</label>
|
||||
<div className="relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md py-2 border"
|
||||
placeholder="adresse@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendEmail}
|
||||
disabled={sendingEmail}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sendingEmail ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{sendingEmail ? 'Envoi en cours...' : 'Envoyer par email'}
|
||||
</button>
|
||||
</div>
|
||||
{copySuccess && (
|
||||
<div className="mt-2 text-sm font-medium text-green-600 bg-green-50 p-3 rounded-md border border-green-200">
|
||||
✓ L'email a été envoyé avec succès via Gmail !
|
||||
</div>
|
||||
)}
|
||||
{emailError && (
|
||||
<div className="mt-2 text-sm font-medium text-red-600 bg-red-50 p-3 rounded-md border border-red-200">
|
||||
Erreur : {emailError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
{synthesis.sections && synthesis.sections.length > 0 ? (
|
||||
synthesis.sections.map((section, index) => (
|
||||
<Section key={index} title={section.title} items={section.items} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Section title="Annonces majeures / importantes" items={synthesis.majorAnnouncements || []} />
|
||||
<Section title="Entreprises des secteurs financiers" items={synthesis.financialSector || []} />
|
||||
<Section title="Grandes entreprises des autres secteurs" items={synthesis.otherEnterprises || []} />
|
||||
<Section title="Secteurs publics (Défense, Éducation, etc.)" items={synthesis.publicSector || []} />
|
||||
<Section title="Grand public / Particuliers" items={synthesis.generalPublic || []} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,318 +0,0 @@
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { AppSettings, DEFAULT_SETTINGS, NewsItem, SynthesisData, NewsSection } from '../types';
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
|
||||
class RateLimiter {
|
||||
private timestamps: number[] = [];
|
||||
private maxRequests: number;
|
||||
private timeWindowMs: number;
|
||||
|
||||
constructor(maxRequests: number = 29, timeWindowMs: number = 60000) {
|
||||
this.maxRequests = maxRequests;
|
||||
this.timeWindowMs = timeWindowMs;
|
||||
}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
const now = Date.now();
|
||||
this.timestamps = this.timestamps.filter(t => now - t < this.timeWindowMs);
|
||||
|
||||
if (this.timestamps.length >= this.maxRequests) {
|
||||
const waitTime = this.timeWindowMs - (now - this.timestamps[0]);
|
||||
if (waitTime > 0) {
|
||||
console.log(`Rate limit reached. Waiting for ${waitTime}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
return this.acquire();
|
||||
}
|
||||
}
|
||||
|
||||
this.timestamps.push(Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
export const geminiRateLimiter = new RateLimiter(29, 60000);
|
||||
|
||||
export interface CustomSource {
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ScrapedNewsItem extends NewsItem {
|
||||
scrapedContent: string;
|
||||
}
|
||||
|
||||
const newsItemSchema = {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING },
|
||||
url: { type: Type.STRING },
|
||||
summary: { type: Type.STRING }
|
||||
},
|
||||
required: ["title", "url", "summary"]
|
||||
}
|
||||
};
|
||||
|
||||
export async function generateWeeklySynthesis(
|
||||
customSources: CustomSource[] = [],
|
||||
settings: AppSettings = DEFAULT_SETTINGS
|
||||
): Promise<SynthesisData> {
|
||||
const customSourcesText = customSources.length > 0
|
||||
? `\nEn plus des sources par défaut, tu DOIS impérativement consulter et intégrer les informations provenant de ces sources personnalisées :\n${customSources.map(s => `- ${s.title} (${s.url})`).join('\n')}\n`
|
||||
: "";
|
||||
|
||||
const currentDate = new Date().toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
// Generate dynamic schema based on categories
|
||||
const dynamicProperties: Record<string, any> = {};
|
||||
const dynamicRequired: string[] = [];
|
||||
|
||||
settings.categories.forEach((cat, index) => {
|
||||
const key = `category_${index}`;
|
||||
dynamicProperties[key] = newsItemSchema;
|
||||
dynamicRequired.push(key);
|
||||
});
|
||||
|
||||
const synthesisSchema = {
|
||||
type: Type.OBJECT,
|
||||
properties: dynamicProperties,
|
||||
required: dynamicRequired
|
||||
};
|
||||
|
||||
const categoriesText = settings.categories.map((cat, index) => `${index + 1}. ${cat}`).join('\n');
|
||||
|
||||
const prompt1 = `
|
||||
Aujourd'hui, nous sommes le ${currentDate}.
|
||||
Tu es un expert en analyse de l'actualité sur le thème : "${settings.theme}".
|
||||
Ta tâche est de rechercher les actualités STRICTEMENT des ${settings.maxAgeDays} derniers jours.
|
||||
Ne retourne AUCUNE actualité datant de plus de ${settings.maxAgeDays} jours.
|
||||
|
||||
Tu DOIS impérativement t'appuyer sur le contenu des sites web pertinents pour ce thème.${customSourcesText}
|
||||
${settings.searchAgentBehavior || "Tu peux également utiliser d'autres sources pertinentes trouvées via la recherche Google."}
|
||||
|
||||
La synthèse doit être divisée en ${settings.categories.length} grandes sections :
|
||||
${categoriesText}
|
||||
|
||||
Pour chaque catégorie, fournis au maximum ${settings.maxItemsPerCategory} actualités.
|
||||
Pour chaque actualité, fournis un titre provisoire, l'URL source exacte et complète, et un résumé provisoire.
|
||||
Retourne le résultat au format JSON en utilisant les clés category_0, category_1, etc. correspondant à l'ordre des sections ci-dessus.
|
||||
`;
|
||||
|
||||
try {
|
||||
await geminiRateLimiter.acquire();
|
||||
const response1 = await ai.models.generateContent({
|
||||
model: settings.aiModel || "gemini-3.1-pro-preview",
|
||||
contents: prompt1,
|
||||
config: {
|
||||
systemInstruction: `Tu es un assistant IA précis. Tu dois TOUJOURS fournir des URLs complètes et exactes. Ne tronque jamais les URLs. Tu dois te concentrer UNIQUEMENT sur les actualités des ${settings.maxAgeDays} derniers jours.`,
|
||||
tools: [{ googleSearch: {} }],
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: synthesisSchema
|
||||
}
|
||||
});
|
||||
|
||||
if (!response1.text) {
|
||||
throw new Error("Failed to generate initial synthesis");
|
||||
}
|
||||
|
||||
const parsedData = JSON.parse(response1.text) as Record<string, NewsItem[]>;
|
||||
|
||||
// Filter and scrape content for each category
|
||||
const scrapedCategories: Record<string, ScrapedNewsItem[]> = {};
|
||||
const validationPromises = settings.categories.map(async (cat, index) => {
|
||||
const key = `category_${index}`;
|
||||
const items = parsedData[key] || [];
|
||||
scrapedCategories[key] = await filterValidNewsItems(items, settings.maxAgeDays);
|
||||
});
|
||||
|
||||
await Promise.all(validationPromises);
|
||||
|
||||
const prompt2 = `
|
||||
Tu es un expert en analyse de l'actualité.
|
||||
Voici une liste d'articles d'actualité classés par catégorie, avec leur contenu textuel brut extrait des sites web ('scrapedContent').
|
||||
Ta tâche est de réécrire le 'title' et le 'summary' (4 ou 5 lignes) pour chaque article afin qu'ils reflètent EXACTEMENT et FIDÈLEMENT le contenu textuel fourni.
|
||||
Si le 'scrapedContent' est vide ou insuffisant, utilise le titre et le résumé originaux pour faire au mieux.
|
||||
Conserve EXACTEMENT les mêmes URLs. Ne supprime aucun article de cette liste.
|
||||
|
||||
Données des articles :
|
||||
${JSON.stringify(scrapedCategories, null, 2)}
|
||||
`;
|
||||
|
||||
await geminiRateLimiter.acquire();
|
||||
const response2 = await ai.models.generateContent({
|
||||
model: settings.aiModel || "gemini-3.1-pro-preview",
|
||||
contents: prompt2,
|
||||
config: {
|
||||
systemInstruction: "Tu es un assistant IA précis. Tu dois générer des titres et résumés fidèles au contenu fourni.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: synthesisSchema
|
||||
}
|
||||
});
|
||||
|
||||
if (!response2.text) {
|
||||
throw new Error("Failed to generate final synthesis");
|
||||
}
|
||||
|
||||
const finalParsedData = JSON.parse(response2.text) as Record<string, NewsItem[]>;
|
||||
|
||||
// Map back to SynthesisData with sections array
|
||||
const sections: NewsSection[] = settings.categories.map((cat, index) => ({
|
||||
title: cat,
|
||||
items: finalParsedData[`category_${index}`] || []
|
||||
}));
|
||||
|
||||
return { sections };
|
||||
} catch (error: any) {
|
||||
if (error.message && error.message.includes('429') && error.message.includes('quota')) {
|
||||
throw new Error("Quota d'utilisation de l'API Gemini dépassé. Veuillez vérifier votre plan et vos détails de facturation sur Google AI Studio.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHtmlContent(url: string): Promise<{ ok: boolean, status: number, html: string }> {
|
||||
// 1. Try allorigins
|
||||
try {
|
||||
const response = await fetch(`https://api.allorigins.win/get?url=${encodeURIComponent(url)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
ok: data.status ? data.status.http_code < 400 : true,
|
||||
status: data.status ? data.status.http_code : 200,
|
||||
html: data.contents || ''
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`allorigins proxy failed for ${url}`);
|
||||
}
|
||||
|
||||
// 2. Try codetabs
|
||||
try {
|
||||
const response = await fetch(`https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(url)}`);
|
||||
if (response.ok) {
|
||||
const html = await response.text();
|
||||
return { ok: true, status: response.status, html };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`codetabs proxy failed for ${url}`);
|
||||
}
|
||||
|
||||
// 3. Try corsproxy.io
|
||||
try {
|
||||
const response = await fetch(`https://corsproxy.io/?${encodeURIComponent(url)}`);
|
||||
if (response.ok) {
|
||||
const html = await response.text();
|
||||
return { ok: true, status: response.status, html };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`corsproxy failed for ${url}`);
|
||||
}
|
||||
|
||||
throw new Error("Failed to fetch from all proxies");
|
||||
}
|
||||
|
||||
async function filterValidNewsItems(items: NewsItem[], maxAgeDays: number = 7): Promise<ScrapedNewsItem[]> {
|
||||
if (!items || !Array.isArray(items)) return [];
|
||||
|
||||
const validationPromises = items.map(async (item) => {
|
||||
try {
|
||||
new URL(item.url); // Lève une TypeError si l'URL est invalide
|
||||
} catch (e) {
|
||||
console.warn(`URL malformée ignorée: ${item.url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, status, html } = await fetchHtmlContent(item.url);
|
||||
|
||||
if (!ok || status >= 400) {
|
||||
console.warn(`URL invalide (status ${status}) ignorée: ${item.url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (html) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// 1. Vérification d'erreur dans le contenu (Soft 404)
|
||||
const title = doc.title.toLowerCase();
|
||||
const h1 = doc.querySelector('h1')?.textContent?.toLowerCase() || '';
|
||||
const errorKeywords = [
|
||||
'page not found', 'page introuvable', 'access denied', 'accès refusé',
|
||||
'404', '403', 'forbidden', 'not found', 'n\'existe pas', 'are you lost',
|
||||
'erreur 404', 'error 404', 'page inexistante', 'introuvable'
|
||||
];
|
||||
|
||||
const hasError = errorKeywords.some(keyword => title.includes(keyword) || h1.includes(keyword)) ||
|
||||
title.startsWith('404') || h1.startsWith('404') || title === 'error' || h1 === 'error' ||
|
||||
doc.querySelector('meta[name="robots"][content*="noindex"]') !== null;
|
||||
|
||||
if (hasError) {
|
||||
console.warn(`Contenu d'erreur détecté pour l'URL: ${item.url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Vérification de la date de publication
|
||||
const metaPubTime = doc.querySelector('meta[property="article:published_time"]')?.getAttribute('content') ||
|
||||
doc.querySelector('meta[property="og:article:published_time"]')?.getAttribute('content') ||
|
||||
doc.querySelector('meta[itemprop="datePublished"]')?.getAttribute('content') ||
|
||||
doc.querySelector('meta[name="dc.date"]')?.getAttribute('content') ||
|
||||
doc.querySelector('meta[name="date"]')?.getAttribute('content') ||
|
||||
doc.querySelector('meta[name="pubdate"]')?.getAttribute('content');
|
||||
const timeTag = doc.querySelector('time[datetime]')?.getAttribute('datetime');
|
||||
|
||||
// Recherche dans les balises JSON-LD
|
||||
let jsonLdDate = '';
|
||||
const scripts = doc.querySelectorAll('script[type="application/ld+json"]');
|
||||
scripts.forEach(script => {
|
||||
try {
|
||||
if (script.textContent) {
|
||||
const json = JSON.parse(script.textContent);
|
||||
if (json && json.datePublished) {
|
||||
jsonLdDate = json.datePublished;
|
||||
} else if (Array.isArray(json)) {
|
||||
const article = json.find(j => j.datePublished);
|
||||
if (article) jsonLdDate = article.datePublished;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de parsing JSON
|
||||
}
|
||||
});
|
||||
|
||||
const pubDateStr = jsonLdDate || metaPubTime || timeTag || '';
|
||||
|
||||
if (pubDateStr) {
|
||||
const pubDate = new Date(pubDateStr);
|
||||
if (!isNaN(pubDate.getTime())) {
|
||||
const now = new Date();
|
||||
const diffTime = now.getTime() - pubDate.getTime();
|
||||
const diffDays = diffTime / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (diffDays > maxAgeDays) {
|
||||
console.warn(`Article trop ancien (${Math.round(diffDays)} jours) ignoré: ${item.url}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extraction du contenu textuel pour l'analyse
|
||||
const elementsToRemove = doc.querySelectorAll('script, style, noscript, iframe, nav, footer, header, aside');
|
||||
elementsToRemove.forEach(el => el.remove());
|
||||
const scrapedContent = doc.body?.textContent?.replace(/\s+/g, ' ').trim().substring(0, 4000) || '';
|
||||
|
||||
return { ...item, scrapedContent };
|
||||
}
|
||||
|
||||
return { ...item, scrapedContent: '' };
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de la vérification de l'URL ${item.url}:`, error);
|
||||
// En cas d'erreur du proxy, on garde l'annonce par précaution pour ne pas perdre de données
|
||||
return { ...item, scrapedContent: '' };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(validationPromises);
|
||||
return results.filter((item): item is ScrapedNewsItem => item !== null);
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
import { Timestamp } from 'firebase/firestore';
|
||||
|
||||
export interface NewsItem {
|
||||
title: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface NewsSection {
|
||||
title: string;
|
||||
items: NewsItem[];
|
||||
}
|
||||
|
||||
export interface SynthesisData {
|
||||
majorAnnouncements?: NewsItem[];
|
||||
financialSector?: NewsItem[];
|
||||
otherEnterprises?: NewsItem[];
|
||||
publicSector?: NewsItem[];
|
||||
generalPublic?: NewsItem[];
|
||||
sections?: NewsSection[];
|
||||
}
|
||||
|
||||
export interface SynthesisDocument extends SynthesisData {
|
||||
id: string;
|
||||
week: string;
|
||||
createdAt: Timestamp;
|
||||
authorUid: string;
|
||||
}
|
||||
|
||||
export interface SourceDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
authorUid: string;
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
theme: string;
|
||||
maxAgeDays: number;
|
||||
categories: string[];
|
||||
maxItemsPerCategory: number;
|
||||
searchAgentBehavior: string;
|
||||
aiModel: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
theme: "Intelligence Artificielle",
|
||||
maxAgeDays: 7,
|
||||
categories: [
|
||||
"Annonces majeures / importantes",
|
||||
"Entreprises des secteurs financiers (banques, assurances, etc.)",
|
||||
"Grandes entreprises des autres secteurs",
|
||||
"Secteurs publics (Défense, Éducation, etc.)",
|
||||
"Grand public / Particuliers"
|
||||
],
|
||||
maxItemsPerCategory: 4,
|
||||
searchAgentBehavior: "Tu peux également utiliser d'autres sources pertinentes trouvées via la recherche Google.",
|
||||
aiModel: "gemini-3.1-pro-preview"
|
||||
};
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue