├── .env ├── .env.test ├── .github └── workflows │ └── pipeline.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── assets ├── app.js ├── bootstrap.js ├── controllers.json ├── controllers │ ├── audio_controller.js │ ├── chat_controller.js │ ├── video_controller.js │ ├── wikipedia_controller.js │ └── youtube_controller.js ├── icons │ ├── bi │ │ └── youtube.svg │ ├── fluent │ │ └── bot-24-filled.svg │ ├── iconoir │ │ ├── microphone-mute-solid.svg │ │ ├── microphone-solid.svg │ │ └── timer-solid.svg │ ├── material-symbols │ │ └── cancel.svg │ ├── mdi │ │ ├── github.svg │ │ ├── symfony.svg │ │ └── wikipedia.svg │ ├── mingcute │ │ └── send-fill.svg │ ├── solar │ │ ├── code-linear.svg │ │ └── user-bold.svg │ ├── symfony.svg │ └── tabler │ │ └── video-filled.svg └── styles │ ├── app.css │ ├── audio.css │ ├── blog.css │ ├── video.css │ ├── wikipedia.css │ └── youtube.css ├── bin └── console ├── compose.yaml ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── packages │ ├── asset_mapper.yaml │ ├── cache.yaml │ ├── chromadb.yaml │ ├── debug.yaml │ ├── framework.yaml │ ├── llm_chain.yaml │ ├── monolog.yaml │ ├── property_info.yaml │ ├── routing.yaml │ ├── twig.yaml │ ├── twig_component.yaml │ └── web_profiler.yaml ├── preload.php ├── routes.yaml ├── routes │ ├── framework.yaml │ ├── ux_live_component.yaml │ └── web_profiler.yaml ├── secrets │ └── dev │ │ ├── dev.OPENAI_API_KEY.66949e.php │ │ ├── dev.encrypt.public.php │ │ └── dev.list.php └── services.yaml ├── demo.png ├── importmap.php ├── phpstan.dist.neon ├── phpunit.xml ├── public ├── favicon.ico ├── index.php └── wiki.png ├── src ├── Audio │ ├── Chat.php │ └── TwigComponent.php ├── Blog │ ├── Chat.php │ ├── Command │ │ ├── EmbedCommand.php │ │ └── QueryCommand.php │ ├── Embedder.php │ ├── FeedLoader.php │ ├── Post.php │ └── TwigComponent.php ├── Kernel.php ├── Video │ └── TwigComponent.php ├── Wikipedia │ ├── Chat.php │ └── TwigComponent.php └── YouTube │ ├── Chat.php │ ├── TranscriptFetcher.php │ └── TwigComponent.php ├── symfony.lock ├── templates ├── _message.html.twig ├── base.html.twig ├── chat.html.twig ├── components │ ├── audio.html.twig │ ├── blog.html.twig │ ├── video.html.twig │ ├── wikipedia.html.twig │ └── youtube.html.twig └── index.html.twig └── tests ├── Blog ├── LoaderTest.php ├── PostTest.php └── fixtures │ └── blog.rss ├── SmokeTest.php ├── YouTube ├── TranscriptFetcherTest.php └── fixtures │ ├── transcript.xml │ └── video.html └── bootstrap.php /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET=ccb9dca72dce53c683eaaf775bfdb253 20 | ###< symfony/framework-bundle ### 21 | 22 | CHROMADB_HOST=chromadb 23 | OPENAI_API_KEY=sk-... 24 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='$ecretf0rt3st' 4 | SYMFONY_DEPRECATIONS_HELPER=999999 5 | PANTHER_APP_ENV=panther 6 | PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots 7 | OPENAI_API_KEY=sk-proj-testing1234 8 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: pipeline 2 | on: pull_request 3 | 4 | jobs: 5 | tests: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v4 10 | 11 | - name: Setup PHP 12 | uses: shivammathur/setup-php@v2 13 | with: 14 | php-version: 8.4 15 | coverage: none 16 | 17 | - name: Install Composer 18 | uses: ramsey/composer-install@v3 19 | 20 | - name: Composer Validation 21 | run: composer validate --strict 22 | 23 | - name: Install PHP Dependencies 24 | run: composer install --no-scripts 25 | 26 | - name: Lint PHP 27 | run: php -l src/**/*.php tests/**/*.php 28 | 29 | - name: Lint Templates 30 | run: bin/console lint:twig templates 31 | 32 | - name: Lint Config 33 | run: bin/console lint:yaml config 34 | 35 | - name: Lint Container 36 | run: bin/console lint:container 37 | 38 | - name: Code Style PHP 39 | run: PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run 40 | 41 | - name: PHPStan 42 | run: vendor/bin/phpstan analyse 43 | 44 | - name: Tests 45 | run: vendor/bin/phpunit 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | /.env.local 3 | /.env.local.php 4 | /.env.*.local 5 | /config/secrets/dev/dev.decrypt.private.php 6 | /config/secrets/prod/prod.decrypt.private.php 7 | /public/bundles/ 8 | /var/ 9 | /vendor/ 10 | ###< symfony/framework-bundle ### 11 | ###> symfony/asset-mapper ### 12 | /public/assets/ 13 | /assets/vendor/ 14 | ###< symfony/asset-mapper ### 15 | 16 | ###> php-cs-fixer/shim ### 17 | /.php-cs-fixer.php 18 | /.php-cs-fixer.cache 19 | ###< php-cs-fixer/shim ### 20 | 21 | ###> phpstan/phpstan ### 22 | phpstan.neon 23 | ###< phpstan/phpstan ### 24 | 25 | ###> phpunit/phpunit ### 26 | .phpunit.cache 27 | ###< phpunit/phpunit ### 28 | 29 | chromadb 30 | coverage 31 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ->exclude('config/secrets') 7 | ; 8 | 9 | return (new PhpCsFixer\Config()) 10 | ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) 11 | ->setRules([ 12 | '@Symfony' => true, 13 | ]) 14 | ->setFinder($finder) 15 | ; 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Christopher Hertel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LLM Chain - Symfony Demo Chatbot Application 2 | 3 | Simple Symfony demo application on top of [LLM Chain](https://github.com/php-llm/llm-chain) and its [integration bundle](https://github.com/php-llm/llm-chain-bundle). 4 | 5 | ## Examples 6 | 7 | ![demo.png](demo.png) 8 | 9 | ## Requirements 10 | 11 | What you need to run this demo: 12 | 13 | * Internet Connection 14 | * Terminal & Browser 15 | * [Git](https://git-scm.com/) & [GitHub Account](https://github.com) 16 | * [Docker](https://www.docker.com/) with [Docker Compose Plugin](https://docs.docker.com/compose/) 17 | * Your Favorite IDE or Editor 18 | * An [OpenAI API Key](https://platform.openai.com/docs/api-reference/create-and-export-an-api-key) 19 | 20 | ## Technology 21 | 22 | This small demo sits on top of following technologies: 23 | 24 | * [PHP >= 8.4](https://www.php.net/releases/8.4/en.php) 25 | * [Symfony 7.2 incl. Twig, Asset Mapper & UX](https://symfony.com/) 26 | * [Bootstrap 5](https://getbootstrap.com/docs/5.0/getting-started/introduction/) 27 | * [OpenAI's GPT & Embeddings](https://platform.openai.com/docs/overview) 28 | * [ChromaDB Vector Store](https://www.trychroma.com/) 29 | * [FrankenPHP](https://frankenphp.dev/) 30 | 31 | ## Setup 32 | 33 | The setup is split into three parts, the Symfony application, the OpenAI configuration, and initializing the Chroma DB. 34 | 35 | ### 1. Symfony App 36 | 37 | Checkout the repository, start the docker environment and install dependencies: 38 | 39 | ```shell 40 | git clone git@github.com:php-llm/llm-chain-symfony-demo.git 41 | cd llm-chain-symfony-demo 42 | docker compose up -d 43 | docker compose run composer install 44 | ``` 45 | 46 | Now you should be able to open https://localhost/ in your browser, 47 | and the chatbot UI should be available for you to start chatting. 48 | 49 | > [!NOTE] 50 | > You might have to bypass the security warning of your browser with regard to self-signed certificates. 51 | 52 | ### 2. OpenAI Configuration 53 | 54 | For using GPT and embedding models from OpenAI, you need to configure an OpenAI API key as environment variable. 55 | This requires you to have an OpenAI account, create a valid API key and set it as `OPENAI_API_KEY` in `.env.local` file. 56 | 57 | ```shell 58 | echo "OPENAI_API_KEY='sk-...'" > .env.local 59 | ``` 60 | 61 | Verify the success of this step by running the following command: 62 | 63 | ```shell 64 | docker compose exec app bin/console debug:dotenv 65 | ``` 66 | 67 | You should be able to see the `OPENAI_API_KEY` in the list of environment variables. 68 | 69 | ### 3. Chroma DB Initialization 70 | 71 | The [Chroma DB](https://www.trychroma.com/) is a vector store that is used to store embeddings of the chatbot's context. 72 | 73 | To initialize the Chroma DB, you need to run the following command: 74 | 75 | ```shell 76 | docker compose exec app bin/console app:blog:embed -vv 77 | ``` 78 | 79 | Now you should be able to run the test command and get some results: 80 | 81 | ```shell 82 | docker compose exec app bin/console app:blog:query 83 | ``` 84 | 85 | **Don't forget to set up the project in your favorite IDE or editor.** 86 | 87 | ## Functionality 88 | 89 | * The chatbot application is a simple and small Symfony 7.2 application. 90 | * The UI is coupled to a [Twig LiveComponent](https://symfony.com/bundles/ux-live-component/current/index.html), that integrates different `Chat` implementations on top of the user's session. 91 | * You can reset the chat context by hitting the `Reset` button in the top right corner. 92 | * You find three different usage scenarios in the upper navbar. 93 | -------------------------------------------------------------------------------- /assets/app.js: -------------------------------------------------------------------------------- 1 | import './bootstrap.js'; 2 | import 'bootstrap/dist/css/bootstrap.min.css'; 3 | import './styles/app.css'; 4 | import './styles/audio.css'; 5 | import './styles/blog.css'; 6 | import './styles/youtube.css'; 7 | import './styles/video.css'; 8 | import './styles/wikipedia.css'; 9 | -------------------------------------------------------------------------------- /assets/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { startStimulusApp } from '@symfony/stimulus-bundle'; 2 | 3 | const app = startStimulusApp(); 4 | // register any custom, 3rd party controllers here 5 | // app.register('some_controller_name', SomeImportedController); 6 | -------------------------------------------------------------------------------- /assets/controllers.json: -------------------------------------------------------------------------------- 1 | { 2 | "controllers": { 3 | "@symfony/ux-live-component": { 4 | "live": { 5 | "enabled": true, 6 | "fetch": "eager", 7 | "autoimport": { 8 | "@symfony/ux-live-component/dist/live.min.css": true 9 | } 10 | } 11 | }, 12 | "@symfony/ux-turbo": { 13 | "turbo-core": { 14 | "enabled": true, 15 | "fetch": "eager" 16 | }, 17 | "mercure-turbo-stream": { 18 | "enabled": false, 19 | "fetch": "eager" 20 | } 21 | }, 22 | "@symfony/ux-typed": { 23 | "typed": { 24 | "enabled": true, 25 | "fetch": "eager" 26 | } 27 | } 28 | }, 29 | "entrypoints": [] 30 | } 31 | -------------------------------------------------------------------------------- /assets/controllers/audio_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { getComponent } from '@symfony/ux-live-component'; 3 | 4 | export default class extends Controller { 5 | async initialize() { 6 | this.component = await getComponent(this.element); 7 | this.scrollToBottom(); 8 | 9 | const resetButton = document.getElementById('chat-reset'); 10 | resetButton.addEventListener('click', (event) => { 11 | this.component.action('reset'); 12 | }); 13 | 14 | const startButton = document.getElementById('micro-start'); 15 | const stopButton = document.getElementById('micro-stop'); 16 | const botThinkingButton = document.getElementById('bot-thinking'); 17 | 18 | startButton.addEventListener('click', (event) => { 19 | event.preventDefault(); 20 | startButton.classList.add('d-none'); 21 | stopButton.classList.remove('d-none'); 22 | this.startRecording(); 23 | }); 24 | stopButton.addEventListener('click', (event) => { 25 | event.preventDefault(); 26 | stopButton.classList.add('d-none'); 27 | botThinkingButton.classList.remove('d-none'); 28 | this.mediaRecorder.stop(); 29 | }); 30 | 31 | this.component.on('loading.state:started', (e,r) => { 32 | if (r.actions.includes('reset')) { 33 | return; 34 | } 35 | document.getElementById('welcome')?.remove(); 36 | document.getElementById('loading-message').removeAttribute('class'); 37 | this.scrollToBottom(); 38 | }); 39 | 40 | this.component.on('loading.state:finished', () => { 41 | document.getElementById('loading-message').setAttribute('class', 'd-none'); 42 | botThinkingButton.classList.add('d-none'); 43 | startButton.classList.remove('d-none'); 44 | }); 45 | 46 | this.component.on('render:finished', () => { 47 | this.scrollToBottom(); 48 | }); 49 | }; 50 | 51 | async startRecording() { 52 | const stream = await navigator.mediaDevices.getUserMedia({audio: true}); 53 | this.mediaRecorder = new MediaRecorder(stream); 54 | let audioChunks = []; 55 | 56 | this.mediaRecorder.ondataavailable = (event) => { 57 | audioChunks.push(event.data); 58 | }; 59 | 60 | this.mediaRecorder.onstop = async () => { 61 | const audioBlob = new Blob(audioChunks, {type: 'audio/wav'}); 62 | this.mediaRecorder.stream.getAudioTracks().forEach(track => track.stop()); 63 | 64 | const base64String = await this.blobToBase64(audioBlob); 65 | this.component.action('submit', { audio: base64String }); 66 | }; 67 | 68 | this.mediaRecorder.start(); 69 | } 70 | 71 | scrollToBottom() { 72 | const chatBody = document.getElementById('chat-body'); 73 | chatBody.scrollTop = chatBody.scrollHeight; 74 | } 75 | 76 | blobToBase64(blob) { 77 | return new Promise((resolve) => { 78 | const reader = new FileReader(); 79 | reader.readAsDataURL(blob); 80 | reader.onloadend = () => resolve(reader.result.split(',')[1]); 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /assets/controllers/chat_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { getComponent } from '@symfony/ux-live-component'; 3 | 4 | export default class extends Controller { 5 | async initialize() { 6 | this.component = await getComponent(this.element); 7 | this.scrollToBottom(); 8 | 9 | const input = document.getElementById('chat-message'); 10 | input.addEventListener('keypress', (event) => { 11 | if (event.key === 'Enter') { 12 | this.submitMessage(); 13 | } 14 | }); 15 | input.focus(); 16 | 17 | const resetButton = document.getElementById('chat-reset'); 18 | resetButton.addEventListener('click', (event) => { 19 | this.component.action('reset'); 20 | }); 21 | 22 | const submitButton = document.getElementById('chat-submit'); 23 | submitButton.addEventListener('click', (event) => { 24 | this.submitMessage(); 25 | }); 26 | 27 | this.component.on('loading.state:started', (e,r) => { 28 | if (r.actions.includes('reset')) { 29 | return; 30 | } 31 | document.getElementById('welcome')?.remove(); 32 | document.getElementById('loading-message').removeAttribute('class'); 33 | this.scrollToBottom(); 34 | }); 35 | 36 | this.component.on('loading.state:finished', () => { 37 | document.getElementById('loading-message').setAttribute('class', 'd-none'); 38 | }); 39 | 40 | this.component.on('render:finished', () => { 41 | this.scrollToBottom(); 42 | }); 43 | }; 44 | 45 | submitMessage() { 46 | const input = document.getElementById('chat-message'); 47 | const message = input.value; 48 | document 49 | .getElementById('loading-message') 50 | .getElementsByClassName('user-message')[0].innerHTML = message; 51 | this.component.action('submit', { message }); 52 | input.value = ''; 53 | } 54 | 55 | scrollToBottom() { 56 | const chatBody = document.getElementById('chat-body'); 57 | chatBody.scrollTop = chatBody.scrollHeight; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /assets/controllers/video_controller.js: -------------------------------------------------------------------------------- 1 | import {Controller} from '@hotwired/stimulus'; 2 | import {getComponent} from '@symfony/ux-live-component'; 3 | 4 | /** 5 | * Heavily inspired by https://github.com/ngxson/smolvlm-realtime-webcam 6 | */ 7 | export default class extends Controller { 8 | async initialize() { 9 | this.component = await getComponent(this.element); 10 | 11 | this.video = document.getElementById('videoFeed'); 12 | this.canvas = document.getElementById('canvas'); 13 | 14 | const input = document.getElementById('chat-message'); 15 | input.addEventListener('keypress', (event) => { 16 | if (event.key === 'Enter') { 17 | this.submitMessage(); 18 | } 19 | }); 20 | input.focus(); 21 | 22 | const submitButton = document.getElementById('chat-submit'); 23 | submitButton.addEventListener('click', (event) => { 24 | this.submitMessage(); 25 | }); 26 | 27 | await this.initCamera(); 28 | }; 29 | 30 | async initCamera() { 31 | try { 32 | this.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); 33 | this.video.srcObject = this.stream; 34 | console.log('Camera access granted. Ready to start.'); 35 | } catch (err) { 36 | console.error('Error accessing camera:', err); 37 | alert(`Error accessing camera: ${err.name}. Make sure you've granted permission and are on HTTPS or localhost.`); 38 | } 39 | } 40 | 41 | submitMessage() { 42 | const input = document.getElementById('chat-message'); 43 | const instruction = input.value; 44 | const image = this.captureImage(); 45 | 46 | if (null === image) { 47 | console.warn('No image captured. Cannot submit message.'); 48 | return; 49 | } 50 | 51 | this.component.action('submit', { instruction, image }); 52 | input.value = ''; 53 | } 54 | 55 | captureImage() { 56 | if (!this.stream || !this.video.videoWidth) { 57 | console.warn('Video stream not ready for capture.'); 58 | return null; 59 | } 60 | 61 | this.canvas.width = this.video.videoWidth; 62 | this.canvas.height = this.video.videoHeight; 63 | const context = this.canvas.getContext('2d'); 64 | context.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height); 65 | return this.canvas.toDataURL('image/jpeg', 0.8); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /assets/controllers/wikipedia_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { getComponent } from '@symfony/ux-live-component'; 3 | 4 | export default class extends Controller { 5 | async initialize() { 6 | this.component = await getComponent(this.element); 7 | this.scrollToBottom(); 8 | 9 | const input = document.getElementById('chat-message'); 10 | input.addEventListener('keypress', (event) => { 11 | if (event.key === 'Enter') { 12 | this.submitMessage(); 13 | } 14 | }); 15 | input.focus(); 16 | 17 | const resetButton = document.getElementById('chat-reset'); 18 | resetButton.addEventListener('click', (event) => { 19 | this.component.action('reset'); 20 | }); 21 | 22 | const submitButton = document.getElementById('chat-submit'); 23 | submitButton.addEventListener('click', (event) => { 24 | this.submitMessage(); 25 | }); 26 | 27 | this.component.on('loading.state:started', (e,r) => { 28 | if (r.actions.includes('reset')) { 29 | return; 30 | } 31 | document.getElementById('welcome')?.remove(); 32 | document.getElementById('loading-message').removeAttribute('class'); 33 | this.scrollToBottom(); 34 | }); 35 | 36 | this.component.on('loading.state:finished', () => { 37 | document.getElementById('loading-message').setAttribute('class', 'd-none'); 38 | }); 39 | 40 | this.component.on('render:finished', () => { 41 | this.scrollToBottom(); 42 | }); 43 | }; 44 | 45 | submitMessage() { 46 | const input = document.getElementById('chat-message'); 47 | const message = input.value; 48 | document 49 | .getElementById('loading-message') 50 | .getElementsByClassName('user-message')[0].innerHTML = message; 51 | this.component.action('submit', { message }); 52 | input.value = ''; 53 | } 54 | 55 | scrollToBottom() { 56 | const chatBody = document.getElementById('chat-body'); 57 | chatBody.scrollTop = chatBody.scrollHeight; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /assets/controllers/youtube_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { getComponent } from '@symfony/ux-live-component'; 3 | 4 | export default class extends Controller { 5 | async initialize() { 6 | this.component = await getComponent(this.element); 7 | this.scrollToBottom(); 8 | 9 | const input = document.getElementById('chat-message'); 10 | input.addEventListener('keypress', (event) => { 11 | if (event.key === 'Enter') { 12 | this.submitMessage(); 13 | } 14 | }); 15 | input.focus(); 16 | 17 | const resetButton = document.getElementById('chat-reset'); 18 | resetButton.addEventListener('click', (event) => { 19 | this.component.action('reset'); 20 | }); 21 | 22 | if (document.getElementById('welcome')) { 23 | this.initStartButton(); 24 | } 25 | 26 | const submitButton = document.getElementById('chat-submit'); 27 | submitButton.addEventListener('click', (event) => { 28 | this.submitMessage(); 29 | }); 30 | 31 | this.component.on('loading.state:started', (e,r) => { 32 | if (r.actions.includes('reset') || r.actions.includes('start')) { 33 | return; 34 | } 35 | document.getElementById('welcome')?.remove(); 36 | document.getElementById('loading-message').removeAttribute('class'); 37 | this.scrollToBottom(); 38 | }); 39 | 40 | this.component.on('loading.state:finished', () => { 41 | document.getElementById('loading-message').setAttribute('class', 'd-none'); 42 | }); 43 | 44 | this.component.on('render:finished', () => { 45 | this.scrollToBottom(); 46 | if (document.getElementById('welcome')) { 47 | this.initStartButton(); 48 | } 49 | }); 50 | }; 51 | 52 | initStartButton() { 53 | const input = document.getElementById('youtube-id'); 54 | input.disabled = false; 55 | input.value = ''; 56 | input.focus(); 57 | const startButton = document.getElementById('chat-start'); 58 | startButton.disabled = false; 59 | startButton.addEventListener('click', (event) => { 60 | this.start(); 61 | }); 62 | document.getElementById('chat-message').disabled = true; 63 | document.getElementById('chat-submit').disabled = true; 64 | } 65 | 66 | start() { 67 | const input = document.getElementById('youtube-id'); 68 | input.disabled = true; 69 | const videoId = input.value; 70 | const button = document.getElementById('chat-start'); 71 | button.disabled = true; 72 | button.innerHTML = 'Loading...'; 73 | document 74 | .getElementById('loading-message') 75 | .getElementsByClassName('user-message')[0].innerHTML = 'Starting chat for video ID: ' + videoId; 76 | this.component.action('start', { videoId }); 77 | document.getElementById('chat-message').disabled = false; 78 | document.getElementById('chat-submit').disabled = false; 79 | } 80 | 81 | submitMessage() { 82 | const input = document.getElementById('chat-message'); 83 | const message = input.value; 84 | document 85 | .getElementById('loading-message') 86 | .getElementsByClassName('user-message')[0].innerHTML = message; 87 | this.component.action('submit', { message }); 88 | input.value = ''; 89 | } 90 | 91 | scrollToBottom() { 92 | const chatBody = document.getElementById('chat-body'); 93 | chatBody.scrollTop = chatBody.scrollHeight; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /assets/icons/bi/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/fluent/bot-24-filled.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/iconoir/microphone-mute-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/iconoir/microphone-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/iconoir/timer-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/material-symbols/cancel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/mdi/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/mdi/symfony.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/mdi/wikipedia.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/mingcute/send-fill.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/solar/code-linear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/solar/user-bold.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/symfony.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/tabler/video-filled.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | min-height: 100vh; 3 | 4 | footer, footer a { 5 | color: #6c757d; 6 | } 7 | } 8 | 9 | .index { 10 | .card-img-top { 11 | text-align: center; 12 | } 13 | } 14 | 15 | .chat { 16 | .card { 17 | border: 1px solid #bcbcbc; 18 | background-color: rgba(250, 250, 250, 0.9); 19 | } 20 | 21 | .card-header { 22 | background: #efefef; 23 | 24 | svg { 25 | margin-top: -2px; 26 | } 27 | } 28 | 29 | .card-body { 30 | height: 700px; 31 | 32 | .user-message { 33 | border-radius: 10px 10px 0 10px; 34 | color: #292929; 35 | } 36 | 37 | .bot-message { 38 | border-radius: 10px 10px 10px 0; 39 | color: #292929; 40 | 41 | &.loading { 42 | color: rgba(41, 41, 41, 0.5); 43 | } 44 | 45 | p { 46 | margin-bottom: 0; 47 | } 48 | } 49 | 50 | .avatar { 51 | width: 50px; 52 | height: 50px; 53 | border: 2px solid white; 54 | } 55 | } 56 | 57 | .card-footer { 58 | background: #efefef; 59 | 60 | input:focus { 61 | outline: none !important; 62 | box-shadow: none !important; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /assets/styles/audio.css: -------------------------------------------------------------------------------- 1 | .audio { 2 | body&, .card-img-top { 3 | background: #df662f; 4 | background: linear-gradient(0deg, #df662f 0%, #a80a1d 100%); 5 | } 6 | 7 | .card-img-top { 8 | color: #ffffff; 9 | } 10 | 11 | &.chat { 12 | .user-message { 13 | background: #df662f; 14 | color: #ffffff; 15 | 16 | #loading-message & { 17 | color: rgba(255, 255, 255, 0.7); 18 | } 19 | } 20 | 21 | .bot-message { 22 | color: #ffffff; 23 | background: #215d9a; 24 | 25 | &.loading { 26 | color: rgba(255, 255, 255, 0.5); 27 | } 28 | 29 | a { 30 | color: #c8d8ef; 31 | 32 | &:hover { 33 | color: #ffffff; 34 | } 35 | } 36 | 37 | code { 38 | color: #ffb1ca; 39 | } 40 | } 41 | 42 | .avatar { 43 | &.bot { 44 | outline: 1px solid #c0dbf4; 45 | background: #c0dbf4; 46 | } 47 | 48 | &.user { 49 | outline: 1px solid #f3b396; 50 | background: #f3b396; 51 | } 52 | } 53 | 54 | #welcome h4 { 55 | color: #2c5282; 56 | } 57 | 58 | #chat-reset, #chat-submit { 59 | &:hover { 60 | background: #a80a1d; 61 | border-color: #a80a1d; 62 | } 63 | } 64 | } 65 | 66 | footer { 67 | color: #ffffff; 68 | 69 | a { 70 | color: #ffffff; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /assets/styles/blog.css: -------------------------------------------------------------------------------- 1 | .blog { 2 | body&, .card-img-top { 3 | background: #2c5282; 4 | background: linear-gradient(0deg, #2c5282 0%, #3c366b 100%); 5 | } 6 | 7 | .card-img-top { 8 | color: #ffffff; 9 | } 10 | 11 | &.chat { 12 | .user-message { 13 | background: #d5054e; 14 | color: #ffffff; 15 | } 16 | 17 | .bot-message { 18 | color: #ffffff; 19 | background: #3182ce; 20 | 21 | &.loading { 22 | color: rgba(255, 255, 255, 0.5); 23 | } 24 | 25 | a { 26 | color: #c8d8ef; 27 | 28 | &:hover { 29 | color: #ffffff; 30 | } 31 | } 32 | 33 | code { 34 | color: #ffb1ca; 35 | } 36 | } 37 | 38 | .avatar { 39 | &.bot { 40 | outline: 1px solid #b8d8fb; 41 | background: #b8d8fb; 42 | } 43 | 44 | &.user { 45 | outline: 1px solid #ffb1ca; 46 | background: #ffb1ca; 47 | } 48 | } 49 | 50 | #welcome h4 { 51 | color: #2c5282; 52 | } 53 | 54 | #chat-reset, #chat-submit { 55 | &:hover { 56 | background: #d5054e; 57 | border-color: #d5054e; 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /assets/styles/video.css: -------------------------------------------------------------------------------- 1 | .video { 2 | body&, .card-img-top { 3 | background: #26931e; 4 | background: linear-gradient(0deg, #186361 0%, #26931e 100%); 5 | } 6 | 7 | .card-img-top { 8 | color: #ffffff; 9 | } 10 | 11 | &.chat { 12 | #chat-submit { 13 | &:hover { 14 | background: #186361; 15 | border-color: #186361; 16 | } 17 | } 18 | } 19 | 20 | footer { 21 | color: #ffffff; 22 | 23 | a { 24 | color: #ffffff; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /assets/styles/wikipedia.css: -------------------------------------------------------------------------------- 1 | .wikipedia { 2 | body&, .card-img-top { 3 | background: url('/wiki.png') no-repeat right 50px bottom 50px fixed, linear-gradient(0deg, rgb(246, 246, 246) 0%, rgb(197, 197, 197) 100%); 4 | } 5 | 6 | &.chat { 7 | .card-body { 8 | background-image: linear-gradient(135deg, #f2f2f2 16.67%, #ebebeb 16.67%, #ebebeb 50%, #f2f2f2 50%, #f2f2f2 66.67%, #ebebeb 66.67%, #ebebeb 100%); 9 | background-size: 21.21px 21.21px; 10 | } 11 | 12 | .user-message { 13 | background: #ffffff; 14 | } 15 | 16 | .bot-message { 17 | background: #ffffff; 18 | 19 | a { 20 | color: #3e2926; 21 | } 22 | } 23 | 24 | .avatar { 25 | &.bot, &.user { 26 | outline: 1px solid #eaeaea; 27 | background: #eaeaea; 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /assets/styles/youtube.css: -------------------------------------------------------------------------------- 1 | .youtube { 2 | body&, .card-img-top { 3 | background: rgb(34,34,34); 4 | background: linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(71, 71, 71) 100%); 5 | } 6 | 7 | .card-img-top { 8 | color: #ff0000; 9 | } 10 | 11 | &.chat { 12 | .user-message { 13 | background: #3e2926; 14 | color: #fafafa; 15 | } 16 | 17 | .bot-message { 18 | color: #ffffff; 19 | background: #df3535; 20 | 21 | &.loading { 22 | color: rgba(255, 255, 255, 0.5); 23 | } 24 | } 25 | 26 | .avatar { 27 | &.bot { 28 | outline: 1px solid #ffcccc; 29 | background: #ffcccc; 30 | } 31 | 32 | &.user { 33 | outline: 1px solid #9e8282; 34 | background: #9e8282; 35 | } 36 | } 37 | 38 | #welcome h4 { 39 | color: #ff0000; 40 | } 41 | 42 | #chat-reset, #chat-submit { 43 | &:hover { 44 | background: #ff0000; 45 | border-color: #ff0000; 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =8.4", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "codewithkyrian/chromadb-php": "^0.4.0", 11 | "league/commonmark": "^2.7", 12 | "php-llm/llm-chain-bundle": "^0.22", 13 | "runtime/frankenphp-symfony": "^0.2.0", 14 | "symfony/asset": "7.3.*", 15 | "symfony/asset-mapper": "7.3.*", 16 | "symfony/clock": "7.3.*", 17 | "symfony/console": "7.3.*", 18 | "symfony/css-selector": "7.3.*", 19 | "symfony/dom-crawler": "7.3.*", 20 | "symfony/dotenv": "7.3.*", 21 | "symfony/flex": "^2.5", 22 | "symfony/framework-bundle": "7.3.*", 23 | "symfony/http-client": "7.3.*", 24 | "symfony/monolog-bundle": "^3.10", 25 | "symfony/runtime": "7.3.*", 26 | "symfony/twig-bundle": "7.3.*", 27 | "symfony/uid": "7.3.*", 28 | "symfony/ux-icons": "^2.25", 29 | "symfony/ux-live-component": "^2.25", 30 | "symfony/ux-turbo": "^2.25", 31 | "symfony/ux-typed": "^2.25", 32 | "symfony/yaml": "7.3.*", 33 | "twig/extra-bundle": "^3.21", 34 | "twig/markdown-extra": "^3.21", 35 | "twig/twig": "^3.21" 36 | }, 37 | "replace": { 38 | "symfony/polyfill-ctype": "*", 39 | "symfony/polyfill-iconv": "*", 40 | "symfony/polyfill-mbstring": "*", 41 | "symfony/polyfill-php72": "*", 42 | "symfony/polyfill-php73": "*", 43 | "symfony/polyfill-php74": "*", 44 | "symfony/polyfill-php80": "*", 45 | "symfony/polyfill-php81": "*", 46 | "symfony/polyfill-php82": "*", 47 | "symfony/polyfill-php83": "*", 48 | "symfony/polyfill-php84": "*" 49 | }, 50 | "conflict": { 51 | "symfony/symfony": "*" 52 | }, 53 | "require-dev": { 54 | "nyholm/nsa": "^1.3", 55 | "php-cs-fixer/shim": "^3.75", 56 | "phpstan/phpstan": "^2.1", 57 | "phpunit/phpunit": "^11.5", 58 | "symfony/browser-kit": "7.3.*", 59 | "symfony/debug-bundle": "7.3.*", 60 | "symfony/stopwatch": "7.3.*", 61 | "symfony/web-profiler-bundle": "7.3.*" 62 | }, 63 | "config": { 64 | "allow-plugins": { 65 | "php-http/discovery": true, 66 | "symfony/flex": true, 67 | "symfony/runtime": true 68 | }, 69 | "platform": { 70 | "php": "8.4.7" 71 | }, 72 | "sort-packages": true 73 | }, 74 | "extra": { 75 | "symfony": { 76 | "allow-contrib": false, 77 | "require": "7.3.*" 78 | } 79 | }, 80 | "autoload": { 81 | "psr-4": { 82 | "App\\": "src/" 83 | } 84 | }, 85 | "autoload-dev": { 86 | "psr-4": { 87 | "App\\Tests\\": "tests/" 88 | } 89 | }, 90 | "scripts": { 91 | "post-install-cmd": [ 92 | "@auto-scripts" 93 | ], 94 | "post-update-cmd": [ 95 | "@auto-scripts" 96 | ], 97 | "auto-scripts": { 98 | "cache:clear": "symfony-cmd", 99 | "assets:install %PUBLIC_DIR%": "symfony-cmd", 100 | "importmap:install": "symfony-cmd" 101 | }, 102 | "pipeline": [ 103 | "composer validate --strict", 104 | "php -l src/**/*.php tests/**/*.php", 105 | "bin/console lint:twig templates", 106 | "bin/console lint:yaml config", 107 | "bin/console lint:container", 108 | "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix", 109 | "phpstan analyse", 110 | "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage" 111 | ] 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 6 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 7 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 8 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 9 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 10 | Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], 11 | Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true], 12 | Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], 13 | Symfony\UX\Turbo\TurboBundle::class => ['all' => true], 14 | Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], 15 | PhpLlm\LlmChainBundle\LlmChainBundle::class => ['all' => true], 16 | Symfony\UX\Typed\TypedBundle::class => ['all' => true], 17 | ]; 18 | -------------------------------------------------------------------------------- /config/packages/asset_mapper.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | asset_mapper: 3 | # The paths to make available to the asset mapper. 4 | paths: 5 | - assets/ 6 | missing_import_mode: strict 7 | 8 | when@prod: 9 | framework: 10 | asset_mapper: 11 | missing_import_mode: warn 12 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /config/packages/chromadb.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Codewithkyrian\ChromaDB\Factory: 3 | calls: 4 | - withHost: ['%env(CHROMADB_HOST)%'] 5 | 6 | Codewithkyrian\ChromaDB\Client: 7 | factory: ['@Codewithkyrian\ChromaDB\Factory', 'connect'] 8 | lazy: true 9 | -------------------------------------------------------------------------------- /config/packages/debug.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | debug: 3 | # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. 4 | # See the "server:dump" command to start a new server. 5 | dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" 6 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | #csrf_protection: true 5 | 6 | # Note that the session will be started ONLY if you read or write from it. 7 | session: true 8 | 9 | #esi: true 10 | #fragments: true 11 | 12 | when@test: 13 | framework: 14 | test: true 15 | session: 16 | storage_factory_id: session.storage.factory.mock_file 17 | -------------------------------------------------------------------------------- /config/packages/llm_chain.yaml: -------------------------------------------------------------------------------- 1 | llm_chain: 2 | platform: 3 | openai: 4 | api_key: '%env(OPENAI_API_KEY)%' 5 | chain: 6 | blog: 7 | # platform: 'llm_chain.platform.anthropic' 8 | model: 9 | name: 'GPT' 10 | version: 'gpt-4o-mini' 11 | tools: 12 | - 'PhpLlm\LlmChain\Chain\Toolbox\Tool\SimilaritySearch' 13 | - service: 'clock' 14 | name: 'clock' 15 | description: 'Provides the current date and time.' 16 | method: 'now' 17 | youtube: 18 | model: 19 | name: 'GPT' 20 | version: 'gpt-4o-mini' 21 | tools: false 22 | wikipedia: 23 | model: 24 | name: 'GPT' 25 | version: 'gpt-4o-mini' 26 | options: 27 | temperature: 0.5 28 | system_prompt: 'Please answer the users question based on Wikipedia and provide a link to the article.' 29 | include_tools: true 30 | tools: 31 | - 'PhpLlm\LlmChain\Chain\Toolbox\Tool\Wikipedia' 32 | audio: 33 | model: 34 | name: 'GPT' 35 | version: 'gpt-4o-mini' 36 | system_prompt: 'You are a friendly chatbot that likes to have a conversation with users and asks them some questions.' 37 | tools: 38 | # Chain in chain 🤯 39 | - service: 'llm_chain.chain.blog' 40 | name: 'symfony_blog' 41 | description: 'Can answer questions based on the Symfony blog.' 42 | is_chain: true 43 | store: 44 | chroma_db: 45 | symfonycon: 46 | collection: 'symfony_blog' 47 | embedder: 48 | default: 49 | model: 50 | name: 'Embeddings' 51 | version: 'text-embedding-ada-002' 52 | 53 | services: 54 | _defaults: 55 | autowire: true 56 | autoconfigure: true 57 | 58 | # PhpLlm\LlmChain\Chain\Toolbox\Tool\Clock: ~ 59 | # PhpLlm\LlmChain\Chain\Toolbox\Tool\OpenMeteo: ~ 60 | # PhpLlm\LlmChain\Chain\Toolbox\Tool\SerpApi: 61 | # $apiKey: '%env(SERP_API_KEY)%' 62 | PhpLlm\LlmChain\Chain\Toolbox\Tool\Wikipedia: ~ 63 | PhpLlm\LlmChain\Chain\Toolbox\Tool\SimilaritySearch: 64 | $model: '@llm_chain.embedder.default.model' 65 | 66 | -------------------------------------------------------------------------------- /config/packages/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: 3 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists 4 | 5 | when@dev: 6 | monolog: 7 | handlers: 8 | main: 9 | type: stream 10 | path: "%kernel.logs_dir%/%kernel.environment%.log" 11 | level: debug 12 | channels: ["!event"] 13 | # uncomment to get logging in your browser 14 | # you may have to allow bigger header sizes in your Web server configuration 15 | #firephp: 16 | # type: firephp 17 | # level: info 18 | #chromephp: 19 | # type: chromephp 20 | # level: info 21 | console: 22 | type: console 23 | process_psr_3_messages: false 24 | channels: ["!event", "!doctrine", "!console"] 25 | 26 | when@test: 27 | monolog: 28 | handlers: 29 | main: 30 | type: fingers_crossed 31 | action_level: error 32 | handler: nested 33 | excluded_http_codes: [404, 405] 34 | channels: ["!event"] 35 | nested: 36 | type: stream 37 | path: "%kernel.logs_dir%/%kernel.environment%.log" 38 | level: debug 39 | 40 | when@prod: 41 | monolog: 42 | handlers: 43 | main: 44 | type: fingers_crossed 45 | action_level: error 46 | handler: nested 47 | excluded_http_codes: [404, 405] 48 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 49 | nested: 50 | type: stream 51 | path: php://stderr 52 | level: debug 53 | formatter: monolog.formatter.json 54 | console: 55 | type: console 56 | process_psr_3_messages: false 57 | channels: ["!event", "!doctrine"] 58 | deprecation: 59 | type: stream 60 | channels: [deprecation] 61 | path: php://stderr 62 | formatter: monolog.formatter.json 63 | -------------------------------------------------------------------------------- /config/packages/property_info.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | property_info: 3 | with_constructor_extractor: true 4 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 4 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 5 | #default_uri: http://localhost 6 | 7 | when@prod: 8 | framework: 9 | router: 10 | strict_requirements: null 11 | -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | file_name_pattern: '*.twig' 3 | 4 | when@test: 5 | twig: 6 | strict_variables: true 7 | -------------------------------------------------------------------------------- /config/packages/twig_component.yaml: -------------------------------------------------------------------------------- 1 | twig_component: 2 | anonymous_template_directory: 'components/' 3 | defaults: 4 | # Namespace & directory for components 5 | App\Twig\Components\: 'components/' 6 | -------------------------------------------------------------------------------- /config/packages/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler: 3 | toolbar: 4 | enabled: true 5 | ajax_replace: true 6 | intercept_redirects: false 7 | 8 | framework: 9 | profiler: 10 | only_exceptions: false 11 | collect_serializer_data: true 12 | 13 | when@test: 14 | web_profiler: 15 | toolbar: false 16 | intercept_redirects: false 17 | 18 | framework: 19 | profiler: { collect: false } 20 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | null, 5 | ]; 6 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration 6 | 7 | services: 8 | # default configuration for services in *this* file 9 | _defaults: 10 | autowire: true # Automatically injects dependencies in your services. 11 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 12 | 13 | # makes classes in src/ available to be used as services 14 | # this creates a service per class whose id is the fully-qualified class name 15 | App\: 16 | resource: '../src/' 17 | exclude: 18 | - '../src/DependencyInjection/' 19 | - '../src/Entity/' 20 | - '../src/Kernel.php' 21 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/php-llm/llm-chain-symfony-demo/77af4db452a14b4ab000593eb294818d0b7e973f/demo.png -------------------------------------------------------------------------------- /importmap.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'path' => './assets/app.js', 17 | 'entrypoint' => true, 18 | ], 19 | '@symfony/ux-live-component' => [ 20 | 'path' => './vendor/symfony/ux-live-component/assets/dist/live_controller.js', 21 | ], 22 | '@symfony/stimulus-bundle' => [ 23 | 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', 24 | ], 25 | 'bootstrap' => [ 26 | 'version' => '5.3.3', 27 | ], 28 | '@popperjs/core' => [ 29 | 'version' => '2.11.8', 30 | ], 31 | 'bootstrap/dist/css/bootstrap.min.css' => [ 32 | 'version' => '5.3.3', 33 | 'type' => 'css', 34 | ], 35 | '@hotwired/stimulus' => [ 36 | 'version' => '3.2.2', 37 | ], 38 | '@hotwired/turbo' => [ 39 | 'version' => '8.0.4', 40 | ], 41 | 'typed.js' => [ 42 | 'version' => '2.1.0', 43 | ], 44 | ]; 45 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - bin/ 5 | - config/ 6 | - public/ 7 | - src/ 8 | - tests/ 9 | reportUnmatchedIgnoredErrors: false 10 | typeAliases: 11 | MessageArray: 'array{role: string, content: string}' 12 | MessageList: 'list' 13 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | tests 22 | 23 | 24 | 25 | 26 | 27 | src 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/php-llm/llm-chain-symfony-demo/77af4db452a14b4ab000593eb294818d0b7e973f/public/favicon.ico -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | platform->request(new Whisper(), Audio::fromFile($path)); 37 | assert($response instanceof AsyncResponse); 38 | $response = $response->unwrap(); 39 | assert($response instanceof TextResponse); 40 | 41 | $this->submitMessage($response->getContent()); 42 | } 43 | 44 | public function loadMessages(): MessageBag 45 | { 46 | return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag()); 47 | } 48 | 49 | public function submitMessage(string $message): void 50 | { 51 | $messages = $this->loadMessages(); 52 | 53 | $messages->add(Message::ofUser($message)); 54 | $response = $this->chain->call($messages); 55 | 56 | assert($response instanceof TextResponse); 57 | 58 | $messages->add(Message::ofAssistant($response->getContent())); 59 | 60 | $this->saveMessages($messages); 61 | } 62 | 63 | public function reset(): void 64 | { 65 | $this->requestStack->getSession()->remove(self::SESSION_KEY); 66 | } 67 | 68 | private function saveMessages(MessageBag $messages): void 69 | { 70 | $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Audio/TwigComponent.php: -------------------------------------------------------------------------------- 1 | chat->loadMessages()->withoutSystemMessage()->getMessages(); 29 | } 30 | 31 | #[LiveAction] 32 | public function submit(#[LiveArg] string $audio): void 33 | { 34 | $this->chat->say($audio); 35 | } 36 | 37 | #[LiveAction] 38 | public function reset(): void 39 | { 40 | $this->chat->reset(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Blog/Chat.php: -------------------------------------------------------------------------------- 1 | requestStack->getSession()->get(self::SESSION_KEY, $messages); 38 | } 39 | 40 | public function submitMessage(string $message): void 41 | { 42 | $messages = $this->loadMessages(); 43 | 44 | $messages->add(Message::ofUser($message)); 45 | $response = $this->chain->call($messages); 46 | 47 | assert($response instanceof TextResponse); 48 | 49 | $messages->add(Message::ofAssistant($response->getContent())); 50 | 51 | $this->saveMessages($messages); 52 | } 53 | 54 | public function reset(): void 55 | { 56 | $this->requestStack->getSession()->remove(self::SESSION_KEY); 57 | } 58 | 59 | private function saveMessages(MessageBag $messages): void 60 | { 61 | $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Blog/Command/EmbedCommand.php: -------------------------------------------------------------------------------- 1 | title('Loading RSS of Symfony blog as embeddings into ChromaDB'); 27 | 28 | $this->embedder->embedBlog(); 29 | 30 | $io->success('Symfony Blog Successfully Embedded!'); 31 | 32 | return Command::SUCCESS; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Blog/Command/QueryCommand.php: -------------------------------------------------------------------------------- 1 | title('Testing Chroma DB Connection'); 32 | 33 | $io->comment('Connecting to Chroma DB ...'); 34 | $collection = $this->chromaClient->getOrCreateCollection('symfony_blog'); 35 | $io->table(['Key', 'Value'], [ 36 | ['ChromaDB Version', $this->chromaClient->version()], 37 | ['Collection Name', $collection->name], 38 | ['Collection ID', $collection->id], 39 | ['Total Documents', $collection->count()], 40 | ]); 41 | 42 | $search = $io->ask('What do you want to know about?', 'New Symfony Features'); 43 | $io->comment(sprintf('Converting "%s" to vector & searching in Chroma DB ...', $search)); 44 | $io->comment('Results are limited to 4 most similar documents.'); 45 | 46 | $platformResponse = $this->platform->request(new Embeddings(), $search); 47 | assert($platformResponse instanceof AsyncResponse); 48 | $platformResponse = $platformResponse->unwrap(); 49 | assert($platformResponse instanceof VectorResponse); 50 | $queryResponse = $collection->query( 51 | queryEmbeddings: [$platformResponse->getContent()[0]->getData()], 52 | nResults: 4, 53 | ); 54 | 55 | if (1 === count($queryResponse->ids, COUNT_RECURSIVE)) { 56 | $io->error('No results found!'); 57 | 58 | return Command::FAILURE; 59 | } 60 | 61 | foreach ($queryResponse->ids[0] as $i => $id) { 62 | /* @phpstan-ignore-next-line */ 63 | $io->section($queryResponse->metadatas[0][$i]['title']); 64 | /* @phpstan-ignore-next-line */ 65 | $io->block($queryResponse->metadatas[0][$i]['description']); 66 | } 67 | 68 | $io->success('Chroma DB Connection & Similarity Search Test Successful!'); 69 | 70 | return Command::SUCCESS; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Blog/Embedder.php: -------------------------------------------------------------------------------- 1 | loader->load() as $post) { 23 | $documents[] = new TextDocument($post->id, $post->toString(), new Metadata($post->toArray())); 24 | } 25 | 26 | $this->embedder->embed($documents); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Blog/FeedLoader.php: -------------------------------------------------------------------------------- 1 | httpClient->request('GET', 'https://feeds.feedburner.com/symfony/blog'); 24 | 25 | $posts = []; 26 | $crawler = new Crawler($response->getContent()); 27 | $crawler->filter('item')->each(function (Crawler $node) use (&$posts) { 28 | $title = $node->filter('title')->text(); 29 | $posts[] = new Post( 30 | Uuid::v5(Uuid::fromString('6ba7b810-9dad-11d1-80b4-00c04fd430c8'), $title), 31 | $title, 32 | $node->filter('link')->text(), 33 | $node->filter('description')->text(), 34 | (new Crawler($node->filter('content\:encoded')->text()))->text(), 35 | $node->filter('dc\:creator')->text(), 36 | new \DateTimeImmutable($node->filter('pubDate')->text()), 37 | ); 38 | }); 39 | 40 | return $posts; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Blog/Post.php: -------------------------------------------------------------------------------- 1 | title} 26 | From: {$this->author} on {$this->date->format('Y-m-d')} 27 | Description: {$this->description} 28 | {$this->content} 29 | TEXT; 30 | } 31 | 32 | /** 33 | * @return array{ 34 | * id: string, 35 | * title: string, 36 | * link: string, 37 | * description: string, 38 | * content: string, 39 | * author: string, 40 | * date: string, 41 | * } 42 | */ 43 | public function toArray(): array 44 | { 45 | return [ 46 | 'id' => $this->id->toRfc4122(), 47 | 'title' => $this->title, 48 | 'link' => $this->link, 49 | 'description' => $this->description, 50 | 'content' => $this->content, 51 | 'author' => $this->author, 52 | 'date' => $this->date->format('Y-m-d'), 53 | ]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Blog/TwigComponent.php: -------------------------------------------------------------------------------- 1 | chat->loadMessages()->withoutSystemMessage()->getMessages(); 29 | } 30 | 31 | #[LiveAction] 32 | public function submit(#[LiveArg] string $message): void 33 | { 34 | $this->chat->submitMessage($message); 35 | } 36 | 37 | #[LiveAction] 38 | public function reset(): void 39 | { 40 | $this->chat->reset(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | platform->request(new GPT(GPT::GPT_4O_MINI), $messageBag, [ 46 | 'max_tokens' => 100, 47 | ]); 48 | 49 | assert($response instanceof AsyncResponse); 50 | $response = $response->unwrap(); 51 | assert($response instanceof TextResponse); 52 | 53 | $this->caption = $response->getContent(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Wikipedia/Chat.php: -------------------------------------------------------------------------------- 1 | requestStack->getSession()->get(self::SESSION_KEY, new MessageBag()); 28 | } 29 | 30 | public function submitMessage(string $message): void 31 | { 32 | $messages = $this->loadMessages(); 33 | 34 | $messages->add(Message::ofUser($message)); 35 | $response = $this->chain->call($messages); 36 | 37 | assert($response instanceof TextResponse); 38 | 39 | $messages->add(Message::ofAssistant($response->getContent())); 40 | 41 | $this->saveMessages($messages); 42 | } 43 | 44 | public function reset(): void 45 | { 46 | $this->requestStack->getSession()->remove(self::SESSION_KEY); 47 | } 48 | 49 | private function saveMessages(MessageBag $messages): void 50 | { 51 | $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Wikipedia/TwigComponent.php: -------------------------------------------------------------------------------- 1 | wikipedia->loadMessages()->withoutSystemMessage()->getMessages(); 29 | } 30 | 31 | #[LiveAction] 32 | public function submit(#[LiveArg] string $message): void 33 | { 34 | $this->wikipedia->submitMessage($message); 35 | } 36 | 37 | #[LiveAction] 38 | public function reset(): void 39 | { 40 | $this->wikipedia->reset(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/YouTube/Chat.php: -------------------------------------------------------------------------------- 1 | requestStack->getSession()->get(self::SESSION_KEY, new MessageBag()); 29 | } 30 | 31 | public function start(string $videoId): void 32 | { 33 | $transcript = $this->transcriptFetcher->fetchTranscript($videoId); 34 | $system = <<reset(); 49 | $this->saveMessages($messages); 50 | } 51 | 52 | public function submitMessage(string $message): void 53 | { 54 | $messages = $this->loadMessages(); 55 | 56 | $messages->add(Message::ofUser($message)); 57 | $response = $this->chain->call($messages); 58 | 59 | assert($response instanceof TextResponse); 60 | 61 | $messages->add(Message::ofAssistant($response->getContent())); 62 | 63 | $this->saveMessages($messages); 64 | } 65 | 66 | public function reset(): void 67 | { 68 | $this->requestStack->getSession()->remove(self::SESSION_KEY); 69 | } 70 | 71 | private function saveMessages(MessageBag $messages): void 72 | { 73 | $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/YouTube/TranscriptFetcher.php: -------------------------------------------------------------------------------- 1 | client->request('GET', 'https://youtube.com/watch?v='.$videoId); 21 | $html = $htmlResponse->getContent(); 22 | 23 | // Use DomCrawler to parse the HTML 24 | $crawler = new Crawler($html); 25 | 26 | // Extract the script containing the ytInitialPlayerResponse 27 | $scriptContent = $crawler->filter('script')->reduce(function (Crawler $node) { 28 | return str_contains($node->text(), 'var ytInitialPlayerResponse = {'); 29 | })->text(); 30 | 31 | // Extract and parse the JSON data from the script 32 | $start = strpos($scriptContent, 'var ytInitialPlayerResponse = ') + strlen('var ytInitialPlayerResponse = '); 33 | $dataString = substr($scriptContent, $start); 34 | $dataString = substr($dataString, 0, strrpos($dataString, ';') ?: null); 35 | $data = json_decode(trim($dataString), true); 36 | 37 | // Extract the URL for the captions 38 | if (!isset($data['captions']['playerCaptionsTracklistRenderer']['captionTracks'][0]['baseUrl'])) { 39 | throw new \Exception('Captions are not available for this video.'); 40 | } 41 | $captionsUrl = $data['captions']['playerCaptionsTracklistRenderer']['captionTracks'][0]['baseUrl']; 42 | 43 | // Fetch and parse the captions XML 44 | $xmlResponse = $this->client->request('GET', $captionsUrl); 45 | $xmlContent = $xmlResponse->getContent(); 46 | $xmlCrawler = new Crawler($xmlContent); 47 | 48 | // Collect all text elements from the captions 49 | $transcript = $xmlCrawler->filter('text')->each(function (Crawler $node) { 50 | return $node->text().' '; 51 | }); 52 | 53 | // Combine all the text elements into one string 54 | return implode(PHP_EOL, $transcript); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/YouTube/TwigComponent.php: -------------------------------------------------------------------------------- 1 | getVideoIdFromUrl($videoId); 32 | } 33 | 34 | try { 35 | $this->youTube->start($videoId); 36 | } catch (\Exception $e) { 37 | $this->logger->error('Unable to start YouTube chat.', ['exception' => $e]); 38 | $this->youTube->reset(); 39 | } 40 | } 41 | 42 | /** 43 | * @return MessageInterface[] 44 | */ 45 | public function getMessages(): array 46 | { 47 | return $this->youTube->loadMessages()->withoutSystemMessage()->getMessages(); 48 | } 49 | 50 | #[LiveAction] 51 | public function submit(#[LiveArg] string $message): void 52 | { 53 | $this->youTube->submitMessage($message); 54 | } 55 | 56 | #[LiveAction] 57 | public function reset(): void 58 | { 59 | $this->youTube->reset(); 60 | } 61 | 62 | private function getVideoIdFromUrl(string $url): string 63 | { 64 | $query = parse_url($url, PHP_URL_QUERY); 65 | 66 | if (!$query) { 67 | throw new \InvalidArgumentException('Unable to parse YouTube URL.'); 68 | } 69 | 70 | return u($query)->after('v=')->before('&')->toString(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "doctrine/deprecations": { 3 | "version": "1.1", 4 | "recipe": { 5 | "repo": "github.com/symfony/recipes", 6 | "branch": "main", 7 | "version": "1.0", 8 | "ref": "87424683adc81d7dc305eefec1fced883084aab9" 9 | } 10 | }, 11 | "php-cs-fixer/shim": { 12 | "version": "3.55", 13 | "recipe": { 14 | "repo": "github.com/symfony/recipes", 15 | "branch": "main", 16 | "version": "3.0", 17 | "ref": "16422bf8eac6c3be42afe07d37e2abc89d2bdf6b" 18 | }, 19 | "files": [ 20 | ".php-cs-fixer.dist.php" 21 | ] 22 | }, 23 | "php-llm/llm-chain-bundle": { 24 | "version": "dev-main" 25 | }, 26 | "phpstan/phpstan": { 27 | "version": "1.10", 28 | "recipe": { 29 | "repo": "github.com/symfony/recipes-contrib", 30 | "branch": "main", 31 | "version": "1.0", 32 | "ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767" 33 | }, 34 | "files": [ 35 | "phpstan.dist.neon" 36 | ] 37 | }, 38 | "phpunit/phpunit": { 39 | "version": "11.1", 40 | "recipe": { 41 | "repo": "github.com/symfony/recipes", 42 | "branch": "main", 43 | "version": "9.6", 44 | "ref": "7364a21d87e658eb363c5020c072ecfdc12e2326" 45 | }, 46 | "files": [ 47 | ".env.test", 48 | "phpunit.xml.dist", 49 | "tests/bootstrap.php" 50 | ] 51 | }, 52 | "symfony/asset-mapper": { 53 | "version": "7.1", 54 | "recipe": { 55 | "repo": "github.com/symfony/recipes", 56 | "branch": "main", 57 | "version": "6.4", 58 | "ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a" 59 | }, 60 | "files": [ 61 | "assets/app.js", 62 | "assets/styles/app.css", 63 | "config/packages/asset_mapper.yaml", 64 | "importmap.php" 65 | ] 66 | }, 67 | "symfony/console": { 68 | "version": "7.0", 69 | "recipe": { 70 | "repo": "github.com/symfony/recipes", 71 | "branch": "main", 72 | "version": "5.3", 73 | "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" 74 | }, 75 | "files": [ 76 | "bin/console" 77 | ] 78 | }, 79 | "symfony/debug-bundle": { 80 | "version": "7.0", 81 | "recipe": { 82 | "repo": "github.com/symfony/recipes", 83 | "branch": "main", 84 | "version": "5.3", 85 | "ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b" 86 | }, 87 | "files": [ 88 | "config/packages/debug.yaml" 89 | ] 90 | }, 91 | "symfony/flex": { 92 | "version": "2.4", 93 | "recipe": { 94 | "repo": "github.com/symfony/recipes", 95 | "branch": "main", 96 | "version": "1.0", 97 | "ref": "146251ae39e06a95be0fe3d13c807bcf3938b172" 98 | }, 99 | "files": [ 100 | ".env" 101 | ] 102 | }, 103 | "symfony/framework-bundle": { 104 | "version": "7.0", 105 | "recipe": { 106 | "repo": "github.com/symfony/recipes", 107 | "branch": "main", 108 | "version": "7.0", 109 | "ref": "6356c19b9ae08e7763e4ba2d9ae63043efc75db5" 110 | }, 111 | "files": [ 112 | "config/packages/cache.yaml", 113 | "config/packages/framework.yaml", 114 | "config/preload.php", 115 | "config/routes/framework.yaml", 116 | "config/services.yaml", 117 | "public/index.php", 118 | "src/Controller/.gitignore", 119 | "src/Kernel.php" 120 | ] 121 | }, 122 | "symfony/monolog-bundle": { 123 | "version": "3.10", 124 | "recipe": { 125 | "repo": "github.com/symfony/recipes", 126 | "branch": "main", 127 | "version": "3.7", 128 | "ref": "aff23899c4440dd995907613c1dd709b6f59503f" 129 | }, 130 | "files": [ 131 | "config/packages/monolog.yaml" 132 | ] 133 | }, 134 | "symfony/property-info": { 135 | "version": "7.3", 136 | "recipe": { 137 | "repo": "github.com/symfony/recipes", 138 | "branch": "main", 139 | "version": "7.3", 140 | "ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7" 141 | }, 142 | "files": [ 143 | "config/packages/property_info.yaml" 144 | ] 145 | }, 146 | "symfony/routing": { 147 | "version": "7.0", 148 | "recipe": { 149 | "repo": "github.com/symfony/recipes", 150 | "branch": "main", 151 | "version": "7.0", 152 | "ref": "21b72649d5622d8f7da329ffb5afb232a023619d" 153 | }, 154 | "files": [ 155 | "config/packages/routing.yaml", 156 | "config/routes.yaml" 157 | ] 158 | }, 159 | "symfony/stimulus-bundle": { 160 | "version": "2.17", 161 | "recipe": { 162 | "repo": "github.com/symfony/recipes", 163 | "branch": "main", 164 | "version": "2.13", 165 | "ref": "6acd9ff4f7fd5626d2962109bd4ebab351d43c43" 166 | }, 167 | "files": [ 168 | "assets/bootstrap.js", 169 | "assets/controllers.json", 170 | "assets/controllers/chat_controller.js" 171 | ] 172 | }, 173 | "symfony/twig-bundle": { 174 | "version": "7.0", 175 | "recipe": { 176 | "repo": "github.com/symfony/recipes", 177 | "branch": "main", 178 | "version": "6.4", 179 | "ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877" 180 | }, 181 | "files": [ 182 | "config/packages/twig.yaml", 183 | "templates/base.html.twig" 184 | ] 185 | }, 186 | "symfony/uid": { 187 | "version": "7.0", 188 | "recipe": { 189 | "repo": "github.com/symfony/recipes", 190 | "branch": "main", 191 | "version": "7.0", 192 | "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" 193 | } 194 | }, 195 | "symfony/ux-icons": { 196 | "version": "2.17", 197 | "recipe": { 198 | "repo": "github.com/symfony/recipes", 199 | "branch": "main", 200 | "version": "2.17", 201 | "ref": "803a3bbd5893f9584969ab8670290cdfb6a0a5b5" 202 | }, 203 | "files": [ 204 | "assets/icons/symfony.svg" 205 | ] 206 | }, 207 | "symfony/ux-live-component": { 208 | "version": "2.17", 209 | "recipe": { 210 | "repo": "github.com/symfony/recipes", 211 | "branch": "main", 212 | "version": "2.6", 213 | "ref": "73e69baf18f47740d6f58688c5464b10cdacae06" 214 | }, 215 | "files": [ 216 | "config/routes/ux_live_component.yaml" 217 | ] 218 | }, 219 | "symfony/ux-turbo": { 220 | "version": "v2.17.0" 221 | }, 222 | "symfony/ux-twig-component": { 223 | "version": "2.17", 224 | "recipe": { 225 | "repo": "github.com/symfony/recipes", 226 | "branch": "main", 227 | "version": "2.13", 228 | "ref": "67814b5f9794798b885cec9d3f48631424449a01" 229 | }, 230 | "files": [ 231 | "config/packages/twig_component.yaml" 232 | ] 233 | }, 234 | "symfony/ux-typed": { 235 | "version": "v2.22.0" 236 | }, 237 | "symfony/web-profiler-bundle": { 238 | "version": "7.0", 239 | "recipe": { 240 | "repo": "github.com/symfony/recipes", 241 | "branch": "main", 242 | "version": "6.1", 243 | "ref": "e42b3f0177df239add25373083a564e5ead4e13a" 244 | }, 245 | "files": [ 246 | "config/packages/web_profiler.yaml", 247 | "config/routes/web_profiler.yaml" 248 | ] 249 | }, 250 | "twig/extra-bundle": { 251 | "version": "v3.9.3" 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /templates/_message.html.twig: -------------------------------------------------------------------------------- 1 | {% if message.role.value == 'assistant' %} 2 | {{ _self.bot(message.content, latest: latest) }} 3 | {% else %} 4 | {{ _self.user(message.content) }} 5 | {% endif %} 6 | 7 | {% macro bot(content, loading = false, latest = false) %} 8 |
9 |
10 | {{ ux_icon('fluent:bot-24-filled', { height: '45px', width: '45px' }) }} 11 |
12 |
13 | {% if loading %} 14 |
15 | 16 | {{ content }} 17 |
18 | {% else %} 19 |
20 | {% if latest and app.request.xmlHttpRequest %} 21 | 27 | {% else %} 28 | {{ content|markdown_to_html }} 29 | {% endif %} 30 |
31 | {% endif %} 32 |
33 |
34 | {% endmacro %} 35 | 36 | {% macro user(content, loading = false) %} 37 |
38 |
39 | {% for item in content %} 40 |
41 | {% if loading %} 42 | {{ item.text }} 43 | {% else %} 44 | {{ item.text }} 45 | {% endif %} 46 |
47 | {% endfor %} 48 |
49 |
50 | {{ ux_icon('solar:user-bold', { width: '45px', height: '45px' }) }} 51 |
52 |
53 | {% endmacro %} 54 | -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}LLM Chain Demo{% endblock %} 6 | 7 | 8 | 9 | {% block stylesheets %} 10 | {% endblock %} 11 | 12 | {% block javascripts %} 13 | {% block importmap %}{{ importmap('app') }}{% endblock %} 14 | {% endblock %} 15 | 16 | 17 | 48 |
49 | {% block content %}{% endblock %} 50 |
51 | Symfony demo application for LLM Chain 52 | • 53 | Feel free to propose more examples on GitHub 54 |
55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /templates/chat.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body_class 'chat ' ~ chat %} 4 | 5 | {% block content %} 6 |
7 | {{ component(chat) }} 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /templates/components/audio.html.twig: -------------------------------------------------------------------------------- 1 | {% import "_message.html.twig" as message %} 2 | 3 |
4 |
5 | {{ ux_icon('iconoir:microphone-solid', { height: '32px', width: '32px' }) }} 6 | Conversational Bot 7 | 8 |
9 |
10 | {% for message in this.messages %} 11 | {% include '_message.html.twig' with { message, latest: loop.last } %} 12 | {% else %} 13 |
14 | {{ ux_icon('iconoir:microphone-solid', { height: '200px', width: '200px' }) }} 15 |

