new feature
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
import { userService } from "./userService";
|
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 {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -112,6 +120,59 @@ class ConversationService {
|
|||||||
|
|
||||||
return await response.json();
|
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();
|
export const conversationService = new ConversationService();
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { useEffect, useState, useRef } from "react";
|
|||||||
import { conversationService } from "../api/conversationService";
|
import { conversationService } from "../api/conversationService";
|
||||||
import { QuestionBubble } from "./QuestionBubble";
|
import { QuestionBubble } from "./QuestionBubble";
|
||||||
import { AnswerBubble } from "./AnswerBubble";
|
import { AnswerBubble } from "./AnswerBubble";
|
||||||
|
import { ToolBubble } from "./ToolBubble";
|
||||||
import { MessageInput } from "./MessageInput";
|
import { MessageInput } from "./MessageInput";
|
||||||
import { ConversationList } from "./ConversationList";
|
import { ConversationList } from "./ConversationList";
|
||||||
import catIcon from "../assets/cat.png";
|
import catIcon from "../assets/cat.png";
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
text: string;
|
text: string;
|
||||||
speaker: "simba" | "user";
|
speaker: "simba" | "user" | "tool";
|
||||||
};
|
};
|
||||||
|
|
||||||
type QuestionAnswer = {
|
type QuestionAnswer = {
|
||||||
@@ -25,6 +26,24 @@ type ChatScreenProps = {
|
|||||||
setAuthenticated: (isAuth: boolean) => void;
|
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) => {
|
export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||||
const [query, setQuery] = useState<string>("");
|
const [query, setQuery] = useState<string>("");
|
||||||
const [answer, setAnswer] = useState<string>("");
|
const [answer, setAnswer] = useState<string>("");
|
||||||
@@ -170,17 +189,25 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
abortControllerRef.current = abortController;
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await conversationService.sendQuery(
|
await conversationService.streamQuery(
|
||||||
query,
|
query,
|
||||||
selectedConversation.id,
|
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,
|
abortController.signal,
|
||||||
);
|
);
|
||||||
setQuestionsAnswers(
|
|
||||||
questionsAnswers.concat([{ question: query, answer: result.response }]),
|
|
||||||
);
|
|
||||||
setMessages(
|
|
||||||
currMessages.concat([{ text: result.response, speaker: "simba" }]),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore abort errors (these are intentional cancellations)
|
// Ignore abort errors (these are intentional cancellations)
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
@@ -348,9 +375,10 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg, index) => {
|
{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 <AnswerBubble key={index} text={msg.text} />;
|
||||||
}
|
|
||||||
return <QuestionBubble key={index} text={msg.text} />;
|
return <QuestionBubble key={index} text={msg.text} />;
|
||||||
})}
|
})}
|
||||||
{isLoading && <AnswerBubble text="" loading={true} />}
|
{isLoading && <AnswerBubble text="" loading={true} />}
|
||||||
|
|||||||
5
raggr-frontend/src/components/ToolBubble.tsx
Normal file
5
raggr-frontend/src/components/ToolBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user