Add demo for structured output about generating recipes

This commit is contained in:
Christopher Hertel
2025-11-19 21:51:27 +01:00
parent f9da9c0767
commit 6d198a679b
24 changed files with 595 additions and 123 deletions

View 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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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';

View 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 = '';
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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);
}
}
}
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -18,3 +18,5 @@ services:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
Symfony\Component\Serializer\Normalizer\PropertyNormalizer: ~

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 137 KiB

84
demo/src/Recipe/Chat.php Normal file
View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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')]