new feature

This commit is contained in:
ryan
2026-03-03 08:23:29 -05:00
parent 86cc269b3a
commit 97be5262a8
3 changed files with 104 additions and 10 deletions

View File

@@ -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<void> {
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<void> {
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();

View File

@@ -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<string, string> = {
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<string>("");
const [answer, setAnswer] = useState<string>("");
@@ -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 <ToolBubble key={index} text={msg.text} />;
if (msg.speaker === "simba")
return <AnswerBubble key={index} text={msg.text} />;
}
return <QuestionBubble key={index} text={msg.text} />;
})}
{isLoading && <AnswerBubble text="" loading={true} />}

View File

@@ -0,0 +1,5 @@
export const ToolBubble = ({ text }: { text: string }) => (
<div className="text-sm text-gray-500 italic px-3 py-1 self-start">
{text}
</div>
);