├── .eslintrc.json
├── .example.env
├── .gitignore
├── .node-version
├── .prettierrc
├── LICENSE
├── README.md
├── components.json
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── prisma
└── schema.prisma
├── public
├── file.svg
├── globe.svg
├── next.svg
├── og.jpg
├── vercel.svg
└── window.svg
├── src
├── app
│ ├── actions.ts
│ ├── api
│ │ ├── image
│ │ │ └── route.ts
│ │ ├── s3-upload
│ │ │ └── route.ts
│ │ └── summarize
│ │ │ └── route.ts
│ ├── fonts
│ │ ├── GeistMonoVF.woff
│ │ └── GeistVF.woff
│ ├── globals.css
│ ├── icon.png
│ ├── layout.tsx
│ ├── page.tsx
│ └── pdf
│ │ └── [id]
│ │ ├── page.tsx
│ │ └── smart-pdf-viewer.tsx
├── components
│ ├── HomeLandingDrop.tsx
│ ├── icons
│ │ ├── github.tsx
│ │ ├── sparkles.tsx
│ │ └── x.tsx
│ ├── images
│ │ ├── homepage-image-1.tsx
│ │ └── homepage-image-2.tsx
│ └── ui
│ │ ├── action-button.tsx
│ │ ├── button.tsx
│ │ ├── logo.tsx
│ │ ├── select.tsx
│ │ ├── spinner.tsx
│ │ ├── summary-content.tsx
│ │ ├── table-of-contents.tsx
│ │ ├── toast.tsx
│ │ └── toaster.tsx
├── hooks
│ └── use-toast.ts
└── lib
│ ├── ai.ts
│ ├── prisma.ts
│ ├── s3client.ts
│ ├── summarize.ts
│ └── utils.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/.example.env:
--------------------------------------------------------------------------------
1 | TOGETHER_API_KEY=
2 | DATABASE_URL=
3 |
4 | S3_UPLOAD_KEY=
5 | S3_UPLOAD_SECRET=
6 | S3_UPLOAD_BUCKET=
7 | S3_UPLOAD_REGION=
8 |
--------------------------------------------------------------------------------
/.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.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 |
32 | # env files (can opt-in for committing if needed)
33 | .env*
34 |
35 | # vercel
36 | .vercel
37 |
38 | # typescript
39 | *.tsbuildinfo
40 | next-env.d.ts
41 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 20.12.1
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-tailwindcss"]
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Hassan El Mghari
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | SmartPDF
4 |
5 |
6 |
7 | Instantly summarize and section your PDFs with AI. Powered by Llama 3.3 on Together AI.
8 |
9 |
10 | ## Tech stack
11 |
12 | - [Together AI](https://togetherai.link) for inference
13 | - [Llama 3.3](https://togetherai.link/llama-3.3) for the LLM
14 | - Next.js with Tailwind & TypeScript
15 | - Prisma ORM with Neon (Postgres)
16 | - Helicone for observability
17 | - Plausible for analytics
18 | - S3 for PDF storage
19 |
20 | ## Cloning & running
21 |
22 | 1. Clone the repo: `git clone https://github.com/Nutlope/smartpdfs`
23 | 2. Create a `.env` file and add your environment variables (see `.example.env`):
24 | - `TOGETHER_API_KEY=`
25 | - `DATABASE_URL=`
26 | - `S3_UPLOAD_KEY=`
27 | - `S3_UPLOAD_SECRET=`
28 | - `S3_UPLOAD_BUCKET=`
29 | - `S3_UPLOAD_REGION=us-east-1`
30 | - `HELICONE_API_KEY=` (optional, for observability)
31 | 3. Run `pnpm install` to install dependencies
32 | 4. Run `pnpm prisma generate` to generate the Prisma client
33 | 5. Run `pnpm dev` to start the development server
34 |
35 | ## Roadmap
36 |
37 | - [ ] Add some rate limiting by IP address
38 | - [ ] Integrate OCR for image parsing in PDFs
39 | - [ ] Add a bit more polish (make the link icon nicer) & add a "powered by Together" sign
40 | - [ ] Implement additional revision steps for improved summaries
41 | - [ ] Add a demo PDF for new users to be able to see it in action
42 | - [ ] Add feedback system with thumbs up/down feature
43 |
--------------------------------------------------------------------------------
/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": "src/app/globals.css",
9 | "baseColor": "neutral",
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 | }
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | images: {
5 | remotePatterns: [
6 | {
7 | protocol: "https",
8 | hostname: "napkinsdev.s3.us-east-1.amazonaws.com",
9 | },
10 | {
11 | protocol: "https",
12 | hostname: "api.together.ai",
13 | },
14 | ],
15 | },
16 | };
17 |
18 | export default nextConfig;
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pdf-summary",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "postinstall": "prisma generate",
9 | "start": "next start",
10 | "lint": "next lint",
11 | "update:all": "pnpm update --interactive --latest"
12 | },
13 | "dependencies": {
14 | "@ai-sdk/togetherai": "^0.2.13",
15 | "@aws-sdk/client-s3": "^3.797.0",
16 | "@neondatabase/serverless": "^1.0.0",
17 | "@prisma/adapter-neon": "^6.6.0",
18 | "@prisma/client": "^6.6.0",
19 | "@radix-ui/react-select": "^2.1.2",
20 | "@radix-ui/react-slot": "^1.1.0",
21 | "@radix-ui/react-toast": "^1.2.2",
22 | "@tailwindcss/typography": "^0.5.16",
23 | "ai": "^4.3.10",
24 | "class-variance-authority": "^0.7.0",
25 | "clsx": "^2.1.1",
26 | "dedent": "^1.5.3",
27 | "lucide-react": "^0.503.0",
28 | "nanoid": "^5.1.5",
29 | "next": "15.3.1",
30 | "next-plausible": "^3.12.4",
31 | "next-s3-upload": "^0.3.4",
32 | "openai": "^4.73.1",
33 | "pdfjs-dist": "^4.8.69",
34 | "react": "19.1.0",
35 | "react-dom": "19.1.0",
36 | "react-dropzone": "^14.3.5",
37 | "tailwind-merge": "^2.5.4",
38 | "tailwindcss-animate": "^1.0.7",
39 | "together-ai": "^0.15.2",
40 | "ws": "^8.18.0",
41 | "zod": "^3.24.3",
42 | "zod-to-json-schema": "^3.23.5"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^22.15.3",
46 | "@types/react": "^19.1.2",
47 | "@types/react-dom": "^19.1.2",
48 | "@types/ws": "^8.5.13",
49 | "eslint": "9.25.1",
50 | "eslint-config-next": "15.3.1",
51 | "postcss": "^8",
52 | "prettier": "^3.3.3",
53 | "prettier-plugin-tailwindcss": "^0.6.8",
54 | "prisma": "^6.6.0",
55 | "tailwindcss": "^3.4.1",
56 | "typescript": "^5"
57 | },
58 | "engines": {
59 | "node": "22.x"
60 | },
61 | "pnpm": {
62 | "onlyBuiltDependencies": [
63 | "@prisma/client"
64 | ]
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | datasource db {
12 | provider = "postgresql"
13 | url = env("DATABASE_URL")
14 | }
15 |
16 | model SmartPDF {
17 | id String @id @default(nanoid(5))
18 | createdAt DateTime @default(now())
19 | imageUrl String
20 | pdfUrl String
21 | pdfName String
22 |
23 | sections Section[]
24 | }
25 |
26 | model Section {
27 | id String @id @default(nanoid(5))
28 | type String
29 | title String
30 | summary String
31 | position Int
32 |
33 | SmartPDF SmartPDF @relation(fields: [smartPDFId], references: [id])
34 | smartPDFId String
35 | }
36 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/og.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nutlope/smartpdfs/76205d1a70bd5c488bcf15780abf951c4a9e084a/public/og.jpg
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { nanoid } from "nanoid";
4 | import client from "@/lib/prisma";
5 | import { redirect } from "next/navigation";
6 |
7 | const slugify = (text: string) => {
8 | return text
9 | .toLowerCase()
10 | .replace(/ /g, "-")
11 | .replace(/[^\w-]+/g, "")
12 | .replace(/--+/g, "-")
13 | .replace(/^-+/, "")
14 | .replace(/-+$/, "")
15 | .slice(0, 20);
16 | };
17 |
18 | export async function sharePdf({
19 | pdfName,
20 | pdfUrl,
21 | imageUrl,
22 | sections,
23 | }: {
24 | pdfName: string;
25 | pdfUrl: string;
26 | imageUrl: string;
27 | sections: {
28 | type: string;
29 | title: string;
30 | summary: string;
31 | position: number;
32 | }[];
33 | }) {
34 | const smartPdf = await client.smartPDF.create({
35 | data: {
36 | id: `${slugify(sections[0].title)}-${nanoid(4)}`,
37 | pdfName,
38 | pdfUrl,
39 | imageUrl,
40 | sections: {
41 | createMany: {
42 | data: sections,
43 | },
44 | },
45 | },
46 | });
47 |
48 | redirect(`/pdf/${smartPdf.id}`);
49 | }
50 |
--------------------------------------------------------------------------------
/src/app/api/image/route.ts:
--------------------------------------------------------------------------------
1 | import dedent from "dedent";
2 | import { togetheraiBaseClient } from "@/lib/ai";
3 | import { ImageGenerationResponse } from "@/lib/summarize";
4 | import { awsS3Client } from "@/lib/s3client";
5 | import { PutObjectCommand } from "@aws-sdk/client-s3";
6 |
7 | export async function POST(req: Request) {
8 | const json = await req.json();
9 | const text = "text" in json ? json.text : "";
10 |
11 | const start = new Date();
12 |
13 | const prompt = dedent`
14 | I'm going to give you a short summary of what is in a PDF. I need you to create an image that captures the essence of the content.
15 |
16 | The image should be one that looks good as a hero image on a blog post or website. It should not include any text.
17 |
18 | Here is the summary:
19 |
20 | ${text}
21 | `;
22 |
23 | const generatedImage = await togetheraiBaseClient.images.create({
24 | model: "black-forest-labs/FLUX.1-dev",
25 | width: 1280,
26 | height: 720,
27 | steps: 24,
28 | prompt: prompt,
29 | });
30 |
31 | const end = new Date();
32 | console.log(
33 | `Flux took ${end.getTime() - start.getTime()}ms to generate an image`,
34 | );
35 |
36 | const fluxImageUrl = generatedImage.data[0].url;
37 |
38 | if (!fluxImageUrl) throw new Error("No image URL from Flux");
39 |
40 | const fluxFetch = await fetch(fluxImageUrl);
41 | const fluxImage = await fluxFetch.blob();
42 | const imageBuffer = Buffer.from(await fluxImage.arrayBuffer());
43 |
44 | const coverImageKey = `pdf-cover-${generatedImage.id}.jpg`;
45 |
46 | const uploadedFile = await awsS3Client.send(
47 | new PutObjectCommand({
48 | Bucket: process.env.S3_UPLOAD_BUCKET || "",
49 | Key: coverImageKey,
50 | Body: imageBuffer,
51 | ContentType: "image/jpeg",
52 | }),
53 | );
54 |
55 | if (!uploadedFile) {
56 | throw new Error("Failed to upload enhanced image to S3");
57 | }
58 |
59 | return Response.json({
60 | url: `https://${process.env.S3_UPLOAD_BUCKET}.s3.${
61 | process.env.S3_UPLOAD_REGION || "us-east-1"
62 | }.amazonaws.com/${coverImageKey}`,
63 | } as ImageGenerationResponse);
64 | }
65 |
66 | export const runtime = "edge";
67 |
--------------------------------------------------------------------------------
/src/app/api/s3-upload/route.ts:
--------------------------------------------------------------------------------
1 | export { POST } from "next-s3-upload/route";
2 |
--------------------------------------------------------------------------------
/src/app/api/summarize/route.ts:
--------------------------------------------------------------------------------
1 | import { togetheraiClient } from "@/lib/ai";
2 | import assert from "assert";
3 | import dedent from "dedent";
4 | import { z } from "zod";
5 | import { generateObject } from "ai";
6 |
7 | export async function POST(req: Request) {
8 | const { text, language } = await req.json();
9 |
10 | assert.ok(typeof text === "string");
11 | assert.ok(typeof language === "string");
12 |
13 | const systemPrompt = dedent`
14 | You are an expert at summarizing text.
15 |
16 | Your task:
17 | 1. Read the document excerpt I will provide
18 | 2. Create a concise summary in ${language}
19 | 3. Generate a short, descriptive title in ${language}
20 |
21 | Guidelines for the summary:
22 | - Format the summary in HTML
23 | - Use tags for paragraphs (2-3 sentences each)
24 | - Use
and - tags for bullet points
25 | - Use
tags for subheadings when needed but don't repeat the initial title in the first paragraph
26 | - Ensure proper spacing with appropriate HTML tags
27 |
28 | The summary should be well-structured and easy to scan, while maintaining accuracy and completeness.
29 | Please analyze the text thoroughly before starting the summary.
30 |
31 | IMPORTANT: Output ONLY valid HTML without any markdown or plain text line breaks.
32 | `;
33 |
34 | const summarySchema = z.object({
35 | title: z.string().describe("A title for the summary"),
36 | summary: z
37 | .string()
38 | .describe(
39 | "The actual summary of the text containing new lines breaks between paragraphs or phrases for better readability.",
40 | ),
41 | });
42 |
43 | const summaryResponse = await generateObject({
44 | model: togetheraiClient("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
45 | schema: summarySchema,
46 | maxRetries: 2,
47 | messages: [
48 | {
49 | role: "system",
50 | content: systemPrompt,
51 | },
52 | {
53 | role: "user",
54 | content: text,
55 | },
56 | ],
57 | mode: "json",
58 | // maxTokens: 800,
59 | });
60 |
61 | const rayId = summaryResponse.response?.headers?.["cf-ray"];
62 | console.log("Ray ID:", rayId);
63 |
64 | const content = summaryResponse.object;
65 | console.log(summaryResponse.usage);
66 |
67 | if (!content) {
68 | console.log("Content was blank");
69 | return;
70 | }
71 |
72 | return Response.json(content);
73 | }
74 |
75 | export const runtime = "edge";
76 |
--------------------------------------------------------------------------------
/src/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nutlope/smartpdfs/76205d1a70bd5c488bcf15780abf951c4a9e084a/src/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/src/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nutlope/smartpdfs/76205d1a70bd5c488bcf15780abf951c4a9e084a/src/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | font-family: Arial, Helvetica, sans-serif;
7 | }
8 |
9 | @layer base {
10 | :root {
11 | --background: 37 27% 94%;
12 | --foreground: 0 0% 3.9%;
13 | --card: 0 0% 100%;
14 | --card-foreground: 0 0% 3.9%;
15 | --popover: 0 0% 100%;
16 | --popover-foreground: 0 0% 3.9%;
17 | --primary: 0 2.44% 24.12%;
18 | --primary-foreground: 0 0% 98%;
19 | --secondary: 0 0% 96.1%;
20 | --secondary-foreground: 0 0% 9%;
21 | --muted: 0 0% 96.1%;
22 | --muted-foreground: 0 0% 45.1%;
23 | --accent: 0 0% 96.1%;
24 | --accent-foreground: 0 0% 9%;
25 | --destructive: 0 84.2% 60.2%;
26 | --destructive-foreground: 0 0% 98%;
27 | --border: 0 0% 89.8%;
28 | --input: 0 0% 89.8%;
29 | --ring: 0 0% 3.9%;
30 | --chart-1: 12 76% 61%;
31 | --chart-2: 173 58% 39%;
32 | --chart-3: 197 37% 24%;
33 | --chart-4: 43 74% 66%;
34 | --chart-5: 27 87% 67%;
35 | --radius: 0.5rem;
36 | }
37 | }
38 |
39 | @layer base {
40 | * {
41 | @apply border-border;
42 | }
43 | body {
44 | @apply bg-background text-foreground;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/app/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nutlope/smartpdfs/76205d1a70bd5c488bcf15780abf951c4a9e084a/src/app/icon.png
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import GithubIcon from "@/components/icons/github";
2 | import XIcon from "@/components/icons/x";
3 | import Logo from "@/components/ui/logo";
4 | import type { Metadata } from "next";
5 | import { Plus_Jakarta_Sans } from "next/font/google";
6 | import "./globals.css";
7 | import { Toaster } from "@/components/ui/toaster";
8 | import Link from "next/link";
9 | import PlausibleProvider from "next-plausible";
10 |
11 | const font = Plus_Jakarta_Sans({
12 | subsets: ["latin"],
13 | display: "swap",
14 | variable: "--font-plus-jakarta-sans",
15 | });
16 |
17 | export const metadata: Metadata = {
18 | title: "Smart PDFs | Summarize PDFs in seconds",
19 | description:
20 | "Upload a PDF to get a quick, clear, and shareable summary with AI for free!",
21 | openGraph: {
22 | images: "https://smartpdfs.vercel.app/og.jpg",
23 | },
24 | };
25 |
26 | export default function RootLayout({
27 | children,
28 | }: Readonly<{
29 | children: React.ReactNode;
30 | }>) {
31 | return (
32 |
33 |
34 |
35 |
36 |
39 |
44 |
45 | {children}
46 |
47 |
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useS3Upload } from "next-s3-upload";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Chunk,
7 | chunkPdf,
8 | generateImage,
9 | generateQuickSummary,
10 | summarizeStream,
11 | } from "@/lib/summarize";
12 | import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
13 | import { FormEvent, useState } from "react";
14 | import "pdfjs-dist/legacy/build/pdf.worker.mjs";
15 | import { MenuIcon, SquareArrowOutUpRight } from "lucide-react";
16 | import { sharePdf } from "@/app/actions";
17 | import ActionButton from "@/components/ui/action-button";
18 | import Link from "next/link";
19 | import { useToast } from "@/hooks/use-toast";
20 | import { HomeLandingDrop } from "@/components/HomeLandingDrop";
21 | import SummaryContent from "@/components/ui/summary-content";
22 | import TableOfContents from "@/components/ui/table-of-contents";
23 |
24 | export type StatusApp = "idle" | "parsing" | "generating";
25 |
26 | export default function Home() {
27 | const [status, setStatus] = useState("idle");
28 | const [file, setFile] = useState();
29 | const [fileUrl, setFileUrl] = useState("");
30 | const [chunks, setChunks] = useState([]);
31 | const [activeChunkIndex, setActiveChunkIndex] = useState<
32 | number | "quick-summary" | null
33 | >(null);
34 | const [quickSummary, setQuickSummary] = useState<{
35 | title: string;
36 | summary: string;
37 | }>();
38 | const [image, setImage] = useState();
39 | const [showMobileContents, setShowMobileContents] = useState(true);
40 | const { uploadToS3 } = useS3Upload();
41 |
42 | const { toast } = useToast();
43 |
44 | async function handleSubmit(e: FormEvent) {
45 | e.preventDefault();
46 | const language = new FormData(e.currentTarget).get("language");
47 |
48 | if (!file || typeof language !== "string") return;
49 |
50 | setStatus("parsing");
51 |
52 | const uploadedPdfPromise = uploadToS3(file);
53 |
54 | const arrayBuffer = await file.arrayBuffer();
55 | const pdf = await getDocument({ data: arrayBuffer }).promise;
56 | if (pdf.numPages > 500) {
57 | toast({
58 | variant: "destructive",
59 | title: "PDF too large (500 pages max)",
60 | description: "That PDF has too many pages. Please use a smaller PDF.",
61 | });
62 | setStatus("idle");
63 | return;
64 | }
65 |
66 | const localChunks = await chunkPdf(pdf);
67 | const totalText = localChunks.reduce(
68 | (acc, chunk) => acc + chunk.text.length,
69 | 0,
70 | );
71 |
72 | if (totalText < 500) {
73 | toast({
74 | variant: "destructive",
75 | title: "Unable to process PDF",
76 | description:
77 | "The PDF appears to be a scanned document or contains too little text to process. Please ensure the PDF contains searchable text.",
78 | });
79 | setFile(undefined);
80 | setStatus("idle");
81 | return;
82 | }
83 |
84 | setChunks(localChunks);
85 | setStatus("generating");
86 |
87 | const summarizedChunks: Chunk[] = [];
88 |
89 | const writeStream = new WritableStream({
90 | write(chunk) {
91 | summarizedChunks.push(chunk);
92 | setChunks((chunks) => {
93 | return chunks.map((c) =>
94 | c.text === chunk.text ? { ...c, ...chunk } : c,
95 | );
96 | });
97 | },
98 | });
99 |
100 | const stream = await summarizeStream(localChunks, language);
101 | const controller = new AbortController();
102 | await stream.pipeTo(writeStream, { signal: controller.signal });
103 |
104 | const quickSummary = await generateQuickSummary(summarizedChunks, language);
105 | const imageUrl = await generateImage(quickSummary.summary);
106 |
107 | setQuickSummary(quickSummary);
108 | setImage(imageUrl);
109 |
110 | const uploadedPdf = await uploadedPdfPromise;
111 | setFileUrl(uploadedPdf.url);
112 |
113 | setActiveChunkIndex((activeChunkIndex) =>
114 | activeChunkIndex === null ? "quick-summary" : activeChunkIndex,
115 | );
116 |
117 | await sharePdf({
118 | pdfName: file.name,
119 | pdfUrl: uploadedPdf.url,
120 | imageUrl: imageUrl,
121 | sections: [
122 | {
123 | type: "quick-summary",
124 | title: quickSummary.title,
125 | summary: quickSummary.summary,
126 | position: 0,
127 | },
128 | ...summarizedChunks.map((chunk, index) => ({
129 | type: "summary",
130 | title: chunk?.title ?? "",
131 | summary: chunk?.summary ?? "",
132 | position: index + 1,
133 | })),
134 | ],
135 | });
136 | }
137 |
138 | return (
139 |
140 | {status === "idle" || status === "parsing" ? (
141 |
file && setFile(file)}
145 | handleSubmit={handleSubmit}
146 | />
147 | ) : (
148 |
149 |
150 |
151 |
154 |
155 |
156 | {fileUrl && (
157 |
158 |
159 |
160 | Original PDF
161 |
162 |
163 | )}
164 |
165 |
166 |
167 |
168 |
176 |
177 | {showMobileContents && (
178 |
186 | )}
187 |
188 |
189 |
190 |
191 | {activeChunkIndex === "quick-summary" ? (
192 |
197 | ) : activeChunkIndex !== null ? (
198 |
202 | ) : (
203 |
204 | Generating your Smart PDF…
205 |
206 | )}
207 |
208 |
209 |
217 |
218 |
219 |
220 | )}
221 |
222 | );
223 | }
224 |
--------------------------------------------------------------------------------
/src/app/pdf/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import SmartPDFViewer from "@/app/pdf/[id]/smart-pdf-viewer";
2 | import client from "@/lib/prisma";
3 | import { notFound } from "next/navigation";
4 | import { unstable_cache } from "next/cache";
5 | import { Metadata, ResolvingMetadata } from "next";
6 |
7 | const getSmartPDF = unstable_cache(
8 | async (id: string) => {
9 | return client.smartPDF.findUnique({
10 | where: { id },
11 | include: { sections: true },
12 | });
13 | },
14 | ["smart-pdf-query"],
15 | { revalidate: false },
16 | );
17 |
18 | export async function generateMetadata(
19 | {
20 | params,
21 | }: {
22 | params: Promise<{ id: string }>;
23 | },
24 | parent: ResolvingMetadata,
25 | ): Promise {
26 | // read route params
27 | const { id } = await params;
28 | const parentData = await parent;
29 |
30 | // fetch data
31 | const smartPdf = await getSmartPDF(id);
32 |
33 | if (!smartPdf) notFound();
34 |
35 | return {
36 | title: `${smartPdf.sections[0].title.slice(0, 60)} | ${parentData.title?.absolute}`,
37 | description: `${smartPdf.sections[0].summary
38 | .replace(/<[^>]*>/g, "")
39 | .slice(0, 160)}...`,
40 | openGraph: {
41 | images: [smartPdf.imageUrl],
42 | },
43 | };
44 | }
45 |
46 | export default async function Home({
47 | params,
48 | }: {
49 | params: Promise<{ id: string }>;
50 | }) {
51 | const id = (await params).id;
52 | const smartPdf = await getSmartPDF(id);
53 |
54 | if (!smartPdf) notFound();
55 |
56 | return ;
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/pdf/[id]/smart-pdf-viewer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Section, SmartPDF } from "@prisma/client";
5 | import { LinkIcon, MenuIcon, SquareArrowOutUpRight } from "lucide-react";
6 | import Link from "next/link";
7 | import { useState } from "react";
8 | import TableOfContents from "@/components/ui/table-of-contents";
9 | import SummaryContent from "@/components/ui/summary-content";
10 | import ActionButton from "@/components/ui/action-button";
11 | import { useToast } from "@/hooks/use-toast";
12 |
13 | export default function SmartPDFViewer({
14 | smartPdf,
15 | }: {
16 | smartPdf: SmartPDF & { sections: Section[] };
17 | }) {
18 | const { toast } = useToast();
19 | const [showMobileContents, setShowMobileContents] = useState(true);
20 | const [activeSection, setActiveSection] = useState(
21 | "quick-summary",
22 | );
23 |
24 | const handleShare = () => {
25 | toast({
26 | title: "Copied to Clipboard 📋",
27 | description:
28 | "Share link has been copied. Ready to share your PDF summary! 🔗",
29 | });
30 | navigator.clipboard.writeText(window.location.href);
31 | };
32 |
33 | const quickSummary = smartPdf.sections[0];
34 | const pdfSections = smartPdf.sections.slice(1);
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | {smartPdf.pdfName}
42 |
43 |
44 |
45 |
46 |
47 | Original PDF
48 |
49 |
50 |
55 |
56 |
57 |
58 | Share Summary
59 |
60 |
61 |
62 |
63 |
64 |
65 |
73 |
74 | {showMobileContents && (
75 |
76 |
79 | idx !== null && setActiveSection(idx)
80 | }
81 | quickSummary={smartPdf.sections[0]}
82 | chunks={smartPdf.sections.slice(1).map((section) => ({
83 | ...section,
84 | text: section.summary,
85 | }))}
86 | />
87 |
88 | )}
89 |
90 |
91 |
92 |
93 | {activeSection === "quick-summary" ? (
94 |
99 | ) : activeSection !== null ? (
100 |
104 | ) : (
105 |
106 | Generating your Smart PDF…
107 |
108 | )}
109 |
110 |
111 |
112 |
115 | idx !== null && setActiveSection(idx)
116 | }
117 | quickSummary={smartPdf.sections[0]}
118 | chunks={smartPdf.sections.slice(1).map((section) => ({
119 | ...section,
120 | text: section.summary,
121 | }))}
122 | />
123 |
124 |
125 |
126 |
127 | );
128 | }
129 |
--------------------------------------------------------------------------------
/src/components/HomeLandingDrop.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SparklesIcon } from "lucide-react";
4 | import { Button } from "./ui/button";
5 | import {
6 | Select,
7 | SelectContent,
8 | SelectItem,
9 | SelectTrigger,
10 | SelectValue,
11 | } from "./ui/select";
12 | import Dropzone from "react-dropzone";
13 | import HomepageImage1 from "./images/homepage-image-1";
14 | import HomepageImage2 from "./images/homepage-image-2";
15 | import { StatusApp } from "@/app/page";
16 | import { useToast } from "@/hooks/use-toast";
17 |
18 | export const HomeLandingDrop = ({
19 | status,
20 | file,
21 | setFile,
22 | handleSubmit,
23 | }: {
24 | status: StatusApp;
25 | file?: File | null;
26 | setFile: (file: File | null) => void;
27 | handleSubmit: (e: React.FormEvent) => void;
28 | }) => {
29 | const { toast } = useToast();
30 | return (
31 |
32 |
33 | Summarize PDFs
34 |
in seconds
35 |
36 |
37 | Upload a PDF to get a quick, clear, and shareable
38 | summary.
39 |
40 |
41 |
131 |
132 | );
133 | };
134 |
--------------------------------------------------------------------------------
/src/components/icons/github.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export default function GithubIcon(props: ComponentProps<"svg">) {
4 | return (
5 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/icons/sparkles.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export default function SparklesIcon(props: ComponentProps<"svg">) {
4 | return (
5 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/icons/x.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export default function XIcon(props: ComponentProps<"svg">) {
4 | return (
5 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/images/homepage-image-1.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export default function HomepageImage1(props: ComponentProps<"svg">) {
4 | return (
5 |
1293 | );
1294 | }
1295 |
--------------------------------------------------------------------------------
/src/components/images/homepage-image-2.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export default function HomepageImage2(props: ComponentProps<"svg">) {
4 | return (
5 |
275 | );
276 | }
277 |
--------------------------------------------------------------------------------
/src/components/ui/action-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import Spinner from "@/components/ui/spinner";
5 | import { ComponentProps } from "react";
6 | import { useFormStatus } from "react-dom";
7 |
8 | export default function ActionButton({
9 | children,
10 | className,
11 | ...rest
12 | }: ComponentProps) {
13 | const { pending } = useFormStatus();
14 |
15 | return (
16 |
17 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/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 text-base border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | "outline-active":
19 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
20 | secondary:
21 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
22 | ghost: "hover:bg-accent hover:text-accent-foreground",
23 | link: "text-primary underline-offset-4 hover:underline",
24 | },
25 | size: {
26 | default: "h-9 px-4 py-2",
27 | sm: "h-8 rounded-md px-3 text-xs",
28 | lg: "h-10 rounded-md px-8",
29 | icon: "h-9 w-9",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | },
37 | );
38 |
39 | export interface ButtonProps
40 | extends React.ButtonHTMLAttributes,
41 | VariantProps {
42 | asChild?: boolean;
43 | }
44 |
45 | const Button = React.forwardRef(
46 | ({ className, variant, size, asChild = false, ...props }, ref) => {
47 | const Comp = asChild ? Slot : "button";
48 | return (
49 |
54 | );
55 | },
56 | );
57 | Button.displayName = "Button";
58 |
59 | export { Button, buttonVariants };
60 |
--------------------------------------------------------------------------------
/src/components/ui/logo.tsx:
--------------------------------------------------------------------------------
1 | export default function Logo(props: React.ComponentProps<"svg">) {
2 | return (
3 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 |
3 | export default function Spinner({
4 | loading = true,
5 | children,
6 | className = "",
7 | }: {
8 | loading?: boolean;
9 | children?: ReactNode;
10 | className?: string;
11 | }) {
12 | if (!loading) return children;
13 |
14 | const spinner = (
15 | <>
16 |
28 |
29 | {Array.from(Array(8).keys()).map((i) => (
30 |
38 | ))}
39 |
40 | >
41 | );
42 |
43 | if (!children) return spinner;
44 |
45 | return (
46 |
47 | {children}
48 |
49 |
50 | {spinner}
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/ui/summary-content.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 |
5 | interface SummaryContentProps {
6 | title?: string;
7 | summary?: string;
8 | imageUrl?: string;
9 | }
10 |
11 | export default function SummaryContent({
12 | title,
13 | summary,
14 | imageUrl,
15 | }: SummaryContentProps) {
16 | return (
17 |
18 | {imageUrl && (
19 | <>
20 |
27 |
28 | >
29 | )}
30 |
{title}
31 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/ui/table-of-contents.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import Spinner from "@/components/ui/spinner";
5 | import { Chunk } from "@/lib/summarize";
6 |
7 | interface TableOfContentsProps {
8 | activeChunkIndex: number | "quick-summary" | null;
9 | setActiveChunkIndex: (index: number | "quick-summary" | null) => void;
10 | quickSummary: { title: string; summary: string } | undefined;
11 | chunks: Chunk[];
12 | }
13 |
14 | export default function TableOfContents({
15 | activeChunkIndex,
16 | setActiveChunkIndex,
17 | quickSummary,
18 | chunks,
19 | }: TableOfContentsProps) {
20 | return (
21 |
22 |
31 |
32 |
33 | {chunks.map((chunk, i) => (
34 |
51 | ))}
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ToastPrimitives from "@radix-ui/react-toast"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-background text-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | }
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useToast } from "@/hooks/use-toast"
4 | import {
5 | Toast,
6 | ToastClose,
7 | ToastDescription,
8 | ToastProvider,
9 | ToastTitle,
10 | ToastViewport,
11 | } from "@/components/ui/toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react";
5 |
6 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
7 |
8 | const TOAST_LIMIT = 1;
9 | const TOAST_REMOVE_DELAY = 1000000;
10 |
11 | type ToasterToast = ToastProps & {
12 | id: string;
13 | title?: React.ReactNode;
14 | description?: React.ReactNode;
15 | action?: ToastActionElement;
16 | };
17 |
18 | type ActionTypes = {
19 | ADD_TOAST: "ADD_TOAST";
20 | UPDATE_TOAST: "UPDATE_TOAST";
21 | DISMISS_TOAST: "DISMISS_TOAST";
22 | REMOVE_TOAST: "REMOVE_TOAST";
23 | };
24 |
25 | let count = 0;
26 |
27 | function genId() {
28 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
29 | return count.toString();
30 | }
31 |
32 | type Action =
33 | | {
34 | type: ActionTypes["ADD_TOAST"];
35 | toast: ToasterToast;
36 | }
37 | | {
38 | type: ActionTypes["UPDATE_TOAST"];
39 | toast: Partial;
40 | }
41 | | {
42 | type: ActionTypes["DISMISS_TOAST"];
43 | toastId?: ToasterToast["id"];
44 | }
45 | | {
46 | type: ActionTypes["REMOVE_TOAST"];
47 | toastId?: ToasterToast["id"];
48 | };
49 |
50 | interface State {
51 | toasts: ToasterToast[];
52 | }
53 |
54 | const toastTimeouts = new Map>();
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return;
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId);
63 | dispatch({
64 | type: "REMOVE_TOAST",
65 | toastId: toastId,
66 | });
67 | }, TOAST_REMOVE_DELAY);
68 |
69 | toastTimeouts.set(toastId, timeout);
70 | };
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case "ADD_TOAST":
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | };
79 |
80 | case "UPDATE_TOAST":
81 | return {
82 | ...state,
83 | toasts: state.toasts.map((t) =>
84 | t.id === action.toast.id ? { ...t, ...action.toast } : t,
85 | ),
86 | };
87 |
88 | case "DISMISS_TOAST": {
89 | const { toastId } = action;
90 |
91 | // ! Side effects ! - This could be extracted into a dismissToast() action,
92 | // but I'll keep it here for simplicity
93 | if (toastId) {
94 | addToRemoveQueue(toastId);
95 | } else {
96 | state.toasts.forEach((toast) => {
97 | addToRemoveQueue(toast.id);
98 | });
99 | }
100 |
101 | return {
102 | ...state,
103 | toasts: state.toasts.map((t) =>
104 | t.id === toastId || toastId === undefined
105 | ? {
106 | ...t,
107 | open: false,
108 | }
109 | : t,
110 | ),
111 | };
112 | }
113 | case "REMOVE_TOAST":
114 | if (action.toastId === undefined) {
115 | return {
116 | ...state,
117 | toasts: [],
118 | };
119 | }
120 | return {
121 | ...state,
122 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
123 | };
124 | }
125 | };
126 |
127 | const listeners: Array<(state: State) => void> = [];
128 |
129 | let memoryState: State = { toasts: [] };
130 |
131 | function dispatch(action: Action) {
132 | memoryState = reducer(memoryState, action);
133 | listeners.forEach((listener) => {
134 | listener(memoryState);
135 | });
136 | }
137 |
138 | type Toast = Omit;
139 |
140 | function toast({ ...props }: Toast) {
141 | const id = genId();
142 |
143 | const update = (props: ToasterToast) =>
144 | dispatch({
145 | type: "UPDATE_TOAST",
146 | toast: { ...props, id },
147 | });
148 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
149 |
150 | dispatch({
151 | type: "ADD_TOAST",
152 | toast: {
153 | ...props,
154 | id,
155 | open: true,
156 | onOpenChange: (open) => {
157 | if (!open) dismiss();
158 | },
159 | },
160 | });
161 |
162 | return {
163 | id: id,
164 | dismiss,
165 | update,
166 | };
167 | }
168 |
169 | function useToast() {
170 | const [state, setState] = React.useState(memoryState);
171 |
172 | React.useEffect(() => {
173 | listeners.push(setState);
174 | return () => {
175 | const index = listeners.indexOf(setState);
176 | if (index > -1) {
177 | listeners.splice(index, 1);
178 | }
179 | };
180 | }, [state]);
181 |
182 | return {
183 | ...state,
184 | toast,
185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
186 | };
187 | }
188 |
189 | export { useToast, toast };
190 |
--------------------------------------------------------------------------------
/src/lib/ai.ts:
--------------------------------------------------------------------------------
1 | import { createTogetherAI } from "@ai-sdk/togetherai";
2 | import Together from "together-ai";
3 |
4 | export const togetheraiClient = createTogetherAI({
5 | apiKey: process.env.TOGETHER_API_KEY ?? "",
6 | baseURL: "https://together.helicone.ai/v1",
7 | headers: {
8 | "Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`,
9 | "Helicone-Property-AppName": "SmartPDF",
10 | },
11 | });
12 |
13 | export const togetheraiBaseClient = new Together({
14 | apiKey: process.env.TOGETHER_API_KEY ?? "",
15 | });
16 |
--------------------------------------------------------------------------------
/src/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | declare global {
4 | // eslint-disable-next-line no-var
5 | var prisma: PrismaClient | undefined;
6 | }
7 |
8 | const client = globalThis.prisma || new PrismaClient();
9 | if (process.env.NODE_ENV !== "production") globalThis.prisma = client;
10 |
11 | export default client;
12 |
--------------------------------------------------------------------------------
/src/lib/s3client.ts:
--------------------------------------------------------------------------------
1 | import { S3Client } from "@aws-sdk/client-s3";
2 |
3 | export const awsS3Client = new S3Client({
4 | region: process.env.S3_UPLOAD_REGION || "us-east-1",
5 | credentials: {
6 | accessKeyId: process.env.S3_UPLOAD_KEY || "",
7 | secretAccessKey: process.env.S3_UPLOAD_SECRET || "",
8 | },
9 | });
10 |
--------------------------------------------------------------------------------
/src/lib/summarize.ts:
--------------------------------------------------------------------------------
1 | import { PDFDocumentProxy } from "pdfjs-dist";
2 | import assert from "assert";
3 |
4 | export type Chunk = {
5 | text: string;
6 | summary?: string;
7 | title?: string;
8 | };
9 |
10 | export async function getPdfText(pdf: PDFDocumentProxy) {
11 | const numPages = pdf.numPages;
12 | let fullText = "";
13 |
14 | for (let pageNum = 1; pageNum <= numPages; pageNum++) {
15 | const page = await pdf.getPage(pageNum);
16 | const textContent = await page.getTextContent();
17 |
18 | let lastY = null;
19 | let pageText = "";
20 |
21 | // Process each text item
22 | for (const item of textContent.items) {
23 | if ("str" in item) {
24 | // Check for new line based on Y position
25 | if (lastY !== null && lastY !== item.transform[5]) {
26 | pageText += "\n";
27 |
28 | // Add extra line break if there's significant vertical space
29 | if (lastY - item.transform[5] > 12) {
30 | // Adjust this threshold as needed
31 | pageText += "\n";
32 | }
33 | }
34 |
35 | pageText += item.str;
36 | lastY = item.transform[5];
37 | }
38 | }
39 |
40 | fullText += pageText + "\n\n"; // Add double newline between pages
41 | }
42 |
43 | return fullText;
44 | }
45 |
46 | export async function chunkPdf(pdf: PDFDocumentProxy) {
47 | // const chunkCharSize = 6000; // 100k
48 | // const chunkCharSize = 100_000;
49 | const maxChunkSize = 50_000;
50 | // ideally have at least 4 chunks
51 | // chunk size = total chars / 4 OR 100k, whichever is smaller
52 |
53 | const fullText = await getPdfText(pdf);
54 |
55 | const chunks: Chunk[] = [];
56 | const chunkCharSize = Math.min(maxChunkSize, Math.ceil(fullText.length / 4));
57 |
58 | for (let i = 0; i < fullText.length; i += chunkCharSize) {
59 | const text = fullText.slice(i, i + chunkCharSize);
60 | chunks.push({ text });
61 | }
62 |
63 | return chunks;
64 | }
65 |
66 | export async function summarizeStream(chunks: Chunk[], language: string) {
67 | let reading = true;
68 | const stream = new ReadableStream({
69 | async start(controller) {
70 | const promises = chunks.map(async (chunk) => {
71 | const text = chunk.text;
72 | const response = await fetch("/api/summarize", {
73 | method: "POST",
74 | headers: {
75 | "Content-Type": "application/json",
76 | },
77 | body: JSON.stringify({ text, language }),
78 | });
79 | let data;
80 | try {
81 | data = await response.json();
82 | if (reading) {
83 | controller.enqueue({
84 | ...chunk,
85 | summary: data.summary,
86 | title: data.title,
87 | });
88 | }
89 | } catch (e) {
90 | console.log(e);
91 | }
92 | });
93 |
94 | await Promise.all(promises);
95 | controller.close();
96 | },
97 |
98 | cancel() {
99 | console.log("read stream canceled");
100 | reading = false;
101 | },
102 | });
103 |
104 | return stream;
105 | }
106 |
107 | export async function generateQuickSummary(chunks: Chunk[], language: string) {
108 | const allSummaries = chunks.map((chunk) => chunk.summary).join("\n\n");
109 |
110 | const response = await fetch("/api/summarize", {
111 | method: "POST",
112 | headers: {
113 | "Content-Type": "application/json",
114 | },
115 | body: JSON.stringify({ text: allSummaries, language }),
116 | });
117 |
118 | const { title, summary } = await response.json();
119 |
120 | console.log("title", title);
121 | assert.ok(typeof title === "string");
122 | assert.ok(typeof summary === "string");
123 |
124 | return { title, summary };
125 | }
126 |
127 | export type ImageGenerationResponse = {
128 | url: string;
129 | };
130 |
131 | export async function generateImage(summary: string) {
132 | const response = await fetch("/api/image", {
133 | method: "POST",
134 | headers: {
135 | "Content-Type": "application/json",
136 | },
137 | body: JSON.stringify({ text: summary }),
138 | });
139 |
140 | const data: ImageGenerationResponse = await response.json();
141 |
142 | return data.url;
143 | }
144 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | import tailwindcssAnimate from "tailwindcss-animate";
3 |
4 | export default {
5 | darkMode: ["class"],
6 | content: [
7 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
10 | ],
11 | theme: {
12 | extend: {
13 | borderRadius: {
14 | lg: "var(--radius)",
15 | md: "calc(var(--radius) - 2px)",
16 | sm: "calc(var(--radius) - 4px)",
17 | },
18 | colors: {
19 | gray: {
20 | "100": "#F4F1EC",
21 | "200": "#FAF8F5",
22 | "250": "#E1E1E1",
23 | "300": "#B7B7B7",
24 | "500": "#9A9A9A",
25 | "900": "#3F3C3C",
26 | },
27 | background: "hsl(var(--background))",
28 | foreground: "hsl(var(--foreground))",
29 | card: {
30 | DEFAULT: "hsl(var(--card))",
31 | foreground: "hsl(var(--card-foreground))",
32 | },
33 | popover: {
34 | DEFAULT: "hsl(var(--popover))",
35 | foreground: "hsl(var(--popover-foreground))",
36 | },
37 | primary: {
38 | DEFAULT: "hsl(var(--primary))",
39 | foreground: "hsl(var(--primary-foreground))",
40 | },
41 | secondary: {
42 | DEFAULT: "hsl(var(--secondary))",
43 | foreground: "hsl(var(--secondary-foreground))",
44 | },
45 | muted: {
46 | DEFAULT: "hsl(var(--muted))",
47 | foreground: "hsl(var(--muted-foreground))",
48 | },
49 | accent: {
50 | DEFAULT: "hsl(var(--accent))",
51 | foreground: "hsl(var(--accent-foreground))",
52 | },
53 | destructive: {
54 | DEFAULT: "hsl(var(--destructive))",
55 | foreground: "hsl(var(--destructive-foreground))",
56 | },
57 | border: "hsl(var(--border))",
58 | input: "hsl(var(--input))",
59 | ring: "hsl(var(--ring))",
60 | chart: {
61 | "1": "hsl(var(--chart-1))",
62 | "2": "hsl(var(--chart-2))",
63 | "3": "hsl(var(--chart-3))",
64 | "4": "hsl(var(--chart-4))",
65 | "5": "hsl(var(--chart-5))",
66 | },
67 | },
68 | },
69 | },
70 | plugins: [tailwindcssAnimate, require("@tailwindcss/typography")],
71 | } satisfies Config;
72 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
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 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------