From b2da1aa8e98a5f747b439e5ed514af688aef2324 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Tue, 18 Nov 2025 10:43:45 +0200 Subject: [PATCH 01/22] extends the base apis to support blob response --- surfsense_web/lib/apis/base-api.service.ts | 348 ++++++++++++--------- 1 file changed, 196 insertions(+), 152 deletions(-) diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index ea7b1cb7d..d95d2e417 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,187 +1,231 @@ import type z from "zod"; import { - AppError, - AuthenticationError, - AuthorizationError, - NotFoundError, - ValidationError, + AppError, + AuthenticationError, + AuthorizationError, + NotFoundError, } from "../error"; export type RequestOptions = { - method: "GET" | "POST" | "PUT" | "DELETE"; - headers?: Record; - contentType?: "application/json" | "application/x-www-form-urlencoded"; - signal?: AbortSignal; - body?: any; - // Add more options as needed + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + contentType?: "application/json" | "application/x-www-form-urlencoded"; + signal?: AbortSignal; + body?: any; + responseType?: "json" | "text" | "blob" | "arrayBuffer"; // Add more response types as needed + // Add more options as needed }; -export class BaseApiService { - bearerToken: string; - baseUrl: string; +class BaseApiService { + bearerToken: string; + baseUrl: string; - noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed + noAuthEndpoints: string[] = [ + "/auth/jwt/login", + "/auth/register", + "/auth/refresh", + ]; // Add more endpoints as needed - constructor(bearerToken: string, baseUrl: string) { - this.bearerToken = bearerToken; - this.baseUrl = baseUrl; - } + constructor(bearerToken: string, baseUrl: string) { + this.bearerToken = bearerToken; + this.baseUrl = baseUrl; + } - setBearerToken(bearerToken: string) { - this.bearerToken = bearerToken; - } + setBearerToken(bearerToken: string) { + this.bearerToken = bearerToken; + } - async request( - url: string, - responseSchema?: z.ZodSchema, - options?: RequestOptions - ): Promise { - try { - const defaultOptions: RequestOptions = { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.bearerToken || ""}`, - }, - method: "GET", - }; + async request( + url: string, + responseSchema?: z.ZodSchema, + options?: RequestOptions + ): Promise { + try { + const defaultOptions: RequestOptions = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bearerToken || ""}`, + }, + method: "GET", + responseType: "json", + }; - const mergedOptions: RequestOptions = { - ...defaultOptions, - ...(options ?? {}), - headers: { - ...defaultOptions.headers, - ...(options?.headers ?? {}), - }, - }; + const mergedOptions: RequestOptions = { + ...defaultOptions, + ...(options ?? {}), + headers: { + ...defaultOptions.headers, + ...(options?.headers ?? {}), + }, + }; - if (!this.baseUrl) { - throw new AppError("Base URL is not set."); - } + if (!this.baseUrl) { + throw new AppError("Base URL is not set."); + } - if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { - throw new AuthenticationError("You are not authenticated. Please login again."); - } + if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { + throw new AuthenticationError( + "You are not authenticated. Please login again." + ); + } - const fullUrl = new URL(url, this.baseUrl).toString(); + const fullUrl = new URL(url, this.baseUrl).toString(); - const response = await fetch(fullUrl, mergedOptions); + const response = await fetch(fullUrl, mergedOptions); - if (!response.ok) { - // biome-ignore lint/suspicious: Unknown - let data; + if (!response.ok) { + // biome-ignore lint/suspicious: Unknown + let data; - try { - data = await response.json(); - } catch (error) { - console.error("Failed to parse response as JSON:", error); + try { + data = await response.json(); + } catch (error) { + console.error("Failed to parse response as JSON:", error); - throw new AppError("Something went wrong", response.status, response.statusText); - } + throw new AppError( + "Something went wrong", + response.status, + response.statusText + ); + } - // for fastapi errors response - if ("detail" in data) { - throw new AppError(data.detail, response.status, response.statusText); - } + // for fastapi errors response + if (typeof data === "object" && "detail" in data) { + throw new AppError(data.detail, response.status, response.statusText); + } - switch (response.status) { - case 401: - throw new AuthenticationError( - "You are not authenticated. Please login again.", - response.status, - response.statusText - ); - case 403: - throw new AuthorizationError( - "You don't have permission to access this resource.", - response.status, - response.statusText - ); - case 404: - throw new NotFoundError("Resource not found", response.status, response.statusText); - // Add more cases as needed - default: - throw new AppError("Something went wrong", response.status, response.statusText); - } - } + switch (response.status) { + case 401: + throw new AuthenticationError( + "You are not authenticated. Please login again.", + response.status, + response.statusText + ); + case 403: + throw new AuthorizationError( + "You don't have permission to access this resource.", + response.status, + response.statusText + ); + case 404: + throw new NotFoundError( + "Resource not found", + response.status, + response.statusText + ); + // Add more cases as needed + default: + throw new AppError( + "Something went wrong", + response.status, + response.statusText + ); + } + } - // biome-ignore lint/suspicious: Unknown - let data; + // biome-ignore lint/suspicious: Unknown + let data; + const responseType = mergedOptions.responseType || "json"; - try { - data = await response.json(); - } catch (error) { - console.error("Failed to parse response as JSON:", error); + try { + switch (responseType) { + case "json": + data = await response.json(); + break; + case "text": + data = await response.text(); + break; + case "blob": + data = await response.blob(); + break; + case "arrayBuffer": + data = await response.arrayBuffer(); + break; + // Add more cases as needed + default: + data = await response.text(); + } + } catch (error) { + console.error("Failed to parse response as JSON:", error); + throw new AppError( + "Failed to parse response", + response.status, + response.statusText + ); + } - throw new AppError("Something went wrong", response.status, response.statusText); - } + if (responseType === "json") { + if (!responseSchema) { + return data; + } + const parsedData = responseSchema.safeParse(data); - if (!responseSchema) { - return data; - } + if (!parsedData.success) { + /** The request was successful, but the response data does not match the expected schema. + * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. + * This error should not be shown to the user , it is for dev only. + */ + console.error("Invalid API response schema:", parsedData.error); + } - const parsedData = responseSchema.safeParse(data); + return data; + } - if (!parsedData.success) { - /** The request was successful, but the response data does not match the expected schema. - * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. - * This error should not be shown to the user , it is for dev only. - */ - console.error("Invalid API response schema:", parsedData.error); - } + return data; + } catch (error) { + console.error("Request failed:", error); + throw error; + } + } - return data; - } catch (error) { - console.error("Request failed:", error); - throw error; - } - } + async get( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + ...options, + method: "GET", + }); + } - async get( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, responseSchema, { - ...options, - method: "GET", - }); - } + async post( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "POST", + ...options, + }); + } - async post( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, responseSchema, { - method: "POST", - ...options, - }); - } + async put( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "PUT", + ...options, + }); + } - async put( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, responseSchema, { - method: "PUT", - ...options, - }); - } - - async delete( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, responseSchema, { - method: "DELETE", - ...options, - }); - } + async delete( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "DELETE", + ...options, + }); + } } export const baseApiService = new BaseApiService( - typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") || "" : "", - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "" + typeof window !== "undefined" + ? localStorage.getItem("surfsense_bearer_token") || "" + : "", + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "" ); From de443ccee72bc53f48f56fa27c4fb501416be22c Mon Sep 17 00:00:00 2001 From: thierryverse Date: Tue, 18 Nov 2025 11:32:29 +0200 Subject: [PATCH 02/22] add podcast api service --- .../lib/apis/podcasts-api.service.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 surfsense_web/lib/apis/podcasts-api.service.ts diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts new file mode 100644 index 000000000..336dd35cc --- /dev/null +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -0,0 +1,68 @@ +import { baseApiService } from "./base-api.service"; +import { + GeneratePodcastRequest, + generatePodcastRequest, + getPodcastByChatIdRequest, + GetPodcastByChatIdRequest, + Podcast, + podcast, +} from "@/contracts/types/podcast.types"; +import { ValidationError } from "../error"; + +class PodcastsApiService { + getPodcastByChatId = async (request: GetPodcastByChatIdRequest) => { + // Validate the request + const parsedRequest = getPodcastByChatIdRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors + .map((err) => err.message) + .join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/v1/podcasts/by-chat/${request.chat_id}`, + podcast + ); + }; + + generatePodcast = async (request: GeneratePodcastRequest) => { + // Validate the request + const parsedRequest = generatePodcastRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors + .map((err) => err.message) + .join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post(`/api/v1/podcasts/generate`, undefined, { + body: request, + }); + }; + + loadPodcast = async ({ + podcast, + controller, + }: { + podcast: Podcast; + controller?: AbortController; + }) => { + return await baseApiService.getBlob( + `/api/v1/podcasts/${podcast.id}/stream`, + { + signal: controller?.signal, + } + ); + }; +} + +export const podcastsApiService = new PodcastsApiService(); From e3af39bdbd6229b5b1b2456519491c86b26b6d3f Mon Sep 17 00:00:00 2001 From: thierryverse Date: Tue, 18 Nov 2025 11:33:30 +0200 Subject: [PATCH 03/22] add podcast types --- .../contracts/types/podcast.types.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 surfsense_web/contracts/types/podcast.types.ts diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts new file mode 100644 index 000000000..a1e091214 --- /dev/null +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const podcast = z.object({ + id: z.number(), + title: z.string(), + created_at: z.string(), + file_location: z.string(), + podcast_transcript: z.array(z.any()), + search_space_id: z.number(), + chat_state_version: z.number().nullable(), +}); + +export const generatePodcastRequest = z.object({ + type: z.enum(["CHAT", "DOCUMENT"]), + ids: z.array(z.number()), + search_space_id: z.number(), + podcast_title: z.string().optional(), + user_prompt: z.string().optional(), +}); + +export const getPodcastByChatIdRequest = z.object({ + chat_id: z.number(), +}); + +export type GeneratePodcastRequest = z.infer; +export type GetPodcastByChatIdRequest = z.infer< + typeof getPodcastByChatIdRequest +>; +export type Podcast = z.infer; From 7223a7b04d915eaa2316b0c6271346df3f8a0d04 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Tue, 18 Nov 2025 11:33:57 +0200 Subject: [PATCH 04/22] update api services --- surfsense_web/lib/apis/auth-api.service.ts | 2 +- surfsense_web/lib/apis/base-api.service.ts | 63 +++++++++++++----- surfsense_web/lib/apis/chats-api.service.ts | 4 +- surfsense_web/lib/apis/podcasts.api.ts | 74 --------------------- 4 files changed, 51 insertions(+), 92 deletions(-) delete mode 100644 surfsense_web/lib/apis/podcasts.api.ts diff --git a/surfsense_web/lib/apis/auth-api.service.ts b/surfsense_web/lib/apis/auth-api.service.ts index 3a8933d22..40d496354 100644 --- a/surfsense_web/lib/apis/auth-api.service.ts +++ b/surfsense_web/lib/apis/auth-api.service.ts @@ -9,7 +9,7 @@ import { import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; -export class AuthApiService { + class AuthApiService { login = async (request: LoginRequest) => { // Validate the request const parsedRequest = loginRequest.safeParse(request); diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index d95d2e417..0d59a640a 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -6,13 +6,21 @@ import { NotFoundError, } from "../error"; +enum ResponseType { + JSON = "json", + TEXT = "text", + BLOB = "blob", + ARRAY_BUFFER = "arrayBuffer", + // Add more response types as needed +} + export type RequestOptions = { method: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record; contentType?: "application/json" | "application/x-www-form-urlencoded"; signal?: AbortSignal; body?: any; - responseType?: "json" | "text" | "blob" | "arrayBuffer"; // Add more response types as needed + responseType?: ResponseType; // Add more options as needed }; @@ -35,11 +43,21 @@ class BaseApiService { this.bearerToken = bearerToken; } - async request( + async request( url: string, responseSchema?: z.ZodSchema, - options?: RequestOptions - ): Promise { + options?: RequestOptions & { responseType?: R } + ): Promise< + R extends ResponseType.JSON + ? T + : R extends ResponseType.TEXT + ? string + : R extends ResponseType.BLOB + ? Blob + : R extends ResponseType.ARRAY_BUFFER + ? ArrayBuffer + : unknown + > { try { const defaultOptions: RequestOptions = { headers: { @@ -47,7 +65,7 @@ class BaseApiService { Authorization: `Bearer ${this.bearerToken || ""}`, }, method: "GET", - responseType: "json", + responseType: ResponseType.JSON, }; const mergedOptions: RequestOptions = { @@ -125,20 +143,20 @@ class BaseApiService { // biome-ignore lint/suspicious: Unknown let data; - const responseType = mergedOptions.responseType || "json"; + const responseType = mergedOptions.responseType try { switch (responseType) { - case "json": + case ResponseType.JSON: data = await response.json(); break; - case "text": + case ResponseType.TEXT: data = await response.text(); break; - case "blob": + case ResponseType.BLOB: data = await response.blob(); break; - case "arrayBuffer": + case ResponseType.ARRAY_BUFFER: data = await response.arrayBuffer(); break; // Add more cases as needed @@ -154,7 +172,7 @@ class BaseApiService { ); } - if (responseType === "json") { + if (responseType === ResponseType.JSON) { if (!responseSchema) { return data; } @@ -181,44 +199,59 @@ class BaseApiService { async get( url: string, responseSchema?: z.ZodSchema, - options?: Omit + options?: Omit ) { return this.request(url, responseSchema, { ...options, method: "GET", + responseType: ResponseType.JSON, }); } async post( url: string, responseSchema?: z.ZodSchema, - options?: Omit + options?: Omit ) { return this.request(url, responseSchema, { method: "POST", ...options, + responseType: ResponseType.JSON, }); } async put( url: string, responseSchema?: z.ZodSchema, - options?: Omit + options?: Omit ) { return this.request(url, responseSchema, { method: "PUT", ...options, + responseType: ResponseType.JSON, }); } async delete( url: string, responseSchema?: z.ZodSchema, - options?: Omit + options?: Omit, ) { return this.request(url, responseSchema, { method: "DELETE", ...options, + responseType: ResponseType.JSON, + }); + } + + async getBlob( + url: string, + options?: Omit + ) { + return this.request(url, undefined, { + ...options, + method: "GET", + responseType: ResponseType.BLOB, }); } } diff --git a/surfsense_web/lib/apis/chats-api.service.ts b/surfsense_web/lib/apis/chats-api.service.ts index 69cfab831..d6d309106 100644 --- a/surfsense_web/lib/apis/chats-api.service.ts +++ b/surfsense_web/lib/apis/chats-api.service.ts @@ -17,7 +17,7 @@ import { import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; -export class ChatApiService { + class ChatApiService { getChatDetails = async (request: GetChatDetailsRequest) => { // Validate the request const parsedRequest = getChatDetailsRequest.safeParse(request); @@ -127,4 +127,4 @@ export class ChatApiService { }; } -export const chatApiService = new ChatApiService(); +export const chatsApiService = new ChatApiService(); diff --git a/surfsense_web/lib/apis/podcasts.api.ts b/surfsense_web/lib/apis/podcasts.api.ts deleted file mode 100644 index beaa475ca..000000000 --- a/surfsense_web/lib/apis/podcasts.api.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; -import type { GeneratePodcastRequest } from "@/components/chat/ChatPanel/ChatPanelContainer"; - -export const getPodcastByChatId = async (chatId: string, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/by-chat/${Number(chatId)}`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - method: "GET", - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch podcast"); - } - - return (await response.json()) as PodcastItem | null; -}; - -export const generatePodcast = async (request: GeneratePodcastRequest, authToken: string) => { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, - { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to generate podcast"); - } - - return await response.json(); -}; - -export const loadPodcast = async (podcast: PodcastItem, authToken: string) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); - - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, - { - headers: { - Authorization: `Bearer ${authToken}`, - }, - signal: controller.signal, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch audio stream: ${response.statusText}`); - } - - const blob = await response.blob(); - const objectUrl = URL.createObjectURL(blob); - return objectUrl; - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - throw new Error("Request timed out. Please try again."); - } - throw error; - } finally { - clearTimeout(timeoutId); - } -}; From 5361290315b7dd3dbfd88eb2d89cabca58529727 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Tue, 18 Nov 2025 11:35:06 +0200 Subject: [PATCH 05/22] refactor podcast api calls --- .../podcasts/podcasts-client.tsx | 54 +-- .../atoms/chats/chat-mutation.atoms.ts | 4 +- .../atoms/chats/chat-querie.atoms.ts | 14 +- .../chat/ChatPanel/ChatPanelContainer.tsx | 13 +- .../ChatPanel/PodcastPlayer/PodcastPlayer.tsx | 32 +- .../contracts/types/podcast.types.ts | 30 +- surfsense_web/lib/apis/auth-api.service.ts | 2 +- surfsense_web/lib/apis/base-api.service.ts | 420 ++++++++---------- surfsense_web/lib/apis/chats-api.service.ts | 2 +- .../lib/apis/podcasts-api.service.ts | 94 ++-- 10 files changed, 288 insertions(+), 377 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx index 9f0a7be29..535524b8e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx @@ -6,7 +6,7 @@ import { MoreHorizontal, Pause, Play, - Podcast, + Podcast as PodcastIcon, Search, SkipBack, SkipForward, @@ -46,16 +46,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; - -export interface PodcastItem { - id: number; - title: string; - created_at: string; - file_location: string; - podcast_transcript: any[]; - search_space_id: number; - chat_state_version: number | null; -} +import type { Podcast } from "@/contracts/types/podcast.types"; +import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; interface PodcastsPageClientProps { searchSpaceId: string; @@ -85,8 +77,8 @@ const podcastCardVariants: Variants = { const MotionCard = motion(Card); export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) { - const [podcasts, setPodcasts] = useState([]); - const [filteredPodcasts, setFilteredPodcasts] = useState([]); + const [podcasts, setPodcasts] = useState([]); + const [filteredPodcasts, setFilteredPodcasts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -99,7 +91,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient const [isDeleting, setIsDeleting] = useState(false); // Audio player state - const [currentPodcast, setCurrentPodcast] = useState(null); + const [currentPodcast, setCurrentPodcast] = useState(null); const [audioSrc, setAudioSrc] = useState(undefined); const [isAudioLoading, setIsAudioLoading] = useState(false); const [isPlaying, setIsPlaying] = useState(false); @@ -148,7 +140,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient ); } - const data: PodcastItem[] = await response.json(); + const data: Podcast[] = await response.json(); setPodcasts(data); setFilteredPodcasts(data); setError(null); @@ -305,7 +297,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient }; // Play podcast - Fetch blob and set object URL - const playPodcast = async (podcast: PodcastItem) => { + const playPodcast = async (podcast: Podcast) => { // If the same podcast is selected, just toggle play/pause if (currentPodcast && currentPodcast.id === podcast.id) { togglePlayPause(); @@ -326,11 +318,6 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient setIsPlaying(false); setIsAudioLoading(true); - const token = localStorage.getItem("surfsense_bearer_token"); - if (!token) { - throw new Error("Authentication token not found."); - } - // Revoke previous object URL if exists (only after we've started the new request) if (currentObjectUrlRef.current) { URL.revokeObjectURL(currentObjectUrlRef.current); @@ -342,22 +329,11 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - signal: controller.signal, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch audio stream: ${response.statusText}`); - } - - const blob = await response.blob(); - const objectUrl = URL.createObjectURL(blob); + const response = await podcastsApiService.loadPodcast({ + podcast, + controller, + }); + const objectUrl = URL.createObjectURL(response); currentObjectUrlRef.current = objectUrl; // Set audio source @@ -501,7 +477,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient {!isLoading && !error && filteredPodcasts.length === 0 && (
- +

No podcasts found

{searchQuery @@ -829,7 +805,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient duration: 2, }} > - +

