Skip to content

[Demo] Add an example with streaming #118

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions demo/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/app.css';
import './styles/audio.css';
import './styles/blog.css';
import './styles/stream.css';
import './styles/youtube.css';
import './styles/video.css';
import './styles/wikipedia.css';
33 changes: 33 additions & 0 deletions demo/assets/styles/stream.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.stream {
body&, .card-img-top {
background: #ececec;
background: linear-gradient(0deg, #d26f15 0%, #ff9f4c 100%);
}

&.chat {
.user-message {
background: #ffffff;
}

.bot-message {
background: #ffffff;

a {
color: #3e2926;
}
}

.avatar {
&.bot, &.user {
outline: 1px solid #eaeaea;
background: #eaeaea;
}
}

footer {
&, & a {
color: var(--bs-light);
}
}
}
}
11 changes: 10 additions & 1 deletion demo/config/packages/ai.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ ai:
name: 'clock'
description: 'Provides the current date and time.'
method: 'now'
stream:
model:
class: 'Symfony\AI\Platform\Bridge\OpenAI\GPT'
name: !php/const Symfony\AI\Platform\Bridge\OpenAI\GPT::GPT_4O_MINI
system_prompt: |
You are an example chat application where messages from the LLM are streamed to the user using
Server-Sent Events via `symfony/ux-turbo` / Turbo Streams. This example does not use any custom
javascript and solely relies on the built-in `live` & `turbo_stream` Stimulus controllers.
Whatever the user asks, tell them about the application & used technologies.
tools: false
youtube:
model:
class: 'Symfony\AI\Platform\Bridge\OpenAI\GPT'
Expand Down Expand Up @@ -62,4 +72,3 @@ services:
Symfony\AI\Agent\Toolbox\Tool\Wikipedia: ~
Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch:
$model: '@ai.indexer.default.model'

11 changes: 11 additions & 0 deletions demo/config/routes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ wikipedia:
defaults:
template: 'chat.html.twig'
context: { chat: 'wikipedia' }

stream:
path: '/stream'
controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController'
defaults:
template: 'chat.html.twig'
context: { chat: 'stream' }

stream_assistant_reply:
path: '/stream/assistant-reply'
controller: 'App\Stream\TwigComponent::streamContent'
Binary file modified demo/demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions demo/src/Stream/Chat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Stream;

use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\AssistantMessage;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Message\UserMessage;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;

final class Chat
{
private const SESSION_KEY = 'stream-chat';

public function __construct(
private readonly RequestStack $requestStack,
#[Autowire(service: 'ai.agent.stream')]
private readonly AgentInterface $agent,
) {
}

public function loadMessages(): MessageBag
{
return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag());
}

public function submitMessage(string $message): UserMessage
{
$messages = $this->loadMessages();

$userMessage = Message::ofUser($message);
$messages->add($userMessage);

$this->saveMessages($messages);

return $userMessage;
}

/**
* @return \Generator<int, string, void, AssistantMessage>
*/
public function getAssistantResponse(MessageBag $messages): \Generator
{
$stream = $this->agent->call($messages, ['stream' => true])->getContent();
\assert(is_iterable($stream));

$response = '';
foreach ($stream as $chunk) {
yield $chunk;
$response .= $chunk;
}

$assistantMessage = Message::ofAssistant($response);
$messages->add($assistantMessage);
$this->saveMessages($messages);

return $assistantMessage;
}

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);
}
}
94 changes: 94 additions & 0 deletions demo/src/Stream/TwigComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Stream;

use Symfony\AI\Platform\Message\MessageInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\EventStreamResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerEvent;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('stream')]
final class TwigComponent extends AbstractController
{
use DefaultActionTrait;

#[LiveProp(writable: true)]
public ?string $message = null;
public bool $stream = false;

public function __construct(
private readonly Chat $chat,
) {
}

/**
* @return MessageInterface[]
*/
public function getMessages(): array
{
return $this->chat->loadMessages()->withoutSystemMessage()->getMessages();
}

#[LiveAction]
public function submit(): void
{
if (!$this->message) {
return;
}

$this->chat->submitMessage($this->message);
$this->message = null;
$this->stream = true;
}

#[LiveAction]
public function reset(): void
{
$this->chat->reset();
}

public function streamContent(Request $request): EventStreamResponse
{
$messages = $this->chat->loadMessages();

$actualSession = $request->getSession();

// Overriding session will prevent the framework calling save() on the actual session.
// This fixes "Failed to start the session because headers have already been sent" error.
$request->setSession(new Session(new MockArraySessionStorage()));

return new EventStreamResponse(function () use ($request, $actualSession, $messages) {
$request->setSession($actualSession);
$response = $this->chat->getAssistantResponse($messages);

$thinking = true;
foreach ($response as $chunk) {
// Remove "Thinking..." when we receive something
if ($thinking && trim($chunk)) {
$thinking = false;
yield new ServerEvent(explode("\n", $this->renderBlockView('_stream.html.twig', 'start')));
}

yield new ServerEvent(explode("\n", $this->renderBlockView('_stream.html.twig', 'partial', ['part' => $chunk])));
}

yield new ServerEvent(explode("\n", $this->renderBlockView('_stream.html.twig', 'end', ['message' => $response->getReturn()])));
});
}
}
6 changes: 3 additions & 3 deletions demo/templates/_message.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
{{ _self.user(message.content) }}
{% endif %}

