- Remove unused image_url from upload response and TS type - Remove bare except in serve_image that masked config errors as 404s - Add error state and broken-image placeholder in QuestionBubble Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
222 lines
5.0 KiB
TypeScript
222 lines
5.0 KiB
TypeScript
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;
|
|
speaker: "user" | "simba";
|
|
created_at: string;
|
|
image_key?: string | null;
|
|
}
|
|
|
|
interface Conversation {
|
|
id: string;
|
|
name: string;
|
|
messages?: Message[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
user_id?: string;
|
|
}
|
|
|
|
interface QueryRequest {
|
|
query: string;
|
|
}
|
|
|
|
interface QueryResponse {
|
|
response: string;
|
|
}
|
|
|
|
interface CreateConversationRequest {
|
|
user_id: string;
|
|
}
|
|
|
|
class ConversationService {
|
|
private baseUrl = "/api";
|
|
private conversationBaseUrl = "/api/conversation";
|
|
|
|
async sendQuery(
|
|
query: string,
|
|
conversation_id: string,
|
|
signal?: AbortSignal,
|
|
): Promise<QueryResponse> {
|
|
const response = await userService.fetchWithRefreshToken(
|
|
`${this.conversationBaseUrl}/query`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ query, conversation_id }),
|
|
signal,
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to send query");
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async getMessages(): Promise<Conversation> {
|
|
const response = await userService.fetchWithRefreshToken(
|
|
`${this.baseUrl}/messages`,
|
|
{
|
|
method: "GET",
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch messages");
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async getConversation(conversationId: string): Promise<Conversation> {
|
|
const response = await userService.fetchWithRefreshToken(
|
|
`${this.conversationBaseUrl}/${conversationId}`,
|
|
{
|
|
method: "GET",
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch conversation");
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async createConversation(): Promise<Conversation> {
|
|
const response = await userService.fetchWithRefreshToken(
|
|
`${this.conversationBaseUrl}/`,
|
|
{
|
|
method: "POST",
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to create conversation");
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async getAllConversations(): Promise<Conversation[]> {
|
|
const response = await userService.fetchWithRefreshToken(
|
|
`${this.conversationBaseUrl}/`,
|
|
{
|
|
method: "GET",
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch conversations");
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async uploadImage(
|
|
file: File,
|
|
conversationId: string,
|
|
): Promise<{ image_key: string }> {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
formData.append("conversation_id", conversationId);
|
|
|
|
const response = await userService.fetchWithRefreshToken(
|
|
`${this.conversationBaseUrl}/upload-image`,
|
|
{
|
|
method: "POST",
|
|
body: formData,
|
|
},
|
|
{ skipContentType: true },
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || "Failed to upload image");
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async getPresignedImageUrl(imageKey: string): Promise<string> {
|
|
const response = await userService.fetchWithRefreshToken(
|
|
`${this.conversationBaseUrl}/image/${imageKey}`,
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error("Failed to get image URL");
|
|
}
|
|
const data = await response.json();
|
|
return data.url;
|
|
}
|
|
|
|
async streamQuery(
|
|
query: string,
|
|
conversation_id: string,
|
|
onEvent: SSEEventCallback,
|
|
signal?: AbortSignal,
|
|
imageKey?: string,
|
|
): Promise<void> {
|
|
const body: Record<string, string> = { query, conversation_id };
|
|
if (imageKey) {
|
|
body.image_key = imageKey;
|
|
}
|
|
|
|
const response = await userService.fetchWithRefreshToken(
|
|
`${this.conversationBaseUrl}/stream-query`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
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();
|