Audio Bot

16 | Please hit the button below to start talking and again to stop 17 |
18 | {% endfor %} 19 |
20 | {{ message.user([{text:'Converting your speech to text ...'}], true) }} 21 | {{ message.bot('The Bot is looking for an answer ...', true) }} 22 |
23 |
24 | 38 |
39 | -------------------------------------------------------------------------------- /templates/components/blog.html.twig: -------------------------------------------------------------------------------- 1 | {% import "_message.html.twig" as message %} 2 | 3 |
4 |
5 | {{ ux_icon('mdi:symfony', { height: '32px', width: '32px' }) }} 6 | Symfony Blog Bot 7 | 8 |
9 |
10 | {% for message in this.messages %} 11 | {% include '_message.html.twig' with { message, latest: loop.last } %} 12 | {% else %} 13 |
14 | {{ ux_icon('mdi:symfony', { height: '200px', width: '200px' }) }} 15 |

Retrieval Augmented Generation based on the Symfony blog

16 | Please use the text input at the bottom to start chatting. 17 |
18 | {% endfor %} 19 |
20 | {{ message.user([{text:''}]) }} 21 | {{ message.bot('The Symfony Bot is looking for an answer ...', true) }} 22 |
23 |
24 | 30 |
31 | -------------------------------------------------------------------------------- /templates/components/video.html.twig: -------------------------------------------------------------------------------- 1 | {% import "_message.html.twig" as message %} 2 | 3 |
4 |
5 | {{ ux_icon('tabler:video-filled', { height: '32px', width: '32px' }) }} 6 | Video Bot 7 |
8 |
9 |
10 |
11 | 12 |
13 | 14 | {{ this.caption }} 15 |
16 |
17 | 23 |
24 | -------------------------------------------------------------------------------- /templates/components/wikipedia.html.twig: -------------------------------------------------------------------------------- 1 | {% import "_message.html.twig" as message %} 2 | 3 |
4 |
5 | {{ ux_icon('mdi:wikipedia', { height: '32px', width: '32px' }) }} 6 | Wikipedia Research Bot 7 | 8 |
9 |
10 | {% for message in this.messages %} 11 | {% include '_message.html.twig' with { message, latest: loop.last } %} 12 | {% else %} 13 |
14 | {{ ux_icon('mdi:wikipedia', { height: '200px', width: '200px' }) }} 15 |

