diff --git a/assets/app.js b/assets/app.js index 82c28fd..d76b7c8 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,6 +1,7 @@ import './bootstrap.js'; import 'bootstrap/dist/css/bootstrap.min.css'; import './styles/app.css'; +import './styles/audio.css'; import './styles/blog.css'; import './styles/youtube.css'; import './styles/wikipedia.css'; diff --git a/assets/controllers/audio_controller.js b/assets/controllers/audio_controller.js new file mode 100644 index 0000000..20f272a --- /dev/null +++ b/assets/controllers/audio_controller.js @@ -0,0 +1,89 @@ +import { Controller } from '@hotwired/stimulus'; +import { getComponent } from '@symfony/ux-live-component'; + +export default class extends Controller { + async initialize() { + this.component = await getComponent(this.element); + this.scrollToBottom(); + + const resetButton = document.getElementById('chat-reset'); + resetButton.addEventListener('click', (event) => { + this.component.action('reset'); + }); + + const startButton = document.getElementById('micro-start'); + const stopButton = document.getElementById('micro-stop'); + const botThinkingButton = document.getElementById('bot-thinking'); + + startButton.addEventListener('click', (event) => { + event.preventDefault(); + startButton.classList.add('d-none'); + stopButton.classList.remove('d-none'); + this.startRecording(); + }); + stopButton.addEventListener('click', (event) => { + event.preventDefault(); + stopButton.classList.add('d-none'); + botThinkingButton.classList.remove('d-none'); + this.mediaRecorder.stop(); + }); + + this.component.on('loading.state:started', (e,r) => { + if (r.actions.includes('reset')) { + return; + } + document.getElementById('welcome')?.remove(); + document.getElementById('loading-message').removeAttribute('class'); + this.scrollToBottom(); + }); + + this.component.on('loading.state:finished', () => { + document.getElementById('loading-message').setAttribute('class', 'd-none'); + botThinkingButton.classList.add('d-none'); + startButton.classList.remove('d-none'); + }); + + this.component.on('render:finished', () => { + this.scrollToBottom(); + }); + }; + + async startRecording() { + const stream = await navigator.mediaDevices.getUserMedia({audio: true}); + this.mediaRecorder = new MediaRecorder(stream); + let audioChunks = []; + + this.mediaRecorder.ondataavailable = (event) => { + audioChunks.push(event.data); + }; + + this.mediaRecorder.onstop = async () => { + const audioBlob = new Blob(audioChunks, {type: 'audio/wav'}); + + const base64String = await this.blobToBase64(audioBlob); + this.component.action('submit', { audio: base64String }); + }; + + this.mediaRecorder.start(); + } + + scrollToBottom() { + const chatBody = document.getElementById('chat-body'); + chatBody.scrollTop = chatBody.scrollHeight; + } + + blobToBase64(blob) { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => resolve(reader.result.split(',')[1]); + }); + } + + playBase64Audio(base64String) { + const audioSrc = "data:audio/wav;base64," + base64String; + const audio = new Audio(audioSrc); + + audio.play().catch(error => console.error("Playback error:", error)); + } +} diff --git a/assets/icons/iconoir/microphone-mute-solid.svg b/assets/icons/iconoir/microphone-mute-solid.svg new file mode 100644 index 0000000..3d6f673 --- /dev/null +++ b/assets/icons/iconoir/microphone-mute-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/iconoir/microphone-solid.svg b/assets/icons/iconoir/microphone-solid.svg new file mode 100644 index 0000000..32c72a8 --- /dev/null +++ b/assets/icons/iconoir/microphone-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/iconoir/timer-solid.svg b/assets/icons/iconoir/timer-solid.svg new file mode 100644 index 0000000..7a79500 --- /dev/null +++ b/assets/icons/iconoir/timer-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/styles/audio.css b/assets/styles/audio.css new file mode 100644 index 0000000..3707577 --- /dev/null +++ b/assets/styles/audio.css @@ -0,0 +1,61 @@ +.audio { + body&, .card-img-top { + background: #df662f; + background: linear-gradient(0deg, #df662f 0%, #a80a1d 100%); + } + + .card-img-top { + color: #ffffff; + } + + &.chat { + .user-message { + background: #df662f; + color: #ffffff; + } + + .bot-message { + color: #ffffff; + background: #215d9a; + + &.loading { + color: rgba(255, 255, 255, 0.5); + } + + a { + color: #c8d8ef; + + &:hover { + color: #ffffff; + } + } + + code { + color: #ffb1ca; + } + } + + .avatar { + &.bot { + outline: 1px solid #c0dbf4; + background: #c0dbf4; + } + + &.user { + outline: 1px solid #f3b396; + background: #f3b396; + } + } + + #welcome h4 { + color: #2c5282; + } + + #chat-reset, #chat-submit { + &:hover { + background: #a80a1d; + border-color: #a80a1d; + } + } + } +} diff --git a/composer.json b/composer.json index ae36435..b81ef3a 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "ext-iconv": "*", "codewithkyrian/chromadb-php": "^0.3.0", "league/commonmark": "^2.6", - "php-llm/llm-chain-bundle": "^0.17", + "php-llm/llm-chain-bundle": "^0.18", "runtime/frankenphp-symfony": "^0.2.0", "symfony/asset": "7.2.*", "symfony/asset-mapper": "7.2.*", diff --git a/composer.lock b/composer.lock index 247b610..96982ee 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1e758ae89d7b6866084eae0528ab851b", + "content-hash": "a22ceb12255d2617d5c64f93df0ee8b8", "packages": [ { "name": "codewithkyrian/chromadb-php", @@ -1146,16 +1146,16 @@ }, { "name": "php-llm/llm-chain", - "version": "0.17.0", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/php-llm/llm-chain.git", - "reference": "6e374148d0e31240ac57a679ab889d95ac629389" + "reference": "6a5dbe767ed05512cd82cf7cab2d2f14aed5ef3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-llm/llm-chain/zipball/6e374148d0e31240ac57a679ab889d95ac629389", - "reference": "6e374148d0e31240ac57a679ab889d95ac629389", + "url": "https://api.github.com/repos/php-llm/llm-chain/zipball/6a5dbe767ed05512cd82cf7cab2d2f14aed5ef3c", + "reference": "6a5dbe767ed05512cd82cf7cab2d2f14aed5ef3c", "shasum": "" }, "require": { @@ -1225,27 +1225,27 @@ "description": "A slim PHP component with tooling around LLMs.", "support": { "issues": "https://github.com/php-llm/llm-chain/issues", - "source": "https://github.com/php-llm/llm-chain/tree/0.17.0" + "source": "https://github.com/php-llm/llm-chain/tree/0.18.0" }, - "time": "2025-03-07T09:29:46+00:00" + "time": "2025-03-13T23:29:22+00:00" }, { "name": "php-llm/llm-chain-bundle", - "version": "0.17.0", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/php-llm/llm-chain-bundle.git", - "reference": "48e18502b7a90ee9e83db8e0a90adf7a11d93d04" + "reference": "53fdc4eb706501a921322aa717b05d28166ec874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-llm/llm-chain-bundle/zipball/48e18502b7a90ee9e83db8e0a90adf7a11d93d04", - "reference": "48e18502b7a90ee9e83db8e0a90adf7a11d93d04", + "url": "https://api.github.com/repos/php-llm/llm-chain-bundle/zipball/53fdc4eb706501a921322aa717b05d28166ec874", + "reference": "53fdc4eb706501a921322aa717b05d28166ec874", "shasum": "" }, "require": { "php": ">=8.2", - "php-llm/llm-chain": "^0.17", + "php-llm/llm-chain": "^0.18", "symfony/config": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", "symfony/framework-bundle": "^6.4 || ^7.0", @@ -1280,9 +1280,9 @@ "description": "Symfony integration bundle for php-llm/llm-chain", "support": { "issues": "https://github.com/php-llm/llm-chain-bundle/issues", - "source": "https://github.com/php-llm/llm-chain-bundle/tree/0.17.0" + "source": "https://github.com/php-llm/llm-chain-bundle/tree/0.18.0" }, - "time": "2025-03-07T10:14:27+00:00" + "time": "2025-03-13T23:47:22+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -6591,16 +6591,16 @@ }, { "name": "php-cs-fixer/shim", - "version": "v3.70.2", + "version": "v3.72.0", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "ff041542719ad3be54bd34647d0fd5bc7900115e" + "reference": "f5131b7a7d0103919a1b478a2763bd76c98e472e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/ff041542719ad3be54bd34647d0fd5bc7900115e", - "reference": "ff041542719ad3be54bd34647d0fd5bc7900115e", + "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/f5131b7a7d0103919a1b478a2763bd76c98e472e", + "reference": "f5131b7a7d0103919a1b478a2763bd76c98e472e", "shasum": "" }, "require": { @@ -6637,22 +6637,22 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/PHP-CS-Fixer/shim/issues", - "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.70.2" + "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.72.0" }, - "time": "2025-03-03T21:07:46+00:00" + "time": "2025-03-13T11:26:00+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.7", + "version": "2.1.8", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "12567f91a74036d56ba0af6d56c8e73ac0e8d850" + "reference": "f9adff3b87c03b12cc7e46a30a524648e497758f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/12567f91a74036d56ba0af6d56c8e73ac0e8d850", - "reference": "12567f91a74036d56ba0af6d56c8e73ac0e8d850", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f9adff3b87c03b12cc7e46a30a524648e497758f", + "reference": "f9adff3b87c03b12cc7e46a30a524648e497758f", "shasum": "" }, "require": { @@ -6697,7 +6697,7 @@ "type": "github" } ], - "time": "2025-03-05T13:43:55+00:00" + "time": "2025-03-09T09:30:48+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/config/packages/llm_chain.yaml b/config/packages/llm_chain.yaml index 149c13c..ad0d4cb 100644 --- a/config/packages/llm_chain.yaml +++ b/config/packages/llm_chain.yaml @@ -37,6 +37,12 @@ llm_chain: system_prompt: 'Please answer the users question based on Wikipedia and provide a link to the article.' tools: - 'PhpLlm\LlmChain\Chain\ToolBox\Tool\Wikipedia' + audio: + model: + name: 'GPT' + version: 'gpt-4o-mini' + system_prompt: 'You are a friendly chatbot that likes to have a conversation with users and asks them some questions.' + tools: false store: chroma_db: symfonycon: diff --git a/config/routes.yaml b/config/routes.yaml index 0da2b06..8cf42f8 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -11,6 +11,13 @@ blog: template: 'chat.html.twig' context: { chat: 'blog' } +audio: + path: '/audio' + controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController' + defaults: + template: 'chat.html.twig' + context: { chat: 'audio' } + youtube: path: '/youtube' controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController' diff --git a/demo.png b/demo.png index 703b245..d62462c 100644 Binary files a/demo.png and b/demo.png differ diff --git a/src/Audio/Chat.php b/src/Audio/Chat.php new file mode 100644 index 0000000..16bb4bb --- /dev/null +++ b/src/Audio/Chat.php @@ -0,0 +1,72 @@ +platform->request(new Whisper(), new File($path)); + assert($response instanceof AsyncResponse); + $response = $response->unwrap(); + assert($response instanceof TextResponse); + + $this->submitMessage($response->getContent()); + } + + public function loadMessages(): MessageBag + { + return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag()); + } + + public function submitMessage(string $message): void + { + $messages = $this->loadMessages(); + + $messages->add(Message::ofUser($message)); + $response = $this->chain->call($messages); + + assert($response instanceof TextResponse); + + $messages->add(Message::ofAssistant($response->getContent())); + + $this->saveMessages($messages); + } + + public function reset(): void + { + $this->requestStack->getSession()->remove(self::SESSION_KEY); + } + + private function saveMessages(MessageBag $messages): void + { + $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); + } +} diff --git a/src/Audio/TwigComponent.php b/src/Audio/TwigComponent.php new file mode 100644 index 0000000..e607437 --- /dev/null +++ b/src/Audio/TwigComponent.php @@ -0,0 +1,42 @@ +chat->loadMessages()->withoutSystemMessage()->getMessages(); + } + + #[LiveAction] + public function submit(#[LiveArg] string $audio): void + { + $this->chat->say($audio); + } + + #[LiveAction] + public function reset(): void + { + $this->chat->reset(); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 3770655..a1a49a7 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -31,6 +31,9 @@ +