From 97be5262a8ad4e471459632e6fc1fd7dcb68538d Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 3 Mar 2026 08:23:29 -0500 Subject: [PATCH] new feature --- raggr-frontend/src/api/conversationService.ts | 61 +++++++++++++++++++ raggr-frontend/src/components/ChatScreen.tsx | 48 ++++++++++++--- raggr-frontend/src/components/ToolBubble.tsx | 5 ++ 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 raggr-frontend/src/components/ToolBubble.tsx diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index f5bfa1a..f80eb6a 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -1,5 +1,13 @@ import { userService } from "./userService"; +export type SSEEvent = + | { type: "tool_start"; tool: string } + | { type: "tool_end"; tool: string } + | { type: "response"; message: string } + | { type: "error"; message: string }; + +export type SSEEventCallback = (event: SSEEvent) => void; + interface Message { id: string; text: string; @@ -112,6 +120,59 @@ class ConversationService { return await response.json(); } + + async streamQuery( + query: string, + conversation_id: string, + onEvent: SSEEventCallback, + signal?: AbortSignal, + ): Promise { + const response = await userService.fetchWithRefreshToken( + `${this.conversationBaseUrl}/stream-query`, + { + method: "POST", + body: JSON.stringify({ query, conversation_id }), + signal, + }, + ); + + if (!response.ok) { + throw new Error("Failed to stream query"); + } + + await this._readSSEStream(response, onEvent); + } + + private async _readSSEStream( + response: Response, + onEvent: SSEEventCallback, + ): Promise { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n\n"); + buffer = parts.pop() ?? ""; + + for (const part of parts) { + const line = part.trim(); + if (!line.startsWith("data: ")) continue; + const data = line.slice(6); + if (data === "[DONE]") return; + try { + const event = JSON.parse(data) as SSEEvent; + onEvent(event); + } catch { + // ignore malformed events + } + } + } + } } export const conversationService = new ConversationService(); diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index 5b57bac..3129b57 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -2,13 +2,14 @@ import { useEffect, useState, useRef } from "react"; import { conversationService } from "../api/conversationService"; import { QuestionBubble } from "./QuestionBubble"; import { AnswerBubble } from "./AnswerBubble"; +import { ToolBubble } from "./ToolBubble"; import { MessageInput } from "./MessageInput"; import { ConversationList } from "./ConversationList"; import catIcon from "../assets/cat.png"; type Message = { text: string; - speaker: "simba" | "user"; + speaker: "simba" | "user" | "tool"; }; type QuestionAnswer = { @@ -25,6 +26,24 @@ type ChatScreenProps = { setAuthenticated: (isAuth: boolean) => void; }; +const TOOL_MESSAGES: Record = { + simba_search: "🔍 Searching Simba's records...", + web_search: "🌐 Searching the web...", + get_current_date: "📅 Checking today's date...", + ynab_budget_summary: "💰 Checking budget summary...", + ynab_search_transactions: "💳 Looking up transactions...", + ynab_category_spending: "📊 Analyzing category spending...", + ynab_insights: "📈 Generating budget insights...", + obsidian_search_notes: "📝 Searching notes...", + obsidian_read_note: "📖 Reading note...", + obsidian_create_note: "✏️ Saving note...", + obsidian_create_task: "✅ Creating task...", + journal_get_today: "📔 Reading today's journal...", + journal_get_tasks: "📋 Getting tasks...", + journal_add_task: "➕ Adding task...", + journal_complete_task: "✔️ Completing task...", +}; + export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { const [query, setQuery] = useState(""); const [answer, setAnswer] = useState(""); @@ -170,17 +189,25 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { abortControllerRef.current = abortController; try { - const result = await conversationService.sendQuery( + await conversationService.streamQuery( query, selectedConversation.id, + (event) => { + if (!isMountedRef.current) return; + if (event.type === "tool_start") { + const friendly = + TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`; + setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }])); + } else if (event.type === "response") { + setMessages((prev) => + prev.concat([{ text: event.message, speaker: "simba" }]), + ); + } else if (event.type === "error") { + console.error("Stream error:", event.message); + } + }, abortController.signal, ); - setQuestionsAnswers( - questionsAnswers.concat([{ question: query, answer: result.response }]), - ); - setMessages( - currMessages.concat([{ text: result.response, speaker: "simba" }]), - ); } catch (error) { // Ignore abort errors (these are intentional cancellations) if (error instanceof Error && error.name === "AbortError") { @@ -348,9 +375,10 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { )} {messages.map((msg, index) => { - if (msg.speaker === "simba") { + if (msg.speaker === "tool") + return ; + if (msg.speaker === "simba") return ; - } return ; })} {isLoading && } diff --git a/raggr-frontend/src/components/ToolBubble.tsx b/raggr-frontend/src/components/ToolBubble.tsx new file mode 100644 index 0000000..505dcb5 --- /dev/null +++ b/raggr-frontend/src/components/ToolBubble.tsx @@ -0,0 +1,5 @@ +export const ToolBubble = ({ text }: { text: string }) => ( +
+ {text} +
+);