Wikipedia Research

16 | Please provide the bot with a topic down below to start the research. 17 |
18 | {% endfor %} 19 |
20 | {{ message.user([{text:''}]) }} 21 | {{ message.bot('The Wikipedia Bot is doing some research ...', true) }} 22 |
23 |
24 | 30 |
31 | -------------------------------------------------------------------------------- /templates/components/youtube.html.twig: -------------------------------------------------------------------------------- 1 | {% import "_message.html.twig" as message %} 2 | 3 |
4 |
5 | {{ ux_icon('bi:youtube', { height: '32px', width: '32px' }) }} 6 | YouTube Transcript Bot 7 | 8 |
9 |
10 | {% set messages = this.messages %} 11 | {% for message in messages %} 12 | {% include '_message.html.twig' with { message, latest: loop.last } %} 13 | {% else %} 14 |
15 | {{ ux_icon('bi:youtube', { color: '#FF0000', height: '200px', width: '200px' }) }} 16 |

Chat about a YouTube Video

17 |
18 | 19 |
20 | https://youtube.com/watch?v= 21 | 22 | 23 |
24 |
25 |
26 | {% endfor %} 27 |
28 | {{ message.user([{text:''}]) }} 29 | {{ message.bot('The Youtube Bot is looking for an answer ...', true) }} 30 |
31 |
32 | 38 |
39 | -------------------------------------------------------------------------------- /templates/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body_class 'index' %} 4 | 5 | {% block content %} 6 |
7 |

