├── .eslintrc.json ├── .env.example ├── app ├── favicon.ico ├── page.tsx ├── layout.tsx ├── api │ ├── authenticate │ │ └── route.ts │ └── validate-statement │ │ └── route.ts ├── globals.css └── context │ ├── DeepgramContextProvider.tsx │ └── MicrophoneContextProvider.tsx ├── declarations.d.ts ├── postcss.config.js ├── next.config.js ├── utils ├── hooks.ts └── schemas.ts ├── lib └── utils.ts ├── components ├── tooltip-trigger-context.tsx ├── loading-indicator.tsx ├── ui │ ├── label.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ └── select.tsx ├── tooltip-root.tsx ├── settings-modal.tsx ├── flowing-transcript.tsx ├── icons.tsx └── app-layout.tsx ├── components.json ├── .gitignore ├── tsconfig.json ├── package.json ├── middleware.ts ├── README.md └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DEEPGRAM_API_KEY= 2 | OPENAI_API_KEY= 3 | PERPLEXITY_API_KEY= 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-facts/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /declarations.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | webkitAudioContext: typeof AudioContext; 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import AppLayout from "@/components/app-layout"; 4 | 5 | export default function Home() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const nextConfig = { 4 | reactStrictMode: false, 5 | }; 6 | 7 | module.exports = nextConfig; 8 | -------------------------------------------------------------------------------- /utils/hooks.ts: -------------------------------------------------------------------------------- 1 | export function useHasHover() { 2 | try { 3 | return matchMedia('(hover: hover)').matches 4 | } catch { 5 | // Assume that if browser too old to support matchMedia it's likely not a touch device 6 | return true 7 | } 8 | } -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export const splitIntoStatements = (text: string): string[] => { 9 | return text.match(/[^.!?]+[.!?]+/g) || []; 10 | }; 11 | -------------------------------------------------------------------------------- /components/tooltip-trigger-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createContext } from 'react'; 4 | 5 | type TooltipTriggerContextType = { 6 | open: boolean; 7 | setOpen: React.Dispatch>; 8 | }; 9 | 10 | export const TooltipTriggerContext = createContext({ 11 | open: false, 12 | setOpen: () => {}, // eslint-disable-line 13 | }); -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/loading-indicator.tsx: -------------------------------------------------------------------------------- 1 | export function TalkingIndicator() { 2 | return ( 3 |
4 |
5 |
6 |
7 | User is typing 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | 39 | # Contentlayer 40 | .contentlayer 41 | 42 | # vscode 43 | .vscode 44 | 45 | # container 46 | .devcontainer 47 | 48 | # pycharm 49 | .idea -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /utils/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const validatedStatementSchema = z.object({ 4 | reasoning: z 5 | .string() 6 | .describe( 7 | "Explain your reasoning for classifying the statement as true, dubious or obviously-fake. If the statement is not-checkable, return null.", 8 | ) 9 | .nullable(), 10 | accuracy: z 11 | .enum(["dubious", "obviously-fake", "true"]) 12 | .nullable() 13 | .describe("If the statement is not-checkable, return null"), 14 | }); 15 | 16 | export const validatedStatementResultSchema = validatedStatementSchema.extend({ 17 | statement: z.string(), 18 | type: z 19 | .enum(["checkable", "non-checkable"]) 20 | .describe( 21 | "Whether the statement needs fact-checking. If the statement is a greeting, random talking or other non-checkable phrase, set this to 'non-checkable'.", 22 | ), 23 | }); 24 | 25 | export type ValidatedStatement = z.infer; 26 | 27 | export interface Statement { 28 | text: string; 29 | timestamp: number; 30 | index: number; 31 | processed: boolean; 32 | result?: ValidatedStatement; 33 | } 34 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-facts", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ai-sdk/openai": "^1.0.6", 13 | "@deepgram/sdk": "^3.3.0", 14 | "@radix-ui/react-dialog": "^1.1.2", 15 | "@radix-ui/react-label": "^2.1.0", 16 | "@radix-ui/react-select": "^2.1.2", 17 | "@radix-ui/react-slot": "^1.1.0", 18 | "@radix-ui/react-tooltip": "^1.1.4", 19 | "ai": "^4.0.10", 20 | "class-variance-authority": "^0.7.1", 21 | "classnames": "^2.5.1", 22 | "clsx": "^2.1.1", 23 | "geist": "^1.3.1", 24 | "lucide-react": "^0.464.0", 25 | "motion": "^11.13.1", 26 | "next": "^14.1.3", 27 | "next-themes": "^0.4.3", 28 | "react": "^18", 29 | "react-device-detect": "^2.2.3", 30 | "react-dom": "^18", 31 | "sonner": "^1.7.0", 32 | "tailwind-merge": "^2.5.5", 33 | "tailwindcss-animate": "^1.0.7", 34 | "zod": "^3.23.8", 35 | "zod-to-json-schema": "^3.23.5" 36 | }, 37 | "devDependencies": { 38 | "@types/node": "^20", 39 | "@types/react": "^18", 40 | "@types/react-dom": "^18", 41 | "autoprefixer": "^10.0.1", 42 | "eslint": "^8", 43 | "eslint-config-next": "14.0.1", 44 | "postcss": "^8", 45 | "pretty-quick": "^4.0.0", 46 | "tailwindcss": "^3.4.1", 47 | "typescript": "^5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { Toaster } from "@/components/ui/sonner"; 3 | import { GeistMono } from "geist/font/mono"; 4 | import { GeistSans } from "geist/font/sans"; 5 | 6 | import { DeepgramContextProvider } from "./context/DeepgramContextProvider"; 7 | import { MicrophoneContextProvider } from "./context/MicrophoneContextProvider"; 8 | 9 | import "./globals.css"; 10 | 11 | import type { Metadata, Viewport } from "next"; 12 | 13 | export const viewport: Viewport = { 14 | themeColor: "#000000", 15 | initialScale: 1, 16 | width: "device-width", 17 | // maximumScale: 1, hitting accessability 18 | }; 19 | 20 | export const metadata: Metadata = { 21 | metadataBase: new URL("https://ai-facts.vercel.app"), 22 | title: "AI Facts", 23 | description: `Realtime fact checking with Deepgram, Perplexity, OpenAI, and the AI SDK`, 24 | robots: { 25 | index: false, 26 | follow: false, 27 | }, 28 | }; 29 | 30 | export default function RootLayout({ 31 | children, 32 | }: { 33 | children: React.ReactNode; 34 | }) { 35 | return ( 36 | 37 | 43 | 44 | {children} 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /components/tooltip-root.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TooltipTriggerContext } from "./tooltip-trigger-context"; 3 | import * as Tooltip from '@radix-ui/react-tooltip' 4 | import { useHasHover } from "@/utils/hooks"; 5 | 6 | 7 | export const TooltipRoot: React.FC = ({ children, ...props }) => { 8 | const [open, setOpen] = React.useState(props.defaultOpen ?? false); 9 | 10 | // we only want to enable the "click to open" functionality on mobile 11 | const hasHover = useHasHover(); 12 | 13 | return ( 14 | { 17 | setOpen(e); 18 | }} 19 | open={open} 20 | > 21 | 22 | {children} 23 | 24 | 25 | ); 26 | }; 27 | 28 | export const TooltipTrigger = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ children, ...props }, ref) => { 32 | const hasHover = useHasHover(); 33 | const { setOpen } = React.useContext(TooltipTriggerContext); 34 | 35 | return ( 36 | { 40 | !hasHover && e.preventDefault(); 41 | setOpen(true); 42 | }} 43 | > 44 | {children} 45 | 46 | ); 47 | }); 48 | 49 | TooltipTrigger.displayName = 'TooltipTrigger'; -------------------------------------------------------------------------------- /app/api/authenticate/route.ts: -------------------------------------------------------------------------------- 1 | import { DeepgramError, createClient } from "@deepgram/sdk"; 2 | import { NextResponse, type NextRequest } from "next/server"; 3 | 4 | export const revalidate = 0; 5 | 6 | export async function GET(request: NextRequest) { 7 | // exit early so we don't request keys while in devmode 8 | if (process.env.DEEPGRAM_ENV === "development") { 9 | return NextResponse.json({ 10 | key: process.env.DEEPGRAM_API_KEY ?? "", 11 | }); 12 | } 13 | 14 | // use the request object to invalidate the cache every request 15 | const url = request.url; 16 | const deepgram = createClient(process.env.DEEPGRAM_API_KEY ?? ""); 17 | 18 | let { result: projectsResult, error: projectsError } = 19 | await deepgram.manage.getProjects(); 20 | 21 | if (projectsError) { 22 | return NextResponse.json(projectsError); 23 | } 24 | 25 | const project = projectsResult?.projects[0]; 26 | 27 | if (!project) { 28 | return NextResponse.json( 29 | new DeepgramError( 30 | "Cannot find a Deepgram project. Please create a project first." 31 | ) 32 | ); 33 | } 34 | 35 | let { result: newKeyResult, error: newKeyError } = 36 | await deepgram.manage.createProjectKey(project.project_id, { 37 | comment: "Temporary API key", 38 | scopes: ["usage:write"], 39 | tags: ["next.js"], 40 | time_to_live_in_seconds: 60, 41 | }); 42 | 43 | if (newKeyError) { 44 | return NextResponse.json(newKeyError); 45 | } 46 | 47 | const response = NextResponse.json({ ...newKeyResult, url }); 48 | response.headers.set("Surrogate-Control", "no-store"); 49 | response.headers.set( 50 | "Cache-Control", 51 | "s-maxage=0, no-store, no-cache, must-revalidate, proxy-revalidate" 52 | ); 53 | response.headers.set("Expires", "0"); 54 | 55 | return response; 56 | } 57 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | @layer base { 7 | :root { 8 | --background: 0 0% 100%; 9 | --foreground: 240 10% 3.9%; 10 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | --popover: 0 0% 100%; 13 | --popover-foreground: 240 10% 3.9%; 14 | --primary: 240 5.9% 10%; 15 | --primary-foreground: 0 0% 98%; 16 | --secondary: 240 4.8% 95.9%; 17 | --secondary-foreground: 240 5.9% 10%; 18 | --muted: 240 4.8% 95.9%; 19 | --muted-foreground: 240 3.8% 46.1%; 20 | --accent: 240 4.8% 95.9%; 21 | --accent-foreground: 240 5.9% 10%; 22 | --destructive: 0 84.2% 60.2%; 23 | --destructive-foreground: 0 0% 98%; 24 | --border: 240 5.9% 90%; 25 | --input: 240 5.9% 90%; 26 | --ring: 240 10% 3.9%; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | --radius: 0.5rem; 33 | } 34 | .dark { 35 | --background: 240 10% 3.9%; 36 | --foreground: 0 0% 98%; 37 | --card: 240 10% 3.9%; 38 | --card-foreground: 0 0% 98%; 39 | --popover: 240 10% 3.9%; 40 | --popover-foreground: 0 0% 98%; 41 | --primary: 0 0% 98%; 42 | --primary-foreground: 240 5.9% 10%; 43 | --secondary: 240 3.7% 15.9%; 44 | --secondary-foreground: 0 0% 98%; 45 | --muted: 240 3.7% 15.9%; 46 | --muted-foreground: 240 5% 64.9%; 47 | --accent: 240 3.7% 15.9%; 48 | --accent-foreground: 0 0% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 0 0% 98%; 51 | --border: 240 3.7% 15.9%; 52 | --input: 240 3.7% 15.9%; 53 | --ring: 240 4.9% 83.9%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | @layer base { 62 | * { 63 | @apply border-border; 64 | } 65 | body { 66 | @apply bg-background text-foreground; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /components/settings-modal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogHeader, 6 | DialogTitle, 7 | } from "@/components/ui/dialog"; 8 | import { 9 | Select, 10 | SelectContent, 11 | SelectItem, 12 | SelectTrigger, 13 | SelectValue, 14 | } from "@/components/ui/select"; 15 | import { Label } from "@/components/ui/label"; 16 | import { useMicrophone } from "@/app/context/MicrophoneContextProvider"; 17 | import { useEffect } from "react"; 18 | 19 | interface SettingsModalProps { 20 | isOpen: boolean; 21 | onClose: () => void; 22 | } 23 | 24 | export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { 25 | const { 26 | availableMicrophones, 27 | selectedMicrophone, 28 | loadAvailableMicrophones, 29 | selectMicrophone, 30 | } = useMicrophone(); 31 | 32 | useEffect(() => { 33 | loadAvailableMicrophones(); 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | }, []); 36 | 37 | return ( 38 | 39 | 40 | 41 | Settings 42 | 43 |
44 |
45 | 46 | 61 |
62 |
63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, type NextRequest } from "next/server"; 2 | 3 | const corsOptions: { 4 | allowedMethods: string[]; 5 | allowedOrigins: string[]; 6 | allowedHeaders: string[]; 7 | exposedHeaders: string[]; 8 | maxAge?: number; 9 | credentials: boolean; 10 | } = { 11 | allowedMethods: (process.env?.ALLOWED_METHODS || "").split(","), 12 | allowedOrigins: (process.env?.ALLOWED_ORIGIN || "").split(","), 13 | allowedHeaders: (process.env?.ALLOWED_HEADERS || "").split(","), 14 | exposedHeaders: (process.env?.EXPOSED_HEADERS || "").split(","), 15 | maxAge: 16 | (process.env?.PREFLIGHT_MAX_AGE && 17 | parseInt(process.env?.PREFLIGHT_MAX_AGE)) || 18 | undefined, // 60 * 60 * 24 * 30, // 30 days 19 | credentials: process.env?.CREDENTIALS == "true", 20 | }; 21 | 22 | /** 23 | * Middleware function that handles CORS configuration for API routes. 24 | * 25 | * This middleware function is responsible for setting the appropriate CORS headers 26 | * on the response, based on the configured CORS options. It checks the origin of 27 | * the request and sets the `Access-Control-Allow-Origin` header accordingly. It 28 | * also sets the other CORS-related headers, such as `Access-Control-Allow-Credentials`, 29 | * `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, and 30 | * `Access-Control-Expose-Headers`. 31 | * 32 | * The middleware function is configured to be applied to all API routes, as defined 33 | * by the `config` object at the end of the file. 34 | */ 35 | export function middleware(request: NextRequest) { 36 | // Response 37 | const response = NextResponse.next(); 38 | 39 | // Allowed origins check 40 | const origin = request.headers.get("origin") ?? ""; 41 | if ( 42 | corsOptions.allowedOrigins.includes("*") || 43 | corsOptions.allowedOrigins.includes(origin) 44 | ) { 45 | response.headers.set("Access-Control-Allow-Origin", origin); 46 | } 47 | 48 | // Set default CORS headers 49 | response.headers.set( 50 | "Access-Control-Allow-Credentials", 51 | corsOptions.credentials.toString() 52 | ); 53 | response.headers.set( 54 | "Access-Control-Allow-Methods", 55 | corsOptions.allowedMethods.join(",") 56 | ); 57 | response.headers.set( 58 | "Access-Control-Allow-Headers", 59 | corsOptions.allowedHeaders.join(",") 60 | ); 61 | response.headers.set( 62 | "Access-Control-Expose-Headers", 63 | corsOptions.exposedHeaders.join(",") 64 | ); 65 | response.headers.set( 66 | "Access-Control-Max-Age", 67 | corsOptions.maxAge?.toString() ?? "" 68 | ); 69 | 70 | // Return 71 | return response; 72 | } 73 | 74 | // See "Matching Paths" below to learn more 75 | export const config = { 76 | matcher: "/api/authenticate", 77 | }; 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Facts 2 | 3 | **Note:** LLMs can sometimes provide incorrect or outdated information. Always verify critical information through trusted sources. 4 | 5 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-facts&env=OPENAI_API_KEY,DEEPGRAM_API_KEY,PERPLEXITY_API_KEY&envDescription=Learn%20more%20about%20how%20to%20get%20the%20API%20Keys%20for%20the%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-facts%2Fblob%2Fmain%2F.env.example&demo-title=AI%20Facts&demo-description=Real-time%20fact%20checking%20using%20audio%20transcription%20and%20AI&demo-url=https%3A%2F%2Fai-facts.vercel.app) 6 | 7 | This project is a Next.js application that performs real-time fact checking on spoken statements. It uses Deepgram for audio transcription and leverages both OpenAI and Perplexity to verify the accuracy of claims. 8 | 9 | ## Features 10 | 11 | - Real-time Audio Transcription: Captures and transcribes spoken audio using Deepgram's API 12 | - AI Fact Checking: Uses both OpenAI and Perplexity to cross-reference and verify statements 13 | - Live Results: Shows fact-checking results in real-time as statements are processed 14 | - Explanation of Validity: Provides detailed explanations for why statements are considered true or false 15 | 16 | ## Technology Stack 17 | 18 | - [Next.js](https://nextjs.org/) for the frontend and API routes 19 | - [AI SDK](https://sdk.vercel.ai/) for interacting with LLMs 20 | - [Deepgram](https://deepgram.com/) for audio transcription 21 | - [OpenAI](https://openai.com/) and [Perplexity](https://perplexity.ai/) for validating claims 22 | - [ShadcnUI](https://ui.shadcn.com/) for UI components 23 | - [Tailwind CSS](https://tailwindcss.com/) for styling 24 | 25 | ## How It Works 26 | 27 | 1. Speak into microphone 28 | 2. Deepgram processes the audio stream in real-time and returns transcribed text 29 | 3. The transcribed text is analyzed for distinct statements ('?!.') 30 | 4. Each statement is sent to OpenAI and Perplexity for fact checking 31 | 5. The verification status and explanation are displayed to the user 32 | 33 | ## Getting Started 34 | 35 | To get the project up and running, follow these steps: 36 | 37 | 1. Install dependencies: 38 | 39 | ```bash 40 | npm install 41 | ``` 42 | 43 | 2. Copy the example environment file: 44 | 45 | ```bash 46 | cp .env.example .env 47 | ``` 48 | 49 | 3. Add your API keys to the `.env` file: 50 | 51 | ``` 52 | OPENAI_API_KEY=your_api_key_here 53 | DEEPGRAM_API_KEY=your_api_key_here 54 | PERPLEXITY_API_KEY=your_api_key_here 55 | ``` 56 | 57 | 4. Start the development server: 58 | ```bash 59 | npm run dev 60 | ``` 61 | 62 | Your project should now be running on [http://localhost:3000](http://localhost:3000). 63 | 64 | ## Deployment 65 | 66 | The project is set up for one-click deployment on Vercel. Use the "Deploy with Vercel" button above to create your own instance of the application. 67 | 68 | ## Learn More 69 | 70 | To learn more about the technologies used in this project, check out the following resources: 71 | 72 | - [Next.js Documentation](https://nextjs.org/docs) 73 | - [AI SDK](https://sdk.vercel.ai/docs) 74 | - [OpenAI](https://openai.com/) 75 | - [Deepgram](https://deepgram.com/) 76 | - [Perplexity AI](https://perplexity.ai/) 77 | - [ShadcnUI](https://ui.shadcn.com/) 78 | - [Tailwind CSS](https://tailwindcss.com/docs) -------------------------------------------------------------------------------- /app/context/DeepgramContextProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | createClient, 5 | LiveClient, 6 | LiveConnectionState, 7 | LiveTranscriptionEvents, 8 | type LiveSchema, 9 | type LiveTranscriptionEvent, 10 | } from "@deepgram/sdk"; 11 | 12 | import { 13 | createContext, 14 | useContext, 15 | useState, 16 | ReactNode, 17 | FunctionComponent, 18 | } from "react"; 19 | 20 | interface DeepgramContextType { 21 | connection: LiveClient | null; 22 | connectToDeepgram: (options: LiveSchema, endpoint?: string) => Promise; 23 | disconnectFromDeepgram: () => void; 24 | connectionState: LiveConnectionState; 25 | error?: any; 26 | } 27 | 28 | const DeepgramContext = createContext( 29 | undefined, 30 | ); 31 | 32 | interface DeepgramContextProviderProps { 33 | children: ReactNode; 34 | } 35 | 36 | const getApiKey = async (): Promise => { 37 | const response = await fetch("/api/authenticate", { cache: "no-store" }); 38 | const result = await response.json(); 39 | return result.key; 40 | }; 41 | 42 | const DeepgramContextProvider: FunctionComponent< 43 | DeepgramContextProviderProps 44 | > = ({ children }) => { 45 | const [connection, setConnection] = useState(null); 46 | const [connectionState, setConnectionState] = useState( 47 | LiveConnectionState.CLOSED, 48 | ); 49 | const [error, setError] = useState(); 50 | 51 | /** 52 | * Connects to the Deepgram speech recognition service and sets up a live transcription session. 53 | * 54 | * @param options - The configuration options for the live transcription session. 55 | * @param endpoint - The optional endpoint URL for the Deepgram service. 56 | * @returns A Promise that resolves when the connection is established. 57 | */ 58 | const connectToDeepgram = async (options: LiveSchema, endpoint?: string) => { 59 | const key = await getApiKey(); 60 | const deepgram = createClient(key); 61 | 62 | const conn = deepgram.listen.live(options, endpoint); 63 | 64 | conn.addListener(LiveTranscriptionEvents.Open, () => { 65 | setConnectionState(LiveConnectionState.OPEN); 66 | }); 67 | 68 | conn.addListener(LiveTranscriptionEvents.Close, () => { 69 | setConnectionState(LiveConnectionState.CLOSED); 70 | }); 71 | 72 | conn.addListener(LiveTranscriptionEvents.Error, (e) => { 73 | setError(e); 74 | }); 75 | 76 | setConnection(conn); 77 | }; 78 | 79 | const disconnectFromDeepgram = async () => { 80 | if (connection) { 81 | connection.finish(); 82 | setConnection(null); 83 | } 84 | }; 85 | 86 | return ( 87 | 96 | {children} 97 | 98 | ); 99 | }; 100 | 101 | function useDeepgram(): DeepgramContextType { 102 | const context = useContext(DeepgramContext); 103 | if (context === undefined) { 104 | throw new Error( 105 | "useDeepgram must be used within a DeepgramContextProvider", 106 | ); 107 | } 108 | return context; 109 | } 110 | 111 | export { 112 | DeepgramContextProvider, 113 | useDeepgram, 114 | LiveConnectionState, 115 | LiveTranscriptionEvents, 116 | type LiveTranscriptionEvent, 117 | }; 118 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | animation: { 13 | "ping-short": "ping 1s ease-in-out 5", 14 | "fade-in": "fadeIn 0.2s ease-in", 15 | "fade-in-fade-out": "fadeIn fadeOut 0.5s ease-in", 16 | }, 17 | keyframes: { 18 | fadeIn: { 19 | "0%": { opacity: "0" }, 20 | "100%": { opacity: "1" }, 21 | }, 22 | fadeOut: { 23 | "100%": { opacity: "1" }, 24 | "0%": { opacity: "0" }, 25 | }, 26 | }, 27 | screens: { 28 | betterhover: { 29 | raw: "(hover: hover)", 30 | }, 31 | }, 32 | transitionProperty: { 33 | height: "height", 34 | width: "width", 35 | }, 36 | dropShadow: { 37 | glowBlue: [ 38 | "0px 0px 2px #000", 39 | "0px 0px 4px #000", 40 | "0px 0px 30px #0141ff", 41 | "0px 0px 100px #0141ff80", 42 | ], 43 | glowRed: [ 44 | "0px 0px 2px #f00", 45 | "0px 0px 4px #000", 46 | "0px 0px 15px #ff000040", 47 | "0px 0px 30px #f00", 48 | "0px 0px 100px #ff000080", 49 | ], 50 | }, 51 | backgroundImage: { 52 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 53 | "gradient-conic": 54 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 55 | }, 56 | fontFamily: { 57 | geist: ["var(--font-geist)"], 58 | }, 59 | borderRadius: { 60 | lg: "var(--radius)", 61 | md: "calc(var(--radius) - 2px)", 62 | sm: "calc(var(--radius) - 4px)", 63 | }, 64 | colors: { 65 | background: "hsl(var(--background))", 66 | foreground: "hsl(var(--foreground))", 67 | card: { 68 | DEFAULT: "hsl(var(--card))", 69 | foreground: "hsl(var(--card-foreground))", 70 | }, 71 | popover: { 72 | DEFAULT: "hsl(var(--popover))", 73 | foreground: "hsl(var(--popover-foreground))", 74 | }, 75 | primary: { 76 | DEFAULT: "hsl(var(--primary))", 77 | foreground: "hsl(var(--primary-foreground))", 78 | }, 79 | secondary: { 80 | DEFAULT: "hsl(var(--secondary))", 81 | foreground: "hsl(var(--secondary-foreground))", 82 | }, 83 | muted: { 84 | DEFAULT: "hsl(var(--muted))", 85 | foreground: "hsl(var(--muted-foreground))", 86 | }, 87 | accent: { 88 | DEFAULT: "hsl(var(--accent))", 89 | foreground: "hsl(var(--accent-foreground))", 90 | }, 91 | destructive: { 92 | DEFAULT: "hsl(var(--destructive))", 93 | foreground: "hsl(var(--destructive-foreground))", 94 | }, 95 | border: "hsl(var(--border))", 96 | input: "hsl(var(--input))", 97 | ring: "hsl(var(--ring))", 98 | chart: { 99 | "1": "hsl(var(--chart-1))", 100 | "2": "hsl(var(--chart-2))", 101 | "3": "hsl(var(--chart-3))", 102 | "4": "hsl(var(--chart-4))", 103 | "5": "hsl(var(--chart-5))", 104 | }, 105 | }, 106 | }, 107 | }, 108 | plugins: [require("tailwindcss-animate")], 109 | }; 110 | export default config; 111 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /app/api/validate-statement/route.ts: -------------------------------------------------------------------------------- 1 | import { validatedStatementSchema, } from "@/utils/schemas"; 2 | import { createOpenAI, openai } from "@ai-sdk/openai"; 3 | import { generateObject, generateText } from "ai"; 4 | import { NextResponse } from "next/server"; 5 | import { z } from "zod"; 6 | 7 | const perplexity = createOpenAI({ 8 | name: "perplexity", 9 | apiKey: process.env.PERPLEXITY_API_KEY ?? "", 10 | baseURL: "https://api.perplexity.ai/", 11 | }); 12 | 13 | const generateCheckablePrompt = (statement: string, transcript?: string) => { 14 | return ` 15 | Categorize this statement as one of: 16 | immediately-checkable - Can be verified using general knowledge (e.g. "Water freezes at 0°C", "Earth orbits the Sun") 17 | needs-more-info - Requires additional context or current data (e.g. "It's raining now", "Sarah works at Google", "The stock market is up today") 18 | not-checkable - Conversational/greetings (e.g. "How are you?", "I love pizza") 19 | 20 | Statement: "${statement}"${transcript ? `\n\nConversation context in case subject of statement is ambigious. Infer from here:\n${transcript.concat(" " + statement)}` : ""} 21 | `; 22 | }; 23 | 24 | const generatePrompt = ( 25 | statement: string, 26 | transcript?: string, 27 | additionalInfo?: string, 28 | ) => { 29 | return `Analyze the content for signs of false or misleading information. Use 'dubious' as the classification if the statement seems questionable but you're not certain, or 'obviously-fake' if it contains verifiably false claims. Provide one short sentence explaining your reasoning, using only information that would have been available at the time of the conversation. Slight spelling mistakes are ok and not considered a fake statement. If a statement is highly unlikely, classify it as obviously fake. 30 | Example: 31 | - Statement: "The moon is made of cheese." 32 | - Classification: "obviously-fake" 33 | 34 | Statement to analyze: "${statement}"${additionalInfo ? `\n\nUse the following additional information to guide your response:\n${additionalInfo}` : ""}${transcript ? `\n\nConversation context in case subject of statement is ambigious (infer subject from this trascript):\n${transcript.concat(" " + statement)}` : ""} 35 | `; 36 | }; 37 | 38 | const generatePerplexityPrompt = (statement: string, transcript?: string) => { 39 | return `Analyze this statement and determine if it is true, dubious (questionable but not certain), or obviously fake. Provide a single sentence response in the format: "The statement is [true/dubious/obviously-fake] because [brief reason]." Do not provide sources. Slight spelling mistakes are ok and not considered a fake statement. 40 | 41 | Statement: "${statement}"${transcript ? `\n\nConversation context in case subject of statement is ambigious (infer subject from this trascript):\n${transcript.concat(" " + statement)}` : ""} 42 | 43 | Note: Today's day is ${new Date().toISOString()} if relevant. 44 | `; 45 | }; 46 | 47 | export const POST = async (request: Request) => { 48 | const { statement, transcript: transcriptRaw } = await request.json(); 49 | 50 | const hasAmbiguiousSubject = (text: string): boolean => { 51 | const ambiguousWords = [ 52 | "they", 53 | "he", 54 | "she", 55 | "it", 56 | "they're", 57 | "he's", 58 | "she's", 59 | "it's", 60 | "their", 61 | "his", 62 | "hers", 63 | "its", 64 | "I", 65 | ]; 66 | const lowercaseText = text.toLowerCase(); 67 | return ambiguousWords.some((word) => lowercaseText.includes(word)); 68 | }; 69 | const transcript = hasAmbiguiousSubject(statement) 70 | ? transcriptRaw 71 | : undefined; 72 | 73 | if (!statement) { 74 | return NextResponse.json( 75 | { error: "No statement provided" }, 76 | { status: 400 }, 77 | ); 78 | } 79 | 80 | const { object } = await generateObject({ 81 | model: openai("gpt-4o-mini"), 82 | prompt: generateCheckablePrompt(statement, transcript), 83 | schema: z.object({ 84 | checkableType: z 85 | .enum(["not-checkable", "immediately-checkable", "needs-more-info"]) 86 | .describe( 87 | "If the statement is not checkable (eg. random talking), easily-checkable (eg. the earth is flat), requires-additional-information (eg. x person works at y company).", 88 | ), 89 | }), 90 | }); 91 | 92 | if (object.checkableType === "not-checkable") { 93 | return NextResponse.json({ 94 | statement, 95 | type: "not-checkable", 96 | reasoning: "This statement is not a checkable claim", 97 | }); 98 | } 99 | 100 | let additionalInfo: string | undefined = undefined; 101 | if (object.checkableType === "needs-more-info") { 102 | const { text: perplexityCheck } = await generateText({ 103 | model: perplexity("llama-3.1-sonar-small-128k-online"), 104 | maxTokens: 100, 105 | prompt: generatePerplexityPrompt(statement, transcript), 106 | }); 107 | additionalInfo = perplexityCheck; 108 | } 109 | 110 | const { object: generation } = await generateObject({ 111 | model: openai("gpt-4o-mini"), 112 | schema: validatedStatementSchema, 113 | prompt: generatePrompt(statement, transcript, additionalInfo), 114 | }); 115 | 116 | return NextResponse.json({ statement, type: "checkable", ...generation }); 117 | }; 118 | -------------------------------------------------------------------------------- /app/context/MicrophoneContextProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | createContext, 5 | useCallback, 6 | useContext, 7 | useState, 8 | ReactNode, 9 | } from "react"; 10 | 11 | interface MicrophoneDevice { 12 | deviceId: string; 13 | label: string; 14 | } 15 | 16 | interface MicrophoneContextType { 17 | microphone: MediaRecorder | null; 18 | startMicrophone: () => void; 19 | stopMicrophone: () => void; 20 | setupMicrophone: (deviceId?: string) => void; 21 | microphoneState: MicrophoneState | null; 22 | availableMicrophones: MicrophoneDevice[]; 23 | selectedMicrophone: MicrophoneDevice | null; 24 | loadAvailableMicrophones: () => Promise; 25 | selectMicrophone: (deviceId: string) => Promise; 26 | } 27 | 28 | export enum MicrophoneEvents { 29 | DataAvailable = "dataavailable", 30 | Error = "error", 31 | Pause = "pause", 32 | Resume = "resume", 33 | Start = "start", 34 | Stop = "stop", 35 | } 36 | 37 | export enum MicrophoneState { 38 | NotSetup = -1, 39 | SettingUp = 0, 40 | Ready = 1, 41 | Opening = 2, 42 | Open = 3, 43 | Error = 4, 44 | Pausing = 5, 45 | Paused = 6, 46 | } 47 | 48 | const MicrophoneContext = createContext( 49 | undefined 50 | ); 51 | 52 | interface MicrophoneContextProviderProps { 53 | children: ReactNode; 54 | } 55 | 56 | const MicrophoneContextProvider: React.FC = ({ 57 | children, 58 | }) => { 59 | const [microphoneState, setMicrophoneState] = useState( 60 | MicrophoneState.NotSetup 61 | ); 62 | const [microphone, setMicrophone] = useState(null); 63 | const [availableMicrophones, setAvailableMicrophones] = useState([]); 64 | const [selectedMicrophone, setSelectedMicrophone] = useState(null); 65 | 66 | const loadAvailableMicrophones = async () => { 67 | try { 68 | // Request permission to access audio devices 69 | await navigator.mediaDevices.getUserMedia({ audio: true }); 70 | 71 | // Get list of audio input devices 72 | const devices = await navigator.mediaDevices.enumerateDevices(); 73 | const audioInputDevices = devices 74 | .filter(device => device.kind === 'audioinput') 75 | .map(device => ({ 76 | deviceId: device.deviceId, 77 | label: device.label || `Microphone ${device.deviceId.slice(0, 5)}...` 78 | })); 79 | 80 | setAvailableMicrophones(audioInputDevices); 81 | 82 | // Select default microphone if none is selected 83 | if (!selectedMicrophone && audioInputDevices.length > 0) { 84 | setSelectedMicrophone(audioInputDevices[0]); 85 | } 86 | } catch (err) { 87 | console.error('Error loading microphones:', err); 88 | throw err; 89 | } 90 | }; 91 | 92 | const selectMicrophone = async (deviceId: string) => { 93 | const selected = availableMicrophones.find(mic => mic.deviceId === deviceId); 94 | if (selected) { 95 | setSelectedMicrophone(selected); 96 | // If there's an active microphone, stop it and setup the new one 97 | if (microphone) { 98 | microphone.stream.getTracks().forEach(track => track.stop()); 99 | setMicrophone(null); 100 | await setupMicrophone(deviceId); 101 | } 102 | } 103 | }; 104 | 105 | const setupMicrophone = async (deviceId?: string) => { 106 | setMicrophoneState(MicrophoneState.SettingUp); 107 | 108 | try { 109 | const userMedia = await navigator.mediaDevices.getUserMedia({ 110 | audio: { 111 | deviceId: deviceId ? { exact: deviceId } : undefined, 112 | noiseSuppression: true, 113 | echoCancellation: true, 114 | }, 115 | }); 116 | 117 | const microphone = new MediaRecorder(userMedia); 118 | 119 | setMicrophoneState(MicrophoneState.Ready); 120 | setMicrophone(microphone); 121 | } catch (err: any) { 122 | console.error(err); 123 | setMicrophoneState(MicrophoneState.Error); 124 | throw err; 125 | } 126 | }; 127 | const stopMicrophone = useCallback(() => { 128 | setMicrophoneState(MicrophoneState.Pausing); 129 | 130 | if (microphone?.state === "recording") { 131 | microphone.pause(); 132 | setMicrophoneState(MicrophoneState.Paused); 133 | } 134 | }, [microphone]); 135 | 136 | const startMicrophone = useCallback(() => { 137 | setMicrophoneState(MicrophoneState.Opening); 138 | 139 | if (microphone?.state === "paused") { 140 | microphone.resume(); 141 | } else { 142 | microphone?.start(250); 143 | } 144 | 145 | setMicrophoneState(MicrophoneState.Open); 146 | }, [microphone]); 147 | 148 | return ( 149 | 162 | {children} 163 | 164 | ); 165 | }; 166 | 167 | function useMicrophone(): MicrophoneContextType { 168 | const context = useContext(MicrophoneContext); 169 | 170 | if (context === undefined) { 171 | throw new Error( 172 | "useMicrophone must be used within a MicrophoneContextProvider", 173 | ); 174 | } 175 | 176 | return context; 177 | } 178 | 179 | export { MicrophoneContextProvider, useMicrophone }; 180 | -------------------------------------------------------------------------------- /components/flowing-transcript.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import * as Tooltip from "@radix-ui/react-tooltip"; 3 | import { TooltipRoot, TooltipTrigger } from "./tooltip-root"; 4 | import Link from "next/link"; 5 | import { VercelIcon, MasonryIcon } from "./icons"; 6 | import { SpeechIcon } from "lucide-react"; 7 | import { Statement, ValidatedStatement } from "@/utils/schemas"; 8 | import { TalkingIndicator } from "./loading-indicator"; 9 | 10 | interface FlowingTranscriptProps { 11 | unprocessed: string | undefined; 12 | statements: Statement[]; 13 | isTalking: boolean; 14 | } 15 | 16 | export default function FlowingTranscript({ 17 | statements, 18 | isTalking, 19 | unprocessed, 20 | }: FlowingTranscriptProps) { 21 | const transcriptRef = useRef(null); 22 | 23 | useEffect(() => { 24 | if (transcriptRef.current) { 25 | transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; 26 | } 27 | }, [statements]); 28 | 29 | const getBackgroundColor = (item: ValidatedStatement) => { 30 | if (item.type === "non-checkable") return "bg-zinc-50 dark:bg-black"; 31 | switch (item.accuracy) { 32 | case "true": 33 | return "bg-green-50 dark:bg-green-900"; 34 | case "dubious": 35 | return "bg-yellow-50 dark:bg-yellow-900"; 36 | case "obviously-fake": 37 | return "bg-red-50 dark:bg-red-900"; 38 | default: 39 | return "bg-zinc-50 dark:bg-zinc-800"; 40 | } 41 | }; 42 | 43 | return ( 44 | 45 |
46 |
47 | {statements.length === 0 && !isTalking ? ( 48 |
49 |
50 |
51 |

52 | 53 | + 54 | 55 |

56 |
57 |
58 | 59 |
60 |

Start Speaking

61 |

62 | Your words will be transcribed and fact-checked in real-time 63 | using AI 64 |

65 |
66 |
67 | Learn more about{" "} 68 | 73 | the AI SDK 74 | 75 |
76 |
77 | ) : ( 78 |

79 | Transcript 80 |

81 | )} 82 |
83 | {statements.map((item, index) => ( 84 |
85 | 86 | 87 | 90 | {item.text} 91 | 92 | 93 | 94 | 98 | {item.result && item.result.type === "checkable" ? ( 99 | item.result.reasoning ? ( 100 | <> 101 |

102 | {item.result.reasoning} 103 |

104 |

113 | {item.result.accuracy?.toUpperCase()} 114 |

115 | 116 | ) : ( 117 |

Processing...

118 | ) 119 | ) : ( 120 |

Non-checkable statement

121 | )} 122 | 123 |
124 |
125 |
126 |
127 | ))} 128 | {isTalking && unprocessed && } 129 |
130 |
131 |
132 |
133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )) 134 | SelectItem.displayName = SelectPrimitive.Item.displayName 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )) 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 147 | 148 | export { 149 | Select, 150 | SelectGroup, 151 | SelectValue, 152 | SelectTrigger, 153 | SelectContent, 154 | SelectLabel, 155 | SelectItem, 156 | SelectSeparator, 157 | SelectScrollUpButton, 158 | SelectScrollDownButton, 159 | } 160 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | export const BotIcon = () => { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | }; 19 | 20 | export const UserIcon = () => { 21 | return ( 22 | 30 | 36 | 37 | ); 38 | }; 39 | 40 | export const AttachmentIcon = () => { 41 | return ( 42 | 49 | 55 | 56 | ); 57 | }; 58 | 59 | export const VercelIcon = ({ size = 17 }) => { 60 | return ( 61 | 68 | 74 | 75 | ); 76 | }; 77 | 78 | export const MasonryIcon = () => { 79 | return ( 80 | 87 | 93 | 94 | ); 95 | }; 96 | 97 | export const GitIcon = () => { 98 | return ( 99 | 106 | 107 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | ); 121 | }; 122 | 123 | export const BoxIcon = ({ size = 16 }: { size: number }) => { 124 | return ( 125 | 132 | 138 | 139 | ); 140 | }; 141 | 142 | export const HomeIcon = ({ size = 16 }: { size: number }) => { 143 | return ( 144 | 151 | 157 | 158 | ); 159 | }; 160 | 161 | export const GPSIcon = ({ size = 16 }: { size: number }) => { 162 | return ( 163 | 170 | 178 | 179 | ); 180 | }; 181 | 182 | export const InvoiceIcon = ({ size = 16 }: { size: number }) => { 183 | return ( 184 | 191 | 197 | 198 | ); 199 | }; -------------------------------------------------------------------------------- /components/app-layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useEffect, useRef } from "react"; 4 | import * as Tooltip from "@radix-ui/react-tooltip"; 5 | import { Mic } from "lucide-react"; 6 | import FlowingTranscript from "./flowing-transcript"; 7 | import { Button } from "@/components/ui/button"; 8 | import { motion } from "motion/react"; 9 | 10 | import { 11 | LiveConnectionState, 12 | LiveTranscriptionEvent, 13 | LiveTranscriptionEvents, 14 | useDeepgram, 15 | } from "@/app/context/DeepgramContextProvider"; 16 | import { 17 | MicrophoneEvents, 18 | MicrophoneState, 19 | useMicrophone, 20 | } from "@/app/context/MicrophoneContextProvider"; 21 | import { toast } from "sonner"; 22 | import { Statement, ValidatedStatement } from "@/utils/schemas"; 23 | import { splitIntoStatements } from "@/lib/utils"; 24 | import { GitIcon, VercelIcon } from "./icons"; 25 | import Link from "next/link"; 26 | 27 | export default function AppLayout() { 28 | // Core state 29 | const [isListening, setIsListening] = useState(true); 30 | const [currentPhrase, setCurrentPhrase] = useState(""); 31 | const [statements, setStatements] = useState([]); 32 | const [talking, setIsTalking] = useState(false); 33 | const statementsRef = useRef([]); 34 | useEffect(() => { 35 | statementsRef.current = statements; 36 | }, [statements]); 37 | 38 | // Keep track of statement index 39 | const statementIndex = useRef(0); 40 | 41 | // Deepgram and microphone hooks 42 | const { connection, connectToDeepgram, connectionState, error } = 43 | useDeepgram(); 44 | const { 45 | setupMicrophone, 46 | microphone, 47 | startMicrophone, 48 | microphoneState, 49 | stopMicrophone, 50 | } = useMicrophone(); 51 | 52 | const captionTimeout = useRef(); 53 | const keepAliveInterval = useRef(); 54 | const unfinishedTextRef = useRef(""); 55 | const lastTranscriptTime = useRef(Date.now()); 56 | const pauseTimeout = useRef(); 57 | 58 | const createNewStatement = (text: string) => { 59 | return { 60 | text: text.trim(), 61 | timestamp: Date.now(), 62 | processed: false, 63 | index: statementIndex.current++, 64 | }; 65 | }; 66 | 67 | const processStatement = async (statement: Statement) => { 68 | try { 69 | const result = await checkFakeNews(statement.text); 70 | statement.processed = true; 71 | statement.result = result; 72 | return statement; 73 | } catch (error) { 74 | console.error("Error processing statement:", error); 75 | return statement; 76 | } 77 | }; 78 | 79 | const createStatementFromUnfinished = () => { 80 | if (unfinishedTextRef.current) { 81 | const completeStatement = unfinishedTextRef.current + "."; 82 | const newStatement = createNewStatement(completeStatement); 83 | setStatements((prev) => [...prev, newStatement]); 84 | processStatement(newStatement).then((processedStatement) => { 85 | setStatements((prev) => 86 | prev.map((s) => 87 | s.index === processedStatement.index ? processedStatement : s, 88 | ), 89 | ); 90 | }); 91 | unfinishedTextRef.current = ""; 92 | } 93 | }; 94 | 95 | const checkFakeNews = async ( 96 | statement: string, 97 | ): Promise => { 98 | try { 99 | const response = await fetch(`/api/validate-statement`, { 100 | body: JSON.stringify({ 101 | statement, 102 | transcript: statementsRef.current.map((s) => s.text).join(" "), 103 | }), 104 | method: "POST", 105 | headers: { 106 | "Content-Type": "application/json", 107 | }, 108 | }); 109 | return response.json(); 110 | } catch (error) { 111 | toast.error("Error validating statement", { 112 | position: "top-center", 113 | richColors: true, 114 | }); 115 | throw error; 116 | } 117 | }; 118 | 119 | useEffect(() => { 120 | setupMicrophone(); 121 | // eslint-disable-next-line react-hooks/exhaustive-deps 122 | }, []); 123 | 124 | useEffect(() => { 125 | let timer: NodeJS.Timeout; 126 | if (isListening) { 127 | timer = setTimeout(() => { 128 | setIsListening(false); 129 | toast.warning("Listening ended", { 130 | description: "The 3-minute recording limit was reached", 131 | richColors: true, 132 | }); 133 | }, 180 * 1000); 134 | } else { 135 | // When stopping listening, process any remaining unfinished text 136 | createStatementFromUnfinished(); 137 | } 138 | return () => clearTimeout(timer); 139 | // eslint-disable-next-line react-hooks/exhaustive-deps 140 | }, [isListening]); 141 | 142 | useEffect(() => { 143 | if (microphoneState === MicrophoneState.Ready) { 144 | connectToDeepgram({ 145 | model: "nova-2", 146 | interim_results: true, 147 | smart_format: true, 148 | endpointing: 2500, 149 | filler_words: true, 150 | keywords: [ 151 | "Next.js", 152 | "React", 153 | "Vercel", 154 | "Guillermo", 155 | "Guillermo Rauch", 156 | "Socket.io", 157 | ], 158 | }); 159 | toast.info("Setting up environment", { 160 | position: "top-center", 161 | richColors: true, 162 | description: "Please wait while your microphone is set up.", 163 | }); 164 | } 165 | // eslint-disable-next-line react-hooks/exhaustive-deps 166 | }, [microphoneState]); 167 | 168 | useEffect(() => { 169 | if (!microphone || !connection) return; 170 | 171 | const onData = (e: BlobEvent) => { 172 | if (e.data.size > 0) { 173 | connection?.send(e.data); 174 | } 175 | }; 176 | 177 | const onTranscript = (data: LiveTranscriptionEvent) => { 178 | const { is_final: isFinal } = data; 179 | const thisCaption = data.channel.alternatives[0].transcript; 180 | 181 | if (thisCaption) { 182 | setIsTalking(true); 183 | setCurrentPhrase(thisCaption); 184 | lastTranscriptTime.current = Date.now(); 185 | 186 | clearTimeout(pauseTimeout.current); 187 | pauseTimeout.current = setTimeout(() => { 188 | if (Date.now() - lastTranscriptTime.current >= 3000) { 189 | createStatementFromUnfinished(); 190 | } 191 | }, 3000); 192 | 193 | if (isFinal) { 194 | setIsTalking(false); 195 | // Update both state and ref for unfinished text 196 | const updatedText = ( 197 | unfinishedTextRef.current + 198 | " " + 199 | thisCaption 200 | ).trim(); 201 | unfinishedTextRef.current = updatedText; 202 | 203 | // Find complete statements 204 | const completeStatements = splitIntoStatements(updatedText); 205 | 206 | if (completeStatements.length > 0) { 207 | const newStatements = completeStatements.map(createNewStatement); 208 | setStatements((prev) => [...prev, ...newStatements]); 209 | 210 | // Process each new complete statement 211 | newStatements.forEach(async (statement) => { 212 | const processedStatement = await processStatement(statement); 213 | setStatements((prev) => 214 | prev.map((s) => 215 | s.index === processedStatement.index ? processedStatement : s, 216 | ), 217 | ); 218 | }); 219 | 220 | // Keep any remaining text that doesn't end with punctuation 221 | const remainingText = updatedText.match(/[^.!?]+$/)?.[0]?.trim(); 222 | unfinishedTextRef.current = remainingText || ""; 223 | } else { 224 | // If no complete statements, add to unfinished text 225 | unfinishedTextRef.current = updatedText; 226 | } 227 | 228 | clearTimeout(captionTimeout.current); 229 | captionTimeout.current = setTimeout(() => { 230 | setCurrentPhrase(""); 231 | }, 3000); 232 | } 233 | } 234 | }; 235 | 236 | if (connectionState === LiveConnectionState.OPEN && isListening) { 237 | connection.addListener(LiveTranscriptionEvents.Transcript, onTranscript); 238 | microphone.addEventListener(MicrophoneEvents.DataAvailable, onData); 239 | startMicrophone(); 240 | } 241 | 242 | if (connectionState === LiveConnectionState.OPEN && !isListening) { 243 | stopMicrophone(); 244 | } 245 | 246 | return () => { 247 | connection.removeListener( 248 | LiveTranscriptionEvents.Transcript, 249 | onTranscript, 250 | ); 251 | microphone.removeEventListener(MicrophoneEvents.DataAvailable, onData); 252 | clearTimeout(captionTimeout.current); 253 | clearTimeout(pauseTimeout.current); 254 | }; 255 | // eslint-disable-next-line react-hooks/exhaustive-deps 256 | }, [connectionState, isListening]); 257 | 258 | useEffect(() => { 259 | if (!connection) return; 260 | 261 | if ( 262 | microphoneState !== MicrophoneState.Open && 263 | connectionState === LiveConnectionState.OPEN 264 | ) { 265 | connection.keepAlive(); 266 | keepAliveInterval.current = setInterval(() => { 267 | connection.keepAlive(); 268 | }, 10000); 269 | } else { 270 | clearInterval(keepAliveInterval.current); 271 | } 272 | 273 | return () => clearInterval(keepAliveInterval.current); 274 | // eslint-disable-next-line react-hooks/exhaustive-deps 275 | }, [microphoneState, connectionState]); 276 | 277 | const toggleListening = () => setIsListening(!isListening); 278 | 279 | useEffect(() => { 280 | if ( 281 | microphoneState === MicrophoneState.Open && 282 | connectionState === LiveConnectionState.OPEN 283 | ) { 284 | setIsListening(true); 285 | toast.success("AI Facts is now listening.", { 286 | position: "top-center", 287 | richColors: true, 288 | }); 289 | } 290 | if ( 291 | microphoneState === MicrophoneState.Paused && 292 | connectionState === LiveConnectionState.OPEN 293 | ) { 294 | setIsListening(false); 295 | toast.warning("AI Facts is no longer listening.", { 296 | position: "top-center", 297 | richColors: true, 298 | }); 299 | } 300 | }, [microphoneState, connectionState]); 301 | 302 | useEffect(() => { 303 | if (error) { 304 | toast.error("Connection Error", { 305 | description: "Please try again later.", 306 | position: "top-center", 307 | richColors: true, 308 | }); 309 | } 310 | }, [error]); 311 | 312 | return ( 313 |
314 |
315 |
316 | 317 | 318 | 319 | 339 | 340 | 341 | 342 |

{isListening ? "Stop listening" : "Start listening"}

343 | 344 |
345 |
346 |
347 |
348 |
349 | 354 | 359 | 364 | 365 | View Source Code 366 | 367 | 368 | 373 | 374 | Deploy with Vercel 375 | 376 | 377 |
378 |
379 | ); 380 | } 381 | --------------------------------------------------------------------------------