{% macro bot(content, loading = false, latest = false) %}
<div class="d-flex align-items-baseline mb-4">
{% macro bot(content, loading = false, latest = false, contentId = null, messageId = null) %}
<div class="d-flex align-items-baseline mb-4"{% if messageId %} id="{{ messageId }}"{% endif %}>
<div class="bot avatar rounded-3 shadow-sm">
{{ ux_icon('fluent:bot-24-filled', { height: '45px', width: '45px' }) }}
</div>
Expand All @@ -16,7 +16,7 @@
<i>{{ content }}</i>
</div>
{% else %}
<div class="bot-message d-inline-block p-2 px-3 m-1 border border-light-subtle shadow-sm">
<div class="bot-message d-inline-block p-2 px-3 m-1 border border-light-subtle shadow-sm"{% if contentId %} id="{{ contentId }}"{% endif %}>
{% if latest and app.request.xmlHttpRequest %}
<span
data-controller="symfony--ux-typed"
Expand Down
11 changes: 11 additions & 0 deletions demo/templates/_stream.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% block start %}
<twig:Turbo:Stream:Update target="#bot-message-streamed"/>
{% endblock %}

{% block partial %}
<twig:Turbo:Stream:Append target="#bot-message-streamed">{{ part }}</twig:Turbo:Stream:Append>
{% endblock %}

{% block end %}
<twig:Turbo:Stream:Remove target="#bot-message-stream"/>
{% endblock %}
5 changes: 4 additions & 1 deletion demo/templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<strong>Symfony AI</strong> Demo
</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav ms-auto me-2 mb-0">
<ul class="navbar-nav ms-auto me-2 mb-0 small">
<li class="nav-item">
<a class="nav-link" href="{{ path('blog') }}">{{ ux_icon('mdi:symfony', { height: '20px', width: '20px' }) }} Symfony Blog Bot</a>
</li>
Expand All @@ -37,6 +37,9 @@
<li class="nav-item">
<a class="nav-link" href="{{ path('video') }}">{{ ux_icon('tabler:video-filled', { height: '20px', width: '20px' }) }} Video Bot</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('stream') }}">{{ ux_icon('mdi:car-turbocharger', { height: '20px', width: '20px' }) }} Turbo Stream Bot</a>
</li>
<li class="nav-item"><span class="nav-link">|</span></li>
<li class="nav-item">
<a class="nav-link" href="https://github.com/symfony/ai" target="_blank">{{ ux_icon('mdi:github', { height: '20px', width: '20px' }) }} GitHub</a>
Expand Down
31 changes: 31 additions & 0 deletions demo/templates/components/stream.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% import "_message.html.twig" as message %}

<div class="card mx-auto shadow-lg" {{ attributes }}>
<div class="card-header p-2">
{{ ux_icon('mdi:car-turbocharger', { height: '32px', width: '32px' }) }}
<strong class="ms-1 pt-1 d-inline-block">Turbo Stream Bot</strong>
<button {{ live_action('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">
{% for message in this.messages %}
{% include '_message.html.twig' with { message, latest: loop.last } %}
{% endfor %}
{% if this.stream %}
{{ message.bot('Thinking...', contentId = 'bot-message-streamed') }}
<turbo-stream-source src="{{ path('stream_assistant_reply') }}" id="bot-message-stream"></turbo-stream-source>
{% endif %}
{% if not this.messages|length %}
<div id="welcome" class="text-center mt-5 py-5 bg-white rounded-5 shadow-sm w-75 mx-auto">
{{ ux_icon('mdi:car-turbocharger', { height: '200px', width: '200px' }) }}
<h4 class="mt-5">Turbo Stream Chat</h4>
<span class="text-muted">Please ask the bot about the used technologies in this example.</span>
</div>
{% endif %}
</div>
<div class="card-footer p-2">
<form class="input-group" {{ live_action('submit:prevent') }}>
<input data-model="norender|message" type="text" class="form-control border-0" placeholder="Write a message ...">
<button class="btn btn-outline-secondary border-0">{{ ux_icon('mingcute:send-fill', { height: '25px', width: '25px' }) }} Submit</button>
</form>
</div>
</div>
21 changes: 19 additions & 2 deletions demo/templates/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@
</div>
</div>
<div class="row mt-4">
<div class="col-md-2"></div>
<div class="col-md-4">
<div class="card audio bg-body shadow-sm">
<div class="card-img-top py-2">
Expand Down Expand Up @@ -111,7 +110,25 @@
{% endif %}
</div>
</div>
<div class="col-md-2"></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 %}
</div>
</div>
</div>
</div>
{% endblock %}