Initial commit

This commit is contained in:
jeremy bayse
2026-06-22 16:40:24 +02:00
commit ad127156ff
78 changed files with 13478 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+65
View File
@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+27
View File
@@ -0,0 +1,27 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+130
View File
@@ -0,0 +1,130 @@
# Documentation Technique - Chatbot RAG Multi-Clients
Cette documentation explique le fonctionnement global de l'application de chatbot RAG (Retrieval-Augmented Generation) multi-tenants, ainsi que les concepts mathématiques des vecteurs et des embeddings utilisés pour la recherche sémantique.
---
## 1. Architecture Générale (RAG)
L'application utilise le pattern **RAG (Retrieval-Augmented Generation)**. Contrairement à un chatbot classique qui s'appuie uniquement sur les connaissances générales d'un grand modèle de langage (LLM), le RAG permet d'injecter des données issues de documents propres à l'utilisateur (fichiers PDF, pages de sites web) directement dans le prompt du LLM pour garantir des réponses précises, sourcées et sans hallucinations.
```mermaid
graph TD
A[Utilisateur pose une question] --> B[Recherche Vectorielle]
B -->|Convertit la question en vecteur| C[Weaviate Cloud]
C -->|Retourne les segments de texte pertinents| D[Prompt RAG]
D -->|Construit le prompt : Contexte + Question| E[Mistral AI]
E -->|Génère la réponse finale| F[Retour à l'utilisateur]
```
### Les Composants de l'Application :
1. **Frontend (Livewire & JS Widget)** : Une interface de discussion moderne et réactive qui peut être intégrée sur n'importe quel site internet externe via une simple balise `<script>`.
2. **Backend (Laravel 11)** : Gère le multi-tenancy (isolation des chatbots par client), l'orchestration des données, l'historique et les appels aux services tiers.
3. **Parser PDF (Python + PyMuPDF + Mistral Vision)** : Un script Python haute fidélité qui extrait le texte en respectant la mise en page (colonnes, tableaux) et utilise un modèle de vision pour décrire les images et pictos.
4. **Base de Données Vectorielle (Weaviate Cloud)** : Stocke et recherche les données textuelles sous forme de coordonnées mathématiques (vecteurs).
5. **LLM & Embeddings (Mistral AI)** : Fournit le modèle d'embeddings pour vectoriser le texte et le modèle de langage (`mistral-small-latest`) pour formuler les réponses.
---
## 2. Comment Fonctionnent les Vecteurs et les Embeddings ?
### Qu'est-ce qu'un Embedding ?
Un **embedding** (ou plongement lexical) est la conversion d'un texte (un mot, une phrase ou un paragraphe) en une suite de nombres décimaux, appelée **vecteur**.
Par exemple, le modèle d'embedding de Mistral AI prend un texte et le transforme en un vecteur de **1024 dimensions** (une liste de 1024 nombres comme `[0.012, -0.045, 0.189, ...]`).
### La Représentation Spatiale
Imaginez un espace géométrique.
* Dans un espace à 2 dimensions (un plan $X, Y$), on peut placer des points.
* Dans notre espace d'embedding, nous avons **1024 dimensions**.
La force de cette transformation est que **le positionnement des vecteurs dépend du sens sémantique du texte**. Des textes qui partagent le même sens ou traitent du même sujet se retrouvent géométriquement très proches les uns des autres dans cet espace multi-dimensionnel.
```
[Espace Sémantique Virtuel]
(Tri des déchets)
* "Bac jaune" (Technologie)
* "Recycler carton" * "Ordinateur portable"
* "Processeur silicium"
```
Dans ce schéma simplifié, les vecteurs de "Bac jaune" et "Recycler carton" sont proches, alors que "Ordinateur portable" est très éloigné de ces deux concepts.
### La Recherche Sémantique (Similarité Cosinus)
Lorsque l'utilisateur pose une question (ex: *"Où jeter mes boîtes en carton ?"*) :
1. La question est convertie en vecteur (vecteur A).
2. Weaviate compare ce vecteur A avec tous les vecteurs stockés dans la base de données (vecteurs B, C, D...).
3. Il calcule l'angle entre les vecteurs à l'aide de la **similarité cosinus**. Plus le cosinus de l'angle est proche de 1, plus les sens sémantiques sont proches.
4. Weaviate retourne instantanément les segments de texte (chunks) les plus proches, même si les mots exacts de la question ne figurent pas dans le document (ex: comprendre que "carton" est lié à "emballage").
---
## 3. Le Flux de Traitement Étape par Étape
### Étape A : L'indexation des Données (Ingestion)
#### 1. Pour les sites web :
Le crawler interne explore le site récursivement (limité au même domaine) pour récupérer le code HTML de chaque page. Le HTML est nettoyé des balises inutiles (scripts, styles, menus) pour ne garder que le contenu informatif.
#### 2. Pour les PDF :
Le script [parse_pdf.py](file:///g:/Autres%20Projets/ChatbotAGGLO/scripts/parse_pdf.py) extrait le texte en ordonnant les blocs de haut en bas et de gauche à droite (évitant le mélange de texte des PDF multicolonnes). Si l'option Vision AI est cochée :
- Les images du PDF sont extraites en mémoire.
- Elles sont encodées en Base64 et envoyées au modèle de vision de Mistral (`pixtral-12b-2409`).
- La description générée (ex: *"Pictogramme montrant que les bouteilles en plastique vont dans le bac jaune"*) est insérée dans le texte à l'emplacement de l'image.
#### 3. Découpage (Chunking) :
Le texte complet est découpé en segments (chunks) d'environ 800 caractères avec un chevauchement de 120 caractères. Le chevauchement garantit qu'aucune information n'est coupée au milieu d'une phrase importante.
#### 4. Vectorisation et Stockage :
Chaque segment est envoyé à Mistral AI pour obtenir son vecteur d'embedding, puis stocké dans Weaviate avec les métadonnées suivantes :
* `content` : Le texte brut du segment.
* `source` : L'URL ou le nom du fichier PDF d'origine.
* `title` : Le titre de la page ou de la section.
* `chatbotId` : L'identifiant unique du chatbot (pour cloisonner les données de chaque client).
---
### Étape B : La Discussion (Génération)
```
[Question utilisateur]
[Recherche Weaviate (Filtre par Chatbot ID)]
[Extraction du Contexte sémantique]
[Prompt injecté au LLM]
┌──────────────────────────────────────────────────────────┐
│ INSTRUCTIONS : Réponds uniquement avec le contexte. │
│ CONTEXTE : [Contenu du PDF GUIDE-PREVENTION-2025.pdf] │
│ QUESTION : Quels sont les horaires de Villeneuve ? │
└──────────────────────────────────────────────────────────┘
[Génération Mistral AI]
[Réponse structurée + Sources affichées]
```
1. **Réception de la question** : L'utilisateur envoie un message depuis le widget.
2. **Recherche contextuelle** : La question est vectorisée. Weaviate recherche les 4 segments les plus proches en filtrant strictement par le `chatbotId` du client pour éviter les fuites de données entre locataires (multi-tenancy).
3. **Construction du prompt strict** :
* Si des segments sont trouvés, ils sont insérés dans le prompt système comme contexte.
* Les instructions interdisent formellement au modèle d'utiliser ses connaissances globales si la réponse n'est pas dans le contexte.
4. **Appel LLM** : Mistral AI génère la réponse finale en français.
5. **Affichage** : Le widget affiche la réponse et ajoute un badge cliquable affichant la source (ex: `GUIDE-PREVENTION-2025.pdf`) pour assurer une transparence totale envers l'utilisateur final.
---
## 4. Structure des Fichiers Clés
* [parse_pdf.py](file:///g:/Autres%20Projets/ChatbotAGGLO/scripts/parse_pdf.py) : Script autonome Python d'analyse de PDF (PyMuPDF + Mistral Vision).
* [WeaviateService.php](file:///g:/Autres%20Projets/ChatbotAGGLO/app/Services/WeaviateService.php) : Gestion de la base vectorielle (création du schéma, indexation des vecteurs, recherche sémantique avec filtres GraphQL, et suppression).
* [MistralService.php](file:///g:/Autres%20Projets/ChatbotAGGLO/app/Services/MistralService.php) : Interfacage avec l'API Mistral (génération de réponses avec prompt système contraint RAG).
* [DocumentIngestionService.php](file:///g:/Autres%20Projets/ChatbotAGGLO/app/Services/DocumentIngestionService.php) : Pipeline d'ingestion globale (crawl de site récursif, nettoyage HTML, exécution sécurisée du parser PDF).
* [AdminDashboard.php](file:///g:/Autres%20Projets/ChatbotAGGLO/app/Livewire/AdminDashboard.php) & [admin-dashboard.blade.php](file:///g:/Autres%20Projets/ChatbotAGGLO/resources/views/livewire/admin-dashboard.blade.php) : Interface d'administration pour la configuration et l'indexation.
* [Chatbot.php](file:///g:/Autres%20Projets/ChatbotAGGLO/app/Livewire/Chatbot.php) & [chatbot.blade.php](file:///g:/Autres%20Projets/ChatbotAGGLO/resources/views/livewire/chatbot.blade.php) : Composant Livewire du widget de discussion de l'utilisateur.
+58
View File
@@ -0,0 +1,58 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
@@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\Chatbot;
use App\Services\DocumentIngestionService;
use Illuminate\Console\Command;
class IngestDocumentCommand extends Command
{
protected $signature = 'ingest:document {--url= : URL to scrape and ingest} {--pdf= : Path to PDF file to ingest} {--chatbot= : Slug of the chatbot to associate with}';
protected $description = 'Ingests a URL or PDF document into Weaviate vector DB for a specific chatbot';
public function handle(DocumentIngestionService $ingestionService)
{
$url = $this->option('url');
$pdfPath = $this->option('pdf');
$chatbotSlug = $this->option('chatbot');
if (!$chatbotSlug) {
$this->error('Please specify the chatbot using --chatbot=slug option.');
return 1;
}
$chatbot = Chatbot::where('slug', $chatbotSlug)->first();
if (!$chatbot) {
$this->error("Chatbot not found for slug: {$chatbotSlug}");
return 1;
}
if (!$url && !$pdfPath) {
$this->error('Please provide either a --url or a --pdf option.');
return 1;
}
$chatbotId = (string) $chatbot->id;
if ($url) {
$this->info("Ingesting URL: {$url} for chatbot '{$chatbot->name}'...");
$success = $ingestionService->ingestUrl($url, $chatbotId);
if ($success) {
$this->info("Successfully ingested URL!");
$chatbot->histories()->create([
'action' => 'Indexation URL',
'target' => $url,
'status' => 'success'
]);
} else {
$this->error("Failed to ingest URL.");
$chatbot->histories()->create([
'action' => 'Indexation URL',
'target' => $url,
'status' => 'failed'
]);
return 1;
}
}
if ($pdfPath) {
if (!file_exists($pdfPath)) {
$this->error("PDF file not found at: {$pdfPath}");
return 1;
}
$originalName = basename($pdfPath);
$this->info("Ingesting PDF: {$originalName} for chatbot '{$chatbot->name}'...");
$success = $ingestionService->ingestPdf($pdfPath, $originalName, $chatbotId);
if ($success) {
$this->info("Successfully ingested PDF!");
$chatbot->histories()->create([
'action' => 'Indexation PDF',
'target' => $originalName,
'status' => 'success'
]);
} else {
$this->error("Failed to ingest PDF.");
$chatbot->histories()->create([
'action' => 'Indexation PDF',
'target' => $originalName,
'status' => 'failed'
]);
return 1;
}
}
return 0;
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace App\Livewire;
use App\Models\Chatbot;
use App\Models\ChatbotHistory;
use App\Services\DocumentIngestionService;
use App\Services\WeaviateService;
use Livewire\Component;
use Livewire\WithFileUploads;
class AdminDashboard extends Component
{
use WithFileUploads;
public Chatbot $chatbot;
public string $url = '';
public $pdfFile;
public string $statusMessage = '';
public string $statusType = ''; // 'success', 'error', 'info'
public string $systemPrompt = '';
public string $chatbotName = '';
public array $history = [];
public bool $enableVision = true;
public bool $crawlEntireSite = false;
public int $maxPages = 30;
public function mount(Chatbot $chatbot)
{
$this->chatbot = $chatbot;
$this->systemPrompt = $chatbot->system_prompt ?? '';
$this->chatbotName = $chatbot->name;
$this->loadHistory();
}
protected function loadHistory()
{
$this->history = $this->chatbot->histories()
->latest()
->limit(15)
->get()
->map(function ($item) {
return [
'action' => $item->action,
'target' => $item->target,
'status' => $item->status,
'timestamp' => $item->created_at->format('d/m/Y H:i')
];
})->toArray();
}
public function saveSystemPrompt()
{
$this->validate([
'systemPrompt' => 'required|string|max:5000',
'chatbotName' => 'required|string|max:100',
]);
$this->chatbot->update([
'name' => $this->chatbotName,
'system_prompt' => $this->systemPrompt
]);
$this->logAction("Configuration Chatbot", "Nom modifié en '{$this->chatbotName}'", "success");
$this->statusMessage = "Les paramètres du chatbot ont été mis à jour avec succès !";
$this->statusType = 'success';
$this->dispatch('settings-updated');
}
protected function logAction(string $action, string $target, string $status)
{
$this->chatbot->histories()->create([
'action' => $action,
'target' => $target,
'status' => $status
]);
$this->loadHistory();
}
public function ingestUrl(DocumentIngestionService $ingestionService)
{
$this->validate([
'url' => 'required|url',
'maxPages' => 'required|integer|min:1|max:100',
]);
if ($this->crawlEntireSite) {
$this->statusMessage = "Exploration et indexation de l'ensemble du site en cours (cela peut prendre quelques minutes)...";
$this->statusType = 'info';
// Increase time limit for crawler execution
@set_time_limit(300);
$indexedCount = $ingestionService->crawlAndIngestSite($this->url, (string) $this->chatbot->id, $this->maxPages);
if ($indexedCount > 0) {
$this->logAction("Crawler Site Web", "{$this->url} ({$indexedCount} pages indexées)", "success");
$this->statusMessage = "Le site a été exploré et {$indexedCount} pages ont été indexées avec succès !";
$this->statusType = 'success';
$this->url = '';
} else {
$this->logAction("Crawler Site Web", $this->url, "failed");
$this->statusMessage = "L'exploration du site a échoué. Veuillez vérifier les logs et l'URL.";
$this->statusType = 'error';
}
} else {
$this->statusMessage = "Indexation de l'URL en cours...";
$this->statusType = 'info';
$success = $ingestionService->ingestUrl($this->url, (string) $this->chatbot->id);
if ($success) {
$this->logAction("Indexation URL", $this->url, "success");
$this->statusMessage = "L'URL a été indexée avec succès dans Weaviate !";
$this->statusType = 'success';
$this->url = '';
} else {
$this->logAction("Indexation URL", $this->url, "failed");
$this->statusMessage = "Impossible d'indexer l'URL. Veuillez vérifier les logs et l'état de Weaviate.";
$this->statusType = 'error';
}
}
}
public function ingestPdf(DocumentIngestionService $ingestionService)
{
$this->validate([
'pdfFile' => 'required|file|mimes:pdf|max:10240', // Max 10MB
]);
$this->statusMessage = "Indexation du fichier PDF en cours...";
$this->statusType = 'info';
$originalName = $this->pdfFile->getClientOriginalName();
$storedPath = $this->pdfFile->storeAs('temp', $originalName);
$absolutePath = storage_path('app/private/' . $storedPath);
if (!file_exists($absolutePath)) {
$absolutePath = storage_path('app/' . $storedPath);
}
$success = $ingestionService->ingestPdf($absolutePath, $originalName, (string) $this->chatbot->id, $this->enableVision);
// Cleanup
if (file_exists($absolutePath)) {
@unlink($absolutePath);
}
if ($success) {
$this->logAction("Indexation PDF", $originalName, "success");
$this->statusMessage = "Le fichier PDF '{$originalName}' a été indexé avec succès !";
$this->statusType = 'success';
$this->pdfFile = null;
} else {
$this->logAction("Indexation PDF", $originalName, "failed");
$this->statusMessage = "Une erreur s'est produite lors de l'indexation du PDF.";
$this->statusType = 'error';
}
}
public function clearDatabase(WeaviateService $weaviateService)
{
$success = $weaviateService->clearChatbotData((string) $this->chatbot->id);
if ($success) {
$this->logAction("Réinitialisation Index", "Suppression de toutes les données vectorielles", "success");
$this->statusMessage = "La base de données vectorielle du chatbot a été réinitialisée avec succès.";
$this->statusType = 'success';
} else {
$this->logAction("Réinitialisation Index", "Suppression de toutes les données vectorielles", "failed");
$this->statusMessage = "Impossible de vider les données Weaviate.";
$this->statusType = 'error';
}
}
public function render()
{
return view('livewire.admin-dashboard');
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Livewire;
use App\Models\Chatbot as ChatbotModel;
use App\Services\MistralService;
use App\Services\WeaviateService;
use Livewire\Component;
use Livewire\Attributes\On;
class Chatbot extends Component
{
public ChatbotModel $chatbot;
public string $userMessage = '';
public array $messages = [];
public bool $isSending = false;
public string $chatbotName = '';
public function mount(ChatbotModel $chatbot)
{
$this->chatbot = $chatbot;
$this->loadSettings();
// Load messages from session specific to this chatbot's slug
$this->messages = session()->get("chat_messages_{$chatbot->slug}", [
[
'role' => 'assistant',
'content' => "Bonjour ! Je suis votre assistant virtuel RAG. Je peux répondre à vos questions en me basant sur le contenu de ce site. Que puis-je faire pour vous aujourd'hui ?"
]
]);
}
#[On('settings-updated')]
public function loadSettings()
{
// Reload chatbot model from database to get fresh settings
$this->chatbot->refresh();
$this->chatbotName = $this->chatbot->name;
}
public function sendMessage(WeaviateService $weaviate, MistralService $mistral)
{
$this->validate([
'userMessage' => 'required|string|max:1000',
]);
$query = trim($this->userMessage);
// 1. Add user message
$this->messages[] = [
'role' => 'user',
'content' => $query
];
$this->userMessage = '';
$this->isSending = true;
// Save to session immediately for visual update
session()->put("chat_messages_{$this->chatbot->slug}", $this->messages);
$this->dispatch('message-sent');
}
public function getResponse(WeaviateService $weaviate, MistralService $mistral)
{
if (!$this->isSending) {
return;
}
$lastMessage = end($this->messages);
if (!$lastMessage || $lastMessage['role'] !== 'user') {
$this->isSending = false;
return;
}
$query = $lastMessage['content'];
// 2. Search Weaviate for context using the chatbot ID
$chunks = $weaviate->search($query, (string) $this->chatbot->id, 4);
// 3. Generate response via Mistral AI
$history = array_slice($this->messages, 0, -1);
$response = $mistral->generateResponse($query, $chunks, $history, $this->chatbot->system_prompt);
// Extract sources
$sources = [];
if (!empty($chunks)) {
foreach ($chunks as $chunk) {
if (isset($chunk['source'])) {
$sources[] = $chunk['source'];
}
}
}
$sources = array_unique($sources);
// 4. Add assistant response
$this->messages[] = [
'role' => 'assistant',
'content' => $response ?? "Désolé, je n'ai pas pu obtenir de réponse.",
'sources' => $sources
];
$this->isSending = false;
// Save to session
session()->put("chat_messages_{$this->chatbot->slug}", $this->messages);
}
public function clearChat()
{
session()->forget("chat_messages_{$this->chatbot->slug}");
$this->mount($this->chatbot);
}
public function render()
{
return view('livewire.chatbot');
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Livewire;
use App\Models\Chatbot;
use App\Services\WeaviateService;
use Illuminate\Support\Str;
use Livewire\Component;
class ChatbotManager extends Component
{
public string $name = '';
public string $slug = '';
public string $statusMessage = '';
public string $statusType = '';
public function createChatbot()
{
// Auto-generate slug if empty
if (empty($this->slug)) {
$this->slug = Str::slug($this->name);
} else {
$this->slug = Str::slug($this->slug);
}
$this->validate([
'name' => 'required|string|max:100',
'slug' => 'required|string|unique:chatbots,slug|max:100',
]);
$chatbot = Chatbot::create([
'name' => $this->name,
'slug' => $this->slug,
]);
$this->name = '';
$this->slug = '';
$this->statusMessage = "Le chatbot '{$chatbot->name}' a été créé avec succès !";
$this->statusType = 'success';
}
public function deleteChatbot(int $id, WeaviateService $weaviateService)
{
$chatbot = Chatbot::find($id);
if ($chatbot) {
$name = $chatbot->name;
// Delete data in Weaviate for this chatbot
$weaviateService->clearChatbotData((string) $chatbot->id);
// Delete database records
$chatbot->delete();
$this->statusMessage = "Le chatbot '{$name}' et toutes ses données vectorielles ont été supprimés.";
$this->statusType = 'success';
}
}
public function render()
{
$chatbots = Chatbot::latest()->get();
return view('livewire.chatbot-manager', compact('chatbots'));
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Chatbot extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'system_prompt',
];
/**
* Get histories associated with this chatbot.
*/
public function histories(): HasMany
{
return $this->hasMany(ChatbotHistory::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatbotHistory extends Model
{
use HasFactory;
protected $fillable = [
'chatbot_id',
'action',
'target',
'status',
];
/**
* Get the chatbot associated with this history record.
*/
public function chatbot(): BelongsTo
{
return $this->belongsTo(Chatbot::class);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
class DocumentIngestionService
{
protected WeaviateService $weaviate;
public function __construct(WeaviateService $weaviate)
{
$this->weaviate = $weaviate;
}
/**
* Ingest content from a URL for a specific chatbot.
*/
public function ingestUrl(string $url, string $chatbotId): bool
{
try {
$response = Http::timeout(15)->get($url);
if (!$response->successful()) {
Log::error("Failed to fetch URL for ingestion: {$url}");
return false;
}
$html = $response->body();
// Extract Title
preg_match('/<title>(.*?)<\/title>/is', $html, $matches);
$title = isset($matches[1]) ? trim($matches[1]) : $url;
// Extract Clean Text Content
$text = $this->cleanHtml($html);
if (empty($text)) {
Log::warning("No text extracted from URL: {$url}");
return false;
}
// Chunk and Index
$chunks = $this->chunkText($text, 800, 150);
$formattedChunks = [];
foreach ($chunks as $chunk) {
$formattedChunks[] = [
'content' => $chunk,
'source' => $url,
'title' => $title
];
}
return $this->weaviate->indexChunks($formattedChunks, $chatbotId);
} catch (\Exception $e) {
Log::error("Error ingesting URL {$url} for chatbot {$chatbotId}: " . $e->getMessage());
return false;
}
}
/**
* Crawl and ingest an entire website starting from a base URL.
*/
public function crawlAndIngestSite(string $baseUrl, string $chatbotId, int $maxPages = 50): int
{
$parsedBase = parse_url($baseUrl);
$baseHost = $parsedBase['host'] ?? '';
$baseScheme = $parsedBase['scheme'] ?? 'http';
if (empty($baseHost)) {
Log::error("Invalid base URL for site crawl: {$baseUrl}");
return 0;
}
$queue = [$baseUrl];
$visited = [];
$indexedCount = 0;
Log::info("Starting site crawl of {$baseUrl} for chatbot {$chatbotId} (max pages: {$maxPages})");
while (!empty($queue) && $indexedCount < $maxPages) {
$url = array_shift($queue);
// Normalize URL
$url = strtok($url, '#');
$url = rtrim($url, '/');
if (isset($visited[$url])) {
continue;
}
$visited[$url] = true;
try {
Log::info("Crawler visiting URL [{$indexedCount}/{$maxPages}]: {$url}");
$response = Http::timeout(10)->get($url);
if (!$response->successful()) {
Log::warning("Crawler failed to fetch URL: {$url}");
continue;
}
// Check if response is HTML
$contentType = $response->header('Content-Type');
if ($contentType && strpos($contentType, 'text/html') === false) {
Log::info("Crawler skipping non-HTML response at URL: {$url}");
continue;
}
$html = $response->body();
// Extract Title
preg_match('/<title>(.*?)<\/title>/is', $html, $matches);
$title = isset($matches[1]) ? trim($matches[1]) : $url;
// Extract Clean Text Content
$text = $this->cleanHtml($html);
if (!empty($text)) {
$chunks = $this->chunkText($text, 800, 150);
$formattedChunks = [];
foreach ($chunks as $chunk) {
$formattedChunks[] = [
'content' => $chunk,
'source' => $url,
'title' => $title
];
}
if (!empty($formattedChunks)) {
$this->weaviate->indexChunks($formattedChunks, $chatbotId);
$indexedCount++;
}
}
// Discover links
preg_match_all('/<a\s+[^>]*href="([^"]+)"/i', $html, $linkMatches);
if (isset($linkMatches[1])) {
foreach ($linkMatches[1] as $link) {
$resolvedLink = $this->resolveUrl($link, $url);
if (!$resolvedLink) {
continue;
}
$parsedLink = parse_url($resolvedLink);
$linkHost = $parsedLink['host'] ?? '';
// Only follow same host links using http/https protocols
if ($linkHost === $baseHost && in_array($parsedLink['scheme'] ?? '', ['http', 'https'])) {
$resolvedLink = strtok($resolvedLink, '#');
$resolvedLink = rtrim($resolvedLink, '/');
if (!isset($visited[$resolvedLink]) && !in_array($resolvedLink, $queue)) {
$queue[] = $resolvedLink;
}
}
}
}
} catch (\Exception $e) {
Log::error("Crawler error visiting URL {$url}: " . $e->getMessage());
}
}
Log::info("Crawler finished. Indexed {$indexedCount} pages.");
return $indexedCount;
}
/**
* Resolve a relative URL relative to a base URL.
*/
protected function resolveUrl(string $rel, string $base): ?string
{
if (preg_match('/^(mailto|tel|javascript|#):/i', $rel) || strpos($rel, '#') === 0) {
return null;
}
if (parse_url($rel, PHP_URL_SCHEME) != '') {
return $rel;
}
$baseParts = parse_url($base);
$scheme = $baseParts['scheme'] ?? 'http';
$host = $baseParts['host'] ?? '';
$port = isset($baseParts['port']) ? ':' . $baseParts['port'] : '';
$path = $baseParts['path'] ?? '/';
if (strpos($rel, '//') === 0) {
return $scheme . ':' . $rel;
}
if (strpos($rel, '/') === 0) {
return $scheme . '://' . $host . $port . $rel;
}
$dir = preg_replace('/\/[^\/]*$/', '', $path);
return $scheme . '://' . $host . $port . $dir . '/' . $rel;
}
/**
* Ingest content from a PDF file for a specific chatbot using the Python script.
*/
public function ingestPdf(string $filePath, string $originalName, string $chatbotId, bool $enableVision = false): bool
{
try {
if (!file_exists($filePath)) {
Log::error("PDF file not found: {$filePath}");
return false;
}
$scriptPath = base_path('scripts/parse_pdf.py');
$mistralKey = config('services.mistral.key') ?? env('MISTRAL_API_KEY', '');
$visionFlag = $enableVision ? '1' : '0';
// Run Python script
$result = Process::run([
'python',
$scriptPath,
$filePath,
$mistralKey,
$visionFlag
]);
if (!$result->successful()) {
Log::error("Python PDF parser script failed: " . $result->errorOutput());
return false;
}
$chunks = json_decode($result->output(), true);
if (isset($chunks['error'])) {
Log::error("Python PDF parser error: " . $chunks['error']);
return false;
}
if (empty($chunks)) {
Log::warning("No text chunks extracted from PDF: {$originalName}");
return false;
}
return $this->weaviate->indexChunks($chunks, $chatbotId);
} catch (\Exception $e) {
Log::error("Error ingesting PDF {$originalName} for chatbot {$chatbotId}: " . $e->getMessage());
return false;
}
}
/**
* Clean and strip HTML down to readability.
*/
protected function cleanHtml(string $html): string
{
// Remove scripts, styles, header, footer, nav
$html = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $html);
$html = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $html);
$html = preg_replace('/<header\b[^>]*>(.*?)<\/header>/is', '', $html);
$html = preg_replace('/<footer\b[^>]*>(.*?)<\/footer>/is', '', $html);
$html = preg_replace('/<nav\b[^>]*>(.*?)<\/nav>/is', '', $html);
// Strip tags
$text = strip_tags($html);
// Remove duplicate whitespace and newlines
$text = preg_replace('/[ \t]+/', ' ', $text);
$text = preg_replace('/\s*\n\s*/', "\n", $text);
$text = preg_replace('/\n+/', "\n\n", $text);
return trim($text);
}
/**
* Chunk text using sliding window.
*/
protected function chunkText(string $text, int $chunkSize = 800, int $overlap = 150): array
{
$words = explode(' ', $text);
$chunks = [];
$currentChunk = [];
$currentLength = 0;
foreach ($words as $word) {
$currentChunk[] = $word;
$currentLength += strlen($word) + 1; // +1 for space
if ($currentLength >= $chunkSize) {
$chunks[] = implode(' ', $currentChunk);
// Slide the window back by keeping some words for overlap
$overlapWordsCount = (int) ($overlap / 6); // estimate 6 chars per word
if ($overlapWordsCount > 0 && count($currentChunk) > $overlapWordsCount) {
$currentChunk = array_slice($currentChunk, -$overlapWordsCount);
$currentLength = strlen(implode(' ', $currentChunk));
} else {
$currentChunk = [];
$currentLength = 0;
}
}
}
// Add remaining text
if (!empty($currentChunk)) {
$chunks[] = implode(' ', $currentChunk);
}
return array_filter($chunks, function ($chunk) {
return strlen(trim($chunk)) > 50; // Filter out tiny chunks
});
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MistralService
{
protected string $apiKey;
protected string $model;
public function __construct()
{
$this->apiKey = config('services.mistral.key') ?? env('MISTRAL_API_KEY', '');
$this->model = config('services.mistral.model') ?? env('MISTRAL_MODEL', 'mistral-small-latest');
}
/**
* Generate a response using RAG context and chat history.
*/
public function generateResponse(string $question, array $contextChunks, array $chatHistory = [], ?string $systemPrompt = null): ?string
{
if (empty($this->apiKey)) {
Log::error("Mistral API key is not configured.");
return "Désolé, la clé API Mistral AI n'est pas configurée dans l'application.";
}
// Format context from chunks
$contextText = "";
if (!empty($contextChunks)) {
$contextText = "Voici le contexte extrait des documents et sites internet indexés :\n\n";
foreach ($contextChunks as $index => $chunk) {
$num = $index + 1;
$title = $chunk['title'] ?? 'Document';
$source = $chunk['source'] ?? 'N/A';
$contextText .= "[Document #{$num} : {$title} (Source: {$source})]\n{$chunk['content']}\n\n";
}
} else {
$contextText = "Aucun contexte spécifique n'a été trouvé.";
}
// Prepare system message
if (empty($systemPrompt)) {
$systemPrompt = "Tu es un assistant virtuel intelligent et chaleureux intégré au site internet. Ton but est d'aider les utilisateurs en répondant de manière claire, concise et professionnelle.\n\n";
$systemPrompt .= "INSTRUCTIONS DE SÉCURITÉ CRITIQUES :\n";
$systemPrompt .= "1. Réponds UNIQUEMENT et STRICTEMENT en t'appuyant sur le CONTEXTE fourni ci-dessous.\n";
$systemPrompt .= "2. Si le contexte ne contient pas l'information nécessaire pour répondre à la question, réponds poliment que tu ne disposes pas de cette information car elle ne figure pas dans les documents indexés. N'utilise JAMAIS tes connaissances générales pour inventer, deviner ou extrapoler une réponse en dehors du contexte.\n";
$systemPrompt .= "3. Ne fais aucune supposition et n'invente aucune information. Si le contexte est vide ou insuffisant, refuse de répondre en expliquant que l'information n'est pas répertoriée dans les sources indexées.\n";
$systemPrompt .= "4. Réponds toujours en français, de manière structurée.\n";
$systemPrompt .= "5. Cite obligatoirement la source des documents fournis qui t'ont permis de répondre (ex: 'D'après le document [Titre]...').\n\n";
}
$systemPrompt .= "\n\n--- CONTEXTE DE RECHERCHE ---\n";
$systemPrompt .= $contextText;
// Build messages array
$messages = [
['role' => 'system', 'content' => $systemPrompt]
];
// Append chat history (limit to last 6 messages to keep context window clean)
$recentHistory = array_slice($chatHistory, -6);
foreach ($recentHistory as $msg) {
$messages[] = [
'role' => $msg['role'] === 'user' ? 'user' : 'assistant',
'content' => $msg['content']
];
}
// Add the current user question
$messages[] = ['role' => 'user', 'content' => $question];
try {
$response = Http::withHeaders([
'Authorization' => "Bearer {$this->apiKey}",
'Content-Type' => 'application/json'
])->timeout(30)->post('https://api.mistral.ai/v1/chat/completions', [
'model' => $this->model,
'messages' => $messages,
'temperature' => 0.3, // Low temperature for more factual answers
'max_tokens' => 1000
]);
if ($response->successful()) {
$result = $response->json();
return $result['choices'][0]['message']['content'] ?? null;
}
Log::error("Mistral API Chat Completion failed: " . $response->body());
return "Une erreur s'est produite lors de la génération de la réponse par Mistral AI (Code status: " . $response->status() . ").";
} catch (\Exception $e) {
Log::error("Mistral Service Error: " . $e->getMessage());
return "Impossible de contacter le service de génération de réponses Mistral. Veuillez réessayer.";
}
}
}
+283
View File
@@ -0,0 +1,283 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WeaviateService
{
protected string $host;
protected ?string $mistralKey;
protected ?string $weaviateKey;
public function __construct()
{
$host = config('services.weaviate.host', env('WEAVIATE_HOST', 'http://localhost:8080'));
$host = trim($host, '"\' ');
if ($host && !str_starts_with($host, 'http://') && !str_starts_with($host, 'https://')) {
$host = 'https://' . $host;
}
$this->host = rtrim($host, '/');
$this->mistralKey = config('services.mistral.key', env('MISTRAL_API_KEY', ''));
$this->weaviateKey = config('services.weaviate.api_key', env('WEAVIATE_API_KEY', ''));
}
/**
* Get headers for Weaviate HTTP requests.
*/
protected function getHeaders(): array
{
$headers = [
'Content-Type' => 'application/json',
];
if ($this->mistralKey) {
$headers['X-Mistral-Api-Key'] = $this->mistralKey;
}
if ($this->weaviateKey) {
$headers['Authorization'] = 'Bearer ' . $this->weaviateKey;
$headers['X-Weaviate-Api-Key'] = $this->weaviateKey;
}
return $headers;
}
/**
* Check connection and initialize the schema class if it doesn't exist.
*/
public function initializeSchema(): bool
{
try {
// Check if class already exists
$response = Http::withHeaders($this->getHeaders())
->get("{$this->host}/v1/schema/DocumentChunk");
if ($response->successful()) {
// If it exists, let's verify if the property chatbotId exists
$data = $response->json();
$hasChatbotId = false;
if (isset($data['properties'])) {
foreach ($data['properties'] as $prop) {
if ($prop['name'] === 'chatbotId') {
$hasChatbotId = true;
break;
}
}
}
// If schema exists but doesn't have chatbotId property, we add it
if (!$hasChatbotId) {
$newProp = [
'name' => 'chatbotId',
'dataType' => ['text'],
'description' => 'The unique ID of the chatbot/client',
'moduleConfig' => [
'text2vec-mistral' => [
'skip' => true
]
]
];
Http::withHeaders($this->getHeaders())
->post("{$this->host}/v1/schema/DocumentChunk/properties", $newProp);
}
return true;
}
if ($response->status() === 404) {
// Class doesn't exist, create it
$schema = [
'class' => 'DocumentChunk',
'description' => 'A chunk of text from a web page or PDF document',
'vectorizer' => 'text2vec-mistral',
'moduleConfig' => [
'text2vec-mistral' => [
'model' => 'mistral-embed',
'vectorizeClassName' => false
]
],
'properties' => [
[
'name' => 'chatbotId',
'dataType' => ['text'],
'description' => 'The unique ID of the chatbot/client',
'moduleConfig' => [
'text2vec-mistral' => [
'skip' => true
]
]
],
[
'name' => 'content',
'dataType' => ['text'],
'description' => 'The actual text content of the chunk',
'moduleConfig' => [
'text2vec-mistral' => [
'skip' => false,
'vectorizePropertyName' => false
]
]
],
[
'name' => 'source',
'dataType' => ['text'],
'description' => 'The URL or file path of the source document',
'moduleConfig' => [
'text2vec-mistral' => [
'skip' => true
]
]
],
[
'name' => 'title',
'dataType' => ['text'],
'description' => 'The title of the source document',
'moduleConfig' => [
'text2vec-mistral' => [
'skip' => true
]
]
]
]
];
$createResponse = Http::withHeaders($this->getHeaders())
->post("{$this->host}/v1/schema", $schema);
if ($createResponse->successful()) {
Log::info("Weaviate schema DocumentChunk initialized successfully.");
return true;
}
Log::error("Failed to create Weaviate schema: " . $createResponse->body());
return false;
}
return false;
} catch (\Exception $e) {
Log::error("Weaviate schema initialization error: " . $e->getMessage());
return false;
}
}
/**
* Index a single chunk or multiple chunks of documents for a specific chatbot.
*/
public function indexChunks(array $chunks, string $chatbotId): bool
{
$this->initializeSchema();
try {
$objects = [];
foreach ($chunks as $chunk) {
$objects[] = [
'class' => 'DocumentChunk',
'properties' => [
'chatbotId' => $chatbotId,
'content' => $chunk['content'],
'source' => $chunk['source'],
'title' => $chunk['title'] ?? 'Document',
]
];
}
$response = Http::withHeaders($this->getHeaders())
->post("{$this->host}/v1/batch/objects", [
'objects' => $objects
]);
if ($response->successful()) {
return true;
}
Log::error("Failed to batch index chunks to Weaviate: " . $response->body());
return false;
} catch (\Exception $e) {
Log::error("Weaviate batch index error: " . $e->getMessage());
return false;
}
}
/**
* Delete all indexed documents for a specific chatbot.
*/
public function clearChatbotData(string $chatbotId): bool
{
try {
$payload = [
'match' => [
'class' => 'DocumentChunk',
'where' => [
'path' => ['chatbotId'],
'operator' => 'Equal',
'valueText' => $chatbotId
]
]
];
$response = Http::withHeaders($this->getHeaders())
->delete("{$this->host}/v1/batch/objects", $payload);
return $response->successful();
} catch (\Exception $e) {
Log::error("Weaviate batch delete error: " . $e->getMessage());
return false;
}
}
/**
* Query Weaviate for similar text chunks, scoped to a specific chatbot.
*/
public function search(string $query, string $chatbotId, int $limit = 5): array
{
$this->initializeSchema();
try {
$graphqlQuery = [
'query' => '
{
Get {
DocumentChunk(
nearText: {
concepts: ["' . addslashes($query) . '"]
}
where: {
path: ["chatbotId"]
operator: Equal
valueText: "' . addslashes($chatbotId) . '"
}
limit: ' . $limit . '
) {
content
source
title
_additional {
distance
}
}
}
}
'
];
$response = Http::withHeaders($this->getHeaders())
->post("{$this->host}/v1/graphql", $graphqlQuery);
if ($response->successful()) {
$data = $response->json();
$results = $data['data']['Get']['DocumentChunk'] ?? [];
Log::info("Weaviate search for chatbot {$chatbotId} returned " . count($results) . " chunks.");
return $results;
}
Log::error("Weaviate GraphQL search failed: " . $response->body());
return [];
} catch (\Exception $e) {
Log::error("Weaviate search error: " . $e->getMessage());
return [];
}
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+21
View File
@@ -0,0 +1,21 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.8",
"laravel/tinker": "^3.0",
"livewire/livewire": "^4.3",
"smalot/pdfparser": "^2.12"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated
+8532
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+136
View File
@@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];
+184
View File
@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+282
View File
@@ -0,0 +1,282 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
'with' => [
'js' => false,
'css' => false,
'test' => false,
],
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => ['required', 'file', 'max:51200'], // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 15 * 1024 * 1024, // 15MB - maximum request payload size in bytes
'max_nesting_depth' => 10,
'max_calls' => 50,
'max_components' => 200,
],
];
+132
View File
@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
+49
View File
@@ -0,0 +1,49 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
'weaviate' => [
'host' => env('WEAVIATE_HOST', 'http://localhost:8080'),
'api_key' => env('WEAVIATE_API_KEY'),
],
'mistral' => [
'key' => env('MISTRAL_API_KEY'),
'model' => env('MISTRAL_MODEL', 'mistral-small-latest'),
],
];
+233
View File
@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];
+1
View File
@@ -0,0 +1 @@
*.sqlite*
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('chatbots', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('system_prompt')->nullable();
$table->timestamps();
});
Schema::create('chatbot_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('chatbot_id')->constrained()->cascadeOnDelete();
$table->string('action');
$table->string('target');
$table->string('status');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('chatbot_histories');
Schema::dropIfExists('chatbots');
}
};
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
services:
weaviate:
command:
- --host
- 0.0.0.0
- --port
- '8080'
- --scheme
- http
image: cr.weaviate.io/semitechnologies/weaviate:1.24.26
ports:
- 8080:8080
- 50051:50051
volumes:
- weaviate_data:/var/lib/weaviate
restart: on-failure:0
environment:
MISTRAL_APIKEY: '${MISTRAL_API_KEY}'
QUERY_DEFAULTS_LIMIT: 25
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
DEFAULT_VECTORIZER_MODULE: 'text2vec-mistral'
ENABLE_MODULES: 'text2vec-mistral,generative-mistral'
CLUSTER_HOSTNAME: 'node1'
volumes:
weaviate_data:
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
+167
View File
@@ -0,0 +1,167 @@
(function() {
// 1. Identify the URL of the chatbot server and extract the chatbot slug parameter
const scriptTag = document.currentScript;
const scriptSrc = scriptTag ? scriptTag.src : '';
const baseUrl = scriptSrc ? new URL(scriptSrc).origin : window.location.origin;
let chatbotSlug = 'default';
if (scriptSrc) {
const urlParams = new URL(scriptSrc).searchParams;
chatbotSlug = urlParams.get('chatbot') || '';
}
if (!chatbotSlug) {
console.error("Chatbot Widget error: The 'chatbot' parameter is missing in the script URL. Example: chatbot-widget.js?chatbot=client-slug");
return;
}
// 2. Create and inject Widget styles
const style = document.createElement('style');
style.innerHTML = `
.agglo-chat-widget-btn {
position: fixed;
bottom: 24px;
right: 24px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
cursor: pointer;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid #334155;
outline: none;
}
.agglo-chat-widget-btn:hover {
transform: scale(1.08) rotate(5deg);
box-shadow: 0 15px 30px -5px rgba(0, 0, 0, 0.5);
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
}
.agglo-chat-widget-btn:active {
transform: scale(0.95);
}
.agglo-chat-widget-btn svg {
width: 26px;
height: 26px;
fill: none;
stroke: #94a3b8;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: all 0.3s ease;
}
.agglo-chat-widget-btn:hover svg {
stroke: #f1f5f9;
}
.agglo-chat-widget-btn.open svg {
transform: rotate(90deg);
}
.agglo-chat-widget-container {
position: fixed;
bottom: 96px;
right: 24px;
width: 400px;
height: 600px;
max-height: calc(100vh - 120px);
max-width: calc(100vw - 48px);
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
background-color: #0f172a;
z-index: 999998;
opacity: 0;
transform: translateY(20px) scale(0.95);
pointer-events: none;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
border: 1px solid #1e293b;
}
.agglo-chat-widget-container.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
visibility: visible;
}
.agglo-chat-widget-iframe {
width: 100%;
height: 100%;
border: none;
background: transparent;
}
@media (max-width: 480px) {
.agglo-chat-widget-container {
bottom: 0;
right: 0;
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
border: none;
}
.agglo-chat-widget-btn {
bottom: 16px;
right: 16px;
}
}
`;
document.head.appendChild(style);
// 3. Create the iframe container
const container = document.createElement('div');
container.className = 'agglo-chat-widget-container';
const iframe = document.createElement('iframe');
iframe.className = 'agglo-chat-widget-iframe';
iframe.src = `${baseUrl}/chatbot-widget/${chatbotSlug}`;
container.appendChild(iframe);
document.body.appendChild(container);
// 4. Create the floating button
const button = document.createElement('button');
button.className = 'agglo-chat-widget-btn';
button.ariaLabel = 'Ouvrir le chat';
// Chat icon (closed state)
const chatIcon = `
<svg xmlns="http://www.w3.org/2000/svg" class="icon-chat" viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
`;
// Close icon (opened state)
const closeIcon = `
<svg xmlns="http://www.w3.org/2000/svg" class="icon-close" viewBox="0 0 24 24" style="display: none;">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
button.innerHTML = chatIcon + closeIcon;
document.body.appendChild(button);
// 5. Toggle logic
let isOpen = false;
button.addEventListener('click', function() {
isOpen = !isOpen;
const svgChat = button.querySelector('.icon-chat');
const svgClose = button.querySelector('.icon-close');
if (isOpen) {
button.classList.add('open');
container.classList.add('open');
svgChat.style.display = 'none';
svgClose.style.display = 'block';
} else {
button.classList.remove('open');
container.classList.remove('open');
svgChat.style.display = 'block';
svgClose.style.display = 'none';
}
});
})();
View File
+20
View File
@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
+9
View File
@@ -0,0 +1,9 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
+1
View File
@@ -0,0 +1 @@
//
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gérer le Chatbot - {{ $chatbot->name }}</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'sans-serif'],
},
}
}
}
</script>
@livewireStyles
<style>
body {
font-family: 'Outfit', sans-serif;
background-color: #020617;
}
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #020617;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: #334155;
}
</style>
</head>
<body class="text-slate-100 min-h-screen relative overflow-x-hidden selection:bg-cyan-500/30 selection:text-cyan-200">
<!-- Decorative background glows -->
<div class="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-cyan-900/10 blur-[120px] pointer-events-none"></div>
<div class="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-indigo-900/10 blur-[120px] pointer-events-none"></div>
<div class="max-w-7xl mx-auto px-4 py-8 relative z-10">
<!-- Header -->
<header class="flex flex-col md:flex-row justify-between items-center gap-4 mb-12 border-b border-slate-900 pb-8">
<div class="text-center md:text-left">
<div class="flex items-center justify-center md:justify-start gap-2">
<a href="{{ url('/') }}" class="text-xs text-slate-500 hover:text-slate-300 flex items-center gap-1 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour à la console
</a>
<span class="text-slate-700 text-xs"></span>
<span class="px-2 py-0.5 bg-slate-900 border border-slate-800 rounded-full text-[10px] font-bold text-slate-400">
ID: {{ $chatbot->id }}
</span>
</div>
<h1 class="text-3xl font-extrabold tracking-tight mt-3 bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
Configuration : {{ $chatbot->name }}
</h1>
<p class="text-slate-400 text-sm mt-2 max-w-xl">
Configurez le prompt, indexez des documents et testez le chatbot de votre client en temps réel.
</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-emerald-400/10 text-emerald-400 border border-emerald-500/20">
<span class="w-1.5 h-1.5 mr-1.5 rounded-full bg-emerald-400 animate-ping"></span>
Actif
</span>
</div>
</header>
<!-- Main Content Grid -->
<main class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Left Panel: Administration (Ingestion & Prompt) -->
<section class="lg:col-span-5">
<livewire:admin-dashboard :chatbot="$chatbot" />
</section>
<!-- Right Panel: Chatbot Simulator -->
<section class="lg:col-span-7 h-[650px]">
<livewire:chatbot :chatbot="$chatbot" />
</section>
</main>
<!-- Footer -->
<footer class="mt-20 border-t border-slate-900 pt-8 text-center text-xs text-slate-500">
<p>© {{ date('Y') }} Chatbot AGGLO. Panel client indépendant.</p>
</footer>
</div>
@livewireScripts
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Test Chatbot - {{ $chatbot->name }}</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'sans-serif'],
},
}
}
}
</script>
@livewireStyles
<style>
body {
font-family: 'Outfit', sans-serif;
background-color: #020617;
}
</style>
</head>
<body class="text-slate-100 min-h-screen flex items-center justify-center p-4 relative overflow-x-hidden">
<!-- Decorative background glows -->
<div class="absolute top-[-20%] left-[-20%] w-[60%] h-[60%] rounded-full bg-cyan-900/10 blur-[130px] pointer-events-none"></div>
<div class="absolute bottom-[-20%] right-[-20%] w-[60%] h-[60%] rounded-full bg-indigo-900/10 blur-[130px] pointer-events-none"></div>
<div class="w-full max-w-2xl relative z-10 h-[650px] flex flex-col">
<!-- Back link -->
<div class="mb-4 flex items-center justify-between px-2">
<a href="{{ url('/') }}" class="text-xs text-slate-500 hover:text-slate-350 transition flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour à la console
</a>
<span class="text-[10px] text-slate-600 font-mono">Démonstrateur client ({{ $chatbot->slug }})</span>
</div>
<livewire:chatbot :chatbot="$chatbot" />
</div>
@livewireScripts
</body>
</html>
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chatbot Widget</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'sans-serif'],
},
}
}
}
</script>
@livewireStyles
<style>
body {
font-family: 'Outfit', sans-serif;
margin: 0;
padding: 0;
background-color: transparent;
overflow: hidden;
}
</style>
</head>
<body class="text-slate-100">
<div class="h-screen w-screen flex flex-col">
<livewire:chatbot :chatbot="$chatbot" />
</div>
@livewireScripts
</body>
</html>
@@ -0,0 +1,220 @@
<div class="bg-slate-950 border border-slate-800 rounded-xl p-6 shadow-sm space-y-6">
<!-- Top Header -->
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4 pb-6 border-b border-slate-900">
<div>
<h2 class="text-lg font-semibold text-slate-100 tracking-tight">Panneau de configuration RAG</h2>
<p class="text-slate-500 text-xs mt-0.5">Indexez des sources et ajustez le comportement du chatbot.</p>
</div>
<button
wire:confirm="Êtes-vous sûr de vouloir réinitialiser l'index Weaviate ? Toutes les données seront effacées."
wire:click="clearDatabase"
class="self-start px-3 py-1.5 bg-transparent hover:bg-rose-950/20 border border-slate-800 hover:border-rose-900/50 text-slate-400 hover:text-rose-400 rounded-lg transition duration-200 text-xs font-medium flex items-center gap-1.5"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Réinitialiser l'index
</button>
</div>
<!-- Alert Banner -->
@if ($statusMessage)
<div class="p-3 rounded-lg border text-xs transition-all duration-200 flex items-start gap-2.5
@if($statusType === 'success') bg-emerald-950/20 border-emerald-900/50 text-emerald-400 @elseif($statusType === 'error') bg-rose-950/20 border-rose-900/50 text-rose-400 @else bg-slate-900 border-slate-800 text-slate-350 @endif">
@if($statusType === 'info')
<svg class="animate-spin h-4 w-4 text-slate-400 shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@else
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 mt-0.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@endif
<div class="leading-relaxed">{{ $statusMessage }}</div>
</div>
@endif
<!-- Part 1: Ingestion Grid (Web URL / PDF) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Ingest URL Form -->
<form wire:submit.prevent="ingestUrl" class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1.5">Ajouter une page web par URL</label>
<input
type="url"
wire:model="url"
placeholder="https://example.com/page-a-indexer"
class="w-full bg-slate-900 border border-slate-800 focus:border-slate-700 focus:ring-0 rounded-lg px-3 py-2 text-slate-200 placeholder-slate-650 transition outline-none text-xs"
/>
@error('url') <span class="text-rose-500 text-[10px] mt-1 block">{{ $message }}</span> @enderror
<div class="flex items-center gap-4 mt-2.5">
<div class="flex items-center gap-1.5">
<input type="checkbox" wire:model.live="crawlEntireSite" id="crawlEntireSite" class="rounded bg-slate-900 border-slate-800 text-cyan-600 focus:ring-0 focus:ring-offset-0 h-3.5 w-3.5" />
<label for="crawlEntireSite" class="text-[10px] text-slate-400 cursor-pointer select-none">Explorer tout le site (Crawler)</label>
</div>
</div>
@if($crawlEntireSite)
<div class="mt-2 pl-5 flex items-center gap-2">
<label for="maxPages" class="text-[10px] text-slate-500">Limite de pages :</label>
<input
type="number"
id="maxPages"
wire:model="maxPages"
min="1"
max="100"
class="w-16 bg-slate-900 border border-slate-800 focus:border-slate-700 focus:ring-0 roundedpx-2 py-1 text-slate-200 text-[10px] text-center outline-none"
/>
<span class="text-[9px] text-slate-600">(max. 100)</span>
</div>
@error('maxPages') <span class="text-rose-500 text-[10px] mt-1 block">{{ $message }}</span> @enderror
@endif
</div>
<button
type="submit"
class="w-full bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-slate-700 text-slate-200 font-medium py-2 px-3 rounded-lg transition text-xs flex items-center justify-center gap-1.5"
>
@if($crawlEntireSite)
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 7.89L21 8" />
</svg>
Explorer et indexer le site
@else
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
Indexer la page
@endif
</button>
</form>
<!-- Ingest PDF Form -->
<form wire:submit.prevent="ingestPdf" class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1.5">Importer un fichier PDF</label>
<div class="flex items-center justify-center w-full">
<label class="flex flex-col items-center justify-center w-full h-24 border border-slate-800 border-dashed rounded-lg cursor-pointer bg-slate-900/40 hover:bg-slate-900 hover:border-slate-700 transition">
<div class="flex flex-col items-center justify-center py-4">
<svg class="w-6 h-6 mb-2 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<p class="text-[11px] text-slate-400">
@if($pdfFile)
<span class="text-slate-200 font-medium">{{ $pdfFile->getClientOriginalName() }}</span>
@else
<span>Sélectionner un fichier PDF</span>
@endif
</p>
<p class="text-[9px] text-slate-600 mt-0.5">Taille max. 10 Mo</p>
</div>
<input type="file" wire:model="pdfFile" class="hidden" accept=".pdf" />
</label>
</div>
@error('pdfFile') <span class="text-rose-500 text-[10px] mt-1 block">{{ $message }}</span> @enderror
<div class="flex items-center gap-2 mt-2">
<input type="checkbox" wire:model="enableVision" id="enableVision" class="rounded bg-slate-900 border-slate-800 text-cyan-600 focus:ring-0 focus:ring-offset-0 h-3.5 w-3.5" />
<label for="enableVision" class="text-[10px] text-slate-400 cursor-pointer select-none">Décrire les images via Vision AI (Mistral)</label>
</div>
</div>
<button
type="submit"
class="w-full bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-slate-700 text-slate-200 font-medium py-2 px-3 rounded-lg transition text-xs flex items-center justify-center gap-1.5"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Indexer le document
</button>
</form>
</div>
<!-- Part 2: System Prompt & Chatbot Settings Customization -->
<form wire:submit.prevent="saveSystemPrompt" class="space-y-4 pt-4 border-t border-slate-900">
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1.5">Nom du Chatbot</label>
<input
type="text"
wire:model="chatbotName"
placeholder="Ex: Assistant Virtuel AGGLO"
class="w-full bg-slate-900 border border-slate-800 focus:border-slate-700 focus:ring-0 rounded-lg px-3 py-2 text-slate-200 placeholder-slate-650 transition outline-none text-xs"
/>
@error('chatbotName') <span class="text-rose-500 text-[10px] mt-1 block">{{ $message }}</span> @enderror
</div>
<div>
<div class="flex justify-between items-center mb-1.5">
<label class="block text-xs font-semibold text-slate-400">Prompt système du Chatbot</label>
<span class="text-[10px] text-slate-600">Définit le rôle, les instructions et les limites de l'IA</span>
</div>
<textarea
wire:model="systemPrompt"
rows="6"
placeholder="Rédigez les consignes du chatbot..."
class="w-full bg-slate-900 border border-slate-800 focus:border-slate-700 focus:ring-0 rounded-lg px-3 py-2 text-slate-200 placeholder-slate-650 transition outline-none text-xs font-mono leading-relaxed resize-y"
></textarea>
@error('systemPrompt') <span class="text-rose-500 text-[10px] mt-1 block">{{ $message }}</span> @enderror
</div>
</div>
<button
type="submit"
class="bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-slate-700 text-slate-200 font-medium py-2 px-4 rounded-lg transition text-xs flex items-center justify-center gap-1.5"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
Enregistrer les paramètres
</button>
</form>
<!-- Part 3: Embed Integration Script -->
<div class="pt-4 border-t border-slate-900 space-y-2">
<div class="flex justify-between items-center">
<h3 class="text-xs font-semibold text-slate-350">Script d'intégration</h3>
<span class="text-[9px] text-slate-500">Copiez ce script avant la balise &lt;/body&gt; de votre site externe</span>
</div>
<div class="relative bg-slate-900 border border-slate-800 rounded-lg p-2.5 flex items-center justify-between font-mono text-[10px] text-slate-400">
<code class="select-all overflow-x-auto whitespace-nowrap scrollbar-none pr-4">&lt;script src="{{ url('/chatbot-widget.js') }}?chatbot={{ $chatbot->slug }}" defer&gt;&lt;/script&gt;</code>
</div>
</div>
<!-- Part 4: Action History Logs -->
<div class="pt-4 border-t border-slate-900">
<h3 class="text-xs font-semibold text-slate-350 mb-3">Historique des actions</h3>
@if(empty($history))
<p class="text-slate-600 text-[11px] italic">Aucune action enregistrée pour le moment.</p>
@else
<div class="overflow-hidden border border-slate-900 rounded-lg max-h-48 overflow-y-auto" style="scrollbar-width: thin; scrollbar-color: #1e293b #020617;">
<table class="w-full text-[11px] text-left text-slate-400">
<thead class="bg-slate-900 text-slate-300 font-medium text-[10px] uppercase tracking-wider sticky top-0">
<tr>
<th class="px-3 py-2">Date</th>
<th class="px-3 py-2">Action</th>
<th class="px-3 py-2">Cible</th>
<th class="px-3 py-2 text-right">Statut</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-900">
@foreach(array_reverse($history) as $log)
<tr class="hover:bg-slate-900/40 transition-colors">
<td class="px-3 py-2 text-slate-500 whitespace-nowrap">{{ $log['timestamp'] }}</td>
<td class="px-3 py-2 font-medium text-slate-300 whitespace-nowrap">{{ $log['action'] }}</td>
<td class="px-3 py-2 text-slate-400 truncate max-w-[150px]" title="{{ $log['target'] }}">{{ $log['target'] }}</td>
<td class="px-3 py-2 text-right whitespace-nowrap">
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium
@if($log['status'] === 'success') bg-emerald-950/40 text-emerald-400 border border-emerald-900/30 @else bg-rose-950/40 text-rose-400 border border-rose-900/30 @endif">
{{ $log['status'] === 'success' ? 'Succès' : 'Échec' }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
@@ -0,0 +1,108 @@
<div class="space-y-8">
<!-- Status message -->
@if ($statusMessage)
<div class="p-3 rounded-lg border text-xs transition-all duration-200 flex items-start gap-2.5 bg-emerald-950/20 border-emerald-900/50 text-emerald-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="leading-relaxed">{{ $statusMessage }}</div>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Left Panel: Create Form -->
<div class="lg:col-span-4 bg-slate-950 border border-slate-800 rounded-xl p-6 shadow-sm self-start">
<h2 class="text-sm font-semibold text-slate-100 mb-4 pb-3 border-b border-slate-900">Nouveau Chatbot</h2>
<form wire:submit.prevent="createChatbot" class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1.5">Nom du client / Chatbot</label>
<input
type="text"
wire:model="name"
placeholder="Ex: Clinique du Parc"
class="w-full bg-slate-900 border border-slate-800 focus:border-slate-700 focus:ring-0 rounded-lg px-3 py-2 text-slate-250 placeholder-slate-600 transition outline-none text-xs"
/>
@error('name') <span class="text-rose-500 text-[10px] mt-1 block">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1.5">Slug d'identification (Optionnel)</label>
<input
type="text"
wire:model="slug"
placeholder="Ex: clinique-du-parc"
class="w-full bg-slate-900 border border-slate-800 focus:border-slate-700 focus:ring-0 rounded-lg px-3 py-2 text-slate-250 placeholder-slate-650 transition outline-none text-xs font-mono"
/>
<p class="text-[9px] text-slate-600 mt-1">L'identifiant unique utilisé dans l'URL. Généré automatiquement si laissé vide.</p>
@error('slug') <span class="text-rose-500 text-[10px] mt-1 block">{{ $message }}</span> @enderror
</div>
<button
type="submit"
class="w-full bg-slate-800 hover:bg-slate-750 border border-slate-700 text-slate-200 font-medium py-2 px-3 rounded-lg transition text-xs flex items-center justify-center gap-1.5"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-slate-450" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Créer le chatbot
</button>
</form>
</div>
<!-- Right Panel: Chatbots List -->
<div class="lg:col-span-8 space-y-4">
<h2 class="text-sm font-semibold text-slate-100 pb-2 border-b border-slate-900">Vos Chatbots Actifs</h2>
@if($chatbots->isEmpty())
<div class="bg-slate-950 border border-slate-900 rounded-xl p-8 text-center text-slate-500 text-xs">
<p class="italic">Aucun chatbot n'a été créé pour le moment. Utilisez le formulaire à gauche pour commencer.</p>
</div>
@else
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach($chatbots as $bot)
<div class="bg-slate-950 border border-slate-850 rounded-xl p-5 shadow-sm space-y-4 flex flex-col justify-between">
<div class="space-y-1">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-200">{{ $bot->name }}</h3>
<span class="text-[9px] font-mono bg-slate-900 border border-slate-800 px-1.5 py-0.5 rounded text-slate-500">{{ $bot->slug }}</span>
</div>
<p class="text-[10px] text-slate-500">Créé le {{ $bot->created_at->format('d/m/Y à H:i') }}</p>
</div>
<div class="pt-3 border-t border-slate-900 flex justify-between items-center gap-2">
<div class="flex gap-2">
<a
href="{{ url('/admin/chatbots/' . $bot->slug) }}"
class="px-2.5 py-1.5 bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-slate-700 text-slate-300 hover:text-white rounded-lg transition text-[11px] font-medium flex items-center gap-1"
>
Gérer
</a>
<a
href="{{ url('/chatbot/' . $bot->slug) }}"
class="px-2.5 py-1.5 bg-transparent hover:bg-slate-900 border border-transparent hover:border-slate-800 text-slate-400 hover:text-slate-200 rounded-lg transition text-[11px] font-medium flex items-center gap-1"
target="_blank"
>
Tester
</a>
</div>
<button
wire:confirm="Êtes-vous sûr de vouloir supprimer le chatbot '{{ $bot->name }}' ? Toutes les données vectorielles associées dans Weaviate seront également supprimées."
wire:click="deleteChatbot({{ $bot->id }})"
class="p-1.5 text-slate-600 hover:text-rose-500 hover:bg-rose-950/20 rounded-lg transition"
title="Supprimer ce chatbot"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
+119
View File
@@ -0,0 +1,119 @@
<div class="flex flex-col bg-slate-900 border border-slate-800 rounded-2xl h-full w-full shadow-2xl backdrop-blur-xl bg-opacity-95 overflow-hidden" id="chatbot-container">
<!-- Chat Header -->
<div class="px-6 py-4 bg-slate-950 border-b border-slate-800 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="relative">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-cyan-500 to-indigo-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-500/20">
AI
</div>
<span class="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-emerald-400 ring-2 ring-slate-950"></span>
</div>
<div>
<h3 class="text-sm font-semibold text-white">{{ $chatbotName }}</h3>
<p class="text-[11px] text-emerald-400 font-medium">En ligne RAG Activé</p>
</div>
</div>
<button
wire:click="clearChat"
class="p-2 text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition"
title="Effacer la conversation"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<!-- Messages List -->
<div class="flex-1 overflow-y-auto p-6 space-y-4 scroll-smooth" id="chat-messages-container" style="scrollbar-width: thin; scrollbar-color: #1e293b #0f172a;">
@foreach($messages as $msg)
<div class="flex {{ $msg['role'] === 'user' ? 'justify-end' : 'justify-start' }} transition-all duration-300">
<div class="max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-md
@if($msg['role'] === 'user')
bg-gradient-to-br from-cyan-600 to-indigo-700 text-white rounded-tr-none
@else
bg-slate-950 border border-slate-800 text-slate-200 rounded-tl-none
@endif">
<div class="prose prose-invert prose-sm max-w-none text-slate-200">
{!! \Illuminate\Support\Str::markdown($msg['content']) !!}
</div>
@if(isset($msg['sources']) && !empty($msg['sources']))
<div class="mt-2 pt-2 border-t border-slate-800 text-[10px] text-slate-400">
<span class="font-semibold">Sources consultées :</span>
<div class="flex flex-wrap gap-1 mt-1">
@foreach($msg['sources'] as $src)
<span class="bg-slate-900 border border-slate-800 px-1.5 py-0.5 rounded text-[9px] hover:text-white transition cursor-help" title="{{ $src }}">
{{ basename($src) }}
</span>
@endforeach
</div>
</div>
@endif
</div>
</div>
@endforeach
@if($isSending)
<div class="flex justify-start">
<div class="bg-slate-950 border border-slate-800 rounded-2xl rounded-tl-none px-4 py-3 shadow-md flex items-center gap-1.5">
<span class="w-2 h-2 bg-cyan-400 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
<span class="w-2 h-2 bg-cyan-400 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
<span class="w-2 h-2 bg-cyan-400 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
</div>
</div>
@endif
</div>
<!-- Input Bar -->
<div class="p-4 bg-slate-950 border-t border-slate-800">
<form wire:submit.prevent="sendMessage" class="flex gap-2" id="chat-form">
<input
type="text"
wire:model="userMessage"
placeholder="Posez votre question sur le site..."
class="flex-1 bg-slate-900 border border-slate-800 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 rounded-xl px-4 py-3 text-slate-200 placeholder-slate-500 transition outline-none text-sm"
@if($isSending) disabled @endif
/>
<button
type="submit"
class="bg-gradient-to-r from-cyan-600 to-indigo-600 hover:from-cyan-500 hover:to-indigo-500 disabled:from-slate-800 disabled:to-slate-800 text-white p-3 rounded-xl transition duration-300 shadow-lg shadow-cyan-600/10 flex items-center justify-center shrink-0"
@if($isSending) disabled @endif
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</form>
</div>
@script
<script>
// Automatic scroll to bottom on message updates
const container = document.getElementById('chat-messages-container');
const scrollToBottom = () => {
if (container) {
container.scrollTop = container.scrollHeight;
}
};
// Scroll immediately on load
scrollToBottom();
// Listen for standard Livewire updates
Livewire.hook('request', ({ respond }) => {
respond(() => {
setTimeout(scrollToBottom, 50);
});
});
// Trigger AI response generation after sending message
$wire.on('message-sent', () => {
setTimeout(scrollToBottom, 50);
$wire.getResponse();
});
</script>
@endscript
</div>
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chatbot AGGLO - Administration Multi-Clients</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'sans-serif'],
},
}
}
}
</script>
@livewireStyles
<style>
body {
font-family: 'Outfit', sans-serif;
background-color: #020617;
}
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #020617;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: #334155;
}
</style>
</head>
<body class="text-slate-100 min-h-screen relative overflow-x-hidden selection:bg-cyan-500/30 selection:text-cyan-200">
<!-- Decorative background glows -->
<div class="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-cyan-900/10 blur-[120px] pointer-events-none"></div>
<div class="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-indigo-900/10 blur-[120px] pointer-events-none"></div>
<div class="max-w-7xl mx-auto px-4 py-8 relative z-10">
<!-- Header -->
<header class="flex flex-col md:flex-row justify-between items-center gap-4 mb-12 border-b border-slate-900 pb-8">
<div class="text-center md:text-left">
<div class="flex items-center justify-center md:justify-start gap-3">
<span class="px-3 py-1 bg-gradient-to-r from-cyan-500/10 to-indigo-500/10 border border-cyan-500/30 rounded-full text-xs font-bold tracking-wider text-cyan-400 uppercase">
Communauté AGGLO Béziers Méditerranée
</span>
</div>
<h1 class="text-4xl font-extrabold tracking-tight mt-3 bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
Console de gestion des chatbots
</h1>
<p class="text-slate-400 text-sm mt-2 max-w-xl">
Gérez et déployez des chatbots RAG indépendants pour chacun de vos sites / Besoins. Alimentés par Weaviate et Mistral AI.
</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-emerald-400/10 text-emerald-400 border border-emerald-500/20">
<span class="w-1.5 h-1.5 mr-1.5 rounded-full bg-emerald-400 animate-ping"></span>
RAG Actif
</span>
</div>
</header>
<!-- Main Content -->
<main>
<livewire:chatbot-manager />
</main>
<!-- Footer -->
<footer class="mt-20 border-t border-slate-900 pt-8 text-center text-xs text-slate-500">
<p>© {{ date('Y') }} Chatbot AGGLO SaaS. Construit en Laravel, Weaviate et Mistral AI.</p>
</footer>
</div>
@livewireScripts
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
+24
View File
@@ -0,0 +1,24 @@
<?php
use App\Models\Chatbot;
use Illuminate\Support\Facades\Route;
// Global console / Chatbot manager
Route::get('/', function () {
return view('welcome');
});
// Admin dashboard for a specific chatbot
Route::get('/admin/chatbots/{chatbot:slug}', function (Chatbot $chatbot) {
return view('chatbot-admin', compact('chatbot'));
});
// Full page demo testing for a specific chatbot
Route::get('/chatbot/{chatbot:slug}', function (Chatbot $chatbot) {
return view('chatbot-demo', compact('chatbot'));
});
// Iframe widget frame for a specific chatbot
Route::get('/chatbot-widget/{chatbot:slug}', function (Chatbot $chatbot) {
return view('chatbot-widget-frame', compact('chatbot'));
});
+185
View File
@@ -0,0 +1,185 @@
import sys
import os
import json
# Force stdout to UTF-8 encoding
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
import base64
import glob
# Add user site-packages to sys.path (needed on Windows when executed from PHP server environment)
user_site_packages = os.path.expanduser("~\\AppData\\Roaming\\Python\\Python313\\site-packages")
if os.path.exists(user_site_packages):
sys.path.append(user_site_packages)
# Absolute path fallbacks for Windows User profile
fallback_path = "C:\\Users\\jerem\\AppData\\Roaming\\Python\\Python313\\site-packages"
if os.path.exists(fallback_path) and fallback_path not in sys.path:
sys.path.append(fallback_path)
for path in glob.glob(os.path.expanduser("~\\AppData\\Roaming\\Python\\Python*\\site-packages")):
if path not in sys.path:
sys.path.append(path)
# Also scan C:\Users just in case the home directory is mapped differently
for path in glob.glob("C:\\Users\\*\\AppData\\Roaming\\Python\\Python*\\site-packages"):
if path not in sys.path:
sys.path.append(path)
import fitz # PyMuPDF
import requests
def extract_image_description(image_bytes, api_key):
"""Call Mistral Vision API (pixtral-12b-2409) to describe an image."""
if not api_key:
return ""
url = "https://api.mistral.ai/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
# Base64 encode the image
base64_image = base64.b64encode(image_bytes).decode('utf-8')
payload = {
"model": "pixtral-12b-2409",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Décris cette image, ce pictogramme ou ce schéma en une phrase descriptive claire pour un moteur de recherche."
},
{
"type": "image_url",
"image_url": f"data:image/jpeg;base64,{base64_image}"
}
]
}
],
"temperature": 0.2,
"max_tokens": 150
}
try:
response = requests.post(url, json=payload, headers=headers, timeout=15)
if response.status_code == 200:
result = response.json()
return result['choices'][0]['message']['content'].strip()
else:
return f"[Erreur Vision Status {response.status_code}]"
except Exception as e:
return f"[Erreur Vision: {str(e)}]"
def chunk_text(text, chunk_size=800, overlap=120):
"""Split text into overlapping chunks."""
words = text.split()
chunks = []
current_chunk = []
current_len = 0
for word in words:
current_chunk.append(word)
current_len += len(word) + 1
if current_len >= chunk_size:
chunks.append(" ".join(current_chunk))
# Slide window back
overlap_words = int(overlap / 6) # Approx 6 chars/word
if overlap_words > 0 and len(current_chunk) > overlap_words:
current_chunk = current_chunk[-overlap_words:]
current_len = len(" ".join(current_chunk))
else:
current_chunk = []
current_len = 0
if current_chunk:
chunks.append(" ".join(current_chunk))
return [c for c in chunks if len(c.strip()) > 30]
def main():
if len(sys.argv) < 4:
print(json.dumps({"error": "Missing arguments. Usage: python parse_pdf.py <pdf_path> <mistral_api_key> <enable_vision>"}))
sys.exit(1)
pdf_path = sys.argv[1]
api_key = sys.argv[2]
enable_vision = sys.argv[3] == '1'
if not os.path.exists(pdf_path):
print(json.dumps({"error": f"File not found: {pdf_path}"}))
sys.exit(1)
filename = os.path.basename(pdf_path)
output_chunks = []
try:
doc = fitz.open(pdf_path)
for page_num in range(len(doc)):
page = doc[page_num]
# Extract page text preserving block layouts
page_text = page.get_text("blocks")
# Sort blocks top-to-bottom, left-to-right
page_text.sort(key=lambda b: (b[1], b[0]))
text_lines = []
for b in page_text:
if len(b) > 4 and isinstance(b[4], str) and b[4].strip():
text_lines.append(b[4].strip())
full_page_text = "\n".join(text_lines)
# Extract images if vision is enabled
image_descriptions = []
if enable_vision:
images = page.get_images(full=True)
for img_idx, img in enumerate(images):
xref = img[0]
try:
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
# Skip tiny images (e.g., icons, bullets) to save API calls
if len(image_bytes) < 4000:
continue
desc = extract_image_description(image_bytes, api_key)
if desc:
image_descriptions.append(f"[Illustration Page {page_num+1} - Description: {desc}]")
except Exception as e:
# Log error internally but continue
pass
# Combine text and image descriptions
combined_text = full_page_text
if image_descriptions:
combined_text += "\n\nDescriptions d'illustrations sur cette page:\n" + "\n".join(image_descriptions)
# Chunk the page text
page_chunks = chunk_text(combined_text)
for chunk in page_chunks:
output_chunks.append({
"content": chunk,
"source": filename,
"title": f"{filename} (Page {page_num+1})"
})
doc.close()
print(json.dumps(output_chunks, ensure_ascii=False))
except Exception as e:
print(json.dumps({"error": f"Failed to parse PDF: {str(e)}"}))
sys.exit(1)
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+9
View File
@@ -0,0 +1,9 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json
+3
View File
@@ -0,0 +1,3 @@
*
!data/
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+19
View File
@@ -0,0 +1,19 @@
<?php
require 'vendor/autoload.php';
$app = require_once 'bootstrap/app.php';
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
$ingestionService = app(App\Services\DocumentIngestionService::class);
$filePath = storage_path('app/private/temp/tiny_test.pdf');
$originalName = 'tiny_test.pdf';
// Let's get the chatbot ID from the slug
$chatbot = App\Models\Chatbot::where('slug', 'dtegdcabm')->first();
if (!$chatbot) {
echo "Chatbot not found!\n";
exit(1);
}
echo "Ingesting tiny_test.pdf for chatbot ID: " . $chatbot->id . "\n";
$success = $ingestionService->ingestPdf($filePath, $originalName, (string) $chatbot->id, false);
var_dump($success);
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { bunny } from 'laravel-vite-plugin/fonts';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
fonts: [
bunny('Instrument Sans', {
weights: [400, 500, 600],
}),
],
}),
tailwindcss(),
],
server: {
watch: {
ignored: ['**/storage/framework/views/**'],
},
},
});