From 87c34180d9e34836e69f72fdf4704af1ad334027 Mon Sep 17 00:00:00 2001 From: juan Date: Mon, 29 Jun 2026 11:59:11 -0300 Subject: [PATCH 1/7] feat(ai): streaming SSE en drivers del agente (reasoning + scaffold tool-calls) - base.ts: modelo de eventos AgentStreamEvent + abstract chatStream - sse.ts: parser SSE compartido (resiliente a chunks partidos) + readStreamError - common.ts: refactor a prepareSession/persistAssistant; chatStream con hard fallback al query() no-streaming; abstract queryStream. query()/run() intactos - chatgpt/anthropic/gemini: queryStream parseando SSE (Anthropic con thinking, Gemini con thought parts best-effort) - list.tsx: chatStream que resuelve el driver y delega --- src/drivers/agent/anthropic.ts | 89 +++++++++++++++++++++++ src/drivers/agent/base.ts | 40 +++++++++++ src/drivers/agent/chatgpt.ts | 55 +++++++++++++++ src/drivers/agent/common.ts | 125 ++++++++++++++++++++++++++++++--- src/drivers/agent/gemini.ts | 80 +++++++++++++++++++++ src/drivers/agent/list.tsx | 21 +++++- src/drivers/agent/sse.ts | 95 +++++++++++++++++++++++++ 7 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 src/drivers/agent/sse.ts diff --git a/src/drivers/agent/anthropic.ts b/src/drivers/agent/anthropic.ts index 20f25cd2..5780015c 100644 --- a/src/drivers/agent/anthropic.ts +++ b/src/drivers/agent/anthropic.ts @@ -1,11 +1,30 @@ import { BaseDriver } from "../base-driver"; +import { AgentStreamCallback } from "./base"; import CommonAgentDriverImplementation, { CommonAgentMessage } from "./common"; +import { readSSEStream, readStreamError } from "./sse"; interface AnthropicResponse { content: { type: string; text?: string }[]; error?: { message: string }; } +// Evento SSE de Anthropic (Messages API, stream:true). El campo `type` discrimina: +// content_block_delta trae delta.type === "text_delta" (texto) o "thinking_delta" +// (reasoning); message_stop cierra; error reporta un fallo. +interface AnthropicStreamEvent { + type?: string; + delta?: { type?: string; text?: string; thinking?: string }; + error?: { message?: string }; +} + +// Presupuesto de "extended thinking" para que Claude exponga su razonamiento en el +// stream. Es opt-in y best-effort: en modelos que lo soportan (familia claude-4 / +// 3.7) aparece como bloque "Razonando…"; en los que NO, la API tira 400 y chatStream +// cae al query() no-streaming (sin reasoning, pero responde igual). max_tokens debe +// ser > budget_tokens (4096 > 1024). +const THINKING_BUDGET_TOKENS = 1024; +const STREAM_MAX_TOKENS = 4096; + /** * Driver de agente para Anthropic (Claude). Default: claude-opus-4-8. * La key se pasa por constructor (viene de localStorage, nunca se commitea/loguea). @@ -56,6 +75,76 @@ export class AnthropicDriver extends CommonAgentDriverImplementation { return text; } + // Streaming SSE (stream:true) con reasoning best-effort. Separa el system top-level + // igual que query(), pide thinking, y parsea content_block_delta → text/reasoning. + // Si la respuesta no es ok, tira para que chatStream caiga al fallback. No toca query(). + async queryStream( + messages: CommonAgentMessage[], + onEvent: AgentStreamCallback + ): Promise { + const systemText = messages + .filter((m) => m.role === "system") + .map((m) => m.content) + .join("\n\n"); + const chatMessages = messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => ({ role: m.role, content: m.content })); + + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": this.token, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.model, + max_tokens: STREAM_MAX_TOKENS, + stream: true, + // Reasoning opt-in (best-effort). Con thinking activado, Anthropic exige + // temperature por defecto (=1), por eso NO la seteamos. + thinking: { type: "enabled", budget_tokens: THINKING_BUDGET_TOKENS }, + ...(systemText ? { system: systemText } : {}), + messages: chatMessages, + }), + }); + + if (!response.ok || !response.body) { + throw new Error(await readStreamError(response, "Anthropic")); + } + + let acc = ""; + await readSSEStream(response.body, (data) => { + let event: AnthropicStreamEvent; + try { + event = JSON.parse(data) as AnthropicStreamEvent; + } catch { + return; + } + + if (event.type === "error") { + throw new Error(event.error?.message ?? "Anthropic stream error"); + } + + if (event.type === "message_stop") return "stop"; + + if (event.type === "content_block_delta" && event.delta) { + if (event.delta.type === "text_delta" && event.delta.text) { + acc += event.delta.text; + onEvent({ type: "text", delta: event.delta.text }); + } else if ( + event.delta.type === "thinking_delta" && + event.delta.thinking + ) { + onEvent({ type: "reasoning", delta: event.delta.thinking }); + } + } + }); + + return acc; + } + // Lenient: devuelve el bloque SQL si existe (text-to-SQL), si no el texto (explicar). processResult(result: string): string { const codeBlock = result.match(/```sql\n([\s\S]*?)```/); diff --git a/src/drivers/agent/base.ts b/src/drivers/agent/base.ts index 4cddeadf..1be04ae4 100644 --- a/src/drivers/agent/base.ts +++ b/src/drivers/agent/base.ts @@ -17,6 +17,28 @@ export interface AgentPromptResponse { id: string; } +/** + * Eventos que emite el camino de streaming (chatStream). Es un modelo aditivo: + * el chat tab los consume para renderizar token a token. Hoy solo se emiten + * "text", "reasoning", "done" y "error"; "tool_call" queda como scaffold (la UI + * sabe dibujar chips si llegaran, pero NO definimos tools reales ni ejecutamos + * nada — se mantiene la postura del producto). + * + * - reasoning: razonamiento del modelo (Anthropic/Gemini con flag; OpenAI no lo expone). + * - text: texto visible del assistant (markdown, con posibles bloques ```sql). + * - tool_call: invocación de herramienta (display-only, scaffold). + * - done: el stream terminó OK. + * - error: el stream falló; message es legible para mostrar al usuario. + */ +export type AgentStreamEvent = + | { type: "reasoning"; delta: string } + | { type: "text"; delta: string } + | { type: "tool_call"; id: string; name: string; args?: string } + | { type: "done" } + | { type: "error"; message: string }; + +export type AgentStreamCallback = (event: AgentStreamEvent) => void; + export abstract class AgentBaseDriver { /** * @@ -44,4 +66,22 @@ export abstract class AgentBaseDriver { previousId: string | undefined, option: AgentPromptOption ): Promise; + + /** + * Variante streaming de chat(): arma la MISMA sesión multi-turno que chat()/run() + * pero emite el texto token a token vía onEvent (text/reasoning/tool_call/done/error). + * Acumula el texto y lo persiste en el historial igual que chat(). Devuelve el texto + * final acumulado. NO reemplaza a chat()/run(): es un camino nuevo, aditivo. + * + * @param message User message + * @param previousId Previous message id. If not provided, it is a new conversation + * @param option + * @param onEvent Callback que recibe cada evento del stream + */ + abstract chatStream( + message: string, + previousId: string | undefined, + option: AgentPromptOption, + onEvent: AgentStreamCallback + ): Promise; } diff --git a/src/drivers/agent/chatgpt.ts b/src/drivers/agent/chatgpt.ts index e650d3cd..5c3b8c4e 100644 --- a/src/drivers/agent/chatgpt.ts +++ b/src/drivers/agent/chatgpt.ts @@ -1,10 +1,19 @@ import { BaseDriver } from "../base-driver"; +import { AgentStreamCallback } from "./base"; import CommonAgentDriverImplementation, { CommonAgentMessage } from "./common"; +import { readSSEStream, readStreamError } from "./sse"; interface ChatGPTResponse { choices?: { message: { role: string; content: string } }[]; error?: { message: string }; } +// Chunk de streaming de OpenAI (chat-completions, stream:true). Cada `data:` trae +// un delta incremental en choices[0].delta.content. OpenAI por chat-completions NO +// expone reasoning (solo la Responses API da un resumen), así que acá solo hay texto. +interface ChatGPTStreamChunk { + choices?: { delta?: { content?: string } }[]; +} + export class ChatGPTDriver extends CommonAgentDriverImplementation { constructor( protected driver: BaseDriver, @@ -44,4 +53,50 @@ export class ChatGPTDriver extends CommonAgentDriverImplementation { } return content; } + + // Streaming SSE (stream:true). Parsea las líneas `data:`, lee el delta de texto + // y lo emite; `data: [DONE]` cierra. Si la respuesta no es ok (sin body o {error}), + // tira para que chatStream caiga al query() no-streaming. NO toca query()/run(). + async queryStream( + messages: CommonAgentMessage[], + onEvent: AgentStreamCallback + ): Promise { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.model, + temperature: 0, + stream: true, + messages, + }), + }); + + if (!response.ok || !response.body) { + throw new Error(await readStreamError(response, "OpenAI")); + } + + let acc = ""; + await readSSEStream(response.body, (data) => { + if (data === "[DONE]") return "stop"; + + let chunk: ChatGPTStreamChunk; + try { + chunk = JSON.parse(data) as ChatGPTStreamChunk; + } catch { + return; // chunk parcial/keep-alive: lo ignoramos + } + + const delta = chunk.choices?.[0]?.delta?.content; + if (typeof delta === "string" && delta) { + acc += delta; + onEvent({ type: "text", delta }); + } + }); + + return acc; + } } diff --git a/src/drivers/agent/common.ts b/src/drivers/agent/common.ts index cdfdaa35..d5e31204 100644 --- a/src/drivers/agent/common.ts +++ b/src/drivers/agent/common.ts @@ -4,7 +4,7 @@ import { DatabaseSchemas, DatabaseTableSchema, } from "../base-driver"; -import { AgentBaseDriver, AgentPromptOption } from "./base"; +import { AgentBaseDriver, AgentPromptOption, AgentStreamCallback } from "./base"; export interface ChatHistory { id: string; @@ -41,6 +41,18 @@ export default abstract class CommonAgentDriverImplementation extends AgentBaseD abstract query(messages: CommonAgentMessage[]): Promise; + /** + * Variante streaming de query(): pega al endpoint del provider con stream + * activado, parsea el SSE y emite eventos (text/reasoning) vía onEvent. Devuelve + * el TEXTO acumulado (la fuente de verdad para persistir el historial). Debe + * TIRAR si el stream no arranca (HTTP no-ok, red, CORS) para que chatStream caiga + * al fallback no-streaming. NO toca query()/run(). + */ + abstract queryStream( + messages: CommonAgentMessage[], + onEvent: AgentStreamCallback + ): Promise; + getSystemContent(option: AgentPromptOption): string { // DynamoDB no usa SQL relacional: se consulta con PartiQL. Le damos al LLM // un prompt específico con las reglas + ejemplos canónicos, en vez del de @@ -140,15 +152,15 @@ ${dynamoClosing}`; throw new Error("We cannot generate good response"); } - // Arma la sesión (system + schema + selección en el primer turno), agrega el - // mensaje del usuario, llama al proveedor y persiste el historial. Devuelve la - // respuesta CRUDA del assistant. Compartido por run() (Ctrl+B, text-to-SQL) y - // chat() (tab conversacional), para que ambos usen el mismo historial multi-turno. - private async runRaw( + // Recupera/crea la sesión y le agrega el turno del usuario (system + schema + + // selección SOLO en el primer turno). Compartido por runRaw() y chatStream() + // para que ambos armen el historial multi-turno EXACTAMENTE igual. Devuelve la + // sesión lista para mandar al proveedor (todavía sin la respuesta del assistant). + private prepareSession( message: string, previousId: string | undefined, option: AgentPromptOption - ): Promise { + ): ChatHistory { const session = this.history[previousId ?? ""] ?? { id: previousId || generateId(), createdAt: Date.now(), @@ -180,15 +192,30 @@ ${dynamoClosing}`; content: message, }); - const result = await this.query(session.messages); + return session; + } - // Save the chat history + // Persiste la respuesta del assistant en el historial in-memory de la sesión. + private persistAssistant(session: ChatHistory, result: string) { session.messages.push({ role: "assistant", content: result, }); this.history[session.id] = session; + } + + // Arma la sesión, llama al proveedor (no-streaming) y persiste el historial. + // Devuelve la respuesta CRUDA del assistant. Compartido por run() (Ctrl+B, + // text-to-SQL) y chat() (tab conversacional). Comportamiento idéntico al previo. + private async runRaw( + message: string, + previousId: string | undefined, + option: AgentPromptOption + ): Promise { + const session = this.prepareSession(message, previousId, option); + const result = await this.query(session.messages); + this.persistAssistant(session, result); return result; } @@ -217,6 +244,86 @@ ${dynamoClosing}`; }); } + // Variante STREAMING de chat() para el chat tab. Arma la misma sesión multi-turno + // (conversacional) que chat(), pero delega en queryStream() para emitir el texto + // token a token vía onEvent. Acumula el texto y lo persiste igual que chat(). + // + // Hard fallback (centralizado acá, no duplicado en cada provider): si queryStream + // tira ANTES de emitir nada (caso típico: HTTP no-ok, CORS, red, o un modelo que + // rechaza el flag de reasoning), caemos al query() no-streaming y emitimos un único + // evento "text" con toda la respuesta. Si ya emitió tokens y después explota, no + // rehacemos la respuesta (duplicaría): cerramos con un "error". Nunca tira: siempre + // resuelve emitiendo "done", para que la UI event-driven cierre el turno. + async chatStream( + message: string, + previousId: string | undefined, + option: AgentPromptOption, + onEvent: AgentStreamCallback + ): Promise { + const session = this.prepareSession(message, previousId, { + ...option, + conversational: true, + }); + + let text = ""; + let emittedAny = false; + + // Envoltura: acumulamos el texto y marcamos si ya salió ALGO al usuario, para + // decidir si el fallback es seguro (sin duplicar) ante un fallo a mitad de stream. + const wrapped: AgentStreamCallback = (event) => { + if (event.type === "text") { + text += event.delta; + emittedAny = true; + } else if (event.type === "reasoning" || event.type === "tool_call") { + emittedAny = true; + } + onEvent(event); + }; + + try { + const finalText = await this.queryStream(session.messages, wrapped); + // queryStream devuelve el texto acumulado (fuente de verdad). Si por algún + // motivo no acumulamos vía eventos, usamos su retorno. + if (finalText) text = finalText; + this.persistAssistant(session, text); + onEvent({ type: "done" }); + return text; + } catch (streamError) { + if (emittedAny) { + // Ya mostramos tokens: no podemos rehacer la respuesta sin duplicarla. + onEvent({ + type: "error", + message: + streamError instanceof Error + ? streamError.message + : "El stream se interrumpió", + }); + if (text) this.persistAssistant(session, text); + onEvent({ type: "done" }); + return text; + } + + // Fallback duro: el stream nunca arrancó → usamos el camino no-streaming. + try { + text = await this.query(session.messages); + this.persistAssistant(session, text); + onEvent({ type: "text", delta: text }); + onEvent({ type: "done" }); + return text; + } catch (fallbackError) { + onEvent({ + type: "error", + message: + fallbackError instanceof Error + ? fallbackError.message + : "No se pudo generar la respuesta", + }); + onEvent({ type: "done" }); + return ""; + } + } + } + constructor(protected driver: BaseDriver) { super(); } diff --git a/src/drivers/agent/gemini.ts b/src/drivers/agent/gemini.ts index e7391340..5c6f53c9 100644 --- a/src/drivers/agent/gemini.ts +++ b/src/drivers/agent/gemini.ts @@ -1,11 +1,25 @@ import { BaseDriver } from "../base-driver"; +import { AgentStreamCallback } from "./base"; import CommonAgentDriverImplementation, { CommonAgentMessage } from "./common"; +import { readSSEStream, readStreamError } from "./sse"; interface GeminiResponse { candidates?: { content: { parts: { text?: string }[] } }[]; error?: { message: string }; } +// Chunk de streaming de Gemini (:streamGenerateContent?alt=sse). Cada `data:` trae +// candidates con parts incrementales. Una part con thought:true es reasoning; el +// resto es texto. NO forzamos includeThoughts (rompería el streaming del default +// gemini-2.0-flash, que no es modelo "thinking"): si el modelo elegido emite +// thoughts, los mostramos; si no, no inventamos. +interface GeminiStreamChunk { + candidates?: { + content?: { parts?: { text?: string; thought?: boolean }[] }; + }[]; + error?: { message?: string }; +} + /** * Driver de agente para Google Gemini. Default: gemini-2.0-flash. * La key se pasa por constructor (localStorage). Gemini usa `?key=` y @@ -57,6 +71,72 @@ export class GeminiDriver extends CommonAgentDriverImplementation { return text; } + // Streaming SSE (:streamGenerateContent?alt=sse). Arma el body igual que query() + // y parsea cada chunk: parts con thought:true → reasoning, resto → texto. Si la + // respuesta no es ok, tira para que chatStream caiga al fallback. No toca query(). + async queryStream( + messages: CommonAgentMessage[], + onEvent: AgentStreamCallback + ): Promise { + const systemText = messages + .filter((m) => m.role === "system") + .map((m) => m.content) + .join("\n\n"); + + const contents = messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }], + })); + + const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent( + this.token + )}`; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...(systemText + ? { systemInstruction: { parts: [{ text: systemText }] } } + : {}), + contents, + }), + }); + + if (!response.ok || !response.body) { + throw new Error(await readStreamError(response, "Gemini")); + } + + let acc = ""; + await readSSEStream(response.body, (data) => { + let chunk: GeminiStreamChunk; + try { + chunk = JSON.parse(data) as GeminiStreamChunk; + } catch { + return; + } + + if (chunk.error) { + throw new Error(chunk.error.message ?? "Gemini stream error"); + } + + const parts = chunk.candidates?.[0]?.content?.parts ?? []; + for (const part of parts) { + if (!part.text) continue; + if (part.thought) { + onEvent({ type: "reasoning", delta: part.text }); + } else { + acc += part.text; + onEvent({ type: "text", delta: part.text }); + } + } + }); + + return acc; + } + processResult(result: string): string { const codeBlock = result.match(/```sql\n([\s\S]*?)```/); if (codeBlock) return codeBlock[1]; diff --git a/src/drivers/agent/list.tsx b/src/drivers/agent/list.tsx index 2de52c76..97311fb9 100644 --- a/src/drivers/agent/list.tsx +++ b/src/drivers/agent/list.tsx @@ -1,6 +1,6 @@ import { ReactElement } from "react"; import { BaseDriver } from "../base-driver"; -import { AgentBaseDriver, AgentPromptOption } from "./base"; +import { AgentBaseDriver, AgentPromptOption, AgentStreamCallback } from "./base"; import { AnthropicDriver } from "./anthropic"; import { ChatGPTDriver } from "./chatgpt"; // DEPRECATED: cloudflare — agent free-tier de Cloudflare Workers AI sacado del build. @@ -167,4 +167,23 @@ export default class AgentDriverList { return await driver.chat(message, sessionId, options); } + + // Variante STREAMING para el chat tab: misma resolución que chat() pero delega en + // driver.chatStream(), que emite el texto token a token vía onEvent. Aditivo: no + // toca run()/chat(). + async chatStream( + modelName: string, + message: string, + sessionId: string | undefined, + options: AgentPromptOption, + onEvent: AgentStreamCallback + ): Promise { + const driver = this.resolveDriver(modelName); + + if (!driver) { + throw new Error(`Selected model ${modelName} is not available`); + } + + return await driver.chatStream(message, sessionId, options, onEvent); + } } diff --git a/src/drivers/agent/sse.ts b/src/drivers/agent/sse.ts new file mode 100644 index 00000000..c00e8545 --- /dev/null +++ b/src/drivers/agent/sse.ts @@ -0,0 +1,95 @@ +// Parser mínimo de Server-Sent Events sobre un ReadableStream de fetch. +// Lo usan los queryStream() de cada provider (OpenAI/Anthropic/Gemini): los tres +// mandan payloads de una sola línea `data: ...` por evento, así que alcanza con +// ir cortando por '\n' y procesar las líneas `data:` completas. No metemos +// dependencias nuevas (la consigna es no sumar libs): TextDecoder + getReader(). + +export type SSEDataHandler = ( + data: string +) => void | "stop" | Promise; + +/** + * Lee el stream SSE y llama a onData con el contenido de cada línea `data:` + * (ya sin el prefijo). Ignora líneas `event:`, comentarios `:` y líneas vacías. + * Si onData devuelve "stop" (p.ej. al ver `[DONE]`), corta y libera el reader. + * + * Es resiliente a chunks partidos: acumula en un buffer y solo procesa hasta el + * último '\n' completo; lo que queda sin newline espera al próximo chunk. + */ +export async function readSSEStream( + stream: ReadableStream, + onData: SSEDataHandler +): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + let nlIndex: number; + while ((nlIndex = buffer.indexOf("\n")) !== -1) { + const rawLine = buffer.slice(0, nlIndex); + buffer = buffer.slice(nlIndex + 1); + + const signal = await handleLine(rawLine, onData); + if (signal === "stop") return; + } + } + + // Flush final: por si el stream cerró sin un '\n' al final de la última línea. + if (buffer.length > 0) { + await handleLine(buffer, onData); + } + } finally { + reader.releaseLock(); + } +} + +/** + * Lee el cuerpo de error de una respuesta de streaming fallida y devuelve un + * mensaje legible. Los providers mandan el error como JSON {error:{message}} antes + * de abrir el stream; si no se puede leer, cae al status HTTP. Compartido por los + * queryStream() de los tres providers. + */ +export async function readStreamError( + response: Response, + provider: string +): Promise { + try { + const raw = await response.text(); + if (raw) { + try { + const json = JSON.parse(raw); + const message = json?.error?.message ?? json?.message; + if (message) return message; + } catch { + // no era JSON: devolvemos el texto crudo recortado + return raw.slice(0, 300); + } + } + } catch { + // ignoramos y caemos al status + } + return `${provider} respondió ${response.status} ${response.statusText}`.trim(); +} + +async function handleLine( + rawLine: string, + onData: SSEDataHandler +): Promise { + // Normalizamos CRLF y espacios al final de línea. + const line = rawLine.replace(/\r$/, ""); + if (!line.startsWith("data:")) return; // ignoramos event:/comentarios/vacías + + // `data:foo` y `data: foo` son equivalentes en SSE: el primer espacio opcional + // tras los dos puntos no es parte del dato. + const data = line.slice(5).replace(/^ /, ""); + if (!data) return; + + return await onData(data); +} From 53c9956ef19f239bde388c506a66d08ab45c51f6 Mon Sep 17 00:00:00 2001 From: juan Date: Mon, 29 Jun 2026 11:59:11 -0300 Subject: [PATCH 2/7] feat(ai): helpers de config parcial (mergeAgentConfig/patchAgentConfig) --- src/lib/ai-agent-storage.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/lib/ai-agent-storage.ts b/src/lib/ai-agent-storage.ts index 08e05fa7..24fb7b10 100644 --- a/src/lib/ai-agent-storage.ts +++ b/src/lib/ai-agent-storage.ts @@ -51,6 +51,39 @@ export function updateAgentFromLocalStorage(data: LocalAgentType) { mutate("/local-agent-setting", data); } +// Merge puro (sin side-effects) de una config parcial sobre la actual. Si falta el +// model y cambió el provider, cae al default de ESE provider. Usado por los dialogs +// de provider/model para no perder los campos que no tocan. Testeable sin localStorage. +export function mergeAgentConfig( + current: LocalAgentType | undefined, + partial: Partial +): LocalAgentType { + const provider = partial.provider ?? current?.provider ?? "anthropic"; + const token = partial.token ?? current?.token ?? ""; + + // Si cambió el provider y no vino un model explícito, reseteamos al default de ESE + // provider — mantener el model del provider anterior no tendría sentido (p.ej. un + // "gpt-4o" colgando en una config de anthropic). + const providerChanged = + partial.provider != null && partial.provider !== current?.provider; + const model = + partial.model ?? + (providerChanged ? DEFAULT_MODEL_BY_PROVIDER[provider] : current?.model) ?? + DEFAULT_MODEL_BY_PROVIDER[provider]; + + return { provider, model, token }; +} + +// Aplica un update PARCIAL de la config (merge sobre lo guardado + persiste + mutate). +// Lo usan model-picker (solo model) y provider-picker (provider + model default). +export function patchAgentConfig( + partial: Partial +): LocalAgentType { + const merged = mergeAgentConfig(getAgentFromLocalStorage(), partial); + updateAgentFromLocalStorage(merged); + return merged; +} + export function useAvailableAIAgents(databaseDriver?: BaseDriver | null) { const { data: agentConfig } = useSWR( "/local-agent-setting", From ff6c5423746d15990ab30a41def47eca009f8081 Mon Sep 17 00:00:00 2001 From: juan Date: Mon, 29 Jun 2026 11:59:12 -0300 Subject: [PATCH 3/7] feat(ai): fetch de modelos live por provider + fallback curado --- src/lib/agent-models.ts | 165 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/lib/agent-models.ts diff --git a/src/lib/agent-models.ts b/src/lib/agent-models.ts new file mode 100644 index 00000000..f17cd8c4 --- /dev/null +++ b/src/lib/agent-models.ts @@ -0,0 +1,165 @@ +import { AgentProvider } from "@/lib/ai-agent-storage"; + +// Fetch de la lista de modelos por provider, pegándole al endpoint `/models` con +// la API key del usuario. Si falla (CORS, permiso, offline, key inválida) caemos a +// una lista CURADA hardcodeada por provider — así el picker siempre muestra algo. + +export interface ProviderModelsResult { + models: string[]; + // true cuando la lista viene del fallback curado (no del fetch live). La UI lo + // usa para mostrar el badge "lista offline". + fromFallback: boolean; +} + +// Listas curadas (fallback). Pensadas para ser "lo razonable hoy"; si el fetch live +// anda, esto ni se usa. No pretende ser exhaustivo: es un piso usable sin red. +export const CURATED_MODELS: Record = { + anthropic: [ + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + "claude-opus-4-1-20250805", + "claude-3-7-sonnet-latest", + "claude-3-5-haiku-latest", + ], + openai: [ + "gpt-4o", + "gpt-4o-mini", + "gpt-4.1", + "gpt-4.1-mini", + "o3", + "o4-mini", + ], + gemini: [ + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.0-flash", + "gemini-1.5-pro", + "gemini-1.5-flash", + ], +}; + +// Subcadenas que delatan modelos NO-chat de OpenAI (embeddings, audio, imágenes, +// moderación, etc.). El endpoint /models los mezcla todos; filtramos a chat. +const OPENAI_NON_CHAT = [ + "instruct", + "embedding", + "whisper", + "tts", + "audio", + "realtime", + "transcribe", + "image", + "dall-e", + "moderation", + "search", + "davinci", + "babbage", +]; + +// Filtra los ids de OpenAI a chat models: arrancan con "gpt" o con "o" + dígito +// (o1/o3/o4…), y NO matchean ninguna subcadena no-chat. Ordena alfabéticamente. +// Exportada para testear el parseo sin red. +export function filterOpenAIChatModels(ids: string[]): string[] { + return ids + .filter((id) => { + if (!id) return false; + const isChat = id.startsWith("gpt") || /^o\d/.test(id); + if (!isChat) return false; + return !OPENAI_NON_CHAT.some((bad) => id.includes(bad)); + }) + .sort(); +} + +// Extrae los modelos de Gemini que soportan generateContent y limpia el prefijo +// "models/". Exportada para testear el parseo sin red. +export function extractGeminiModels( + models: { name?: string; supportedGenerationMethods?: string[] }[] +): string[] { + return models + .filter((m) => + (m.supportedGenerationMethods ?? []).includes("generateContent") + ) + .map((m) => (m.name ?? "").replace(/^models\//, "")) + .filter(Boolean) + .sort(); +} + +async function fetchOpenAIModels(token: string): Promise { + const response = await fetch("https://api.openai.com/v1/models", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) throw new Error(`OpenAI /models ${response.status}`); + + const json = (await response.json()) as { + data?: { id?: string }[]; + }; + const ids = (json.data ?? []).map((m) => m.id ?? "").filter(Boolean); + const filtered = filterOpenAIChatModels(ids); + if (filtered.length === 0) throw new Error("OpenAI /models sin chat models"); + return filtered; +} + +async function fetchAnthropicModels(token: string): Promise { + const response = await fetch("https://api.anthropic.com/v1/models", { + headers: { + "x-api-key": token, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }, + }); + if (!response.ok) throw new Error(`Anthropic /models ${response.status}`); + + const json = (await response.json()) as { + data?: { id?: string }[]; + }; + const ids = (json.data ?? []).map((m) => m.id ?? "").filter(Boolean); + if (ids.length === 0) throw new Error("Anthropic /models vacío"); + return ids.sort(); +} + +async function fetchGeminiModels(token: string): Promise { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent( + token + )}` + ); + if (!response.ok) throw new Error(`Gemini /models ${response.status}`); + + const json = (await response.json()) as { + models?: { name?: string; supportedGenerationMethods?: string[] }[]; + }; + const models = extractGeminiModels(json.models ?? []); + if (models.length === 0) throw new Error("Gemini /models sin generateContent"); + return models; +} + +// Pega al /models del provider con la key; ante CUALQUIER fallo cae a la lista +// curada con fromFallback:true. Nunca tira: el picker siempre recibe modelos. +export async function fetchProviderModels( + provider: AgentProvider, + token: string +): Promise { + try { + if (!token) throw new Error("sin token"); + + let models: string[]; + switch (provider) { + case "openai": + models = await fetchOpenAIModels(token); + break; + case "anthropic": + models = await fetchAnthropicModels(token); + break; + case "gemini": + models = await fetchGeminiModels(token); + break; + default: + throw new Error(`provider desconocido: ${provider}`); + } + + return { models, fromFallback: false }; + } catch { + return { models: CURATED_MODELS[provider], fromFallback: true }; + } +} From d631c768f925b445b4f1e6d871ed75cf34da807e Mon Sep 17 00:00:00 2001 From: juan Date: Mon, 29 Jun 2026 11:59:12 -0300 Subject: [PATCH 4/7] feat(ai): header del chat + dialogs de modelo/proveedor/key + shimmer --- src/app/(outerbase)/local-setting-dialog.tsx | 21 +-- src/app/(outerbase)/model-picker-dialog.tsx | 140 ++++++++++++++++++ .../(outerbase)/provider-picker-dialog.tsx | 88 +++++++++++ src/components/gui/tabs/chat-shimmer.tsx | 47 ++++++ src/components/gui/tabs/chat-tab-header.tsx | 85 +++++++++++ 5 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 src/app/(outerbase)/model-picker-dialog.tsx create mode 100644 src/app/(outerbase)/provider-picker-dialog.tsx create mode 100644 src/components/gui/tabs/chat-shimmer.tsx create mode 100644 src/components/gui/tabs/chat-tab-header.tsx diff --git a/src/app/(outerbase)/local-setting-dialog.tsx b/src/app/(outerbase)/local-setting-dialog.tsx index d6c00633..86134c4a 100644 --- a/src/app/(outerbase)/local-setting-dialog.tsx +++ b/src/app/(outerbase)/local-setting-dialog.tsx @@ -54,11 +54,12 @@ export const localSettingDialog = createDialog(({ close }) => { return ( <> - AI Assistant Setting + API Key Elegí un proveedor y pegá tu API key para habilitar el asistente (text-to-SQL y explicaciones). La key se guarda solo en localStorage de - tu navegador. No la guardamos en ningún servidor ni se loguea. + tu navegador. No la guardamos en ningún servidor ni se loguea. El modelo lo + podés elegir desde la barra del chat; acá queda como override manual opcional. @@ -78,14 +79,6 @@ export const localSettingDialog = createDialog(({ close }) => { - - { value={token} onValueChange={setToken} /> + + diff --git a/src/app/(outerbase)/model-picker-dialog.tsx b/src/app/(outerbase)/model-picker-dialog.tsx new file mode 100644 index 00000000..4c854b81 --- /dev/null +++ b/src/app/(outerbase)/model-picker-dialog.tsx @@ -0,0 +1,140 @@ +"use client"; +import { createDialog } from "@/components/create-dialog"; +import { Loader } from "@/components/orbit/loader"; +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { fetchProviderModels } from "@/lib/agent-models"; +import { + getAgentFromLocalStorage, + patchAgentConfig, +} from "@/lib/ai-agent-storage"; +import { cn } from "@/lib/utils"; +import { Check, MagnifyingGlass } from "@phosphor-icons/react"; +import { useEffect, useMemo, useState } from "react"; + +const PROVIDER_LABEL: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + gemini: "Gemini", +}; + +// Dialog para elegir el modelo activo. Fetchea la lista live del provider (con +// fallback curado) y muestra buscador + loader + badge "lista offline". Al elegir, +// hace un update PARCIAL de la config (solo model) reusando patchAgentConfig, que +// mutea el SWR `/local-agent-setting` → el header y el driver se re-arman solos. +export const modelPickerDialog = createDialog(({ close }) => { + // Snapshot de la config al abrir (provider + token para el fetch, model para marcar + // el activo). useMemo([]) → se lee una vez al montar el dialog. + const current = useMemo(() => getAgentFromLocalStorage(), []); + + const [models, setModels] = useState([]); + const [fromFallback, setFromFallback] = useState(false); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + + useEffect(() => { + if (!current) { + setLoading(false); + return; + } + + // alive: evita el setState si el dialog se cierra mientras el fetch está en vuelo. + let alive = true; + setLoading(true); + fetchProviderModels(current.provider, current.token) + .then((res) => { + if (!alive) return; + setModels(res.models); + setFromFallback(res.fromFallback); + }) + .finally(() => { + if (alive) setLoading(false); + }); + + return () => { + alive = false; + }; + }, [current]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return models; + return models.filter((m) => m.toLowerCase().includes(q)); + }, [models, search]); + + const onPick = (model: string) => { + patchAgentConfig({ model }); + close(undefined); + }; + + return ( + <> + + Elegí un modelo + + {current + ? `Modelos de ${PROVIDER_LABEL[current.provider] ?? current.provider}.` + : "Configurá un proveedor y una API key primero."} + {fromFallback && ( + + lista offline + + )} + + + + {current && ( +
+
+ + setSearch(e.currentTarget.value)} + placeholder="Buscar modelo…" + autoFocus + className="h-9 grow bg-transparent text-sm outline-none" + /> +
+ +
+ {loading ? ( +
+ + Cargando modelos… +
+ ) : filtered.length === 0 ? ( +
+ No hay modelos que coincidan con “{search}”. +
+ ) : ( + filtered.map((model) => { + const active = model === current.model; + return ( + + ); + }) + )} +
+
+ )} + + ); +}); diff --git a/src/app/(outerbase)/provider-picker-dialog.tsx b/src/app/(outerbase)/provider-picker-dialog.tsx new file mode 100644 index 00000000..0f30c9fa --- /dev/null +++ b/src/app/(outerbase)/provider-picker-dialog.tsx @@ -0,0 +1,88 @@ +"use client"; +import { createDialog } from "@/components/create-dialog"; +import { Button } from "@/components/orbit/button"; +import { + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + AgentProvider, + DEFAULT_MODEL_BY_PROVIDER, + getAgentFromLocalStorage, + patchAgentConfig, +} from "@/lib/ai-agent-storage"; +import { useMemo, useState } from "react"; +import { localSettingDialog } from "./local-setting-dialog"; + +const PROVIDERS: { value: AgentProvider; label: string }[] = [ + { value: "anthropic", label: "Anthropic (Claude)" }, + { value: "openai", label: "OpenAI (ChatGPT)" }, + { value: "gemini", label: "Google Gemini" }, +]; + +// Dialog para cambiar de proveedor. Al elegir uno distinto, el modelo se resetea al +// default de ESE provider (DEFAULT_MODEL_BY_PROVIDER) vía patchAgentConfig. Si no hay +// API key guardada, ofrece abrir el dialog de key (la key es por-provider). +export const providerPickerDialog = createDialog(({ close }) => { + const current = useMemo(() => getAgentFromLocalStorage(), []); + const [provider, setProvider] = useState( + current?.provider ?? "anthropic" + ); + + const changed = provider !== current?.provider; + + const onSave = () => { + // Reset del modelo al default del provider elegido. patchAgentConfig mergea sobre + // la config actual (mantiene el token) y mutea el SWR → header/driver se re-arman. + patchAgentConfig({ + provider, + model: DEFAULT_MODEL_BY_PROVIDER[provider], + }); + close(undefined); + + // Sin key → ofrecemos pegarla (la key anterior, si la había, es de otro provider). + if (!current?.token) { + localSettingDialog.show({}).then().catch(); + } + }; + + return ( + <> + + Elegí un proveedor + + Al cambiar de proveedor, el modelo se resetea al default. Si no tenés una + API key para ese proveedor, te la vamos a pedir. + + + + setProvider(v as AgentProvider)} + className="flex flex-col gap-1" + > + {PROVIDERS.map((p) => ( + + ))} + + + + + + + ); +}); diff --git a/src/components/gui/tabs/chat-shimmer.tsx b/src/components/gui/tabs/chat-shimmer.tsx new file mode 100644 index 00000000..3207ad8f --- /dev/null +++ b/src/components/gui/tabs/chat-shimmer.tsx @@ -0,0 +1,47 @@ +"use client"; +import { Sparkle } from "@phosphor-icons/react"; + +// Loader estilo Gemini: un sparkle que pulsa + un texto con gradiente que barre +// (shimmer sweep). Se muestra mientras el stream todavía no emitió texto; el chat +// tab le pasa label="Razonando…" cuando llega reasoning. CSS puro (background-position +// animado) inline para que el componente sea autocontenido — sin tocar globals.css. +export default function ChatShimmer({ + label = "Generando…", +}: { + label?: string; +}) { + return ( +
+ + {label} + +
+ ); +} diff --git a/src/components/gui/tabs/chat-tab-header.tsx b/src/components/gui/tabs/chat-tab-header.tsx new file mode 100644 index 00000000..a552ce75 --- /dev/null +++ b/src/components/gui/tabs/chat-tab-header.tsx @@ -0,0 +1,85 @@ +"use client"; +import { localSettingDialog } from "@/app/(outerbase)/local-setting-dialog"; +import { modelPickerDialog } from "@/app/(outerbase)/model-picker-dialog"; +import { providerPickerDialog } from "@/app/(outerbase)/provider-picker-dialog"; +import { getAgentFromLocalStorage } from "@/lib/ai-agent-storage"; +import { Key, Plus, Sparkle } from "@phosphor-icons/react"; +import useSWR from "swr"; + +const PROVIDER_LABEL: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + gemini: "Gemini", +}; + +// Barra superior del chat: [sparkle] Provider · model, botón 🔑 (API key) y + (new +// chat). Lee la config vía el MISMO SWR `/local-agent-setting` que usa el storage, así +// se re-renderiza solo cuando se guarda desde cualquiera de los dialogs. Click en el +// provider abre el provider-picker; click en el modelo abre el model-picker. +export default function ChatTabHeader({ + onNewChat, +}: { + onNewChat: () => void; +}) { + const { data: config } = useSWR( + "/local-agent-setting", + getAgentFromLocalStorage + ); + + return ( +
+ + + {config ? ( +
+ + · + +
+ ) : ( + + )} + +
+ + + +
+ ); +} From 829c953a5de7450a7fd37783f5569c0fa578f1ff Mon Sep 17 00:00:00 2001 From: juan Date: Mon, 29 Jun 2026 11:59:13 -0300 Subject: [PATCH 5/7] feat(ai): el chat-tab consume el stream (reasoning, tool chips, new chat) --- src/components/gui/tabs/chat-tab.tsx | 238 ++++++++++++++++++++++----- 1 file changed, 200 insertions(+), 38 deletions(-) diff --git a/src/components/gui/tabs/chat-tab.tsx b/src/components/gui/tabs/chat-tab.tsx index f2af7c29..6993784d 100644 --- a/src/components/gui/tabs/chat-tab.tsx +++ b/src/components/gui/tabs/chat-tab.tsx @@ -5,21 +5,81 @@ import { Button } from "@/components/ui/button"; import { useStudioContext } from "@/context/driver-provider"; import { useSchema } from "@/context/schema-provider"; import { scc } from "@/core/command"; +import { AgentStreamEvent } from "@/drivers/agent/base"; import { SupportedDialect } from "@/drivers/base-driver"; import { generateId } from "@/lib/generate-id"; import { ArrowSquareOut, + CaretDown, + CaretRight, Copy, PaperPlaneTilt, Robot, Sparkle, + Wrench, } from "@phosphor-icons/react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import ChatShimmer from "./chat-shimmer"; +import ChatTabHeader from "./chat-tab-header"; + +interface ToolCall { + id: string; + name: string; + args?: string; +} interface ChatMessage { role: "user" | "assistant"; content: string; + // Razonamiento del modelo (Anthropic/Gemini con flag). Se renderiza en un bloque + // colapsable arriba del texto. OpenAI no lo expone → queda vacío. + reasoning?: string; + // Tool-calls: scaffold display-only (hoy no se emiten). Se dibujan como chips. + toolCalls?: ToolCall[]; + // El turno del assistant todavía está streameando (controla shimmer + auto-colapso). + streaming?: boolean; +} + +// Aplica un evento del stream al ÚLTIMO mensaje (que siempre es el assistant en +// curso). Devuelve un array nuevo (inmutable) para que React re-renderice. +function applyEvent( + messages: ChatMessage[], + event: AgentStreamEvent +): ChatMessage[] { + const idx = messages.length - 1; + if (idx < 0) return messages; + + const last = messages[idx]; + if (last.role !== "assistant") return messages; + + const next: ChatMessage = { ...last }; + switch (event.type) { + case "text": + next.content = (next.content || "") + event.delta; + break; + case "reasoning": + next.reasoning = (next.reasoning || "") + event.delta; + break; + case "tool_call": + next.toolCalls = [ + ...(next.toolCalls || []), + { id: event.id, name: event.name, args: event.args }, + ]; + break; + case "error": + // Si no hubo nada de texto, mostramos el error como contenido del turno. + next.content = next.content || "Error: " + event.message; + next.streaming = false; + break; + case "done": + next.streaming = false; + break; + } + + const copy = messages.slice(); + copy[idx] = next; + return copy; } // Segmento de un mensaje del assistant: o bien prosa (texto) o un bloque de @@ -168,6 +228,73 @@ function AssistantMessage({ ); } +// Bloque colapsable de razonamiento ("Razonando…"). Mientras el assistant streamea, +// arranca abierto para que se vea el reasoning en vivo; cuando termina (streaming → +// false) se auto-colapsa. El usuario puede togglearlo a mano en cualquier momento. +function ReasoningBlock({ + reasoning, + streaming, +}: { + reasoning: string; + streaming?: boolean; +}) { + const [open, setOpen] = useState(true); + // userToggled: si el usuario tocó el bloque, respetamos su decisión y no + // auto-colapsamos al terminar. + const userToggled = useRef(false); + + useEffect(() => { + if (!streaming && !userToggled.current) { + setOpen(false); + } + }, [streaming]); + + return ( +
+ + {open && ( +
+ {reasoning} +
+ )} +
+ ); +} + +// Chips de tool-calls. Scaffold: hoy no se emiten (no hay tools reales), pero si +// llegaran eventos tool_call en el stream, la UI ya sabe dibujarlos. +function ToolCallChips({ toolCalls }: { toolCalls: ToolCall[] }) { + if (toolCalls.length === 0) return null; + return ( +
+ {toolCalls.map((tc) => ( + + + {tc.name} + + ))} +
+ ); +} + export default function ChatWindow() { const { agentDriver, databaseDriver } = useStudioContext(); const { schema, currentSchemaName } = useSchema(); @@ -195,15 +322,28 @@ export default function ChatWindow() { if (!text || loading || !agentDriver || !agentDriver.hasUsableModel()) return; setInput(""); - setMessages((prev) => [...prev, { role: "user", content: text }]); + // Empujamos el turno del usuario + un placeholder de assistant en streaming. + // El placeholder es el ÚLTIMO mensaje: applyEvent siempre actualiza ese. + setMessages((prev) => [ + ...prev, + { role: "user", content: text }, + { + role: "assistant", + content: "", + reasoning: "", + toolCalls: [], + streaming: true, + }, + ]); setLoading(true); scrollToBottom(); try { - // chat() devuelve el texto crudo del assistant (markdown, con posibles - // bloques ```sql). NO usamos run() porque recorta a SQL y tira error en - // prosa. NO se ejecuta nada: solo se genera y se muestra. - const reply = await agentDriver.chat( + // chatStream() emite el texto token a token vía onEvent (text/reasoning/ + // tool_call/done/error). Arma la MISMA sesión multi-turno que chat() y persiste + // el historial. NO se ejecuta nada: solo se genera y se muestra. Si el stream + // falla, el driver cae solo al query() no-streaming (hard fallback). + await agentDriver.chatStream( agentDriver.getDefaultModelName(), text, sessionId.current, @@ -211,20 +351,22 @@ export default function ChatWindow() { selected: "", schema, selectedSchema: currentSchemaName, + }, + (event) => { + setMessages((prev) => applyEvent(prev, event)); + if (event.type === "text") scrollToBottom(); } ); - - setMessages((prev) => [...prev, { role: "assistant", content: reply }]); } catch (e) { - setMessages((prev) => [ - ...prev, - { - role: "assistant", - content: - "Error: " + - (e instanceof Error ? e.message : "no se pudo generar la respuesta"), - }, - ]); + // chatStream no debería tirar (emite "error" como evento), pero por las dudas + // (p.ej. resolveDriver sin driver) cerramos el turno con el error visible. + setMessages((prev) => + applyEvent(prev, { + type: "error", + message: + e instanceof Error ? e.message : "no se pudo generar la respuesta", + }) + ); } finally { setLoading(false); scrollToBottom(); @@ -238,6 +380,14 @@ export default function ChatWindow() { scrollToBottom, ]); + // Nuevo chat: limpia los mensajes y rota el sessionId → el driver arranca un + // historial in-memory fresco (re-manda system + schema en el próximo turno). + const onNewChat = useCallback(() => { + setMessages([]); + setInput(""); + sessionId.current = generateId(); + }, []); + const onKeyDown = useCallback( (e: React.KeyboardEvent) => { // Enter envía; Shift+Enter inserta salto de línea. @@ -280,6 +430,8 @@ export default function ChatWindow() { return (
+ +
{ const isUser = message.role === "user"; + if (isUser) { + return ( +
+
+ {message.content} +
+
+ ); + } + + // Assistant: reasoning colapsable + tool chips + texto. Mientras no haya + // texto y el turno siga streameando, mostramos el shimmer (muta a + // "Razonando…" si ya llegó reasoning). + const hasReasoning = !!message.reasoning; + const showShimmer = message.streaming && !message.content; + return ( -
-
- {isUser ? ( - message.content +
+
+ {hasReasoning && ( + + )} + + {message.toolCalls && message.toolCalls.length > 0 && ( + + )} + + {showShimmer ? ( + ) : ( ); })} - - {loading && ( -
-
- Pensando… -
-
- )}
From 1f04e5af6807d78a5c49a590ac472d59058dd851 Mon Sep 17 00:00:00 2001 From: juan Date: Mon, 29 Jun 2026 11:59:13 -0300 Subject: [PATCH 6/7] test(ai): agent-models, queryStream SSE por provider y storage helpers --- src/drivers/agent/agent-stream.test.ts | 241 +++++++++++++++++++++++++ src/drivers/agent/agent.test.ts | 5 + src/lib/agent-models.test.ts | 159 ++++++++++++++++ src/lib/ai-agent-storage.test.ts | 123 +++++++++++++ 4 files changed, 528 insertions(+) create mode 100644 src/drivers/agent/agent-stream.test.ts create mode 100644 src/lib/agent-models.test.ts create mode 100644 src/lib/ai-agent-storage.test.ts diff --git a/src/drivers/agent/agent-stream.test.ts b/src/drivers/agent/agent-stream.test.ts new file mode 100644 index 00000000..9e0e425c --- /dev/null +++ b/src/drivers/agent/agent-stream.test.ts @@ -0,0 +1,241 @@ +import { BaseDriver } from "../base-driver"; +import { AgentStreamEvent } from "./base"; +import { AnthropicDriver } from "./anthropic"; +import { ChatGPTDriver } from "./chatgpt"; +import { GeminiDriver } from "./gemini"; + +// Driver de DB falso: los queryStream no usan flags, pero el constructor pide uno. +function fakeDriver(): BaseDriver { + return { + getFlags: () => ({ dialect: "postgres", defaultSchema: "public" }), + escapeId: (id: string) => `"${id}"`, + } as unknown as BaseDriver; +} + +// Response con un body ReadableStream que emite los chunks dados (como si fueran los +// trozos que llegan por la red). Permite partir un evento SSE en varios chunks. +function sseResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(encoder.encode(c)); + controller.close(); + }, + }); + return { ok: true, body } as unknown as Response; +} + +const httpError = (status = 401): Response => + ({ + ok: false, + status, + statusText: "Unauthorized", + text: async () => JSON.stringify({ error: { message: "bad key" } }), + }) as unknown as Response; + +const collect = () => { + const events: AgentStreamEvent[] = []; + return { events, push: (e: AgentStreamEvent) => events.push(e) }; +}; + +const texts = (events: AgentStreamEvent[]) => + events.filter((e) => e.type === "text").map((e) => (e as { delta: string }).delta); +const reasonings = (events: AgentStreamEvent[]) => + events + .filter((e) => e.type === "reasoning") + .map((e) => (e as { delta: string }).delta); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("ChatGPTDriver.queryStream — SSE de OpenAI", () => { + test("acumula choices[].delta.content y corta en [DONE]", async () => { + global.fetch = jest.fn().mockResolvedValue( + sseResponse([ + 'data: {"choices":[{"delta":{"content":"SE"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"LECT 1"}}]}\n\n', + "data: [DONE]\n\n", + ]) + ) as unknown as typeof fetch; + + const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o"); + const c = collect(); + const out = await driver.queryStream([{ role: "user", content: "hi" }], c.push); + + expect(out).toBe("SELECT 1"); + expect(texts(c.events)).toEqual(["SE", "LECT 1"]); + // OpenAI por chat-completions NO emite reasoning. + expect(reasonings(c.events)).toEqual([]); + }); + + test("manda stream:true en el body", async () => { + const fetchSpy = jest + .fn() + .mockResolvedValue(sseResponse(["data: [DONE]\n\n"])); + global.fetch = fetchSpy as unknown as typeof fetch; + + const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o"); + await driver.queryStream([{ role: "user", content: "hi" }], () => {}); + + const body = JSON.parse( + (fetchSpy.mock.calls[0][1] as RequestInit).body as string + ); + expect(body.stream).toBe(true); + expect(body.model).toBe("gpt-4o"); + }); + + test("reensambla un evento partido en varios chunks", async () => { + global.fetch = jest.fn().mockResolvedValue( + sseResponse([ + 'data: {"choi', + 'ces":[{"delta":{"content":"X"}}]}\n\n', + "data: [DONE]\n\n", + ]) + ) as unknown as typeof fetch; + + const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o"); + const c = collect(); + const out = await driver.queryStream([{ role: "user", content: "hi" }], c.push); + expect(out).toBe("X"); + }); + + test("HTTP no-ok → tira (para que chatStream caiga al fallback)", async () => { + global.fetch = jest + .fn() + .mockResolvedValue(httpError(401)) as unknown as typeof fetch; + + const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o"); + await expect( + driver.queryStream([{ role: "user", content: "hi" }], () => {}) + ).rejects.toThrow("bad key"); + }); +}); + +describe("AnthropicDriver.queryStream — SSE de Anthropic (text + thinking)", () => { + test("separa thinking_delta (reasoning) de text_delta (texto) y corta en message_stop", async () => { + global.fetch = jest.fn().mockResolvedValue( + sseResponse([ + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"pienso"}}\n\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"SELECT 1"}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ]) + ) as unknown as typeof fetch; + + const driver = new AnthropicDriver(fakeDriver(), "sk-ant", "claude-opus-4-8"); + const c = collect(); + const out = await driver.queryStream([{ role: "user", content: "hi" }], c.push); + + expect(out).toBe("SELECT 1"); + expect(texts(c.events)).toEqual(["SELECT 1"]); + expect(reasonings(c.events)).toEqual(["pienso"]); + }); + + test("pide thinking + stream en el body", async () => { + const fetchSpy = jest.fn().mockResolvedValue( + sseResponse(['event: message_stop\ndata: {"type":"message_stop"}\n\n']) + ); + global.fetch = fetchSpy as unknown as typeof fetch; + + const driver = new AnthropicDriver(fakeDriver(), "sk-ant", "claude-opus-4-8"); + await driver.queryStream([{ role: "user", content: "hi" }], () => {}); + + const body = JSON.parse( + (fetchSpy.mock.calls[0][1] as RequestInit).body as string + ); + expect(body.stream).toBe(true); + expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 1024 }); + }); + + test("un evento error en el stream → tira", async () => { + global.fetch = jest.fn().mockResolvedValue( + sseResponse([ + 'event: error\ndata: {"type":"error","error":{"message":"overloaded"}}\n\n', + ]) + ) as unknown as typeof fetch; + + const driver = new AnthropicDriver(fakeDriver(), "sk-ant", "claude-opus-4-8"); + await expect( + driver.queryStream([{ role: "user", content: "hi" }], () => {}) + ).rejects.toThrow("overloaded"); + }); +}); + +describe("GeminiDriver.queryStream — SSE de Gemini (thought vs texto)", () => { + test("parts con thought:true → reasoning, el resto → texto", async () => { + global.fetch = jest.fn().mockResolvedValue( + sseResponse([ + 'data: {"candidates":[{"content":{"parts":[{"text":"razono","thought":true}]}}]}\n\n', + 'data: {"candidates":[{"content":{"parts":[{"text":"SELECT 1"}]}}]}\n\n', + ]) + ) as unknown as typeof fetch; + + const driver = new GeminiDriver(fakeDriver(), "g-key", "gemini-2.0-flash"); + const c = collect(); + const out = await driver.queryStream([{ role: "user", content: "hi" }], c.push); + + expect(out).toBe("SELECT 1"); + expect(texts(c.events)).toEqual(["SELECT 1"]); + expect(reasonings(c.events)).toEqual(["razono"]); + }); + + test("usa el endpoint :streamGenerateContent?alt=sse", async () => { + const fetchSpy = jest.fn().mockResolvedValue(sseResponse([])); + global.fetch = fetchSpy as unknown as typeof fetch; + + const driver = new GeminiDriver(fakeDriver(), "g-key", "gemini-2.0-flash"); + await driver.queryStream([{ role: "user", content: "hi" }], () => {}); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain(":streamGenerateContent"); + expect(url).toContain("alt=sse"); + }); +}); + +describe("chatStream — hard fallback al query() no-streaming", () => { + test("si el stream no arranca (HTTP no-ok), cae a query() y emite un único text + done", async () => { + // 1ª llamada (queryStream): HTTP no-ok → tira. 2ª (query no-stream): responde OK. + global.fetch = jest + .fn() + .mockResolvedValueOnce(httpError(500)) + .mockResolvedValueOnce({ + json: async () => ({ + choices: [{ message: { role: "assistant", content: "SELECT 42" } }], + }), + } as Response) as unknown as typeof fetch; + + const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o"); + const c = collect(); + const out = await driver.chatStream( + "dame un número", + undefined, + { selected: "" }, + c.push + ); + + expect(out).toBe("SELECT 42"); + expect(texts(c.events)).toEqual(["SELECT 42"]); + expect(c.events.some((e) => e.type === "done")).toBe(true); + }); + + test("camino feliz: streamea y persiste el texto acumulado", async () => { + global.fetch = jest.fn().mockResolvedValue( + sseResponse([ + 'data: {"choices":[{"delta":{"content":"hola"}}]}\n\n', + "data: [DONE]\n\n", + ]) + ) as unknown as typeof fetch; + + const driver = new ChatGPTDriver(fakeDriver(), "sk", "gpt-4o"); + const c = collect(); + const out = await driver.chatStream( + "hi", + undefined, + { selected: "" }, + c.push + ); + + expect(out).toBe("hola"); + expect(c.events[c.events.length - 1]).toEqual({ type: "done" }); + }); +}); diff --git a/src/drivers/agent/agent.test.ts b/src/drivers/agent/agent.test.ts index 0806363d..970669b5 100644 --- a/src/drivers/agent/agent.test.ts +++ b/src/drivers/agent/agent.test.ts @@ -23,6 +23,11 @@ class TestDriver extends CommonAgentDriverImplementation { return ""; } + // Stub: estos tests no ejercitan el streaming (ver agent-stream.test.ts). + async queryStream(_messages: CommonAgentMessage[]): Promise { + return ""; + } + publicConvertTableToDDLContent( schemaName: string | undefined, table: DatabaseTableSchema diff --git a/src/lib/agent-models.test.ts b/src/lib/agent-models.test.ts new file mode 100644 index 00000000..98e2a943 --- /dev/null +++ b/src/lib/agent-models.test.ts @@ -0,0 +1,159 @@ +import { + CURATED_MODELS, + extractGeminiModels, + fetchProviderModels, + filterOpenAIChatModels, +} from "./agent-models"; + +// Mock de fetch que devuelve una Response con ok:true y json() = payload. +const okResponse = (payload: unknown) => + jest.fn().mockResolvedValue({ + ok: true, + json: async () => payload, + } as Response); + +// Mock de fetch que devuelve ok:false (HTTP error → debe caer al fallback curado). +const errorResponse = (status = 403) => + jest.fn().mockResolvedValue({ + ok: false, + status, + statusText: "Forbidden", + json: async () => ({ error: { message: "nope" } }), + } as Response); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("filterOpenAIChatModels — solo chat models", () => { + test("incluye gpt* y o*, ordenados", () => { + const out = filterOpenAIChatModels([ + "gpt-4o", + "gpt-4o-mini", + "o3", + "o4-mini", + ]); + expect(out).toEqual(["gpt-4o", "gpt-4o-mini", "o3", "o4-mini"]); + }); + + test("excluye embeddings, audio, imágenes, moderación, instruct", () => { + const out = filterOpenAIChatModels([ + "gpt-4o", + "text-embedding-3-small", + "whisper-1", + "dall-e-3", + "tts-1", + "omni-moderation-latest", + "gpt-3.5-turbo-instruct", + "gpt-4o-audio-preview", + "gpt-4o-realtime-preview", + ]); + expect(out).toEqual(["gpt-4o"]); + }); + + test("descarta ids vacíos", () => { + expect(filterOpenAIChatModels(["", "gpt-4o"])).toEqual(["gpt-4o"]); + }); +}); + +describe("extractGeminiModels — filtra generateContent + limpia prefijo", () => { + test("solo modelos con generateContent, sin prefijo models/", () => { + const out = extractGeminiModels([ + { + name: "models/gemini-2.0-flash", + supportedGenerationMethods: ["generateContent", "countTokens"], + }, + { + name: "models/embedding-001", + supportedGenerationMethods: ["embedContent"], + }, + { + name: "models/gemini-1.5-pro", + supportedGenerationMethods: ["generateContent"], + }, + ]); + expect(out).toEqual(["gemini-1.5-pro", "gemini-2.0-flash"]); + }); +}); + +describe("fetchProviderModels — live + fallback curado", () => { + test("openai: parsea data[].id y filtra a chat models", async () => { + global.fetch = okResponse({ + data: [ + { id: "gpt-4o" }, + { id: "o3" }, + { id: "text-embedding-3-large" }, + ], + }) as unknown as typeof fetch; + + const res = await fetchProviderModels("openai", "sk-test"); + expect(res.fromFallback).toBe(false); + expect(res.models).toContain("gpt-4o"); + expect(res.models).toContain("o3"); + expect(res.models).not.toContain("text-embedding-3-large"); + }); + + test("anthropic: parsea data[].id y ordena", async () => { + const fetchSpy = okResponse({ + data: [{ id: "claude-sonnet-4-6" }, { id: "claude-opus-4-8" }], + }); + global.fetch = fetchSpy as unknown as typeof fetch; + + const res = await fetchProviderModels("anthropic", "sk-ant"); + expect(res.fromFallback).toBe(false); + expect(res.models).toEqual(["claude-opus-4-8", "claude-sonnet-4-6"]); + + // Manda la API key por x-api-key + el header de browser access. + const headers = (fetchSpy.mock.calls[0][1] as RequestInit) + .headers as Record; + expect(headers["x-api-key"]).toBe("sk-ant"); + expect(headers["anthropic-dangerous-direct-browser-access"]).toBe("true"); + }); + + test("gemini: filtra generateContent y limpia models/", async () => { + global.fetch = okResponse({ + models: [ + { + name: "models/gemini-2.0-flash", + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/embedding-001", + supportedGenerationMethods: ["embedContent"], + }, + ], + }) as unknown as typeof fetch; + + const res = await fetchProviderModels("gemini", "g-key"); + expect(res.fromFallback).toBe(false); + expect(res.models).toEqual(["gemini-2.0-flash"]); + }); + + test("HTTP error → cae a la lista curada con fromFallback:true", async () => { + global.fetch = errorResponse(403) as unknown as typeof fetch; + + const res = await fetchProviderModels("openai", "sk-bad"); + expect(res.fromFallback).toBe(true); + expect(res.models).toEqual(CURATED_MODELS.openai); + }); + + test("fetch que rechaza (red/CORS) → fallback curado", async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error("Failed to fetch")) as unknown as typeof fetch; + + const res = await fetchProviderModels("gemini", "g-key"); + expect(res.fromFallback).toBe(true); + expect(res.models).toEqual(CURATED_MODELS.gemini); + }); + + test("sin token → fallback curado, sin pegarle a la red", async () => { + const fetchSpy = jest.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + + const res = await fetchProviderModels("anthropic", ""); + expect(res.fromFallback).toBe(true); + expect(res.models).toEqual(CURATED_MODELS.anthropic); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ai-agent-storage.test.ts b/src/lib/ai-agent-storage.test.ts new file mode 100644 index 00000000..e2818792 --- /dev/null +++ b/src/lib/ai-agent-storage.test.ts @@ -0,0 +1,123 @@ +import { + DEFAULT_MODEL_BY_PROVIDER, + getAgentFromLocalStorage, + mergeAgentConfig, + patchAgentConfig, +} from "./ai-agent-storage"; + +// Mock mínimo de localStorage (jest corre en node, no hay window.localStorage real). +function mockLocalStorage() { + const store: Record = {}; + return { + getItem: (k: string) => (k in store ? store[k] : null), + setItem: (k: string, v: string) => { + store[k] = v; + }, + removeItem: (k: string) => { + delete store[k]; + }, + clear: () => { + for (const k of Object.keys(store)) delete store[k]; + }, + }; +} + +beforeEach(() => { + (global as unknown as { localStorage: Storage }).localStorage = + mockLocalStorage() as unknown as Storage; +}); + +describe("mergeAgentConfig — merge parcial puro", () => { + test("solo model: mantiene provider y token", () => { + const out = mergeAgentConfig( + { provider: "anthropic", model: "claude-opus-4-8", token: "k" }, + { model: "claude-sonnet-4-6" } + ); + expect(out).toEqual({ + provider: "anthropic", + model: "claude-sonnet-4-6", + token: "k", + }); + }); + + test("cambio de provider sin model: cae al default del nuevo provider, mantiene token", () => { + const out = mergeAgentConfig( + { provider: "anthropic", model: "claude-opus-4-8", token: "k" }, + { provider: "openai" } + ); + expect(out).toEqual({ + provider: "openai", + model: DEFAULT_MODEL_BY_PROVIDER.openai, + token: "k", + }); + }); + + test("sin current: defaults (anthropic, token vacío) con el partial encima", () => { + const out = mergeAgentConfig(undefined, { model: "x" }); + expect(out).toEqual({ provider: "anthropic", model: "x", token: "" }); + }); +}); + +describe("getAgentFromLocalStorage — parseo defensivo", () => { + test("config válida → la devuelve", () => { + localStorage.setItem( + "agent", + JSON.stringify({ provider: "openai", model: "gpt-4o", token: "sk" }) + ); + expect(getAgentFromLocalStorage()).toEqual({ + provider: "openai", + model: "gpt-4o", + token: "sk", + }); + }); + + test("provider inválido → undefined", () => { + localStorage.setItem( + "agent", + JSON.stringify({ provider: "cohere", model: "x", token: "sk" }) + ); + expect(getAgentFromLocalStorage()).toBeUndefined(); + }); + + test("sin token → undefined", () => { + localStorage.setItem( + "agent", + JSON.stringify({ provider: "openai", model: "gpt-4o" }) + ); + expect(getAgentFromLocalStorage()).toBeUndefined(); + }); + + test("sin model → usa el default del provider", () => { + localStorage.setItem( + "agent", + JSON.stringify({ provider: "gemini", token: "g" }) + ); + expect(getAgentFromLocalStorage()?.model).toBe( + DEFAULT_MODEL_BY_PROVIDER.gemini + ); + }); + + test("JSON inválido → undefined", () => { + localStorage.setItem("agent", "{ no es json"); + expect(getAgentFromLocalStorage()).toBeUndefined(); + }); +}); + +describe("patchAgentConfig — merge + persistencia", () => { + test("patch de model sobre config guardada: persiste el merge y lo devuelve", () => { + localStorage.setItem( + "agent", + JSON.stringify({ provider: "anthropic", model: "claude-opus-4-8", token: "k" }) + ); + + const out = patchAgentConfig({ model: "claude-sonnet-4-6" }); + const merged = { + provider: "anthropic", + model: "claude-sonnet-4-6", + token: "k", + }; + + expect(out).toEqual(merged); + expect(JSON.parse(localStorage.getItem("agent") as string)).toEqual(merged); + }); +}); From a5c92fd8ae150da9b3c79cbaeca5009418b7fc5b Mon Sep 17 00:00:00 2001 From: juan Date: Mon, 29 Jun 2026 12:12:58 -0300 Subject: [PATCH 7/7] =?UTF-8?q?fix(ai):=20review=20adversarial=20=E2=80=94?= =?UTF-8?q?=20fallback=20robusto=20y=20key=20por-provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common.ts: el hard fallback gatea por TEXTO emitido, no por emittedAny. Si el stream emite solo reasoning (Anthropic/Gemini) y después falla antes del texto, ahora cae al query() no-streaming en vez de dejar el turno con 'Error:' - provider-picker: al cambiar de provider limpia el token (la key vieja es de otro provider → 401 silencioso que rompía chat Y Ctrl+B) y abre el dialog de key - sse.ts: cancela el reader en el path de error para no colgar la conexión - tests: reasoning→error→fallback, y merge con token vacío --- .../(outerbase)/provider-picker-dialog.tsx | 13 +++++--- src/drivers/agent/agent-stream.test.ts | 32 +++++++++++++++++++ src/drivers/agent/common.ts | 31 +++++++++--------- src/drivers/agent/sse.ts | 9 ++++++ src/lib/ai-agent-storage.test.ts | 14 ++++++++ 5 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/app/(outerbase)/provider-picker-dialog.tsx b/src/app/(outerbase)/provider-picker-dialog.tsx index 0f30c9fa..78ae550c 100644 --- a/src/app/(outerbase)/provider-picker-dialog.tsx +++ b/src/app/(outerbase)/provider-picker-dialog.tsx @@ -35,16 +35,21 @@ export const providerPickerDialog = createDialog(({ close }) => { const changed = provider !== current?.provider; const onSave = () => { - // Reset del modelo al default del provider elegido. patchAgentConfig mergea sobre - // la config actual (mantiene el token) y mutea el SWR → header/driver se re-arman. + // Reset del modelo al default del provider elegido. Si CAMBIÓ el provider, además + // LIMPIAMOS el token: la config guarda una sola key y la anterior es de OTRO + // provider — dejarla colgada mandaría una key inválida (401 silencioso) y rompería + // tanto el chat como el Ctrl+B text-to-SQL, que comparten esta config. Mejor quedar + // sin key (estado "configurá IA" explícito) que con una key equivocada. patchAgentConfig({ provider, model: DEFAULT_MODEL_BY_PROVIDER[provider], + ...(changed ? { token: "" } : {}), }); close(undefined); - // Sin key → ofrecemos pegarla (la key anterior, si la había, es de otro provider). - if (!current?.token) { + // Si cambió el provider (key vieja inservible) o no había key, abrimos el dialog + // para que el usuario pegue la key correcta del provider nuevo. + if (changed || !current?.token) { localSettingDialog.show({}).then().catch(); } }; diff --git a/src/drivers/agent/agent-stream.test.ts b/src/drivers/agent/agent-stream.test.ts index 9e0e425c..dbd5111c 100644 --- a/src/drivers/agent/agent-stream.test.ts +++ b/src/drivers/agent/agent-stream.test.ts @@ -218,6 +218,38 @@ describe("chatStream — hard fallback al query() no-streaming", () => { expect(c.events.some((e) => e.type === "done")).toBe(true); }); + test("si solo hubo reasoning antes del fallo, cae al fallback (no se pierde la respuesta)", async () => { + // 1ª (queryStream): emite thinking (reasoning) y después un evento error → tira, + // pero con text vacío. 2ª (query no-stream): responde OK. El fallback debe correr. + global.fetch = jest + .fn() + .mockResolvedValueOnce( + sseResponse([ + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"pienso"}}\n\n', + 'event: error\ndata: {"type":"error","error":{"message":"overloaded"}}\n\n', + ]) + ) + .mockResolvedValueOnce({ + json: async () => ({ + content: [{ type: "text", text: "SELECT 99" }], + }), + } as Response) as unknown as typeof fetch; + + const driver = new AnthropicDriver(fakeDriver(), "sk-ant", "claude-opus-4-8"); + const c = collect(); + const out = await driver.chatStream( + "hola", + undefined, + { selected: "" }, + c.push + ); + + expect(out).toBe("SELECT 99"); + expect(reasonings(c.events)).toEqual(["pienso"]); + expect(texts(c.events)).toEqual(["SELECT 99"]); + expect(c.events[c.events.length - 1]).toEqual({ type: "done" }); + }); + test("camino feliz: streamea y persiste el texto acumulado", async () => { global.fetch = jest.fn().mockResolvedValue( sseResponse([ diff --git a/src/drivers/agent/common.ts b/src/drivers/agent/common.ts index d5e31204..bb7180fa 100644 --- a/src/drivers/agent/common.ts +++ b/src/drivers/agent/common.ts @@ -249,11 +249,15 @@ ${dynamoClosing}`; // token a token vía onEvent. Acumula el texto y lo persiste igual que chat(). // // Hard fallback (centralizado acá, no duplicado en cada provider): si queryStream - // tira ANTES de emitir nada (caso típico: HTTP no-ok, CORS, red, o un modelo que - // rechaza el flag de reasoning), caemos al query() no-streaming y emitimos un único - // evento "text" con toda la respuesta. Si ya emitió tokens y después explota, no - // rehacemos la respuesta (duplicaría): cerramos con un "error". Nunca tira: siempre - // resuelve emitiendo "done", para que la UI event-driven cierre el turno. + // tira SIN haber emitido TEXTO (caso típico: HTTP no-ok, CORS, red, un modelo que + // rechaza el flag de reasoning, o un error SSE que llega DESPUÉS del reasoning pero + // ANTES del texto), caemos al query() no-streaming y emitimos un único evento "text" + // con toda la respuesta. La decisión gatea por TEXTO real mostrado al usuario, NO + // por reasoning: el reasoning va a un bloque aparte y no es "la respuesta", así que + // si solo hubo reasoning todavía es seguro (y deseable) reintentar por el camino + // no-streaming. Si ya salió texto y después explota, NO rehacemos (duplicaría): + // cerramos con un "error". Nunca tira: siempre resuelve emitiendo "done", para que + // la UI event-driven cierre el turno. async chatStream( message: string, previousId: string | undefined, @@ -265,17 +269,14 @@ ${dynamoClosing}`; conversational: true, }); + // Acumulamos el texto VISIBLE para: (a) persistir el historial y (b) decidir si el + // hard fallback es seguro. El reasoning NO se acumula acá a propósito (no es la + // respuesta y no debe inhibir el fallback). let text = ""; - let emittedAny = false; - // Envoltura: acumulamos el texto y marcamos si ya salió ALGO al usuario, para - // decidir si el fallback es seguro (sin duplicar) ante un fallo a mitad de stream. const wrapped: AgentStreamCallback = (event) => { if (event.type === "text") { text += event.delta; - emittedAny = true; - } else if (event.type === "reasoning" || event.type === "tool_call") { - emittedAny = true; } onEvent(event); }; @@ -289,8 +290,8 @@ ${dynamoClosing}`; onEvent({ type: "done" }); return text; } catch (streamError) { - if (emittedAny) { - // Ya mostramos tokens: no podemos rehacer la respuesta sin duplicarla. + if (text) { + // Ya mostramos TEXTO real: no podemos rehacer la respuesta sin duplicarla. onEvent({ type: "error", message: @@ -298,12 +299,12 @@ ${dynamoClosing}`; ? streamError.message : "El stream se interrumpió", }); - if (text) this.persistAssistant(session, text); + this.persistAssistant(session, text); onEvent({ type: "done" }); return text; } - // Fallback duro: el stream nunca arrancó → usamos el camino no-streaming. + // No salió texto (a lo sumo reasoning) → fallback duro al camino no-streaming. try { text = await this.query(session.messages); this.persistAssistant(session, text); diff --git a/src/drivers/agent/sse.ts b/src/drivers/agent/sse.ts index c00e8545..9719b4a0 100644 --- a/src/drivers/agent/sse.ts +++ b/src/drivers/agent/sse.ts @@ -45,6 +45,15 @@ export async function readSSEStream( if (buffer.length > 0) { await handleLine(buffer, onData); } + } catch (err) { + // Path de error (p.ej. el callback tira ante un evento "error" del provider): + // cancelamos el body para no dejar la conexión colgada antes de re-lanzar. + try { + await reader.cancel(); + } catch { + // el stream ya pudo haberse cerrado: lo ignoramos + } + throw err; } finally { reader.releaseLock(); } diff --git a/src/lib/ai-agent-storage.test.ts b/src/lib/ai-agent-storage.test.ts index e2818792..e24b7420 100644 --- a/src/lib/ai-agent-storage.test.ts +++ b/src/lib/ai-agent-storage.test.ts @@ -56,6 +56,20 @@ describe("mergeAgentConfig — merge parcial puro", () => { const out = mergeAgentConfig(undefined, { model: "x" }); expect(out).toEqual({ provider: "anthropic", model: "x", token: "" }); }); + + test("token:'' explícito limpia la key (cambio de provider la invalida)", () => { + // El provider-picker manda token:'' al cambiar de provider: la key vieja es de + // otro provider. mergeAgentConfig debe respetar el '' y NO recuperar el viejo. + const out = mergeAgentConfig( + { provider: "anthropic", model: "claude-opus-4-8", token: "sk-ant" }, + { provider: "openai", model: DEFAULT_MODEL_BY_PROVIDER.openai, token: "" } + ); + expect(out).toEqual({ + provider: "openai", + model: DEFAULT_MODEL_BY_PROVIDER.openai, + token: "", + }); + }); }); describe("getAgentFromLocalStorage — parseo defensivo", () => {