{"id":233,"date":"2026-04-13T07:10:38","date_gmt":"2026-04-13T11:10:38","guid":{"rendered":"https:\/\/juredev.com\/blog\/?p=233"},"modified":"2026-04-13T21:25:01","modified_gmt":"2026-04-14T01:25:01","slug":"symfony-ai-practica-chat-con-historial-tools-y-vanilla-js","status":"publish","type":"post","link":"https:\/\/juredev.com\/blog\/2026\/04\/symfony-ai-practica-chat-con-historial-tools-y-vanilla-js\/","title":{"rendered":"Symfony AI en la pr\u00e1ctica (II): chat conversacional con historial, m\u00faltiples tools y Vanilla JS"},"content":{"rendered":"\n<p>En el <a href=\"https:\/\/juredev.com\/blog\/2026\/04\/symfony-ai-agentes-tools-php\/\">art\u00edculo anterior<\/a> construimos un agente b\u00e1sico que solo sab\u00eda hacer una cosa: consultar el precio de un producto a partir de su SKU. Era \u00fatil, pero muy limitado.<\/p>\n\n\n\n<p>En esta segunda parte lo convertimos en un <strong>asistente conversacional real<\/strong>: ahora puede buscar productos por categor\u00eda, mantener el contexto de la conversaci\u00f3n y responder de forma natural en espa\u00f1ol, como si estuviera atendiendo a un cliente de verdad.<\/p>\n\n\n\n<p>Los principales cambios respecto a la primera parte son:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Tres herramientas coordinadas<\/strong>: b\u00fasqueda por texto\/categor\u00eda, precio por SKU y listado de categor\u00edas disponibles.<\/li>\n\n\n\n<li><strong>Historial de conversaci\u00f3n<\/strong> persistente entre peticiones, usando la sesi\u00f3n HTTP de Symfony.<\/li>\n\n\n\n<li><strong>Cat\u00e1logo ampliado<\/strong> a 20 productos distribuidos en 4 categor\u00edas, con b\u00fasqueda basada en slugs normalizados.<\/li>\n\n\n\n<li><strong>Interfaz de chat completa<\/strong> hecha 100% con Vanilla JS y Twig, sin frameworks de frontend.<\/li>\n<\/ul>\n\n\n\n<p>Todo el c\u00f3digo actualizado est\u00e1 disponible en el <a href=\"https:\/\/github.com\/jure-ve\/symfony-ai-product-agent\" data-type=\"link\" data-id=\"https:\/\/github.com\/jure-ve\/symfony-ai-product-agent\">repositorio del proyecto<\/a>.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>Actualizaci\u00f3n a v0.7.0: Mientras prepar\u00e1ba este art\u00edculo, Symfony lanz\u00f3 la versi\u00f3n 0.7.0 del AI Bundle. Actualic\u00e9 todo el proyecto y nos beneficiamos de varias correcciones importantes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Fix en el Generic Bridge<\/strong> (<code>#1876<\/code>): corrige los argumentos del factory del platform gen\u00e9rico. Si usabas Groq (u otros proveedores compatibles) a trav\u00e9s del bridge gen\u00e9rico en v0.6.0 y ten\u00edas errores al inicializarlo, este fix lo soluciona sin tocar tu configuraci\u00f3n.<\/li>\n\n\n\n<li><strong>Mejoras en el Profiler<\/strong> (<code>#1894<\/code>, <code>#1748<\/code>): el panel de AI ya no se rompe cuando la respuesta no es texto plano y desaparecen los duplicados en el listado de tools. Mucho m\u00e1s fiable para depurar durante el desarrollo.<\/li>\n\n\n\n<li><strong>Fix en el streaming de Ollama<\/strong> (<code>#1827<\/code>): cambia el protocolo interno de SSE a NDJSON, lo que soluciona los problemas de streaming con Ollama (la alternativa local gratuita que recomendamos en la primera parte).<\/li>\n<\/ul>\n\n\n\n<p>Recuerda que Symfony AI sigue en estado <strong>experimental<\/strong> y sin promesa de compatibilidad hacia atr\u00e1s. Siempre consulta la <a href=\"https:\/\/symfony.com\/doc\/current\/ai\/index.html\" target=\"_blank\" rel=\"noreferrer noopener\">documentaci\u00f3n oficial<\/a> antes de actualizar.<\/p>\n<\/blockquote>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p><strong>Nota<\/strong>: Este art\u00edculo parte del c\u00f3digo del art\u00edculo anterior. Aqu\u00ed solo muestro los fragmentos m\u00e1s relevantes de los cambios y adiciones. El c\u00f3digo completo est\u00e1 en el repositorio.<\/p>\n<\/blockquote>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p><strong>Sobre el streaming<\/strong>: En la primera parte prometimos un chat en tiempo real con Server-Sent Events. Finalmente decidimos dedicar este art\u00edculo a consolidar bien las bases conversacionales (historial, m\u00faltiples tools y una interfaz decente) para no meter demasiadas cosas a la vez. El streaming real ser\u00e1 el protagonista del siguiente art\u00edculo.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">1. De una tool a tres: coordinando herramientas<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">El problema del agente con una sola herramienta<\/h3>\n\n\n\n<p>El agente de la primera parte ten\u00eda una limitaci\u00f3n muy clara: solo pod\u00eda responder si el usuario conoc\u00eda el SKU exacto del producto. Preguntas tan normales como \u00ab\u00bfqu\u00e9 port\u00e1tiles tienes?\u00bb o \u00ab\u00bfqu\u00e9 auriculares me recomiendas?\u00bb lo dejaban completamente mudo sin poder contestar.<\/p>\n\n\n\n<p>Para solucionarlo, a\u00f1adimos dos herramientas nuevas y mejoramos la existente.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Las tres tools<\/h3>\n\n\n\n<p><code>ProductSearchTool<\/code>: Busca productos por categor\u00eda (usando el slug) y devuelve resultados paginados. Limitamos a 3 productos por p\u00e1gina para que las respuestas del modelo no se hagan eternas. El par\u00e1metro de p\u00e1gina permite al agente pedir m\u00e1s resultados en turnos posteriores si lo necesita.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#&#91;AsTool(\n    name: 'search',\n    description: 'Searches products by category slug. Returns up to 3 products per page.',\n)]\nfinal class ProductSearchTool\n{\n    \/**\n     * @param string $category Category slug (e.g. \"telefono\", \"portatil\")\n     * @param string $page     Page number for pagination (default: \"1\")\n     *\/\n    public function __invoke(string $category, string $page = '1'): string\n    {\n        $pageInt = max(1, (int) $page);\n        $products = $this-&gt;productRepository-&gt;searchByCategory(\n            $category, 3, ($pageInt - 1) * 3\n        );\n\n        if (empty($products)) {\n            return sprintf('No se encontraron productos en \"%s\" (p\u00e1g %d).', $category, $pageInt);\n        }\n\n        $result = sprintf('Categor\u00eda \"%s\" \u2014 P\u00e1gina %d:' . \"\\n\", $category, $pageInt);\n        foreach ($products as $product) {\n            $result .= sprintf(\"- %s (SKU: %s) \u2014 %.2f EUR\\n\",\n                $product-&gt;getName(), $product-&gt;getSku(), $product-&gt;getPrice()\n            );\n        }\n\n        return $result;\n    }\n}<\/code><\/pre>\n\n\n\n<p><code>ProductPriceTool<\/code>: La misma herramienta de precio del art\u00edculo anterior, pero ahora con el nombre corto <code>price<\/code> para que el prompt la pueda referenciar f\u00e1cilmente.<\/p>\n\n\n\n<p><code>ProductCategoryTool<\/code>: Devuelve la lista de slugs de categor\u00edas disponibles. Es clave: el agente debe usarla primero para saber qu\u00e9 categor\u00edas puede consultar antes de intentar una b\u00fasqueda.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#&#91;AsTool(\n    name: 'categories',\n    description: 'Returns the list of available product categories in the store.',\n)]\nfinal class ProductCategoryTool\n{\n    public function __invoke(string $_ = ''): string\n    {\n        $categories = $this-&gt;productRepository-&gt;getCategories();\n        return 'The available product categories are: ' . implode(', ', $categories);\n    }\n}<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Registrar las tools<\/h3>\n\n\n\n<p>Gracias al autoconfigure (comportamiento por defecto), las clases con <code>#[AsTool]<\/code> se registran autom\u00e1ticamente. Si quieres asociarlas expl\u00edcitamente a un agente concreto, puedes hacerlo en <code>config\/packages\/ai.yaml<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ai:\n    agent:\n        default:\n            platform: 'ai.platform.generic.groq'\n            model: 'llama-3.3-70b-versatile'\n            tools:\n                services:\n                    - { service: 'App\\AI\\Tool\\ProductSearchTool' }\n                    - { service: 'App\\AI\\Tool\\ProductPriceTool' }\n                    - { service: 'App\\AI\\Tool\\ProductCategoryTool' }<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">El sistema prompt: m\u00e1s importante de lo que parece<\/h3>\n\n\n\n<p>Cuando pasas de una herramienta a tres, el modelo necesita instrucciones mucho m\u00e1s claras para decidir cu\u00e1l usar en cada momento. Un buen system prompt marca una diferencia enorme.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Message::forSystem(\n    \"You are a helpful online store assistant. Reply ONLY in Spanish.\\n\\n\" .\n    \"AVAILABLE TOOLS:\\n\" .\n    \"- 'categories': list available types.\\n\" .\n    \"- 'search': lists products by category. Pass exact slug (telefono, portatil, auriculares, reloj). Page defaults to '1' ('2' for more).\\n\" .\n    \"- 'price': gets the price of an SKU.\\n\\n\" .\n    \"RULES:\\n\" .\n    \"1. Map user synonyms to exact slugs (e.g., 'laptops'-&gt;'portatil', 'relojes'-&gt;'reloj' or 'celulares'-&gt;'telefono').\\n\" .\n    \"2. NO HALLUCINATION: DO NOT invoke unlisted tools (like order\/checkout). Never invent products or SKUs.\\n\" .\n    \"3. NO SALES: You cannot process orders or take payments; decline gracefully.\"\n)<\/code><\/pre>\n\n\n\n<p>Este prompt corto y directo funciona mucho mejor que los prompts kilom\u00e9tricos llenos de \u201cno hagas esto\u201d que suelen confundir a los modelos. Incluye un mapeo sem\u00e1ntico de sin\u00f3nimos y reglas claras anti-alucinaci\u00f3n, lo que reduce dr\u00e1sticamente los errores.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. Historial de conversaci\u00f3n con sesi\u00f3n HTTP<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">El problema<\/h3>\n\n\n\n<p>Antes, cada llamada al endpoint <code>\/api\/chat<\/code> era independiente. El agente no recordaba nada de lo que hab\u00eda dicho antes:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Usuario: \"\u00bfQu\u00e9 port\u00e1tiles tienes?\"\nAgente:  &#91;busca y devuelve 3 port\u00e1tiles]\n\nUsuario: \"\u00bfCu\u00e1l es el m\u00e1s barato de los que me has dicho?\"\nAgente:  &#91;sin contexto \u2192 no sabe a cu\u00e1les se refiere]<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">La soluci\u00f3n: sesi\u00f3n + MessageBag<\/h3>\n\n\n\n<p>Usamos la sesi\u00f3n nativa de Symfony para guardar el historial como un array simple y lo reconstruimos en cada petici\u00f3n como un <code>MessageBag<\/code> completo:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>final class ProductChatService\n{\n    public function ask(string $userQuestion): string\n    {\n        $session = $this-&gt;requestStack-&gt;getSession();\n        $history = $session-&gt;get('chat_history', &#91;]);\n        $history&#91;] = &#91;'role' =&gt; 'user', 'content' =&gt; $userQuestion];\n\n        $result = $this-&gt;defaultAgent-&gt;call($this-&gt;buildMessageBag($history));\n        $answer = (string) $result-&gt;getContent();\n\n        \/\/ Guard contra fugas de tool calls o JSON corrupto\n        if (preg_match('\/^&#91;a-z_]+\\{\/i', trim($answer)) || preg_match('\/function=\/', trim($answer)) || empty(trim($answer))) {\n            $answer = 'Lo siento, no pude obtener la informaci\u00f3n correctamente. \u00bfPuedes reformular tu pregunta?';\n        }\n\n        $history&#91;] = &#91;'role' =&gt; 'assistant', 'content' =&gt; $answer];\n        $session-&gt;set('chat_history', array_slice($history, -10));\n\n        return $answer;\n    }\n\n    private function buildMessageBag(array $history): MessageBag\n    {\n        $messages = &#91;Message::forSystem(\"You are a helpful online store assistant...\")];\n\n        foreach ($history as $msg) {\n            $messages&#91;] = match ($msg&#91;'role']) {\n                'user'      =&gt; Message::ofUser($msg&#91;'content']),\n                'assistant' =&gt; Message::ofAssistant($msg&#91;'content']),\n                default     =&gt; null,\n            };\n        }\n\n        return new MessageBag(...array_filter($messages));\n    }\n}<\/code><\/pre>\n\n\n\n<p>A\u00f1adimos un peque\u00f1o \u00abguard\u00bb con regex para evitar que posibles fugas de JSON o llamadas a tools fallidas contaminen la respuesta visible al usuario. En el controlador tambi\u00e9n tenemos un catch general que devuelve mensajes amigables en caso de error.<\/p>\n\n\n\n<p>Limitamos el historial a los \u00faltimos 10 mensajes por una cuesti\u00f3n pr\u00e1ctica: m\u00e1s all\u00e1 de eso el consumo de tokens se dispara.<\/p>\n\n\n\n<p>Con esto, el agente ya puede mantener conversaciones coherentes:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Usuario: \"\u00bfQu\u00e9 port\u00e1tiles tienes por menos de 1000\u20ac?\"\nAgente:  &#91;devuelve Ultrabook Zen 13 y Port\u00e1til Estudiante]\n\nUsuario: \"\u00bfCu\u00e1l es el m\u00e1s barato?\"\nAgente:  &#91;con contexto \u2192 \"La m\u00e1s barata es la Port\u00e1til Estudiante a 599\u20ac\"]<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">3. Mejoras en el cat\u00e1logo y la b\u00fasqueda<\/h2>\n\n\n\n<p>Ampliamos el cat\u00e1logo a <strong>20 productos<\/strong> reales distribuidos en 4 categor\u00edas: smartphones, port\u00e1tiles, auriculares y smartwatches.<\/p>\n\n\n\n<p>La mejora m\u00e1s importante a nivel t\u00e9cnico es la normalizaci\u00f3n de categor\u00edas. Ahora cada producto tiene un campo category con su slug (portatil, telefono, etc.). Esto permite b\u00fasquedas exactas y elimina ambig\u00fcedades.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public function searchByCategory(string $category, int $limit = 3, int $offset = 0): array\n{\n    $search = $this-&gt;normalize($category);\n\n    $matches = array_filter($this-&gt;products, function (Product $p) use ($search) {\n        return $this-&gt;normalize($p-&gt;getCategory()) === $search;\n    });\n\n    return array_slice(array_values($matches), $offset, $limit);\n}\n\nprivate function normalize(string $text): string\n{\n    return str_replace(\n        &#91;'\u00e1', '\u00e9', '\u00ed', '\u00f3', '\u00fa', '\u00fc', '\u00f1'],\n        &#91;'a', 'e', 'i', 'o', 'u', 'u', 'n'],\n        mb_strtolower(trim($text))\n    );\n}<\/code><\/pre>\n\n\n\n<p>El agente recibe los slugs v\u00e1lidos a trav\u00e9s de <code>ProductCategoryTool<\/code> y los usa directamente en <code>ProductSearchTool<\/code>. Mucho m\u00e1s limpio y fiable.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Interfaz de chat con Vanilla JS<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">La plantilla Twig<\/h3>\n\n\n\n<p>Todo el frontend est\u00e1 en un \u00fanico archivo Twig. Sin React, sin Vue, sin npm, sin bundlers:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{# templates\/chat\/demo.html.twig #}\n{% extends 'base.html.twig' %}\n\n{% block body %}\n&lt;div class=\"chat-container\"&gt;\n    &lt;div class=\"chat-header\"&gt;Asistente de Productos&lt;\/div&gt;\n    &lt;div class=\"chat-messages\" id=\"chat\"&gt;\n        &lt;div class=\"message ai\"&gt;\u00a1Hola! Soy tu asistente de tienda. \u00bfEn qu\u00e9 puedo ayudarte?&lt;\/div&gt;\n    &lt;\/div&gt;\n    &lt;div class=\"chat-input\"&gt;\n        &lt;input type=\"text\" id=\"prompt\" placeholder=\"Escribe tu pregunta...\"&gt;\n        &lt;button id=\"btn-send\"&gt;Enviar&lt;\/button&gt;\n    &lt;\/div&gt;\n&lt;\/div&gt;\n{% endblock %}<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">El JavaScript del cliente<\/h3>\n\n\n\n<p>El flujo es cl\u00e1sico, pero con algunos detalles importantes: bloqueo de UI para evitar mensajes duplicados, efecto de escritura progresiva y protecci\u00f3n b\u00e1sica contra XSS.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>async function sendMessage() {\n    const text = inputPrompt.value.trim();\n    if (!text) return;\n\n    addMessageBubble(text, 'user');\n    inputPrompt.value = '';\n\n    btnSend.disabled = true;\n    inputPrompt.disabled = true;\n\n    const aiBubble = addMessageBubble('\u23f3 Pensando...', 'ai');\n\n    try {\n        const response = await fetch('\/api\/chat', {\n            method: 'POST',\n            headers: { 'Content-Type': 'application\/json' },\n            body: JSON.stringify({ question: text }),\n        });\n\n        const data = await response.json();\n\n        if (!response.ok) {\n            aiBubble.innerHTML = `&lt;span class=\"error\"&gt;${escapeHTML(data.error)}&lt;\/span&gt;`;\n            return;\n        }\n\n        await typeWriter(aiBubble, data.answer);\n\n    } catch {\n        aiBubble.innerHTML = '&lt;span class=\"error\"&gt;No se pudo conectar con el servidor.&lt;\/span&gt;';\n    } finally {\n        btnSend.disabled = false;\n        inputPrompt.disabled = false;\n    }\n}\n\nasync function typeWriter(element, text) {\n    let current = '';\n    for (const word of text.split(' ')) {\n        current += word + ' ';\n        element.innerHTML = escapeHTML(current).replace(\/\\n\/g, '&lt;br&gt;');\n        chatContainer.scrollTop = chatContainer.scrollHeight;\n        await new Promise(r =&gt; setTimeout(r, 30));\n    }\n}\n\nfunction escapeHTML(str) {\n    const p = document.createElement('p');\n    p.textContent = str;\n    return p.innerHTML;\n}<\/code><\/pre>\n\n\n\n<p><strong>Importante<\/strong>: El efecto de escritura es solo visual. El servidor todav\u00eda devuelve la respuesta completa de una vez. El \u201cpensando\u2026\u201d se muestra durante toda la generaci\u00f3n del modelo. El streaming real (token por token) lo dejaremos para el pr\u00f3ximo art\u00edculo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. Arquitectura resultante<\/h2>\n\n\n\n<script type=\"module\">\n  import mermaid from 'https:\/\/cdn.jsdelivr.net\/npm\/mermaid@10\/dist\/mermaid.esm.min.mjs';\n  mermaid.initialize({ \n    startOnLoad: true, \n    theme: 'dark'\n  });\n<\/script>\n\n<div style=\"display: flex; justify-content: center; align-items: center; width: 100%; margin: 20px 0;\">\n    <pre class=\"mermaid\" style=\"background: transparent; border: none; color: transparent;\">\ngraph TD\n    %% Estilos\n    classDef fe fill:#1a237e,stroke:#fff,color:#fff,stroke-width:2px;\n    classDef logic fill:#0d47a1,stroke:#fff,color:#fff,stroke-width:1px;\n    classDef agent fill:#4a148c,stroke:#fff,color:#fff,stroke-width:2px;\n    classDef tool fill:#263238,stroke:#fff,color:#fff,stroke-width:1px;\n    classDef repo fill:#004d40,stroke:#fff,color:#fff,stroke-width:2px;\n\n    %% Nodos\n    FE[Frontend Vanilla JS]:::fe\n    PC[ProductChatController]:::logic\n    PS[ProductChatService]:::logic\n    AI[AgentInterface Groq-Llama]:::agent\n    T1[search]:::tool\n    T2[price]:::tool\n    T3[categories]:::tool\n    PR[(ProductRepository)]:::repo\n\n    %% Flujos\n    FE --> PC\n    PC --> PS\n    PS --> AI\n    AI --> T1\n    AI --> T2\n    AI --> T3\n    T1 --> PR\n    T2 --> PR\n    T3 --> PR\n    <\/pre>\n<\/div>\n\n\n\n<h2 class=\"wp-block-heading\">6. Resumen de cambios respecto al art\u00edculo anterior<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Aspecto<\/th><th>Art\u00edculo 1<\/th><th>Art\u00edculo 2<\/th><\/tr><tr><td>Tools<\/td><td>1 (price)<\/td><td>3 (search, price, categories)<\/td><\/tr><tr><td>Historial<\/td><td>No<\/td><td>Sesi\u00f3n HTTP, \u00faltimos 10 mensajes<\/td><\/tr><tr><td>B\u00fasqueda<\/td><td>Solo por SKU exacto<\/td><td>Por categor\u00eda normalizada (slugs) + paginaci\u00f3n<\/td><\/tr><tr><td>Frontend<\/td><td>Ninguno<\/td><td>Chat UI con Vanilla JS y efecto de escritura<\/td><\/tr><tr><td>Cat\u00e1logo<\/td><td>4 productos<\/td><td>20 productos en 4 categor\u00edas<\/td><\/tr><tr><td>Sistema prompt<\/td><td>2 l\u00edneas<\/td><td>Instrucciones detalladas + reglas anti-alucinaci\u00f3n<\/td><\/tr><tr><td>Robustez<\/td><td>B\u00e1sica<\/td><td>Guard contra fugas de JSON + manejo de errores<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">7. Novedades de la v0.7.0 que afectan a este proyecto<\/h2>\n\n\n\n<p>Adem\u00e1s de los fixes ya mencionados en la introducci\u00f3n, la versi\u00f3n 0.7.0 trae otras mejoras interesantes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Validaci\u00f3n de argumentos de tools<\/strong> con Symfony Validator (<code>#1681<\/code>). Ahora puedes poner constraints directamente en los par\u00e1metros del __invoke() y el framework se encarga de validarlos antes de ejecutar la tool.<\/li>\n\n\n\n<li><strong>Streaming en el componente Chat <\/strong>(<code>#1583<\/code> y <code>#1759<\/code>): soporte mejorado y objetos <code>DeltaInterface<\/code> tipados. Esto nos va a facilitar mucho la vida en el pr\u00f3ximo art\u00edculo.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Hasta donde llegamos y pr\u00f3ximos pasos<\/h2>\n\n\n\n<p>Con lo que hemos visto en este art\u00edculo, ya tenemos un asistente conversacional bastante completo: maneja m\u00faltiples tools de forma coordinada, recuerda el contexto de la conversaci\u00f3n y ofrece una interfaz limpia hecha con herramientas nativas de Symfony y JavaScript puro.<\/p>\n\n\n\n<p>Es un ejemplo realista de c\u00f3mo empezar a integrar IA en aplicaciones PHP\/Symfony sin complicaciones innecesarias y manteniendo el control total del c\u00f3digo.<\/p>\n\n\n\n<p>Todav\u00eda queda pendiente la gran mejora de experiencia de usuario: <strong>el streaming real<\/strong>. En el pr\u00f3ximo art\u00edculo nos centraremos exclusivamente en implementar Server-Sent Events para que la respuesta aparezca token por token, eliminando esa molesta espera inicial.<\/p>\n\n\n\n<p>Gracias a la actualizaci\u00f3n a <code>v0.7.0<\/code> y las nuevas capacidades del componente Chat, esa implementaci\u00f3n deber\u00eda quedar mucho m\u00e1s limpia y robusta.<\/p>\n\n\n\n<p>El repositorio est\u00e1 p\u00fablico y puedes levantarlo en minutos. Si tienes dudas, mejoras o quieres ver alguna funcionalidad extra, d\u00e9jame un comentario. En el siguiente art\u00edculo nos metemos de lleno con el streaming.<\/p>\n\n\n\n<p>Sigamos codificando.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>En el art\u00edculo anterior construimos un agente b\u00e1sico que solo sab\u00eda hacer una cosa: consultar el precio de un producto a partir de su SKU. Era \u00fatil, pero muy limitado. En esta segunda parte lo convertimos en un asistente conversacional real: ahora puede buscar productos por categor\u00eda, mantener el contexto de la conversaci\u00f3n y responder [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[134,138,15],"class_list":["post-233","post","type-post","status-publish","format-standard","hentry","category-desarrollo","tag-ia","tag-llm","tag-php"],"_links":{"self":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/233","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/comments?post=233"}],"version-history":[{"count":0,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/233\/revisions"}],"wp:attachment":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/media?parent=233"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/categories?post=233"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/tags?post=233"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}