From 5fca5a298f0de7b3855f5246ed11412ceeea845c Mon Sep 17 00:00:00 2001 From: zfpxs <2436871821@qq.com> Date: Thu, 24 Jul 2025 00:05:38 +0800 Subject: [PATCH 01/15] Complete AI Meeting Digest implementation with Next.js, Supabase, and Gemini API - Implemented full-stack application with Next.js 15 and TypeScript - Integrated Supabase for database and Google Gemini for AI summaries - Added features: meeting digest generation, history view, shareable links - Bonus: Real-time streaming responses with SSE - Included deployment configuration for Vercel --- .env.local.example | 6 ++ .eslintrc.json | 6 ++ CANDIDATE_README.md | Bin 0 -> 6063 bytes DEPLOYMENT.md | 73 ++++++++++++++++ Dockerfile | 40 +++++++++ app/api/digest/[id]/route.ts | 30 +++++++ app/api/digest/create/route.ts | 51 +++++++++++ app/api/digest/list/route.ts | 34 ++++++++ app/api/digest/stream/route.ts | 101 ++++++++++++++++++++++ app/api/test/route.ts | 35 ++++++++ app/digest/[id]/page.tsx | 151 +++++++++++++++++++++++++++++++++ app/globals.css | 3 + app/layout.tsx | 22 +++++ app/page.tsx | 124 +++++++++++++++++++++++++++ backend/src/example.py | 0 components/DigestCard.tsx | 39 +++++++++ components/StreamingDigest.tsx | 107 +++++++++++++++++++++++ frontend/src/example.ts | 0 next-env.d.ts | 5 ++ next.config.js | 7 ++ package.json | 43 ++++++++++ postcss.config.js | 6 ++ supabase-schema.sql | 46 ++++++++++ tailwind.config.js | 12 +++ tsconfig.json | 40 +++++++++ types/digest.ts | 22 +++++ 26 files changed, 1003 insertions(+) create mode 100644 .env.local.example create mode 100644 .eslintrc.json create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 app/api/digest/[id]/route.ts create mode 100644 app/api/digest/create/route.ts create mode 100644 app/api/digest/list/route.ts create mode 100644 app/api/digest/stream/route.ts create mode 100644 app/api/test/route.ts create mode 100644 app/digest/[id]/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx delete mode 100644 backend/src/example.py create mode 100644 components/DigestCard.tsx create mode 100644 components/StreamingDigest.tsx delete mode 100644 frontend/src/example.ts create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 supabase-schema.sql create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 types/digest.ts diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..5ff1f15 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,6 @@ +# Supabase +NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key + +# Google Gemini API +GOOGLE_GEMINI_API_KEY=your_google_gemini_api_key \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..cd0edd9 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "@next/next/no-img-element": "off" + } +} \ No newline at end of file diff --git a/CANDIDATE_README.md b/CANDIDATE_README.md index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..5b44b510c69fbdad758fcb39f01bc9db0c126fd9 100644 GIT binary patch literal 6063 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 line.trim()) + let section = '' + + for (const line of lines) { + if (line.toLowerCase().includes('overview') || line.toLowerCase().includes('summary')) { + section = 'overview' + } else if (line.toLowerCase().includes('key decision') || line.toLowerCase().includes('decision')) { + section = 'decisions' + } else if (line.toLowerCase().includes('action item') || line.toLowerCase().includes('task')) { + section = 'actions' + } else if (line.trim()) { + if (section === 'overview' && !overview) { + overview = line.trim() + } else if (section === 'decisions' && line.trim().startsWith('-')) { + keyDecisions.push(line.trim().substring(1).trim()) + } else if (section === 'actions' && line.trim().startsWith('-')) { + actionItems.push(line.trim().substring(1).trim()) + } + } + } + + // Save to database + const { data, error } = await supabase + .from('digests') + .insert({ + transcript, + summary: fullContent, + overview: overview || fullContent.substring(0, 200) + '...', + key_decisions: keyDecisions, + action_items: actionItems, + }) + .select() + .single() + + if (!error && data) { + // Send the final digest data + const finalData = `data: ${JSON.stringify({ digest: data, done: true })}\n\n` + controller.enqueue(encoder.encode(finalData)) + } + + controller.close() + } catch (error) { + console.error('Stream error:', error) + controller.error(error) + } + }, + }) + + return new Response(customReadableStream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }) + } catch (error) { + console.error('Error in stream route:', error) + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } +} \ No newline at end of file diff --git a/app/api/test/route.ts b/app/api/test/route.ts new file mode 100644 index 0000000..fcf3bab --- /dev/null +++ b/app/api/test/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' +import { supabase } from '@/lib/supabase' + +export async function GET() { + try { + // Test if environment variables are set + const envCheck = { + supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL ? 'Set' : 'Missing', + supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ? 'Set' : 'Missing', + geminiKey: process.env.GOOGLE_GEMINI_API_KEY ? 'Set' : 'Missing', + } + + // Test database connection + const { data, error } = await supabase + .from('digests') + .select('count') + .limit(1) + + return NextResponse.json({ + status: 'ok', + env: envCheck, + database: { + connected: !error, + error: error?.message || null, + code: error?.code || null, + }, + timestamp: new Date().toISOString(), + }) + } catch (error) { + return NextResponse.json({ + status: 'error', + message: error instanceof Error ? error.message : 'Unknown error', + }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/digest/[id]/page.tsx b/app/digest/[id]/page.tsx new file mode 100644 index 0000000..0390d17 --- /dev/null +++ b/app/digest/[id]/page.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { Digest } from '@/types/digest' +import Link from 'next/link' + +export default function DigestPage() { + const params = useParams() + const router = useRouter() + const [digest, setDigest] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (params.id) { + fetchDigest(params.id as string) + } + }, [params.id]) + + const fetchDigest = async (id: string) => { + try { + const response = await fetch(`/api/digest/${id}`) + if (!response.ok) { + if (response.status === 404) { + setError('Digest not found') + } else { + throw new Error('Failed to fetch digest') + } + return + } + const data = await response.json() + setDigest(data.digest) + } catch (err) { + setError('Failed to load digest') + console.error(err) + } finally { + setIsLoading(false) + } + } + + const copyShareLink = () => { + const url = window.location.href + navigator.clipboard.writeText(url) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error || !digest) { + return ( +
+
+