Welcome to the LLM Chain Demo

8 |

9 | This is a small demo app that can be used to explore the capabilities of LLM Chain together with Symfony, 10 | Symfony UX and Twig Live Components.
11 | Central to this demo are three chatbot examples that are implemented in src/**/Chat.php and LLM Chain 12 | configuration can be found in config/packages/llm_chain.yaml. 13 |

14 |

Examples

15 |
16 |
17 |
18 |
19 | {{ ux_icon('mdi:symfony', { height: '150px', width: '150px' }) }} 20 |
21 |
22 |
Symfony Blog Bot
23 |

Retrieval Augmented Generation (RAG) based on Symfony's blog dumped to a vector store.

24 | Try Symfony Blog Bot 25 |
26 | {# Profiler route only available in dev #} 27 | {% if 'dev' == app.environment %} 28 | 32 | {% endif %} 33 |
34 |
35 |
36 |
37 |
38 | {{ ux_icon('bi:youtube', { height: '150px', width: '150px' }) }} 39 |
40 |
41 |
YouTube Transcript Bot
42 |

Question answering started with a YouTube video ID which gets converted into a transcript.

43 | Try YouTube Transcript Bot 44 |
45 | {# Profiler route only available in dev #} 46 | {% if 'dev' == app.environment %} 47 | 51 | {% endif %} 52 |
53 |
54 |
55 |
56 |
57 | {{ ux_icon('mdi:wikipedia', { height: '150px', width: '150px' }) }} 58 |
59 |
60 |
Wikipedia Research Bot
61 |

A chatbot equipped with tools to search and read on Wikipedia about topics the user asks for.

62 | Try Wikipedia Research Bot 63 |
64 | {# Profiler route only available in dev #} 65 | {% if 'dev' == app.environment %} 66 | 70 | {% endif %} 71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | {{ ux_icon('iconoir:microphone-solid', { height: '150px', width: '150px' }) }} 80 |
81 |
82 |
Audio Bot
83 |

Simple demonstration of speech-to-text with Whisper in combination with GPT.

84 | Try Audio Bot 85 |
86 | {# Profiler route only available in dev #} 87 | {% if 'dev' == app.environment %} 88 | 92 | {% endif %} 93 |
94 |
95 |
96 |
97 |
98 | {{ ux_icon('tabler:video-filled', { height: '150px', width: '150px' }) }} 99 |
100 |
101 |
Video Bot
102 |

Simple demonstration of vision capabilities of GPT in combination with your webcam.

103 | Try Video Bot 104 |
105 | {# Profiler route only available in dev #} 106 | {% if 'dev' == app.environment %} 107 | 111 | {% endif %} 112 |
113 |
114 |
115 |
116 |
117 | {% endblock %} 118 | -------------------------------------------------------------------------------- /tests/Blog/LoaderTest.php: -------------------------------------------------------------------------------- 1 | load(); 26 | 27 | self::assertCount(10, $posts); 28 | 29 | self::assertSame('A Week of Symfony #936 (2-8 December 2024)', $posts[0]->title); 30 | self::assertSame('https://symfony.com/blog/a-week-of-symfony-936-2-8-december-2024?utm_source=Symfony%20Blog%20Feed&utm_medium=feed', $posts[0]->link); 31 | self::assertStringContainsString('This week, Symfony celebrated the SymfonyCon 2024 Vienna conference with great success.', $posts[0]->description); 32 | self::assertStringContainsString('Select a track for a guided path through 100+ video tutorial courses about Symfony', $posts[0]->content); 33 | self::assertSame('Javier Eguiluz', $posts[0]->author); 34 | self::assertEquals(new \DateTimeImmutable('8.12.2024 09:39:00 +0100'), $posts[0]->date); 35 | 36 | self::assertSame('A Week of Symfony #935 (25 November - 1 December 2024)', $posts[1]->title); 37 | self::assertSame('Symfony 7.2 curated new features', $posts[2]->title); 38 | self::assertSame('Symfony 7.2.0 released', $posts[3]->title); 39 | self::assertSame('Symfony 5.4.49 released', $posts[4]->title); 40 | self::assertSame('SymfonyCon Vienna 2024: See you next week!', $posts[5]->title); 41 | self::assertSame('New in Symfony 7.2: Misc. Improvements (Part 2)', $posts[6]->title); 42 | self::assertSame('Symfony 7.1.9 released', $posts[7]->title); 43 | self::assertSame('Symfony 6.4.16 released', $posts[8]->title); 44 | self::assertSame('Symfony 5.4.48 released', $posts[9]->title); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Blog/PostTest.php: -------------------------------------------------------------------------------- 1 | toString()); 35 | } 36 | 37 | public function testPostToArray(): void 38 | { 39 | $id = Uuid::v4(); 40 | $post = new Post( 41 | $id, 42 | 'Hello, World!', 43 | 'https://example.com/hello-world', 44 | 'This is a test description.', 45 | 'This is a test post.', 46 | 'John Doe', 47 | new \DateTimeImmutable('2024-12-08 09:39:00'), 48 | ); 49 | 50 | $expected = [ 51 | 'id' => $id->toRfc4122(), 52 | 'title' => 'Hello, World!', 53 | 'link' => 'https://example.com/hello-world', 54 | 'description' => 'This is a test description.', 55 | 'content' => 'This is a test post.', 56 | 'author' => 'John Doe', 57 | 'date' => '2024-12-08', 58 | ]; 59 | 60 | self::assertSame($expected, $post->toArray()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/SmokeTest.php: -------------------------------------------------------------------------------- 1 | request('GET', '/'); 21 | 22 | self::assertResponseIsSuccessful(); 23 | self::assertSelectorTextSame('h1', 'Welcome to the LLM Chain Demo'); 24 | self::assertSelectorCount(5, '.card'); 25 | } 26 | 27 | #[DataProvider('provideChats')] 28 | public function testChats(string $path, string $expectedHeadline): void 29 | { 30 | $client = static::createClient(); 31 | $client->request('GET', $path); 32 | 33 | self::assertResponseIsSuccessful(); 34 | self::assertSelectorTextSame('h4', $expectedHeadline); 35 | self::assertSelectorCount(1, '#chat-submit'); 36 | } 37 | 38 | /** 39 | * @return iterable 40 | */ 41 | public static function provideChats(): iterable 42 | { 43 | yield 'Blog' => ['/blog', 'Retrieval Augmented Generation based on the Symfony blog']; 44 | yield 'YouTube' => ['/youtube', 'Chat about a YouTube Video']; 45 | yield 'Wikipedia' => ['/wikipedia', 'Wikipedia Research']; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/YouTube/TranscriptFetcherTest.php: -------------------------------------------------------------------------------- 1 | fetchTranscript('6uXW-ulpj0s'); 24 | 25 | self::assertStringContainsString('symphony is a PHP framework', $transcript); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/YouTube/fixtures/transcript.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | symphony is a PHP framework and this is 4 | an advantage why because almost 80 5 | percent of all websites in the internet 6 | use PHP as a server-side language eighty 7 | percent this does mean that wherever you 8 | are right now there is someone near you 9 | probably searching to hire a PHP 10 | developer Symphony will not only teach 11 | you more PHP but also will teach you 12 | different software architecture patterns 13 | that you can use in different languages 14 | software architecture patterns are so 15 | much important in your skill sets 16 | because they allow you to understand 17 | complex software when you only know the 18 | architecture that was used in that 19 | software please let me tell you a story 20 | that happened to me I had a PHP laravel 21 | interview in a company and the interview 22 | was successful so they invite me for a 23 | test work day and that test work day 24 | they asked me to build an application 25 | using.net and Seashore they told me we 26 | know you didn&#39;t had any previous 27 | experience using C sharp but we want to 28 | see how you can get along with different 29 | programming language I was able to 30 | Google and found out that the dotnet 31 | framework is an MVC architect framework 32 | the same framework is used in Symphony 33 | and Bam I built the application in 34 | c-sharp and I got a wonderful job offer 35 | so yes learning Symphony will teach you 36 | software architecture patterns that are 37 | essential in your skill set symphony is 38 | a full stack framework that mean you can 39 | create deploy ready application you will 40 | be using front-end Technologies like 41 | HTML CSS and JavaScript and back-end or 42 | server Technologies like PHP databases 43 | that will process the user request all 44 | in one place Symphony can integrate 45 | easily with modern JavaScript Frameworks 46 | like vue.js or react.js or even you can 47 | set up different databases like MySQL 48 | postgres or whatever you want Symphony 49 | has a CLI tool that can help build and 50 | speak and debug your application and is 51 | one of the most advanced code generation 52 | tool in the planner is relate in the 53 | comment if you know anything that is 54 | good finally documentation Symphony has 55 | good documentation that will make it 56 | easy for newcomers to learn and have fun 57 | with the technology so that was my 6y2 58 | simple normally I don&#39;t do this but 59 | right now go and check the description 60 | see the comment and write me what you 61 | think like the video and subscribe to my 62 | channel then go to the channel check the 63 | videos and like each one of them and 64 | comment again and come back to this 65 | video and watch it again thank you 66 | 67 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 14 | } 15 | --------------------------------------------------------------------------------