Add demo for structured output about generating recipes
1
ai.symfony.com/assets/icons/mdi/cook.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12.5 1.5c-1.77 0-3.33 1.17-3.83 2.87C8.14 4.13 7.58 4 7 4a4 4 0 0 0-4 4a4.01 4.01 0 0 0 3 3.87V19h13v-7.13c1.76-.46 3-2.05 3-3.87a4 4 0 0 0-4-4c-.58 0-1.14.13-1.67.37c-.5-1.7-2.06-2.87-3.83-2.87m-.5 9h1v7h-1zm-3 2h1v5H9zm6 0h1v5h-1zM6 20v1a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-1z"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -215,13 +215,14 @@ button#themeToggle {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.demo-blog .demo-icon { background: linear-gradient(180deg, #433F77, #C43BC2); }
|
||||
.demo-youtube .demo-icon { background: linear-gradient(180deg, #C05920, #CF781A); }
|
||||
.demo-wikipedia .demo-icon { background: linear-gradient(180deg, #1CA574, #56AB48); }
|
||||
.demo-crop .demo-icon { background: linear-gradient(180deg, #85A72B, #97BC43); }
|
||||
.demo-audio .demo-icon { background: linear-gradient(180deg, #42DEEE, #7069B0); }
|
||||
.demo-video .demo-icon { background: linear-gradient(180deg, #3B9D87, #35A781); }
|
||||
.demo-blog .demo-icon { background: linear-gradient(180deg, #433F77, #C43BC2); }
|
||||
.demo-crop .demo-icon { background: linear-gradient(180deg, #85A72B, #97BC43); }
|
||||
.demo-recipe .demo-icon { background: linear-gradient(180deg, #83A659, #71BCB8); }
|
||||
.demo-turbo .demo-icon { background: linear-gradient(180deg, #E94E77, #D68189); }
|
||||
.demo-video .demo-icon { background: linear-gradient(180deg, #3B9D87, #35A781); }
|
||||
.demo-wikipedia .demo-icon { background: linear-gradient(180deg, #1CA574, #56AB48); }
|
||||
.demo-youtube .demo-icon { background: linear-gradient(180deg, #C05920, #CF781A); }
|
||||
|
||||
.footer-meta {
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -168,28 +168,6 @@
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<article class="demo-blog sf-ai-card sf-ai-card-hover h-100">
|
||||
<div class="position-relative card-body p-3 p-lg-4 d-flex gap-3 align-items-start">
|
||||
<div class="demo-icon bg-primary bg-opacity-10 text-primary flex-shrink-0">
|
||||
{{ ux_icon('logos:symfony-letters', {width: 36, height: 36}) }}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-start gap-2 mb-1">
|
||||
<h3 class="h5 ff-title fw-bold mb-0">
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Symfony Blog Bot</a>
|
||||
</h3>
|
||||
{{ ux_icon('tabler:arrow-right', {width: 24, height: 24, class: 'ms-1'}) }}
|
||||
</div>
|
||||
<p class="text-balance mb-0 text-muted small">
|
||||
Retrieval Augmented Generation (RAG) based on Symfony's blog dumped to a
|
||||
vector store.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<article class="demo-youtube sf-ai-card sf-ai-card-hover h-100">
|
||||
<div class="position-relative card-body p-3 p-lg-4 d-flex gap-3 align-items-start">
|
||||
@@ -212,6 +190,27 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<article class="demo-recipe sf-ai-card sf-ai-card-hover h-100">
|
||||
<div class="position-relative card-body p-3 p-lg-4 d-flex gap-3 align-items-start">
|
||||
<div class="demo-icon bg-warning bg-opacity-10 text-warning flex-shrink-0">
|
||||
{{ ux_icon('mdi:cook', {width: 42, height: 42}) }}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-start gap-2 mb-1">
|
||||
<h3 class="h5 ff-title fw-bold mb-0">
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Recipe Bot</a>
|
||||
</h3>
|
||||
{{ ux_icon('tabler:arrow-right', {width: 24, height: 24, class: 'ms-1'}) }}
|
||||
</div>
|
||||
<p class="text-balance mb-0 text-muted small">
|
||||
Chatbot for proposing cooking recipes - powered by structured output.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<article class="demo-wikipedia sf-ai-card sf-ai-card-hover h-100">
|
||||
<div class="position-relative card-body p-3 p-lg-4 d-flex gap-3 align-items-start">
|
||||
@@ -235,20 +234,21 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<article class="demo-crop sf-ai-card sf-ai-card-hover h-100">
|
||||
<article class="demo-blog sf-ai-card sf-ai-card-hover h-100">
|
||||
<div class="position-relative card-body p-3 p-lg-4 d-flex gap-3 align-items-start">
|
||||
<div class="demo-icon bg-success bg-opacity-10 text-success flex-shrink-0">
|
||||
{{ ux_icon('tabler:crop', {width: 42, height: 42}) }}
|
||||
<div class="demo-icon bg-primary bg-opacity-10 text-primary flex-shrink-0">
|
||||
{{ ux_icon('logos:symfony-letters', {width: 36, height: 36}) }}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-start gap-2 mb-1">
|
||||
<h3 class="h5 ff-title fw-bold mb-0">
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Smart Cropping</a>
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Symfony Blog Bot</a>
|
||||
</h3>
|
||||
{{ ux_icon('tabler:arrow-right', {width: 24, height: 24, class: 'ms-1'}) }}
|
||||
</div>
|
||||
<p class="text-balance mb-0 text-muted small">
|
||||
AI-assisted image cropping to focus on key elements on the image while resizing.
|
||||
Retrieval Augmented Generation (RAG) based on Symfony's blog dumped to a
|
||||
vector store.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,13 +264,13 @@
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-start gap-2 mb-1">
|
||||
<h3 class="h5 ff-title fw-bold mb-0">
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Audio Bot</a>
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Audio Bot + Subagent</a>
|
||||
</h3>
|
||||
{{ ux_icon('tabler:arrow-right', {width: 24, height: 24, class: 'ms-1'}) }}
|
||||
</div>
|
||||
<p class="text-balance mb-0 text-muted small">
|
||||
Simple demonstration of speech to text with Whisper in combination with
|
||||
GPT.
|
||||
Demonstration of speech-to-text & text-to-speech and a subagent, combining 4
|
||||
models in total.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,8 +291,28 @@
|
||||
{{ ux_icon('tabler:arrow-right', {width: 24, height: 24, class: 'ms-1'}) }}
|
||||
</div>
|
||||
<p class="text-balance mb-0 text-muted small">
|
||||
Simple demonstration of vision capabilities of GPT in combination with
|
||||
your webcam.
|
||||
Demonstration of vision capabilities of GPT in combination with your webcam.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<article class="demo-crop sf-ai-card sf-ai-card-hover h-100">
|
||||
<div class="position-relative card-body p-3 p-lg-4 d-flex gap-3 align-items-start">
|
||||
<div class="demo-icon bg-success bg-opacity-10 text-success flex-shrink-0">
|
||||
{{ ux_icon('tabler:crop', {width: 42, height: 42}) }}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-start gap-2 mb-1">
|
||||
<h3 class="h5 ff-title fw-bold mb-0">
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Smart Image Cropping</a>
|
||||
</h3>
|
||||
{{ ux_icon('tabler:arrow-right', {width: 24, height: 24, class: 'ms-1'}) }}
|
||||
</div>
|
||||
<p class="text-balance mb-0 text-muted small">
|
||||
AI-assisted image cropping to focus on key elements on the image while resizing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,12 +328,13 @@
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-start gap-2 mb-1">
|
||||
<h3 class="h5 ff-title fw-bold mb-0">
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Turbo stream Bot</a>
|
||||
<a href="https://github.com/symfony/ai-demo" class="stretched-link">Turbo Stream Bot</a>
|
||||
</h3>
|
||||
{{ ux_icon('tabler:arrow-right', {width: 24, height: 24, class: 'ms-1'}) }}
|
||||
</div>
|
||||
<p class="text-balance mb-0 text-muted small">
|
||||
Simple demonstration of text streaming capabilities.
|
||||
Demonstration of streaming text responses in combination with markdown based on
|
||||
Turbo and SSE.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import './styles/app.css';
|
||||
import './styles/audio.css';
|
||||
import './styles/blog.css';
|
||||
import './styles/crop.css';
|
||||
import './styles/recipe.css';
|
||||
import './styles/stream.css';
|
||||
import './styles/youtube.css';
|
||||
import './styles/video.css';
|
||||
|
||||
47
demo/assets/controllers/recipe_controller.js
Normal file
@@ -0,0 +1,47 @@
|
||||
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);
|
||||
|
||||
const input = document.getElementById('chat-message');
|
||||
input.addEventListener('keypress', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.submitMessage();
|
||||
}
|
||||
});
|
||||
input.focus();
|
||||
|
||||
const resetButton = document.getElementById('chat-reset');
|
||||
resetButton.addEventListener('click', (event) => {
|
||||
this.component.action('reset');
|
||||
});
|
||||
|
||||
const submitButton = document.getElementById('chat-submit');
|
||||
submitButton.addEventListener('click', (event) => {
|
||||
this.submitMessage();
|
||||
});
|
||||
|
||||
this.component.on('loading.state:started', (e,r) => {
|
||||
if (r.actions.includes('reset')) {
|
||||
return;
|
||||
}
|
||||
document.getElementById('welcome')?.remove();
|
||||
document.getElementById('recipe-card')?.setAttribute('class', 'd-none');
|
||||
document.getElementById('loading-message').removeAttribute('class');
|
||||
});
|
||||
|
||||
this.component.on('loading.state:finished', () => {
|
||||
document.getElementById('loading-message').setAttribute('class', 'd-none');
|
||||
document.getElementById('recipe-card')?.removeAttribute('class');
|
||||
});
|
||||
};
|
||||
|
||||
submitMessage() {
|
||||
const input = document.getElementById('chat-message');
|
||||
const message = input.value;
|
||||
this.component.action('submit', { message });
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
1
demo/assets/icons/mdi/clipboard-list.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1s-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2M7 8h2v4H8V9H7zm3 9v1H7v-.92L9 15H7v-1h2.25c.41 0 .75.34.75.75c0 .2-.08.39-.21.52L8.12 17zm1-13c0-.55.45-1 1-1s1 .45 1 1s-.45 1-1 1s-1-.45-1-1m6 13h-5v-2h5zm0-6h-5V9h5z"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
1
demo/assets/icons/mdi/clock.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m4.2 14.2L11 13V7h1.5v5.2l4.5 2.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 207 B |
1
demo/assets/icons/mdi/cook.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12.5 1.5c-1.77 0-3.33 1.17-3.83 2.87C8.14 4.13 7.58 4 7 4a4 4 0 0 0-4 4a4.01 4.01 0 0 0 3 3.87V19h13v-7.13c1.76-.46 3-2.05 3-3.87a4 4 0 0 0-4-4c-.58 0-1.14.13-1.67.37c-.5-1.7-2.06-2.87-3.83-2.87m-.5 9h1v7h-1zm-3 2h1v5H9zm6 0h1v5h-1zM6 20v1a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-1z"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
1
demo/assets/icons/mdi/format-list-checkbox.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M21 19v-2H8v2zm0-6v-2H8v2zM8 7h13V5H8zM4 5v2h2V5zM3 5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zm1 6v2h2v-2zm-1 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zm1 6v2h2v-2zm-1 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/></svg>
|
||||
|
After Width: | Height: | Size: 374 B |
1
demo/assets/icons/mdi/leaf.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66l.95-2.3c.48.17.98.3 1.34.3C19 20 22 3 22 3c-1 2-8 2.25-13 3.25S2 11.5 2 13.5s1.75 3.75 1.75 3.75C7 8 17 8 17 8"/></svg>
|
||||
|
After Width: | Height: | Size: 248 B |
1
demo/assets/icons/mdi/signal-variant.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 6V4h.1C12.9 4 20 11.1 20 19.9v.1h-2v-.1C18 12.2 11.8 6 4 6m0 4V8a12 12 0 0 1 12 12h-2A10 10 0 0 0 4 10m0 4v-2a8 8 0 0 1 8 8h-2a6 6 0 0 0-6-6m0 2a4 4 0 0 1 4 4H4z"/></svg>
|
||||
|
After Width: | Height: | Size: 262 B |
28
demo/assets/styles/recipe.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.recipe {
|
||||
body&, .card-img-top {
|
||||
background: rgb(34,34,34);
|
||||
background: linear-gradient(0deg, rgb(131, 166, 89) 0%, rgb(113, 188, 184) 100%);
|
||||
}
|
||||
|
||||
.card-img-top {
|
||||
color: #253724;
|
||||
}
|
||||
|
||||
&.chat {
|
||||
.user-message {
|
||||
background: #3e2926;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
#welcome h4 {
|
||||
color: #253724;
|
||||
}
|
||||
|
||||
#chat-reset, #chat-submit {
|
||||
&:hover {
|
||||
background: rgba(48, 158, 29, 0.65);
|
||||
border-color: rgba(48, 158, 29, 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ ai:
|
||||
platform: 'ai.platform.openai'
|
||||
model: 'gpt-4o-mini'
|
||||
tools: false
|
||||
recipe:
|
||||
platform: 'ai.platform.openai'
|
||||
model: 'gpt-4o-mini'
|
||||
prompt:
|
||||
text: 'You are a helpful cooking assistant. Provide detailed recipes based on user requests.'
|
||||
tools: false
|
||||
wikipedia:
|
||||
platform: 'ai.platform.openai'
|
||||
model:
|
||||
@@ -52,6 +58,7 @@ ai:
|
||||
description: |
|
||||
Subagent, that can answer questions about latest news around the Symfony Framework, like latest
|
||||
features, events or community news.
|
||||
# Multi-agent setup
|
||||
orchestrator:
|
||||
platform: 'ai.platform.openai'
|
||||
model: 'gpt-4o-mini'
|
||||
|
||||
@@ -24,6 +24,13 @@ crop:
|
||||
defaults:
|
||||
template: 'crop.html.twig'
|
||||
|
||||
recipe:
|
||||
path: '/recipe'
|
||||
controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController'
|
||||
defaults:
|
||||
template: 'chat.html.twig'
|
||||
context: { chat: 'recipe' }
|
||||
|
||||
stream:
|
||||
path: '/stream'
|
||||
controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController'
|
||||
|
||||
@@ -18,3 +18,5 @@ services:
|
||||
- '../src/DependencyInjection/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Kernel.php'
|
||||
|
||||
Symfony\Component\Serializer\Normalizer\PropertyNormalizer: ~
|
||||
|
||||
BIN
demo/demo.png
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 137 KiB |
84
demo/src/Recipe/Chat.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Recipe;
|
||||
|
||||
use App\Recipe\Data\Recipe;
|
||||
use Symfony\AI\Agent\AgentInterface;
|
||||
use Symfony\AI\Platform\Message\Message;
|
||||
use Symfony\AI\Platform\Message\MessageBag;
|
||||
use Symfony\AI\Platform\Result\ObjectResult;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
final class Chat
|
||||
{
|
||||
private const SESSION_KEY = 'recipe-chat';
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
#[Autowire(service: 'ai.agent.recipe')]
|
||||
private readonly AgentInterface $agent,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRecipe(): Recipe
|
||||
{
|
||||
$messages = $this->loadMessages()->getMessages();
|
||||
|
||||
if (0 === \count($messages)) {
|
||||
throw new \RuntimeException('No recipe generated yet. Please submit a message first.');
|
||||
}
|
||||
|
||||
$message = $messages[\count($messages) - 1];
|
||||
|
||||
if (!$message->getMetadata()->has('recipe')) {
|
||||
throw new \RuntimeException('The last message does not contain a recipe.');
|
||||
}
|
||||
|
||||
return $message->getMetadata()->get('recipe');
|
||||
}
|
||||
|
||||
public function submitMessage(string $message): void
|
||||
{
|
||||
$messages = $this->loadMessages();
|
||||
|
||||
$messages->add(Message::ofUser($message));
|
||||
$result = $this->agent->call($messages, ['response_format' => Recipe::class]);
|
||||
|
||||
\assert($result instanceof ObjectResult);
|
||||
|
||||
$recipe = $result->getContent();
|
||||
|
||||
\assert($recipe instanceof Recipe);
|
||||
|
||||
$assistantMessage = Message::ofAssistant($recipe->toString());
|
||||
$assistantMessage->getMetadata()->add('recipe', $result->getContent());
|
||||
$messages->add($assistantMessage);
|
||||
|
||||
$this->saveMessages($messages);
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->requestStack->getSession()->remove(self::SESSION_KEY);
|
||||
}
|
||||
|
||||
private function loadMessages(): MessageBag
|
||||
{
|
||||
return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag());
|
||||
}
|
||||
|
||||
private function saveMessages(MessageBag $messages): void
|
||||
{
|
||||
$this->requestStack->getSession()->set(self::SESSION_KEY, $messages);
|
||||
}
|
||||
}
|
||||
30
demo/src/Recipe/Data/Ingredient.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Recipe\Data;
|
||||
|
||||
final class Ingredient
|
||||
{
|
||||
/**
|
||||
* @var string Name of the ingredient
|
||||
*/
|
||||
public string $name;
|
||||
|
||||
/**
|
||||
* @var string Quantity of the ingredient (e.g., "2 cups", "150g")
|
||||
*/
|
||||
public string $quantity;
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return \sprintf('%s of %s', $this->quantity, $this->name);
|
||||
}
|
||||
}
|
||||
68
demo/src/Recipe/Data/Recipe.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Recipe\Data;
|
||||
|
||||
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
|
||||
|
||||
final class Recipe
|
||||
{
|
||||
/**
|
||||
* @var string Name of the recipe
|
||||
*/
|
||||
public string $name;
|
||||
|
||||
/**
|
||||
* @var int Duration in minutes
|
||||
*/
|
||||
#[With(minimum: 5, maximum: 240)]
|
||||
public int $duration;
|
||||
|
||||
/**
|
||||
* @var string Difficulty level of the recipe
|
||||
*/
|
||||
#[With(enum: ['Beginner', 'Intermediate', 'Advanced'])]
|
||||
public string $level;
|
||||
|
||||
/**
|
||||
* @var string Dietary preference
|
||||
*/
|
||||
#[With(enum: ['Vegetarian', 'Vegan', 'Gluten-Free', 'Keto', 'Paleo'])]
|
||||
public string $diet;
|
||||
|
||||
/**
|
||||
* @var Ingredient[] List of ingredients
|
||||
*/
|
||||
public array $ingredients;
|
||||
|
||||
/**
|
||||
* @var string[] Cooking instructions
|
||||
*/
|
||||
public array $steps;
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
$ingredients = implode(\PHP_EOL, array_map(fn (Ingredient $ing) => $ing->toString(), $this->ingredients));
|
||||
$steps = implode(\PHP_EOL, $this->steps);
|
||||
|
||||
return <<<RECIPE
|
||||
Recipe: {$this->name}
|
||||
Duration: {$this->duration} minutes
|
||||
Level: {$this->level}
|
||||
Diet: {$this->diet}
|
||||
Ingredients:
|
||||
{$ingredients}
|
||||
|
||||
Steps:
|
||||
{$steps}
|
||||
RECIPE;
|
||||
}
|
||||
}
|
||||
50
demo/src/Recipe/TwigComponent.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Recipe;
|
||||
|
||||
use App\Recipe\Data\Recipe;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveAction;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveArg;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
|
||||
#[AsLiveComponent('recipe')]
|
||||
final class TwigComponent
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly Chat $chat,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRecipe(): ?Recipe
|
||||
{
|
||||
try {
|
||||
return $this->chat->getRecipe();
|
||||
} catch (\RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#[LiveAction]
|
||||
public function submit(#[LiveArg] string $message): void
|
||||
{
|
||||
$this->chat->submitMessage($message);
|
||||
}
|
||||
|
||||
#[LiveAction]
|
||||
public function reset(): void
|
||||
{
|
||||
$this->chat->reset();
|
||||
}
|
||||
}
|
||||
@@ -22,15 +22,18 @@
|
||||
</a>
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav ms-auto me-2 mb-0 small">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('youtube') }}">{{ ux_icon('bi:youtube', { height: '20px', width: '20px' }) }} YouTube</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('recipe') }}">{{ ux_icon('mdi:cook', { height: '20px', width: '20px' }) }} Recipe</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('wikipedia') }}">{{ ux_icon('mdi:wikipedia', { height: '20px', width: '20px' }) }} Wikipedia</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('blog') }}">{{ ux_icon('mdi:symfony', { height: '20px', width: '20px' }) }} Symfony Blog</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('youtube') }}">{{ ux_icon('bi:youtube', { height: '20px', width: '20px' }) }} YouTube Transcript</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('wikipedia') }}">{{ ux_icon('mdi:wikipedia', { height: '20px', width: '20px' }) }} Wikipedia Research</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ path('crop') }}">{{ ux_icon('material-symbols:crop', { height: '20px', width: '20px' }) }} Smart Crop</a>
|
||||
</li>
|
||||
|
||||
101
demo/templates/components/recipe.html.twig
Normal file
@@ -0,0 +1,101 @@
|
||||
<div class="card mx-auto shadow-lg" {{ attributes.defaults(stimulus_controller('recipe')) }}>
|
||||
<div class="card-header p-2">
|
||||
{{ ux_icon('mdi:cook', { height: '32px', width: '32px' }) }}
|
||||
<strong class="ms-1 pt-1 d-inline-block">Recipe Bot</strong>
|
||||
<button id="chat-reset" class="btn btn-sm btn-outline-secondary float-end">{{ ux_icon('material-symbols:cancel') }} Reset Chat</button>
|
||||
</div>
|
||||
<div id="chat-body" class="card-body p-4 overflow-auto">
|
||||
{% if this.recipe %}
|
||||
{% set recipe = this.recipe %}
|
||||
<div id="recipe-card">
|
||||
<h2 class="mb-4 pb-3 border-bottom">{{ recipe.name }}</h2>
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4 text-center">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="info-box p-2 bg-light rounded">
|
||||
{{ ux_icon('mdi:clock', { height: '24px', width: '24px', class: 'text-primary' }) }}
|
||||
<p class="mb-0 mt-2 small">
|
||||
<strong>{{ recipe.duration }}</strong><br>
|
||||
<span class="text-muted">minutes</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="info-box p-2 bg-light rounded">
|
||||
{{ ux_icon('mdi:signal-variant', { height: '24px', width: '24px', class: 'text-warning' }) }}
|
||||
<p class="mb-0 mt-2 small">
|
||||
<strong>{{ recipe.level }}</strong><br>
|
||||
<span class="text-muted">difficulty</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="info-box p-2 bg-light rounded">
|
||||
{{ ux_icon('mdi:leaf', { height: '24px', width: '24px', class: 'text-success' }) }}
|
||||
<p class="mb-0 mt-2 small">
|
||||
<strong>{{ recipe.diet }}</strong><br>
|
||||
<span class="text-muted">diet</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients Section -->
|
||||
<div class="mb-4 text-start mt-4">
|
||||
<h5 class="mb-3">
|
||||
{{ ux_icon('mdi:format-list-checkbox', { height: '20px', width: '20px', class: 'me-2' }) }}
|
||||
Ingredients
|
||||
</h5>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for ingredient in recipe.ingredients %}
|
||||
<span class="badge text-start bg-success bg-opacity-50 text-white py-2 px-3">
|
||||
<strong>{{ ingredient.name }}</strong><br />
|
||||
<small>{{ ingredient.quantity }}</small>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8 text-start">
|
||||
<h5 class="mb-3">
|
||||
{{ ux_icon('mdi:clipboard-list', { height: '20px', width: '20px', class: 'me-2' }) }}
|
||||
Instructions
|
||||
</h5>
|
||||
<div class="">
|
||||
{% for step in recipe.steps %}
|
||||
<div class="d-flex mb-2 bg-light-subtle p-2 rounded">
|
||||
<div class="bg-dark text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="min-width: 32px; width: 32px; height: 32px;">
|
||||
<small class="fw-bold">{{ loop.index }}</small>
|
||||
</div>
|
||||
{{ step }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="welcome" class="text-center mt-5 py-5 bg-white rounded-5 shadow-sm w-75 mx-auto">
|
||||
{{ ux_icon('mdi:cook', { height: '200px', width: '200px' }) }}
|
||||
<h4 class="mt-5">Cooking Recipes</h4>
|
||||
<span class="text-muted">Please provide your preferences for a recipe.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="loading-message" class="d-none">
|
||||
<div class="d-flex flex-column align-items-center justify-content-center w-100">
|
||||
<div class="spinner-border text-success mb-4" style="width: 60px; height: 60px;">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<h4 class="text-muted mb-2">The bot is cooking your recipe...</h4>
|
||||
<p class="text-secondary small">This may take a moment</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer p-2">
|
||||
<div class="input-group">
|
||||
<input id="chat-message" type="text" class="form-control border-0" placeholder="Write a message ...">
|
||||
<button id="chat-submit" class="btn btn-outline-secondary border-0" type="button">{{ ux_icon('mingcute:send-fill', { height: '25px', width: '25px' }) }} Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,25 +13,6 @@
|
||||
</p>
|
||||
<h3 class="text-dark">Examples</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card blog bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('mdi:symfony', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Symfony Blog Bot</h5>
|
||||
<p class="card-text">Retrieval Augmented Generation (RAG) based on Symfony's blog dumped to a vector store.</p>
|
||||
<a href="{{ path('blog') }}" class="btn btn-outline-dark d-block">Try Symfony Blog Bot</a>
|
||||
</div>
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Blog/Chat.php', line: 21 }) }}">See Implementation</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card youtube bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
@@ -51,6 +32,25 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card recipe bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('mdi:cook', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Recipe Bot</h5>
|
||||
<p class="card-text">Chatbot for proposing cooking recipes - powered by structured output.</p>
|
||||
<a href="{{ path('recipe') }}" class="btn btn-outline-dark d-block">Try Recipe Bot</a>
|
||||
</div>
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Recipe/Chat.php', line: 22 }) }}">See Implementation</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card wikipedia bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
@@ -70,6 +70,65 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card blog bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('mdi:symfony', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Symfony Blog Bot</h5>
|
||||
<p class="card-text">Retrieval Augmented Generation (RAG) based on Symfony's blog dumped to a vector store.</p>
|
||||
<a href="{{ path('blog') }}" class="btn btn-outline-dark d-block">Try Symfony Blog Bot</a>
|
||||
</div>
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Blog/Chat.php', line: 21 }) }}">See Implementation</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card audio bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('iconoir:microphone-solid', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Audio Bot + Subagent</h5>
|
||||
<p class="card-text">Demonstration of speech-to-text & text-to-speech and a subagent, combining 4 models in total.</p>
|
||||
<a href="{{ path('audio') }}" class="btn btn-outline-dark d-block">Try Audio Bot</a>
|
||||
</div>
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Audio/Chat.php', line: 25 }) }}">See Implementation</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card video bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('tabler:video-filled', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Video Bot</h5>
|
||||
<p class="card-text">Demonstration of vision capabilities of GPT in combination with your webcam.</p>
|
||||
<a href="{{ path('video') }}" class="btn btn-outline-dark d-block">Try Video Bot</a>
|
||||
</div>
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Video/TwigComponent.php', line: 41 }) }}">See Implementation</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card crop bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
@@ -89,67 +148,23 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-9 mx-auto">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card audio bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('iconoir:microphone-solid', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Audio Bot</h5>
|
||||
<p class="card-text">Simple demonstration of speech-to-text with Whisper in combination with GPT.</p>
|
||||
<a href="{{ path('audio') }}" class="btn btn-outline-dark d-block">Try Audio Bot</a>
|
||||
</div>
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Audio/Chat.php', line: 25 }) }}">See Implementation</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stream bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('mdi:car-turbocharger', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card video bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('tabler:video-filled', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Video Bot</h5>
|
||||
<p class="card-text">Simple demonstration of vision capabilities of GPT in combination with your webcam.</p>
|
||||
<a href="{{ path('video') }}" class="btn btn-outline-dark d-block">Try Video Bot</a>
|
||||
</div>
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Video/TwigComponent.php', line: 41 }) }}">See Implementation</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Turbo Stream Bot</h5>
|
||||
<p class="card-text">Demonstration of streaming text responses in combination with markdown based on Turbo and SSE.</p>
|
||||
<a href="{{ path('stream') }}" class="btn btn-outline-dark d-block">Try Turbo Stream Bot</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stream bg-body shadow-sm">
|
||||
<div class="card-img-top py-2">
|
||||
{{ ux_icon('mdi:car-turbocharger', { height: '150px', width: '150px' }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Turbo Stream Bot</h5>
|
||||
<p class="card-text">Simple demonstration of text streaming capabilities based on Turbo and SSE.</p>
|
||||
<a href="{{ path('stream') }}" class="btn btn-outline-dark d-block">Try Turbo Stream Bot</a>
|
||||
</div>
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Stream/TwigComponent.php', line: 41 }) }}">See Implementation</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Profiler route only available in dev #}
|
||||
{% if 'dev' == app.environment %}
|
||||
<div class="card-footer">
|
||||
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
|
||||
<a href="{{ path('_profiler_open_file', { file: 'src/Stream/TwigComponent.php', line: 41 }) }}">See Implementation</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ final class SmokeTest extends WebTestCase
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertSelectorTextContains('h1', 'Welcome to the Symfony AI Demo');
|
||||
$this->assertSelectorCount(7, '.card');
|
||||
$this->assertSelectorCount(8, '.card');
|
||||
}
|
||||
|
||||
#[DataProvider('provideChats')]
|
||||
|
||||