+ {error || 'Digest not found'} +

+ + ← Back to home + +
+
+ ) + } + + const formattedDate = new Date(digest.created_at).toLocaleString() + + return ( +
+
+
+ + ← Back to home + + +
+ +
+
+

+ Meeting Summary +

+ +
+ + {/* Overview */} +
+

Overview

+

{digest.overview}

+
+ + {/* Key Decisions */} + {digest.key_decisions.length > 0 && ( +
+

+ Key Decisions +

+
    + {digest.key_decisions.map((decision, index) => ( +
  • + + {decision} +
  • + ))} +
+
+ )} + + {/* Action Items */} + {digest.action_items.length > 0 && ( +
+

+ Action Items +

+
    + {digest.action_items.map((item, index) => ( +
  • + + {item} +
  • + ))} +
+
+ )} + + {/* Original Transcript */} +
+ + View Original Transcript + +
+

{digest.transcript}

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..1dcdcd9 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'AI Meeting Digest', + description: 'Generate AI-powered meeting summaries', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..a81a51a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,124 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Digest } from '@/types/digest' +import DigestCard from '@/components/DigestCard' + +export default function Home() { + const [transcript, setTranscript] = useState('') + const [digests, setDigests] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + fetchDigests() + }, []) + + const fetchDigests = async () => { + setIsLoading(true) + try { + const response = await fetch('/api/digest/list') + if (!response.ok) throw new Error('Failed to fetch digests') + const data = await response.json() + setDigests(data.digests) + } catch (err) { + setError('Failed to load digests') + console.error(err) + } finally { + setIsLoading(false) + } + } + + const generateDigest = async () => { + if (!transcript.trim()) { + setError('Please enter a meeting transcript') + return + } + + setIsGenerating(true) + setError(null) + + try { + const response = await fetch('/api/digest/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transcript }), + }) + + if (!response.ok) throw new Error('Failed to generate digest') + + const data = await response.json() + setDigests([data.digest, ...digests]) + setTranscript('') + } catch (err) { + setError('Failed to generate digest. Please try again.') + console.error(err) + } finally { + setIsGenerating(false) + } + } + + return ( +
+
+
+

+ AI Meeting Digest +

+

+ Transform your meeting transcripts into actionable summaries +

+
+ +
+ {/* Input Section */} +
+

New Meeting Transcript

+ + +
+ +

Output:

+
+ + + + + ` + + return new Response(html, { + headers: { + 'Content-Type': 'text/html', + }, + }) +} \ No newline at end of file diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..218eaac --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + const envCheck = { + hasSupabaseUrl: !!process.env.NEXT_PUBLIC_SUPABASE_URL, + hasSupabaseKey: !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + hasGeminiKey: !!process.env.GOOGLE_GEMINI_API_KEY, + nodeEnv: process.env.NODE_ENV, + vercel: !!process.env.VERCEL, + vercelEnv: process.env.VERCEL_ENV, + } + + // Test Supabase connection + let supabaseStatus = 'not tested' + try { + const { supabase } = await import('@/lib/supabase') + const { error } = await supabase.from('digests').select('count', { count: 'exact' }) + supabaseStatus = error ? `Error: ${error.message}` : 'Connected' + } catch (e) { + supabaseStatus = `Exception: ${e instanceof Error ? e.message : String(e)}` + } + + // Test Gemini API + let geminiStatus = 'not tested' + try { + const { GoogleGenerativeAI } = await import('@google/generative-ai') + if (process.env.GOOGLE_GEMINI_API_KEY) { + geminiStatus = 'API key present' + } else { + geminiStatus = 'API key missing' + } + } catch (e) { + geminiStatus = `Exception: ${e instanceof Error ? e.message : String(e)}` + } + + return NextResponse.json({ + status: 'ok', + timestamp: new Date().toISOString(), + environment: envCheck, + services: { + supabase: supabaseStatus, + gemini: geminiStatus, + } + }) +} \ No newline at end of file diff --git a/app/api/test-env/route.ts b/app/api/test-env/route.ts new file mode 100644 index 0000000..ccc75d9 --- /dev/null +++ b/app/api/test-env/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ + env: { + NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL ? 'SET' : 'NOT SET', + NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ? 'SET' : 'NOT SET', + GOOGLE_GEMINI_API_KEY: process.env.GOOGLE_GEMINI_API_KEY ? 'SET' : 'NOT SET', + NODE_ENV: process.env.NODE_ENV, + VERCEL: process.env.VERCEL ? 'YES' : 'NO', + }, + timestamp: new Date().toISOString() + }) +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index a81a51a..685a643 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,6 +10,8 @@ export default function Home() { const [isLoading, setIsLoading] = useState(false) const [isGenerating, setIsGenerating] = useState(false) const [error, setError] = useState(null) + const [streamingContent, setStreamingContent] = useState('') + const [useStreaming, setUseStreaming] = useState(true) useEffect(() => { fetchDigests() @@ -38,79 +40,169 @@ export default function Home() { setIsGenerating(true) setError(null) + setStreamingContent('') - try { - const response = await fetch('/api/digest/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ transcript }), - }) - - if (!response.ok) throw new Error('Failed to generate digest') - - const data = await response.json() - setDigests([data.digest, ...digests]) - setTranscript('') - } catch (err) { - setError('Failed to generate digest. Please try again.') - console.error(err) - } finally { - setIsGenerating(false) + if (useStreaming) { + // Stream the response + try { + const response = await fetch('/api/digest/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transcript }), + }) + + if (!response.ok) throw new Error('Failed to generate digest') + + const reader = response.body?.getReader() + const decoder = new TextDecoder() + + if (!reader) throw new Error('No reader available') + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = decoder.decode(value) + const lines = text.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)) + if (data.text) { + setStreamingContent(prev => prev + data.text) + } + if (data.done && data.digest) { + setDigests([data.digest, ...digests]) + setTranscript('') + setTimeout(() => setStreamingContent(''), 3000) + } + } catch (e) { + // Ignore parse errors + } + } + } + } + } catch (err) { + setError('Failed to generate digest. Please try again.') + console.error(err) + } finally { + setIsGenerating(false) + } + } else { + // Non-streaming response + try { + const response = await fetch('/api/digest/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transcript }), + }) + + if (!response.ok) throw new Error('Failed to generate digest') + + const data = await response.json() + setDigests([data.digest, ...digests]) + setTranscript('') + } catch (err) { + setError('Failed to generate digest. Please try again.') + console.error(err) + } finally { + setIsGenerating(false) + } } } return ( -
-
-
-

+
+
+
+

AI Meeting Digest

-

- Transform your meeting transcripts into actionable summaries +

+ Transform your meeting transcripts into actionable summaries with AI

{/* Input Section */} -
-

New Meeting Transcript

+
+
+

New Meeting Transcript

+ +