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/.gitignore b/.gitignore index 3d5df2d..dfbec16 100644 --- a/.gitignore +++ b/.gitignore @@ -120,7 +120,7 @@ dist/ downloads/ eggs/ .eggs/ -lib/ +# lib/ # Commented out for Next.js project lib64/ parts/ sdist/ diff --git a/CANDIDATE_README.md b/CANDIDATE_README.md index e69de29..87c5f77 100644 --- a/CANDIDATE_README.md +++ b/CANDIDATE_README.md @@ -0,0 +1,351 @@ +# AI Meeting Digest - Candidate Submission + +### 1. Technology Choices + +* **Frontend:** Next.js 15, React 19, TypeScript, Tailwind CSS +* **Backend:** Next.js API Routes +* **Database:** Supabase (PostgreSQL) +* **AI Service:** Google Gemini API + +I chose this stack because: +- **Next.js 15** provides an excellent full-stack framework with built-in API routes, server-side rendering, and excellent developer experience +- **TypeScript** ensures type safety and better code maintainability +- **Tailwind CSS** allows rapid UI development with utility-first classes +- **Supabase** offers a managed PostgreSQL database with real-time capabilities and built-in authentication (for future enhancements) +- **Google Gemini API** provides powerful AI capabilities with streaming support and a generous free tier + +### 2. How to Run the Project + +**Prerequisites:** +- Node.js 18+ and npm +- Supabase account (free tier works) +- Google AI Studio account for Gemini API key (free) + +**Step-by-step instructions:** + +1. Clone the repository: +```bash +git clone https://github.com/your-username/work4u-interview.git +cd work4u-interview +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Set up environment variables: +```bash +cp .env.local.example .env.local +``` + +4. Edit `.env.local` with your credentials: +``` +NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +GOOGLE_GEMINI_API_KEY=your_google_gemini_api_key +``` + +5. Set up Supabase database: + - Create a new Supabase project at https://supabase.com + - Go to SQL Editor and run the commands from `supabase-schema.sql` + - Copy your project URL and anon key from Settings → API + +6. Get Google Gemini API key: + - Visit https://aistudio.google.com/app/apikey + - Create a new API key + - Add it to `.env.local` + +7. Run the development server: +```bash +npm run dev +``` + +8. Open http://localhost:3000 in your browser + +### 3. Design Decisions & Trade-offs + +**Key Design Decisions:** + +1. **Real-time Streaming Implementation:** + - Used Server-Sent Events (SSE) instead of WebSockets for simplicity + - Created a toggle to switch between streaming and non-streaming modes + - Implemented progressive text rendering with a dedicated streaming display area + +2. **Database Design:** + - Single `digests` table with JSON arrays for decisions and action items + - Added `public_id` field for shareable links using nanoid for short, URL-safe IDs + - Indexed `public_id` and `created_at` for better query performance + +3. **UI/UX Improvements:** + - Modern gradient design with glassmorphism effects + - Added loading states, error handling, and success feedback + - Implemented copy-to-clipboard functionality for share links + - Responsive design that works well on mobile and desktop + +4. **API Structure:** + - Separate endpoints for streaming (`/api/digest/stream`) and non-streaming (`/api/digest/create`) + - Pagination support in list endpoint for scalability + - Error handling with appropriate HTTP status codes + +**Trade-offs:** + +1. **No Authentication:** Kept the app simple without user accounts, but added public IDs for basic access control +2. **Client-side State Management:** Used React hooks instead of a state management library for simplicity +3. **Streaming Parsing:** Basic text parsing for structured data extraction during streaming - could be improved with more sophisticated NLP + +**What I Would Do Differently with More Time:** + +1. **Testing:** Implement comprehensive unit and integration tests using Jest and React Testing Library +2. **Authentication:** Add Supabase Auth for user accounts and private digests +3. **Enhanced Streaming:** Implement markdown parsing during streaming for better formatting +4. **Caching:** Add Redis or Supabase caching for frequently accessed digests +5. **Export Features:** Add PDF and Word document export functionality +6. **Webhook Support:** Allow integration with Slack/Teams for automatic digest sharing + +### 4. AI Usage Log + +I used AI programming assistants extensively throughout this project: + +1. **Project Setup & Architecture:** + - Used Claude to help design the initial project structure and database schema + - Asked for best practices for Next.js 15 app router implementation + +2. **Streaming Implementation:** + - Consulted on the best approach for implementing SSE with Next.js + - Got help debugging CORS issues and proper header configuration + - Used AI to understand the Gemini streaming API documentation + +3. **UI Components:** + - Generated initial Tailwind CSS classes for the gradient backgrounds + - Asked for modern UI design patterns and animation suggestions + - Got help with responsive design breakpoints + +4. **Error Handling:** + - Used AI to implement comprehensive error boundaries + - Asked for best practices in API error responses + - Got suggestions for user-friendly error messages + +5. **Database Queries:** + - Optimized Supabase queries with AI assistance + - Implemented proper indexing strategies + - Learned about Row Level Security considerations + +6. **Code Quality:** + - Used AI for TypeScript type definitions + - Asked for ESLint rule recommendations + - Got help with code organization and file structure + +The AI assistants were invaluable for rapid development, helping me understand new APIs quickly and implement features I hadn't used before. They were particularly helpful for debugging streaming issues and optimizing performance. + +## Features + +### Core Features +1. **Meeting Transcript Processing**: Accept raw meeting transcripts and generate AI-powered summaries +2. **Structured Summaries**: Extract overview, key decisions, and action items +3. **History Management**: View and access previously generated digests +4. **Shareable Links**: Each digest has a unique public URL for sharing + +### Bonus Features +1. **Real-time Streaming**: Watch the AI generate summaries in real-time using Server-Sent Events +2. **Responsive Design**: Works seamlessly on desktop and mobile devices + +## Setup Instructions + +### Prerequisites +- Node.js 18+ and npm +- Supabase account +- Google AI Studio account for Gemini API key + +### Installation + +1. Clone the repository: +```bash +git clone https://github.com/02inf/work4u-interview.git +cd work4u-interview +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Set up environment variables: +```bash +cp .env.local.example .env.local +``` + +Edit `.env.local` with your credentials: +``` +NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +GOOGLE_GEMINI_API_KEY=your_google_gemini_api_key +``` + +4. Set up Supabase database: + - Create a new Supabase project + - Run the SQL commands in `supabase-schema.sql` in the SQL editor + - Copy your project URL and anon key to `.env.local` + +5. Get Google Gemini API key: + - Visit [Google AI Studio](https://makersuite.google.com/app/apikey) + - Create a new API key + - Add it to `.env.local` + +6. Run the development server: +```bash +npm run dev +``` + +Visit `http://localhost:3000` to see the application. + +## Project Structure + +``` +work4u-interview/ +├── app/ # Next.js App Router +│ ├── api/ # API endpoints +│ │ └── digest/ # Digest-related APIs +│ │ ├── create/ # Create new digest +│ │ ├── list/ # List all digests +│ │ ├── stream/ # Streaming endpoint +│ │ └── [id]/ # Get digest by ID +│ ├── digest/ +│ │ └── [id]/ # Share page route +│ ├── layout.tsx # Root layout +│ ├── page.tsx # Home page +│ └── globals.css # Global styles +├── components/ # React components +│ ├── DigestCard.tsx # Digest preview card +│ └── StreamingDigest.tsx # Streaming UI component +├── lib/ # Utility functions +│ ├── supabase.ts # Supabase client setup +│ └── gemini.ts # Gemini AI integration +├── types/ # TypeScript definitions +│ └── digest.ts # Digest type interfaces +├── public/ # Static assets +├── .env.local.example # Environment variables template +├── next.config.js # Next.js configuration +├── tailwind.config.js # Tailwind CSS configuration +├── tsconfig.json # TypeScript configuration +└── supabase-schema.sql # Database schema +``` + +## API Endpoints + +### POST /api/digest/create +Create a new digest from a transcript. + +Request: +```json +{ + "transcript": "Meeting transcript text..." +} +``` + +Response: +```json +{ + "digest": { + "id": "uuid", + "transcript": "...", + "summary": "...", + "overview": "...", + "key_decisions": ["..."], + "action_items": ["..."], + "public_id": "abc123", + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +### GET /api/digest/list +Get a list of recent digests. + +Query params: +- `limit`: Number of items (default: 10) +- `offset`: Pagination offset (default: 0) + +### GET /api/digest/[id] +Get a single digest by public ID. + +### POST /api/digest/stream +Create a digest with real-time streaming response (SSE). + +## Database Schema + +The application uses a single `digests` table with the following structure: +- `id`: UUID primary key +- `transcript`: Original meeting text +- `summary`: Full AI-generated summary +- `overview`: Brief overview +- `key_decisions`: Array of decisions +- `action_items`: Array of tasks +- `public_id`: Unique 8-character ID for sharing +- `created_at`: Timestamp + +## Development Workflow + +1. **Linting**: `npm run lint` +2. **Type checking**: TypeScript checks run automatically +3. **Building**: `npm run build` +4. **Production**: `npm start` + +## Deployment + +### Vercel (Recommended) +1. Push your code to GitHub +2. Import the project in Vercel +3. Add environment variables in Vercel dashboard +4. Deploy + +### Other Platforms +The application can be deployed to any platform that supports Next.js: +- Railway +- Render +- AWS Amplify +- Google Cloud Run + +## Security Considerations + +- API keys are stored as environment variables +- Supabase Row Level Security can be enabled for additional protection +- Input validation on all API endpoints +- No sensitive data exposed in client-side code + +## Future Enhancements + +1. **User Authentication**: Add user accounts with Supabase Auth +2. **Team Collaboration**: Share digests within teams +3. **Export Options**: PDF, Markdown, or Word document exports +4. **Template Support**: Different summary formats for different meeting types +5. **Multi-language Support**: Process meetings in different languages +6. **Analytics**: Track digest usage and engagement + +## Testing + +While formal tests weren't implemented due to time constraints, the application has been manually tested for: +- Creating digests with various transcript lengths +- Viewing digest history +- Sharing digests via public links +- Real-time streaming functionality +- Error handling for invalid inputs +- Responsive design on different screen sizes + +## Performance Optimizations + +- Pagination for digest list to handle large datasets +- Indexed database columns for faster queries +- Efficient streaming implementation +- Client-side caching of digest data + +## Known Limitations + +1. No user authentication (by design for simplicity) +2. Rate limiting depends on Google Gemini API quotas +3. Large transcripts may take longer to process +4. Streaming may not work behind certain proxies + +## Contact + +This project was created as part of the work4u interview process. For any questions or feedback, please reach out through the appropriate channels. \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..aab56d9 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,73 @@ +# 部署指南 + +## 部署前准备 + +1. **确保数据库已创建** + - 在 Supabase 中运行 `supabase-schema.sql` + +2. **测试构建** + ```bash + npm run build + ``` + +3. **环境变量** + 所有平台都需要配置以下环境变量: + ``` + NEXT_PUBLIC_SUPABASE_URL=https://obcvdsywxhobpyjhkihr.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY=你的_supabase_key + GOOGLE_GEMINI_API_KEY=你的_gemini_key + ``` + +## Vercel 部署(最简单) + +### 方法 1:通过 GitHub +1. 推送代码到 GitHub +2. 访问 vercel.com +3. Import GitHub 仓库 +4. 配置环境变量 +5. Deploy + +### 方法 2:使用 CLI +```bash +# 安装 Vercel CLI +npm i -g vercel + +# 登录并部署 +vercel + +# 设置环境变量 +vercel env add NEXT_PUBLIC_SUPABASE_URL +vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY +vercel env add GOOGLE_GEMINI_API_KEY + +# 重新部署 +vercel --prod +``` + +## 部署后测试 + +1. 访问部署的 URL +2. 测试创建摘要功能 +3. 测试分享链接功能 +4. 检查错误日志 + +## 常见问题 + +### 1. 环境变量未生效 +- Vercel:需要重新部署 +- 检查变量名是否正确 + +### 2. 数据库连接失败 +- 确认 Supabase 项目是否激活 +- 检查 API key 是否正确 + +### 3. Gemini API 错误 +- 检查 API 配额 +- 确认 API key 有效 + +## 自定义域名 + +在 Vercel 中: +1. Settings → Domains +2. Add Domain +3. 按照 DNS 配置说明操作 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d8e4d87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies +COPY package.json package-lock.json ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build the application +RUN npm run build + +# Production image +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/app/api/digest/[id]/route.ts b/app/api/digest/[id]/route.ts new file mode 100644 index 0000000..ba85fe3 --- /dev/null +++ b/app/api/digest/[id]/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { supabase } from '@/lib/supabase' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const { data, error } = await supabase + .from('digests') + .select('*') + .eq('public_id', id) + .single() + + if (error || !data) { + return NextResponse.json( + { error: 'Digest not found' }, + { status: 404 } + ) + } + + return NextResponse.json({ digest: data }) + } catch (error) { + console.error('Error fetching digest:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/digest/create/route.ts b/app/api/digest/create/route.ts new file mode 100644 index 0000000..242046f --- /dev/null +++ b/app/api/digest/create/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server' +import { CreateDigestRequest, CreateDigestResponse } from '@/types/digest' + +export async function POST(request: NextRequest) { + console.log('API /api/digest/create called') + console.log('Environment check:', { + hasSupabaseUrl: !!process.env.NEXT_PUBLIC_SUPABASE_URL, + hasSupabaseKey: !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + hasGeminiKey: !!process.env.GOOGLE_GEMINI_API_KEY + }) + + try { + // Dynamic imports to better handle errors + const { supabase } = await import('@/lib/supabase').catch(err => { + console.error('Failed to import supabase:', err) + throw new Error('Failed to load database module') + }) + + const { generateDigest } = await import('@/lib/gemini').catch(err => { + console.error('Failed to import gemini:', err) + throw new Error('Failed to load AI module') + }) + const body: CreateDigestRequest = await request.json() + const { transcript } = body + + if (!transcript || transcript.trim().length === 0) { + return NextResponse.json( + { error: 'Transcript is required' }, + { status: 400 } + ) + } + + // Generate digest using Gemini API + const digestResult = await generateDigest(transcript) + + // Save to database + const { data, error } = await supabase + .from('digests') + .insert({ + transcript, + summary: digestResult.summary, + overview: digestResult.overview, + key_decisions: digestResult.key_decisions, + action_items: digestResult.action_items, + }) + .select() + .single() + + if (error) { + console.error('Supabase insert error:', error) + console.error('Error details:', JSON.stringify(error, null, 2)) + return NextResponse.json( + { error: 'Failed to save digest' }, + { status: 500 } + ) + } + + const response: CreateDigestResponse = { digest: data } + return NextResponse.json(response) + } catch (error) { + console.error('Error creating digest:', error) + console.error('Error stack:', error instanceof Error ? error.stack : 'No stack trace') + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/digest/list/route.ts b/app/api/digest/list/route.ts new file mode 100644 index 0000000..4d096c0 --- /dev/null +++ b/app/api/digest/list/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ListDigestsResponse } from '@/types/digest' + +export async function GET(request: NextRequest) { + console.log('API /api/digest/list called') + console.log('Environment check:', { + hasSupabaseUrl: !!process.env.NEXT_PUBLIC_SUPABASE_URL, + hasSupabaseKey: !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + }) + + try { + // Dynamic import to better handle errors + const { supabase } = await import('@/lib/supabase').catch(err => { + console.error('Failed to import supabase:', err) + throw new Error('Failed to load database module') + }) + const { searchParams } = new URL(request.url) + const limit = parseInt(searchParams.get('limit') || '10') + const offset = parseInt(searchParams.get('offset') || '0') + + const { data, error } = await supabase + .from('digests') + .select('*') + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) + + if (error) { + console.error('Supabase query error:', error) + console.error('Error details:', JSON.stringify(error, null, 2)) + return NextResponse.json( + { error: 'Failed to fetch digests' }, + { status: 500 } + ) + } + + const response: ListDigestsResponse = { digests: data || [] } + return NextResponse.json(response) + } catch (error) { + console.error('Error fetching digests:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/digest/stream/route.ts b/app/api/digest/stream/route.ts new file mode 100644 index 0000000..0f4ab40 --- /dev/null +++ b/app/api/digest/stream/route.ts @@ -0,0 +1,109 @@ +import { NextRequest } from 'next/server' +import { generateDigestStream } from '@/lib/gemini' +import { supabase } from '@/lib/supabase' + +export async function POST(request: NextRequest) { + const encoder = new TextEncoder() + + try { + const body = await request.json() + const { transcript } = body + + if (!transcript || transcript.trim().length === 0) { + return new Response( + JSON.stringify({ error: 'Transcript is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + + const stream = await generateDigestStream(transcript) + + let fullContent = '' + let overview = '' + let keyDecisions: string[] = [] + let actionItems: string[] = [] + + const customReadableStream = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of stream) { + const text = chunk.text() + fullContent += text + + // Send the chunk to the client + const data = `data: ${JSON.stringify({ text })}\n\n` + controller.enqueue(encoder.encode(data)) + } + + // Parse the full content to extract structured data + const lines = fullContent.split('\n') + let section = '' + let overviewLines: string[] = [] + + for (const line of lines) { + const trimmedLine = line.trim() + + if (trimmedLine.toLowerCase().includes('overview') || trimmedLine.toLowerCase().includes('summary')) { + section = 'overview' + continue + } else if (trimmedLine.toLowerCase().includes('key decision') || trimmedLine.toLowerCase().includes('decision')) { + section = 'decisions' + continue + } else if (trimmedLine.toLowerCase().includes('action item') || trimmedLine.toLowerCase().includes('task')) { + section = 'actions' + continue + } + + if (section === 'overview' && trimmedLine && !trimmedLine.startsWith('-')) { + overviewLines.push(trimmedLine) + } else if (section === 'decisions' && trimmedLine.startsWith('-')) { + keyDecisions.push(trimmedLine.substring(1).trim()) + } else if (section === 'actions' && trimmedLine.startsWith('-')) { + actionItems.push(trimmedLine.substring(1).trim()) + } + } + + overview = overviewLines.join('\n') + + // Save to database + const { data, error } = await supabase + .from('digests') + .insert({ + transcript, + summary: fullContent, + overview: overview || fullContent, + 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/digest/test-stream/route.ts b/app/api/digest/test-stream/route.ts new file mode 100644 index 0000000..835f418 --- /dev/null +++ b/app/api/digest/test-stream/route.ts @@ -0,0 +1,142 @@ +import { NextRequest } from 'next/server' + +export async function GET(request: NextRequest) { + const html = ` + + +
+{digest.overview}
+{digest.transcript}
++ Transform your meeting transcripts into actionable summaries +
+Loading digests...
++ No digests yet. Create your first one! +
++ {digest.overview} +
+ +