diff --git a/surfsense_web/components/tool-ui/article/index.tsx b/surfsense_web/components/tool-ui/article/index.tsx index bf8e83411..62068d117 100644 --- a/surfsense_web/components/tool-ui/article/index.tsx +++ b/surfsense_web/components/tool-ui/article/index.tsx @@ -17,6 +17,33 @@ import { UserIcon, } from "lucide-react"; import { Component, type ReactNode, useCallback } from "react"; +import { z } from "zod"; + +/** + * Zod schema for serializable article data (from backend) + */ +const SerializableArticleSchema = z.object({ + id: z.string().default("article-unknown"), + assetId: z.string().optional(), + kind: z.literal("article").optional(), + title: z.string().default("Untitled Article"), + description: z.string().optional(), + content: z.string().optional(), + href: z.string().url().optional(), + domain: z.string().optional(), + author: z.string().optional(), + date: z.string().optional(), + word_count: z.number().optional(), + wordCount: z.number().optional(), + was_truncated: z.boolean().optional(), + wasTruncated: z.boolean().optional(), + error: z.string().optional(), +}); + +/** + * Serializable article data type (from backend) + */ +export type SerializableArticle = z.infer; /** * Article component props @@ -61,44 +88,36 @@ export interface ArticleProps { } /** - * Serializable article data type (from backend) - */ -export interface SerializableArticle { - id: string; - assetId?: string; - kind?: "article"; - title: string; - description?: string; - content?: string; - href?: string; - domain?: string; - author?: string; - date?: string; - word_count?: number; - wordCount?: number; - was_truncated?: boolean; - wasTruncated?: boolean; - error?: string; -} - -/** - * Parse serializable article data to ArticleProps + * Parse and validate serializable article data to ArticleProps */ export function parseSerializableArticle(data: unknown): ArticleProps { - const obj = data as Record; + const result = SerializableArticleSchema.safeParse(data); + + if (!result.success) { + console.warn("Invalid article data:", result.error.issues); + // Return fallback with basic info + const obj = (data && typeof data === "object" ? data : {}) as Record; + return { + id: String(obj.id || "article-unknown"), + title: String(obj.title || "Untitled Article"), + error: "Failed to parse article data", + }; + } + + const parsed = result.data; return { - id: String(obj.id || "article-unknown"), - assetId: obj.assetId as string | undefined, - title: String(obj.title || "Untitled Article"), - description: obj.description as string | undefined, - content: obj.content as string | undefined, - href: obj.href as string | undefined, - domain: obj.domain as string | undefined, - author: obj.author as string | undefined, - date: obj.date as string | undefined, - wordCount: (obj.word_count || obj.wordCount) as number | undefined, - wasTruncated: (obj.was_truncated || obj.wasTruncated) as boolean | undefined, - error: obj.error as string | undefined, + id: parsed.id, + assetId: parsed.assetId, + title: parsed.title, + description: parsed.description, + content: parsed.content, + href: parsed.href, + domain: parsed.domain, + author: parsed.author, + date: parsed.date, + wordCount: parsed.word_count ?? parsed.wordCount, + wasTruncated: parsed.was_truncated ?? parsed.wasTruncated, + error: parsed.error, }; } diff --git a/surfsense_web/components/tool-ui/deepagent-thinking.tsx b/surfsense_web/components/tool-ui/deepagent-thinking.tsx index 8cd901e69..ff8baac9a 100644 --- a/surfsense_web/components/tool-ui/deepagent-thinking.tsx +++ b/surfsense_web/components/tool-ui/deepagent-thinking.tsx @@ -3,6 +3,7 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { Brain, CheckCircle2, Loader2, Search, Sparkles } from "lucide-react"; import { useMemo, useState, useEffect, useRef } from "react"; +import { z } from "zod"; import { ChainOfThought, ChainOfThoughtContent, @@ -13,24 +14,61 @@ import { import { cn } from "@/lib/utils"; /** - * Types for the deepagent thinking/reasoning tool + * Zod schemas for runtime validation */ -interface ThinkingStep { - id: string; - title: string; - items: string[]; - status: "pending" | "in_progress" | "completed"; +const ThinkingStepSchema = z.object({ + id: z.string(), + title: z.string(), + items: z.array(z.string()).default([]), + status: z.enum(["pending", "in_progress", "completed"]).default("pending"), +}); + +const DeepAgentThinkingArgsSchema = z.object({ + query: z.string().optional(), + context: z.string().optional(), +}); + +const DeepAgentThinkingResultSchema = z.object({ + steps: z.array(ThinkingStepSchema).optional(), + status: z.enum(["thinking", "searching", "synthesizing", "completed"]).optional(), + summary: z.string().optional(), +}); + +/** + * Types derived from Zod schemas + */ +type ThinkingStep = z.infer; +type DeepAgentThinkingArgs = z.infer; +type DeepAgentThinkingResult = z.infer; + +/** + * Parse and validate a single thinking step + */ +export function parseThinkingStep(data: unknown): ThinkingStep { + const result = ThinkingStepSchema.safeParse(data); + if (!result.success) { + console.warn("Invalid thinking step data:", result.error.issues); + // Return a fallback step + return { + id: "unknown", + title: "Processing...", + items: [], + status: "pending", + }; + } + return result.data; } -interface DeepAgentThinkingArgs { - query?: string; - context?: string; -} - -interface DeepAgentThinkingResult { - steps?: ThinkingStep[]; - status?: "thinking" | "searching" | "synthesizing" | "completed"; - summary?: string; +/** + * Parse and validate thinking result + */ +export function parseThinkingResult(data: unknown): DeepAgentThinkingResult { + const result = DeepAgentThinkingResultSchema.safeParse(data); + if (!result.success) { + console.warn("Invalid thinking result data:", result.error.issues); + return {}; + } + return result.data; } /** diff --git a/surfsense_web/components/tool-ui/display-image.tsx b/surfsense_web/components/tool-ui/display-image.tsx index 37bdad5fa..a9c87b29b 100644 --- a/surfsense_web/components/tool-ui/display-image.tsx +++ b/surfsense_web/components/tool-ui/display-image.tsx @@ -67,6 +67,8 @@ function ImageCancelledState({ src }: { src: string }) { /** * Parsed Image component with error handling + * Note: Image component has built-in click handling via href/src, + * so no additional responseActions needed. */ function ParsedImage({ result }: { result: unknown }) { const image = parseSerializableImage(result); @@ -75,14 +77,6 @@ function ParsedImage({ result }: { result: unknown }) { { - if (id === "open" && image.src) { - window.open(image.src, "_blank", "noopener,noreferrer"); - } - }} /> ); } diff --git a/surfsense_web/components/tool-ui/generate-podcast.tsx b/surfsense_web/components/tool-ui/generate-podcast.tsx index 669b11a57..862eab144 100644 --- a/surfsense_web/components/tool-ui/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/generate-podcast.tsx @@ -3,37 +3,79 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { z } from "zod"; import { Audio } from "@/components/tool-ui/audio"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state"; /** - * Type definitions for the generate_podcast tool + * Zod schemas for runtime validation */ -interface GeneratePodcastArgs { - source_content: string; - podcast_title?: string; - user_prompt?: string; +const GeneratePodcastArgsSchema = z.object({ + source_content: z.string(), + podcast_title: z.string().optional(), + user_prompt: z.string().optional(), +}); + +const GeneratePodcastResultSchema = z.object({ + status: z.enum(["processing", "already_generating", "success", "error"]), + task_id: z.string().optional(), + podcast_id: z.number().optional(), + title: z.string().optional(), + transcript_entries: z.number().optional(), + message: z.string().optional(), + error: z.string().optional(), +}); + +const TaskStatusResponseSchema = z.object({ + status: z.enum(["processing", "success", "error"]), + podcast_id: z.number().optional(), + title: z.string().optional(), + transcript_entries: z.number().optional(), + state: z.string().optional(), + error: z.string().optional(), +}); + +const PodcastTranscriptEntrySchema = z.object({ + speaker_id: z.number(), + dialog: z.string(), +}); + +const PodcastDetailsSchema = z.object({ + podcast_transcript: z.array(PodcastTranscriptEntrySchema).optional(), +}); + +/** + * Types derived from Zod schemas + */ +type GeneratePodcastArgs = z.infer; +type GeneratePodcastResult = z.infer; +type TaskStatusResponse = z.infer; +type PodcastTranscriptEntry = z.infer; + +/** + * Parse and validate task status response + */ +function parseTaskStatusResponse(data: unknown): TaskStatusResponse { + const result = TaskStatusResponseSchema.safeParse(data); + if (!result.success) { + console.warn("Invalid task status response:", result.error.issues); + return { status: "error", error: "Invalid response from server" }; + } + return result.data; } -interface GeneratePodcastResult { - status: "processing" | "already_generating" | "success" | "error"; - task_id?: string; - podcast_id?: number; - title?: string; - transcript_entries?: number; - message?: string; - error?: string; -} - -interface TaskStatusResponse { - status: "processing" | "success" | "error"; - podcast_id?: number; - title?: string; - transcript_entries?: number; - state?: string; - error?: string; +/** + * Parse and validate podcast details + */ +function parsePodcastDetails(data: unknown): { podcast_transcript?: PodcastTranscriptEntry[] } { + const result = PodcastDetailsSchema.safeParse(data); + if (!result.success) { + console.warn("Invalid podcast details:", result.error.issues); + return {}; + } + return result.data; } /** @@ -112,14 +154,6 @@ function AudioLoadingState({ title }: { title: string }) { /** * Podcast Player Component - Fetches audio and transcript with authentication */ -/** - * Transcript entry type for podcast transcripts - */ -interface PodcastTranscriptEntry { - speaker_id: number; - dialog: string; -} - function PodcastPlayer({ podcastId, title, @@ -163,12 +197,12 @@ function PodcastPlayer({ try { // Fetch audio blob and podcast details in parallel - const [audioResponse, podcastDetails] = await Promise.all([ + const [audioResponse, rawPodcastDetails] = await Promise.all([ authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`, { method: "GET", signal: controller.signal } ), - baseApiService.get<{ podcast_transcript?: PodcastTranscriptEntry[] }>( + baseApiService.get( `/api/v1/podcasts/${podcastId}` ), ]); @@ -184,8 +218,9 @@ function PodcastPlayer({ objectUrlRef.current = objectUrl; setAudioSrc(objectUrl); - // Set transcript from podcast details - if (podcastDetails?.podcast_transcript) { + // Parse and validate podcast details, then set transcript + const podcastDetails = parsePodcastDetails(rawPodcastDetails); + if (podcastDetails.podcast_transcript) { setTranscript(podcastDetails.podcast_transcript); } } finally { @@ -268,9 +303,10 @@ function PodcastTaskPoller({ taskId, title }: { taskId: string; title: string }) useEffect(() => { const pollStatus = async () => { try { - const response = await baseApiService.get( + const rawResponse = await baseApiService.get( `/api/v1/podcasts/task/${taskId}/status` ); + const response = parseTaskStatusResponse(rawResponse); setTaskStatus(response); // Stop polling if task is complete or errored diff --git a/surfsense_web/components/tool-ui/image/index.tsx b/surfsense_web/components/tool-ui/image/index.tsx index 97c983625..c9ac72d2f 100644 --- a/surfsense_web/components/tool-ui/image/index.tsx +++ b/surfsense_web/components/tool-ui/image/index.tsx @@ -3,28 +3,43 @@ import { ExternalLinkIcon, ImageIcon, Loader2 } from "lucide-react"; import NextImage from "next/image"; import { Component, type ReactNode, useState } from "react"; +import { z } from "zod"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import { cn } from "@/lib/utils"; /** - * Aspect ratio options for images + * Zod schemas for runtime validation */ -type AspectRatio = "1:1" | "4:3" | "16:9" | "9:16" | "auto"; +const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "auto"]); +const ImageFitSchema = z.enum(["cover", "contain"]); + +const ImageSourceSchema = z.object({ + label: z.string(), + iconUrl: z.string().optional(), + url: z.string().optional(), +}); + +const SerializableImageSchema = z.object({ + id: z.string(), + assetId: z.string(), + src: z.string(), + alt: z.string(), + title: z.string().optional(), + description: z.string().optional(), + href: z.string().optional(), + domain: z.string().optional(), + ratio: AspectRatioSchema.optional(), + source: ImageSourceSchema.optional(), +}); /** - * Image fit options + * Types derived from Zod schemas */ -type ImageFit = "cover" | "contain"; - -/** - * Source attribution - */ -interface ImageSource { - label: string; - iconUrl?: string; - url?: string; -} +type AspectRatio = z.infer; +type ImageFit = z.infer; +type ImageSource = z.infer; +export type SerializableImage = z.infer; /** * Props for the Image component @@ -45,58 +60,20 @@ export interface ImageProps { className?: string; } -/** - * Serializable schema for Image props (for tool results) - */ -export interface SerializableImage { - id: string; - assetId: string; - src: string; - alt: string; - title?: string; - description?: string; - href?: string; - domain?: string; - ratio?: AspectRatio; - source?: ImageSource; -} - /** * Parse and validate serializable image from tool result */ export function parseSerializableImage(result: unknown): SerializableImage { - if (typeof result !== "object" || result === null) { - throw new Error("Invalid image result: expected object"); + const parsed = SerializableImageSchema.safeParse(result); + + if (!parsed.success) { + console.warn("Invalid image data:", parsed.error.issues); + // Try to extract basic info for error display + const obj = (result && typeof result === "object" ? result : {}) as Record; + throw new Error(`Invalid image: ${parsed.error.issues.map(i => i.message).join(", ")}`); } - - const obj = result as Record; - - // Validate required fields - if (typeof obj.id !== "string") { - throw new Error("Invalid image: missing id"); - } - if (typeof obj.assetId !== "string") { - throw new Error("Invalid image: missing assetId"); - } - if (typeof obj.src !== "string") { - throw new Error("Invalid image: missing src"); - } - if (typeof obj.alt !== "string") { - throw new Error("Invalid image: missing alt"); - } - - return { - id: obj.id, - assetId: obj.assetId, - src: obj.src, - alt: obj.alt, - title: typeof obj.title === "string" ? obj.title : undefined, - description: typeof obj.description === "string" ? obj.description : undefined, - href: typeof obj.href === "string" ? obj.href : undefined, - domain: typeof obj.domain === "string" ? obj.domain : undefined, - ratio: typeof obj.ratio === "string" ? (obj.ratio as AspectRatio) : undefined, - source: typeof obj.source === "object" && obj.source !== null ? (obj.source as ImageSource) : undefined, - }; + + return parsed.data; } /** diff --git a/surfsense_web/components/tool-ui/media-card/index.tsx b/surfsense_web/components/tool-ui/media-card/index.tsx index 47c28e49b..dc3b9b59a 100644 --- a/surfsense_web/components/tool-ui/media-card/index.tsx +++ b/surfsense_web/components/tool-ui/media-card/index.tsx @@ -3,6 +3,7 @@ import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } from "lucide-react"; import Image from "next/image"; import { Component, type ReactNode } from "react"; +import { z } from "zod"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -10,24 +11,38 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { cn } from "@/lib/utils"; /** - * Aspect ratio options for media cards + * Zod schemas for runtime validation */ -type AspectRatio = "1:1" | "4:3" | "16:9" | "21:9" | "auto"; +const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "21:9", "auto"]); +const MediaCardKindSchema = z.enum(["link", "image", "video", "audio"]); + +const ResponseActionSchema = z.object({ + id: z.string(), + label: z.string(), + variant: z.enum(["default", "secondary", "outline", "destructive", "ghost"]).optional(), + confirmLabel: z.string().optional(), +}); + +const SerializableMediaCardSchema = z.object({ + id: z.string(), + assetId: z.string(), + kind: MediaCardKindSchema, + href: z.string().optional(), + src: z.string().optional(), + title: z.string(), + description: z.string().optional(), + thumb: z.string().optional(), + ratio: AspectRatioSchema.optional(), + domain: z.string().optional(), +}); /** - * MediaCard kind - determines the display style + * Types derived from Zod schemas */ -type MediaCardKind = "link" | "image" | "video" | "audio"; - -/** - * Response action configuration - */ -interface ResponseAction { - id: string; - label: string; - variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; - confirmLabel?: string; -} +type AspectRatio = z.infer; +type MediaCardKind = z.infer; +type ResponseAction = z.infer; +export type SerializableMediaCard = z.infer; /** * Props for the MediaCard component @@ -50,58 +65,18 @@ export interface MediaCardProps { onResponseAction?: (id: string) => void; } -/** - * Serializable schema for MediaCard props (for tool results) - */ -export interface SerializableMediaCard { - id: string; - assetId: string; - kind: MediaCardKind; - href?: string; - src?: string; - title: string; - description?: string; - thumb?: string; - ratio?: AspectRatio; - domain?: string; -} - /** * Parse and validate serializable media card from tool result */ export function parseSerializableMediaCard(result: unknown): SerializableMediaCard { - if (typeof result !== "object" || result === null) { - throw new Error("Invalid media card result: expected object"); + const parsed = SerializableMediaCardSchema.safeParse(result); + + if (!parsed.success) { + console.warn("Invalid media card data:", parsed.error.issues); + throw new Error(`Invalid media card: ${parsed.error.issues.map(i => i.message).join(", ")}`); } - - const obj = result as Record; - - // Validate required fields - if (typeof obj.id !== "string") { - throw new Error("Invalid media card: missing id"); - } - if (typeof obj.assetId !== "string") { - throw new Error("Invalid media card: missing assetId"); - } - if (typeof obj.kind !== "string") { - throw new Error("Invalid media card: missing kind"); - } - if (typeof obj.title !== "string") { - throw new Error("Invalid media card: missing title"); - } - - return { - id: obj.id, - assetId: obj.assetId, - kind: obj.kind as MediaCardKind, - href: typeof obj.href === "string" ? obj.href : undefined, - src: typeof obj.src === "string" ? obj.src : undefined, - title: obj.title, - description: typeof obj.description === "string" ? obj.description : undefined, - thumb: typeof obj.thumb === "string" ? obj.thumb : undefined, - ratio: typeof obj.ratio === "string" ? (obj.ratio as AspectRatio) : undefined, - domain: typeof obj.domain === "string" ? obj.domain : undefined, - }; + + return parsed.data; } /**