feat: implement Zod schemas for runtime validation across various components to enhance data integrity and error handling

This commit is contained in:
Anish Sarkar 2025-12-23 02:42:48 +05:30
parent 7ca490c740
commit 5fd872b798
6 changed files with 253 additions and 214 deletions

View file

@ -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<typeof SerializableArticleSchema>;
/**
* 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<string, unknown>;
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<string, unknown>;
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,
};
}

View file

@ -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<typeof ThinkingStepSchema>;
type DeepAgentThinkingArgs = z.infer<typeof DeepAgentThinkingArgsSchema>;
type DeepAgentThinkingResult = z.infer<typeof DeepAgentThinkingResultSchema>;
/**
* 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;
}
/**

View file

@ -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 }) {
<Image
{...image}
maxWidth="420px"
responseActions={[
{ id: "open", label: "Open", variant: "default" },
]}
onResponseAction={(id) => {
if (id === "open" && image.src) {
window.open(image.src, "_blank", "noopener,noreferrer");
}
}}
/>
);
}

View file

@ -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<typeof GeneratePodcastArgsSchema>;
type GeneratePodcastResult = z.infer<typeof GeneratePodcastResultSchema>;
type TaskStatusResponse = z.infer<typeof TaskStatusResponseSchema>;
type PodcastTranscriptEntry = z.infer<typeof PodcastTranscriptEntrySchema>;
/**
* 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<unknown>(
`/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<TaskStatusResponse>(
const rawResponse = await baseApiService.get<unknown>(
`/api/v1/podcasts/task/${taskId}/status`
);
const response = parseTaskStatusResponse(rawResponse);
setTaskStatus(response);
// Stop polling if task is complete or errored

View file

@ -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<typeof AspectRatioSchema>;
type ImageFit = z.infer<typeof ImageFitSchema>;
type ImageSource = z.infer<typeof ImageSourceSchema>;
export type SerializableImage = z.infer<typeof SerializableImageSchema>;
/**
* 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<string, unknown>;
throw new Error(`Invalid image: ${parsed.error.issues.map(i => i.message).join(", ")}`);
}
const obj = result as Record<string, unknown>;
// 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;
}
/**

View file

@ -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<typeof AspectRatioSchema>;
type MediaCardKind = z.infer<typeof MediaCardKindSchema>;
type ResponseAction = z.infer<typeof ResponseActionSchema>;
export type SerializableMediaCard = z.infer<typeof SerializableMediaCardSchema>;
/**
* 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<string, unknown>;
// 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;
}
/**