diff --git a/demo/assets/app.js b/demo/assets/app.js index f3e7dc10..30387104 100644 --- a/demo/assets/app.js +++ b/demo/assets/app.js @@ -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'; diff --git a/demo/assets/styles/stream.css b/demo/assets/styles/stream.css new file mode 100644 index 00000000..2405030f --- /dev/null +++ b/demo/assets/styles/stream.css @@ -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); + } + } + } +} diff --git a/demo/config/packages/ai.yaml b/demo/config/packages/ai.yaml index 696f8691..12bcc17e 100644 --- a/demo/config/packages/ai.yaml +++ b/demo/config/packages/ai.yaml @@ -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' @@ -62,4 +72,3 @@ services: Symfony\AI\Agent\Toolbox\Tool\Wikipedia: ~ Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch: $model: '@ai.indexer.default.model' - diff --git a/demo/config/routes.yaml b/demo/config/routes.yaml index 8cd723c5..d27bd8e6 100644 --- a/demo/config/routes.yaml +++ b/demo/config/routes.yaml @@ -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' diff --git a/demo/demo.png b/demo/demo.png index 8007e323..77db610c 100644 Binary files a/demo/demo.png and b/demo/demo.png differ diff --git a/demo/src/Stream/Chat.php b/demo/src/Stream/Chat.php new file mode 100644 index 00000000..1efd2bb9 --- /dev/null +++ b/demo/src/Stream/Chat.php @@ -0,0 +1,80 @@ + + * + * 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 + */ + 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); + } +} diff --git a/demo/src/Stream/TwigComponent.php b/demo/src/Stream/TwigComponent.php new file mode 100644 index 00000000..a0e2793d --- /dev/null +++ b/demo/src/Stream/TwigComponent.php @@ -0,0 +1,94 @@ + + * + * 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()]))); + }); + } +} diff --git a/demo/templates/_message.html.twig b/demo/templates/_message.html.twig index 141dd2f8..36779db3 100644 --- a/demo/templates/_message.html.twig +++ b/demo/templates/_message.html.twig @@ -4,8 +4,8 @@ {{ _self.user(message.content) }} {% endif %} -{% macro bot(content, loading = false, latest = false) %} -
+{% macro bot(content, loading = false, latest = false, contentId = null, messageId = null) %} +
{{ ux_icon('fluent:bot-24-filled', { height: '45px', width: '45px' }) }}
@@ -16,7 +16,7 @@ {{ content }}
{% else %} -
+
{% if latest and app.request.xmlHttpRequest %} +{% endblock %} + +{% block partial %} + {{ part }} +{% endblock %} + +{% block end %} + +{% endblock %} diff --git a/demo/templates/base.html.twig b/demo/templates/base.html.twig index c7286096..520835aa 100644 --- a/demo/templates/base.html.twig +++ b/demo/templates/base.html.twig @@ -21,7 +21,7 @@ Symfony AI Demo
-
@@ -111,7 +110,25 @@ {% endif %}
-
+
+
+
+ {{ ux_icon('mdi:car-turbocharger', { height: '150px', width: '150px' }) }} +
+
+
Turbo Stream Bot
+

Simple demonstration of text streaming capabilities based on Turbo and SSE.

+ Try Turbo Stream Bot +
+ {# Profiler route only available in dev #} + {% if 'dev' == app.environment %} + + {% endif %} +
+
{% endblock %}