diff --git a/surfsense_web/atoms/chats/chat-mutation.atoms.ts b/surfsense_web/atoms/chats/chat-mutation.atoms.ts index da0795afa..cca69fdce 100644 --- a/surfsense_web/atoms/chats/chat-mutation.atoms.ts +++ b/surfsense_web/atoms/chats/chat-mutation.atoms.ts @@ -1,7 +1,7 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; -import { chatApiService } from "@/lib/apis/chats-api.service"; +import { chatsApiService } from "@/lib/apis/chats-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom"; @@ -14,7 +14,7 @@ export const deleteChatMutationAtom = atomWithMutation((get) => { mutationKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), enabled: !!searchSpaceId && !!authToken, mutationFn: async (chatId: number) => { - return chatApiService.deleteChat({ id: chatId }); + return chatsApiService.deleteChat({ id: chatId }); }, onSuccess: (_, chatId) => { diff --git a/surfsense_web/atoms/chats/chat-querie.atoms.ts b/surfsense_web/atoms/chats/chat-querie.atoms.ts index 8ea668eb4..c0269d9b7 100644 --- a/surfsense_web/atoms/chats/chat-querie.atoms.ts +++ b/surfsense_web/atoms/chats/chat-querie.atoms.ts @@ -1,16 +1,16 @@ import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; -import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; -import { chatApiService } from "@/lib/apis/chats-api.service"; -import { getPodcastByChatId } from "@/lib/apis/podcasts.api"; +import type { Podcast } from "@/contracts/types/podcast.types"; +import { chatsApiService } from "@/lib/apis/chats-api.service"; +import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; type ActiveChatState = { chatId: string | null; chatDetails: ChatDetails | null; - podcast: PodcastItem | null; + podcast: Podcast | null; }; export const activeChatIdAtom = atom(null); @@ -31,8 +31,8 @@ export const activeChatAtom = atomWithQuery((get) => { } const [podcast, chatDetails] = await Promise.all([ - getPodcastByChatId(activeChatId, authToken), - chatApiService.getChatDetails({ id: Number(activeChatId) }), + podcastsApiService.getPodcastByChatId({ chat_id: Number(activeChatId) }), + chatsApiService.getChatDetails({ id: Number(activeChatId) }), ]); return { chatId: activeChatId, chatDetails, podcast }; @@ -48,7 +48,7 @@ export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""), enabled: !!searchSpaceId && !!authToken, queryFn: async () => { - return chatApiService.getChatsBySearchSpace({ search_space_id: Number(searchSpaceId) }); + return chatsApiService.getChatsBySearchSpace({ search_space_id: Number(searchSpaceId) }); }, }; }); diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index 3edd00400..1c62d070d 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -4,18 +4,11 @@ import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; -import { generatePodcast } from "@/lib/apis/podcasts.api"; +import type { GeneratePodcastRequest } from "@/contracts/types/podcast.types"; +import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { cn } from "@/lib/utils"; import { ChatPanelView } from "./ChatPanelView"; -export interface GeneratePodcastRequest { - type: "CHAT" | "DOCUMENT"; - ids: number[]; - search_space_id: number; - podcast_title?: string; - user_prompt?: string; -} - export function ChatPanelContainer() { const { data: activeChatState, @@ -31,7 +24,7 @@ export function ChatPanelContainer() { if (!authToken) { throw new Error("Authentication error. Please log in again."); } - await generatePodcast(request, authToken); + await podcastsApiService.generatePodcast(request); toast.success(`Podcast generation started!`); } catch (error) { toast.error("Error generating podcast. Please log in again."); diff --git a/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx index f789ab848..bde6fc5d7 100644 --- a/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx +++ b/surfsense_web/components/chat/ChatPanel/PodcastPlayer/PodcastPlayer.tsx @@ -1,16 +1,17 @@ "use client"; -import { Pause, Play, Podcast, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; +import { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; import { motion } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; +import type { Podcast } from "@/contracts/types/podcast.types"; +import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { PodcastPlayerCompactSkeleton } from "./PodcastPlayerCompactSkeleton"; interface PodcastPlayerProps { - podcast: PodcastItem | null; + podcast: Podcast | null; isLoading?: boolean; onClose?: () => void; compact?: boolean; @@ -56,11 +57,6 @@ export function PodcastPlayer({ const loadPodcast = async () => { setIsFetching(true); try { - const token = localStorage.getItem("surfsense_bearer_token"); - if (!token) { - throw new Error("Authentication token not found."); - } - // Revoke previous object URL if exists if (currentObjectUrlRef.current) { URL.revokeObjectURL(currentObjectUrlRef.current); @@ -71,22 +67,12 @@ export function PodcastPlayer({ const timeoutId = setTimeout(() => controller.abort(), 30000); try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - signal: controller.signal, - } - ); + const response = await podcastsApiService.loadPodcast({ + podcast, + controller, + }); - if (!response.ok) { - throw new Error(`Failed to fetch audio stream: ${response.statusText}`); - } - - const blob = await response.blob(); - const objectUrl = URL.createObjectURL(blob); + const objectUrl = URL.createObjectURL(response); currentObjectUrlRef.current = objectUrl; setAudioSrc(objectUrl); } catch (error) { diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index a1e091214..c037ff43e 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -1,29 +1,27 @@ import { z } from "zod"; export const podcast = z.object({ - id: z.number(), - title: z.string(), - created_at: z.string(), - file_location: z.string(), - podcast_transcript: z.array(z.any()), - search_space_id: z.number(), - chat_state_version: z.number().nullable(), + id: z.number(), + title: z.string(), + created_at: z.string(), + file_location: z.string(), + podcast_transcript: z.array(z.any()), + search_space_id: z.number(), + chat_state_version: z.number().nullable(), }); export const generatePodcastRequest = z.object({ - type: z.enum(["CHAT", "DOCUMENT"]), - ids: z.array(z.number()), - search_space_id: z.number(), - podcast_title: z.string().optional(), - user_prompt: z.string().optional(), + type: z.enum(["CHAT", "DOCUMENT"]), + ids: z.array(z.number()), + search_space_id: z.number(), + podcast_title: z.string().optional(), + user_prompt: z.string().optional(), }); export const getPodcastByChatIdRequest = z.object({ - chat_id: z.number(), + chat_id: z.number(), }); export type GeneratePodcastRequest = z.infer; -export type GetPodcastByChatIdRequest = z.infer< - typeof getPodcastByChatIdRequest ->; +export type GetPodcastByChatIdRequest = z.infer; export type Podcast = z.infer; diff --git a/surfsense_web/lib/apis/auth-api.service.ts b/surfsense_web/lib/apis/auth-api.service.ts index 40d496354..e2be8c860 100644 --- a/surfsense_web/lib/apis/auth-api.service.ts +++ b/surfsense_web/lib/apis/auth-api.service.ts @@ -9,7 +9,7 @@ import { import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; - class AuthApiService { +class AuthApiService { login = async (request: LoginRequest) => { // Validate the request const parsedRequest = loginRequest.safeParse(request); diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 0d59a640a..e396364ec 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,264 +1,232 @@ import type z from "zod"; -import { - AppError, - AuthenticationError, - AuthorizationError, - NotFoundError, -} from "../error"; +import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error"; enum ResponseType { - JSON = "json", - TEXT = "text", - BLOB = "blob", - ARRAY_BUFFER = "arrayBuffer", - // Add more response types as needed + JSON = "json", + TEXT = "text", + BLOB = "blob", + ARRAY_BUFFER = "arrayBuffer", + // Add more response types as needed } export type RequestOptions = { - method: "GET" | "POST" | "PUT" | "DELETE"; - headers?: Record; - contentType?: "application/json" | "application/x-www-form-urlencoded"; - signal?: AbortSignal; - body?: any; - responseType?: ResponseType; - // Add more options as needed + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + contentType?: "application/json" | "application/x-www-form-urlencoded"; + signal?: AbortSignal; + body?: any; + responseType?: ResponseType; + // Add more options as needed }; class BaseApiService { - bearerToken: string; - baseUrl: string; + bearerToken: string; + baseUrl: string; - noAuthEndpoints: string[] = [ - "/auth/jwt/login", - "/auth/register", - "/auth/refresh", - ]; // Add more endpoints as needed + noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; // Add more endpoints as needed - constructor(bearerToken: string, baseUrl: string) { - this.bearerToken = bearerToken; - this.baseUrl = baseUrl; - } + constructor(bearerToken: string, baseUrl: string) { + this.bearerToken = bearerToken; + this.baseUrl = baseUrl; + } - setBearerToken(bearerToken: string) { - this.bearerToken = bearerToken; - } + setBearerToken(bearerToken: string) { + this.bearerToken = bearerToken; + } - async request( - url: string, - responseSchema?: z.ZodSchema, - options?: RequestOptions & { responseType?: R } - ): Promise< - R extends ResponseType.JSON - ? T - : R extends ResponseType.TEXT - ? string - : R extends ResponseType.BLOB - ? Blob - : R extends ResponseType.ARRAY_BUFFER - ? ArrayBuffer - : unknown - > { - try { - const defaultOptions: RequestOptions = { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.bearerToken || ""}`, - }, - method: "GET", - responseType: ResponseType.JSON, - }; + async request( + url: string, + responseSchema?: z.ZodSchema, + options?: RequestOptions & { responseType?: R } + ): Promise< + R extends ResponseType.JSON + ? T + : R extends ResponseType.TEXT + ? string + : R extends ResponseType.BLOB + ? Blob + : R extends ResponseType.ARRAY_BUFFER + ? ArrayBuffer + : unknown + > { + try { + const defaultOptions: RequestOptions = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bearerToken || ""}`, + }, + method: "GET", + responseType: ResponseType.JSON, + }; - const mergedOptions: RequestOptions = { - ...defaultOptions, - ...(options ?? {}), - headers: { - ...defaultOptions.headers, - ...(options?.headers ?? {}), - }, - }; + const mergedOptions: RequestOptions = { + ...defaultOptions, + ...(options ?? {}), + headers: { + ...defaultOptions.headers, + ...(options?.headers ?? {}), + }, + }; - if (!this.baseUrl) { - throw new AppError("Base URL is not set."); - } + if (!this.baseUrl) { + throw new AppError("Base URL is not set."); + } - if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { - throw new AuthenticationError( - "You are not authenticated. Please login again." - ); - } + if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { + throw new AuthenticationError("You are not authenticated. Please login again."); + } - const fullUrl = new URL(url, this.baseUrl).toString(); + const fullUrl = new URL(url, this.baseUrl).toString(); - const response = await fetch(fullUrl, mergedOptions); + const response = await fetch(fullUrl, mergedOptions); - if (!response.ok) { - // biome-ignore lint/suspicious: Unknown - let data; + if (!response.ok) { + // biome-ignore lint/suspicious: Unknown + let data; - try { - data = await response.json(); - } catch (error) { - console.error("Failed to parse response as JSON:", error); + try { + data = await response.json(); + } catch (error) { + console.error("Failed to parse response as JSON:", error); - throw new AppError( - "Something went wrong", - response.status, - response.statusText - ); - } + throw new AppError("Something went wrong", response.status, response.statusText); + } - // for fastapi errors response - if (typeof data === "object" && "detail" in data) { - throw new AppError(data.detail, response.status, response.statusText); - } + // for fastapi errors response + if (typeof data === "object" && "detail" in data) { + throw new AppError(data.detail, response.status, response.statusText); + } - switch (response.status) { - case 401: - throw new AuthenticationError( - "You are not authenticated. Please login again.", - response.status, - response.statusText - ); - case 403: - throw new AuthorizationError( - "You don't have permission to access this resource.", - response.status, - response.statusText - ); - case 404: - throw new NotFoundError( - "Resource not found", - response.status, - response.statusText - ); - // Add more cases as needed - default: - throw new AppError( - "Something went wrong", - response.status, - response.statusText - ); - } - } + switch (response.status) { + case 401: + throw new AuthenticationError( + "You are not authenticated. Please login again.", + response.status, + response.statusText + ); + case 403: + throw new AuthorizationError( + "You don't have permission to access this resource.", + response.status, + response.statusText + ); + case 404: + throw new NotFoundError("Resource not found", response.status, response.statusText); + // Add more cases as needed + default: + throw new AppError("Something went wrong", response.status, response.statusText); + } + } - // biome-ignore lint/suspicious: Unknown - let data; - const responseType = mergedOptions.responseType + // biome-ignore lint/suspicious: Unknown + let data; + const responseType = mergedOptions.responseType; - try { - switch (responseType) { - case ResponseType.JSON: - data = await response.json(); - break; - case ResponseType.TEXT: - data = await response.text(); - break; - case ResponseType.BLOB: - data = await response.blob(); - break; - case ResponseType.ARRAY_BUFFER: - data = await response.arrayBuffer(); - break; - // Add more cases as needed - default: - data = await response.text(); - } - } catch (error) { - console.error("Failed to parse response as JSON:", error); - throw new AppError( - "Failed to parse response", - response.status, - response.statusText - ); - } + try { + switch (responseType) { + case ResponseType.JSON: + data = await response.json(); + break; + case ResponseType.TEXT: + data = await response.text(); + break; + case ResponseType.BLOB: + data = await response.blob(); + break; + case ResponseType.ARRAY_BUFFER: + data = await response.arrayBuffer(); + break; + // Add more cases as needed + default: + data = await response.text(); + } + } catch (error) { + console.error("Failed to parse response as JSON:", error); + throw new AppError("Failed to parse response", response.status, response.statusText); + } - if (responseType === ResponseType.JSON) { - if (!responseSchema) { - return data; - } - const parsedData = responseSchema.safeParse(data); + if (responseType === ResponseType.JSON) { + if (!responseSchema) { + return data; + } + const parsedData = responseSchema.safeParse(data); - if (!parsedData.success) { - /** The request was successful, but the response data does not match the expected schema. - * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. - * This error should not be shown to the user , it is for dev only. - */ - console.error("Invalid API response schema:", parsedData.error); - } + if (!parsedData.success) { + /** The request was successful, but the response data does not match the expected schema. + * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. + * This error should not be shown to the user , it is for dev only. + */ + console.error("Invalid API response schema:", parsedData.error); + } - return data; - } + return data; + } - return data; - } catch (error) { - console.error("Request failed:", error); - throw error; - } - } + return data; + } catch (error) { + console.error("Request failed:", error); + throw error; + } + } - async get( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, responseSchema, { - ...options, - method: "GET", - responseType: ResponseType.JSON, - }); - } + async get( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + ...options, + method: "GET", + responseType: ResponseType.JSON, + }); + } - async post( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, responseSchema, { - method: "POST", - ...options, - responseType: ResponseType.JSON, - }); - } + async post( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "POST", + ...options, + responseType: ResponseType.JSON, + }); + } - async put( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit - ) { - return this.request(url, responseSchema, { - method: "PUT", - ...options, - responseType: ResponseType.JSON, - }); - } + async put( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "PUT", + ...options, + responseType: ResponseType.JSON, + }); + } - async delete( - url: string, - responseSchema?: z.ZodSchema, - options?: Omit, - ) { - return this.request(url, responseSchema, { - method: "DELETE", - ...options, - responseType: ResponseType.JSON, - }); - } + async delete( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit + ) { + return this.request(url, responseSchema, { + method: "DELETE", + ...options, + responseType: ResponseType.JSON, + }); + } - async getBlob( - url: string, - options?: Omit - ) { - return this.request(url, undefined, { - ...options, - method: "GET", - responseType: ResponseType.BLOB, - }); - } + async getBlob(url: string, options?: Omit) { + return this.request(url, undefined, { + ...options, + method: "GET", + responseType: ResponseType.BLOB, + }); + } } export const baseApiService = new BaseApiService( - typeof window !== "undefined" - ? localStorage.getItem("surfsense_bearer_token") || "" - : "", - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "" + typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") || "" : "", + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "" ); diff --git a/surfsense_web/lib/apis/chats-api.service.ts b/surfsense_web/lib/apis/chats-api.service.ts index d6d309106..498c99ef1 100644 --- a/surfsense_web/lib/apis/chats-api.service.ts +++ b/surfsense_web/lib/apis/chats-api.service.ts @@ -17,7 +17,7 @@ import { import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; - class ChatApiService { +class ChatApiService { getChatDetails = async (request: GetChatDetailsRequest) => { // Validate the request const parsedRequest = getChatDetailsRequest.safeParse(request); diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts index 336dd35cc..2142dc6e6 100644 --- a/surfsense_web/lib/apis/podcasts-api.service.ts +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -1,68 +1,58 @@ -import { baseApiService } from "./base-api.service"; import { - GeneratePodcastRequest, - generatePodcastRequest, - getPodcastByChatIdRequest, - GetPodcastByChatIdRequest, - Podcast, - podcast, + type GeneratePodcastRequest, + type GetPodcastByChatIdRequest, + generatePodcastRequest, + getPodcastByChatIdRequest, + type Podcast, + podcast, } from "@/contracts/types/podcast.types"; import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; class PodcastsApiService { - getPodcastByChatId = async (request: GetPodcastByChatIdRequest) => { - // Validate the request - const parsedRequest = getPodcastByChatIdRequest.safeParse(request); + getPodcastByChatId = async (request: GetPodcastByChatIdRequest) => { + // Validate the request + const parsedRequest = getPodcastByChatIdRequest.safeParse(request); - if (!parsedRequest.success) { - console.error("Invalid request:", parsedRequest.error); + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); - // Format a user frendly error message - const errorMessage = parsedRequest.error.errors - .map((err) => err.message) - .join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } - return baseApiService.get( - `/api/v1/podcasts/by-chat/${request.chat_id}`, - podcast - ); - }; + return baseApiService.get(`/api/v1/podcasts/by-chat/${request.chat_id}`, podcast); + }; - generatePodcast = async (request: GeneratePodcastRequest) => { - // Validate the request - const parsedRequest = generatePodcastRequest.safeParse(request); + generatePodcast = async (request: GeneratePodcastRequest) => { + // Validate the request + const parsedRequest = generatePodcastRequest.safeParse(request); - if (!parsedRequest.success) { - console.error("Invalid request:", parsedRequest.error); + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); - // Format a user frendly error message - const errorMessage = parsedRequest.error.errors - .map((err) => err.message) - .join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } - return baseApiService.post(`/api/v1/podcasts/generate`, undefined, { - body: request, - }); - }; + return baseApiService.post(`/api/v1/podcasts/generate`, undefined, { + body: request, + }); + }; - loadPodcast = async ({ - podcast, - controller, - }: { - podcast: Podcast; - controller?: AbortController; - }) => { - return await baseApiService.getBlob( - `/api/v1/podcasts/${podcast.id}/stream`, - { - signal: controller?.signal, - } - ); - }; + loadPodcast = async ({ + podcast, + controller, + }: { + podcast: Podcast; + controller?: AbortController; + }) => { + return await baseApiService.getBlob(`/api/v1/podcasts/${podcast.id}/stream`, { + signal: controller?.signal, + }); + }; } export const podcastsApiService = new PodcastsApiService(); From 20cd1951b59fd97fdcb0b2b8d96272791deb75f5 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Tue, 18 Nov 2025 18:35:48 +0200 Subject: [PATCH 06/22] add podcast api service --- .../atoms/chats/chat-querie.atoms.ts | 10 +--- .../chat/ChatPanel/ChatPanelContainer.tsx | 4 +- .../contracts/types/podcast.types.ts | 3 ++ surfsense_web/lib/apis/base-api.service.ts | 50 ++++++++++++++++--- .../lib/apis/podcasts-api.service.ts | 9 ++-- 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/surfsense_web/atoms/chats/chat-querie.atoms.ts b/surfsense_web/atoms/chats/chat-querie.atoms.ts index c0269d9b7..803295cf0 100644 --- a/surfsense_web/atoms/chats/chat-querie.atoms.ts +++ b/surfsense_web/atoms/chats/chat-querie.atoms.ts @@ -1,21 +1,13 @@ import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; -import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; -import type { Podcast } from "@/contracts/types/podcast.types"; import { chatsApiService } from "@/lib/apis/chats-api.service"; import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; -type ActiveChatState = { - chatId: string | null; - chatDetails: ChatDetails | null; - podcast: Podcast | null; -}; - export const activeChatIdAtom = atom(null); -export const activeChatAtom = atomWithQuery((get) => { +export const activeChatAtom = atomWithQuery((get) => { const activeChatId = get(activeChatIdAtom); const authToken = localStorage.getItem("surfsense_bearer_token"); diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index 1c62d070d..93bcca5b2 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -27,8 +27,8 @@ export function ChatPanelContainer() { await podcastsApiService.generatePodcast(request); toast.success(`Podcast generation started!`); } catch (error) { - toast.error("Error generating podcast. Please log in again."); - console.error("Error generating podcast:", error); + toast.error("Error generating podcast. Please try again later."); + console.error("Error generating podcast:", JSON.stringify(error)); } }; diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index c037ff43e..4301e3fbb 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -22,6 +22,9 @@ export const getPodcastByChatIdRequest = z.object({ chat_id: z.number(), }); +export const getPodcastByChatResponse = podcast.nullish(); + export type GeneratePodcastRequest = z.infer; export type GetPodcastByChatIdRequest = z.infer; +export type GetPodcastByChatResponse = z.infer; export type Podcast = z.infer; diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index e396364ec..51b1f69fb 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,3 +1,4 @@ +import { th } from "date-fns/locale"; import type z from "zod"; import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error"; @@ -50,6 +51,11 @@ class BaseApiService { : unknown > { try { + /** + * ---------- + * REQUEST + * ---------- + */ const defaultOptions: RequestOptions = { headers: { "Content-Type": "application/json", @@ -68,18 +74,46 @@ class BaseApiService { }, }; + // Validate the base URL if (!this.baseUrl) { throw new AppError("Base URL is not set."); } + // Validate the bearer token if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { throw new AuthenticationError("You are not authenticated. Please login again."); } + // Construct the full URL const fullUrl = new URL(url, this.baseUrl).toString(); - const response = await fetch(fullUrl, mergedOptions); + // Prepare fetch options + const fetchOptions: RequestInit = { + method: mergedOptions.method, + headers: mergedOptions.headers, + signal: mergedOptions.signal, + }; + // Automatically stringify body if Content-Type is application/json and body is an object + if (mergedOptions.body !== undefined) { + const contentType = mergedOptions.headers?.["Content-Type"]; + if (contentType === "application/json" && typeof mergedOptions.body === "object") { + fetchOptions.body = JSON.stringify(mergedOptions.body); + } else { + // Pass body as-is for other content types (e.g., form data, already stringified) + fetchOptions.body = mergedOptions.body; + } + } + + const response = await fetch(fullUrl, fetchOptions); + + /** + * ---------- + * RESPONSE + * ---------- + */ + + // Handle errors if (!response.ok) { // biome-ignore lint/suspicious: Unknown let data; @@ -87,12 +121,11 @@ class BaseApiService { try { data = await response.json(); } catch (error) { - console.error("Failed to parse response as JSON:", error); - - throw new AppError("Something went wrong", response.status, response.statusText); + console.error("Failed to parse response as JSON: ", JSON.stringify(error)); + throw new AppError("Failed to parse response", response.status, response.statusText); } - // for fastapi errors response + // For fastapi errors response if (typeof data === "object" && "detail" in data) { throw new AppError(data.detail, response.status, response.statusText); } @@ -138,13 +171,14 @@ class BaseApiService { break; // Add more cases as needed default: - data = await response.text(); + data = await response.json(); } } catch (error) { console.error("Failed to parse response as JSON:", error); throw new AppError("Failed to parse response", response.status, response.statusText); } + // Validate response if (responseType === ResponseType.JSON) { if (!responseSchema) { return data; @@ -156,7 +190,7 @@ class BaseApiService { * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. * This error should not be shown to the user , it is for dev only. */ - console.error("Invalid API response schema:", parsedData.error); + console.error(`Invalid API response schema - ${url} :`, JSON.stringify(parsedData.error)); } return data; @@ -164,7 +198,7 @@ class BaseApiService { return data; } catch (error) { - console.error("Request failed:", error); + console.error("Request failed:", JSON.stringify(error)); throw error; } } diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts index 2142dc6e6..6e183281b 100644 --- a/surfsense_web/lib/apis/podcasts-api.service.ts +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -3,8 +3,8 @@ import { type GetPodcastByChatIdRequest, generatePodcastRequest, getPodcastByChatIdRequest, + getPodcastByChatResponse, type Podcast, - podcast, } from "@/contracts/types/podcast.types"; import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; @@ -22,7 +22,10 @@ class PodcastsApiService { throw new ValidationError(`Invalid request: ${errorMessage}`); } - return baseApiService.get(`/api/v1/podcasts/by-chat/${request.chat_id}`, podcast); + return baseApiService.get( + `/api/v1/podcasts/by-chat/${request.chat_id}`, + getPodcastByChatResponse + ); }; generatePodcast = async (request: GeneratePodcastRequest) => { @@ -38,7 +41,7 @@ class PodcastsApiService { } return baseApiService.post(`/api/v1/podcasts/generate`, undefined, { - body: request, + body: parsedRequest.data, }); }; From bf94903459cca34466cdc9435e9d60c6b217a195 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Tue, 18 Nov 2025 18:57:41 +0200 Subject: [PATCH 07/22] fix hot reloads issue in dev mode --- .../app/dashboard/[search_space_id]/client-layout.tsx | 3 +-- surfsense_web/atoms/chats/chat-querie.atoms.ts | 4 +--- surfsense_web/atoms/chats/ui.atoms.ts | 1 + .../components/chat/ChatPanel/ChatPanelContainer.tsx | 8 +++++--- surfsense_web/components/dashboard-breadcrumb.tsx | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 213868314..4ec8046a4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -7,8 +7,7 @@ import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; import { useEffect, useMemo, useState } from "react"; -import { activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; -import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; +import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; diff --git a/surfsense_web/atoms/chats/chat-querie.atoms.ts b/surfsense_web/atoms/chats/chat-querie.atoms.ts index 803295cf0..cec2802d5 100644 --- a/surfsense_web/atoms/chats/chat-querie.atoms.ts +++ b/surfsense_web/atoms/chats/chat-querie.atoms.ts @@ -1,11 +1,9 @@ -import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; import { chatsApiService } from "@/lib/apis/chats-api.service"; import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; - -export const activeChatIdAtom = atom(null); +import { activeChatIdAtom } from "./ui.atoms"; export const activeChatAtom = atomWithQuery((get) => { const activeChatId = get(activeChatIdAtom); diff --git a/surfsense_web/atoms/chats/ui.atoms.ts b/surfsense_web/atoms/chats/ui.atoms.ts index 4d2b64186..deae59fe3 100644 --- a/surfsense_web/atoms/chats/ui.atoms.ts +++ b/surfsense_web/atoms/chats/ui.atoms.ts @@ -7,3 +7,4 @@ type ActiveChathatUIState = { export const activeChathatUIAtom = atom({ isChatPannelOpen: false, }); +export const activeChatIdAtom = atom(null); diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index 93bcca5b2..019eecd3e 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -2,8 +2,8 @@ import { useAtom, useAtomValue } from "jotai"; import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; -import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; -import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; +import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms"; import type { GeneratePodcastRequest } from "@/contracts/types/podcast.types"; import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { cn } from "@/lib/utils"; @@ -32,6 +32,8 @@ export function ChatPanelContainer() { } }; + console.log("activeChatState", activeChatState, activeChatIdState); + return activeChatIdState ? (
) : chatError ? ( -
+
) : null} diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 65e885ead..d29b54685 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import React, { useEffect } from "react"; -import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; import { Breadcrumb, BreadcrumbItem, From 1a954bc184b582be34962c047e0a6e79526fa381 Mon Sep 17 00:00:00 2001 From: thierryverse Date: Tue, 18 Nov 2025 19:32:24 +0200 Subject: [PATCH 08/22] Add delete podcast api service --- .../[search_space_id]/chats/chats-client.tsx | 2 +- .../podcasts/podcasts-client.tsx | 46 ++++--------------- .../atoms/chats/chat-mutation.atoms.ts | 9 ++-- .../ChatPanel/PodcastPlayer/PodcastPlayer.tsx | 2 +- .../contracts/types/podcast.types.ts | 19 +++++++- .../lib/apis/podcasts-api.service.ts | 42 ++++++++++++++--- surfsense_web/lib/query-client/cache-keys.ts | 2 + 7 files changed, 72 insertions(+), 50 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 64661620d..d7fc5e076 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -161,7 +161,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) const handleDeleteChat = async () => { if (!chatToDelete) return; - await deleteChat(chatToDelete.id); + await deleteChat({ id: chatToDelete.id }); setDeleteDialogOpen(false); setChatToDelete(null); diff --git a/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx index 535524b8e..21bb3d05a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/podcasts/podcasts-client.tsx @@ -1,6 +1,7 @@ "use client"; import { format } from "date-fns"; +import { useAtom } from "jotai"; import { Calendar, MoreHorizontal, @@ -19,6 +20,7 @@ import { AnimatePresence, motion, type Variants } from "motion/react"; import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { deletePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms"; // UI Components import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -88,7 +90,6 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient id: number; title: string; } | null>(null); - const [isDeleting, setIsDeleting] = useState(false); // Audio player state const [currentPodcast, setCurrentPodcast] = useState(null); @@ -101,6 +102,8 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient const [isMuted, setIsMuted] = useState(false); const audioRef = useRef(null); const currentObjectUrlRef = useRef(null); + const [{ isPending: isDeletingPodcast, mutateAsync: deletePodcast, error: deleteError }] = + useAtom(deletePodcastMutationAtom); // Add podcast image URL constant const PODCAST_IMAGE_URL = @@ -330,7 +333,7 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient try { const response = await podcastsApiService.loadPodcast({ - podcast, + request: { id: podcast.id }, controller, }); const objectUrl = URL.createObjectURL(response); @@ -364,38 +367,13 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient const handleDeletePodcast = async () => { if (!podcastToDelete) return; - setIsDeleting(true); try { - const token = localStorage.getItem("surfsense_bearer_token"); - if (!token) { - setIsDeleting(false); - return; - } + await deletePodcast({ id: podcastToDelete.id }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastToDelete.id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to delete podcast: ${response.statusText}`); - } - - // Close dialog and refresh podcasts + // Close dialog setDeleteDialogOpen(false); setPodcastToDelete(null); - // Update local state by removing the deleted podcast - setPodcasts((prevPodcasts) => - prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id) - ); - // If the current playing podcast is deleted, stop playback if (currentPodcast && currentPodcast.id === podcastToDelete.id) { if (audioRef.current) { @@ -404,13 +382,9 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient setCurrentPodcast(null); setIsPlaying(false); } - - toast.success("Podcast deleted successfully"); } catch (error) { console.error("Error deleting podcast:", error); toast.error(error instanceof Error ? error.message : "Failed to delete podcast"); - } finally { - setIsDeleting(false); } }; @@ -933,17 +907,17 @@ export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClient