Initial commit

This commit is contained in:
Laurie Voss
2025-04-17 13:24:58 -07:00
parent 1fae67b5f3
commit 7854e4663f
15 changed files with 2327 additions and 163 deletions
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
credentials.json
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Laurie Voss
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+34 -36
View File
@@ -1,36 +1,34 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
# 10K multi-year Risk Summarizer
This is a quick-and-dirty web app that summarizes how the risks identified by a corporation in its annual 10K filings have changed over time. It use [LlamaExtract](https://docs.cloud.llamaindex.ai/llamaextract/getting_started), part of [LlamaCloud](https://cloud.llamaindex.ai/), and of the course the LlamaIndex framework. It expects to work on 10K filings; you can find [Apple's 10K filings for many years](https://investor.apple.com/sec-filings/default.aspx) on their investor relations site.
We hope this gives you a useful jumping-off point for your own projects using LlamaExtract!
## Setup
You'll need a few values in your `.env.local` file to get this working:
* `NEXTAUTH_URL` which can be set to `http://localhost:3000` for testing
* `NEXTAUTH_SECRET` which can be any random string
* `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` which you can get from the [Google Cloud Console](https://console.cloud.google.com/apis/credentials) by creating a new OAuth client.
* `LLAMA_CLOUD_API_KEY` which you can get from the [LlamaCloud Console](https://cloud.llamaindex.ai/api-keys) for free.
* `LLAMA_EXTRACT_AGENT_ID` which you can get from the "Extraction" screen of your LlamaCloud project. You should use the UI to create your schema.
* `ANTHROPIC_API_KEY` which you can get from the [Anthropic Console](https://console.anthropic.com/settings/keys).
## Running
```bash
npm install
npm run dev
```
## Under the hood
The web app has only a handful of API routes:
* `/api/auth` for authentication (handled by NextAuth)
* `/api/process` accepts the uploaded file and kicks off the extraction job
* `/api/status` is a polling endpoint for the client to check on the status of the job
* `/api/result` fetches the results of the extraction once ready
* `/api/summarize` takes multiple years of risks and summarizes them, then returns the summary as HTML
+1366 -24
View File
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -9,19 +9,28 @@
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^2.2.1",
"@heroicons/react": "^2.2.0",
"@llamaindex/anthropic": "^0.3.3",
"@tanstack/react-query": "^5.74.3",
"axios": "^1.8.4",
"llamaindex": "^0.10.0",
"next": "15.3.0",
"next-auth": "^4.24.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.3.0"
"react-dropzone": "^14.3.8",
"react-icons": "^5.5.0"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"typescript": "^5"
}
}
+18
View File
@@ -0,0 +1,18 @@
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
pages: {
signIn: "/auth/signin",
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+84
View File
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]/route";
const LLAMA_CLOUD_API_URL = "https://api.cloud.llamaindex.ai/api/v1";
const LLAMA_CLOUD_API_KEY = process.env.LLAMA_CLOUD_API_KEY;
const LLAMA_EXTRACT_AGENT_ID = process.env.LLAMA_EXTRACT_AGENT_ID;
async function uploadFile(file: File) {
const formData = new FormData();
formData.append("upload_file", file);
const response = await fetch(`${LLAMA_CLOUD_API_URL}/files`, {
method: "POST",
headers: {
Authorization: `Bearer ${LLAMA_CLOUD_API_KEY}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to upload file: ${response.statusText}`);
}
return response.json();
}
async function createExtractionJob(fileId: string) {
const response = await fetch(`${LLAMA_CLOUD_API_URL}/extraction/jobs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${LLAMA_CLOUD_API_KEY}`,
},
body: JSON.stringify({
extraction_agent_id: LLAMA_EXTRACT_AGENT_ID,
file_id: fileId,
}),
});
if (!response.ok) {
throw new Error(`Failed to create extraction job: ${response.statusText}`);
}
return response.json();
}
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "No file provided" },
{ status: 400 }
);
}
// Upload file to LlamaExtract
const uploadResponse = await uploadFile(file);
const fileId = uploadResponse.id;
// Create extraction job
const jobResponse = await createExtractionJob(fileId);
return NextResponse.json({
filename: file.name,
jobId: jobResponse.id
});
} catch (error) {
console.error("Error processing file:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Error processing file" },
{ status: 500 }
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]/route";
const LLAMA_CLOUD_API_URL = "https://api.cloud.llamaindex.ai/api/v1";
const LLAMA_CLOUD_API_KEY = process.env.LLAMA_CLOUD_API_KEY;
async function getJobResult(jobId: string) {
const response = await fetch(`${LLAMA_CLOUD_API_URL}/extraction/jobs/${jobId}/result`, {
headers: {
Authorization: `Bearer ${LLAMA_CLOUD_API_KEY}`,
},
});
if (!response.ok) {
throw new Error(`Failed to get job result: ${response.statusText}`);
}
return response.json();
}
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(request.url);
const jobId = searchParams.get('jobId');
if (!jobId) {
return NextResponse.json(
{ error: "No jobId provided" },
{ status: 400 }
);
}
const result = await getJobResult(jobId);
return NextResponse.json({ result });
} catch (error) {
console.error("Error getting job result:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Error getting job result" },
{ status: 500 }
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]/route";
const LLAMA_CLOUD_API_URL = "https://api.cloud.llamaindex.ai/api/v1";
const LLAMA_CLOUD_API_KEY = process.env.LLAMA_CLOUD_API_KEY;
async function getJobStatus(jobId: string) {
const response = await fetch(`${LLAMA_CLOUD_API_URL}/extraction/jobs/${jobId}`, {
headers: {
Authorization: `Bearer ${LLAMA_CLOUD_API_KEY}`,
},
});
if (!response.ok) {
throw new Error(`Failed to get job status: ${response.statusText}`);
}
return response.json();
}
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(request.url);
const jobId = searchParams.get('jobId');
if (!jobId) {
return NextResponse.json(
{ error: "No jobId provided" },
{ status: 400 }
);
}
const jobStatus = await getJobStatus(jobId);
console.log(jobStatus);
return NextResponse.json({
status: jobStatus.status,
result: jobStatus.status === "SUCCESS" ? jobStatus.result : null,
error: jobStatus.status === "FAILED" ? "Extraction job failed" : null
});
} catch (error) {
console.error("Error getting job status:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Error getting job status" },
{ status: 500 }
);
}
}
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]/route";
import { Anthropic } from "@llamaindex/anthropic";
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY!;
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { years } = await request.json();
if (!years || typeof years !== 'object') {
return NextResponse.json(
{ error: "Invalid input format" },
{ status: 400 }
);
}
// Format the risks data for the prompt
const risksText = Object.entries(years)
.map(([year, risks]) => {
const risksList = (risks as Array<{ category: string; description: string }>)
.map(risk => `- ${risk.category}: ${risk.description}`)
.join('\n');
return `Year ${year}:\n${risksList}`;
})
.join('\n\n');
const prompt = `
Here are the risks identified in various fiscal years:
${risksText}
Please analyze these risks and provide a summary of the the risks have changed over time.
1. Identify risks that have stayed consistently present
2. Identify risks that are no longer mentioned
3. Mention new risks that have emerged
Be very specific about the risks and also very concise in the description of each risk,
something like:
<h2>Ongoing risks:</h2>
<ul>
<li>Legal regulations</li>
<li>Supply chain disruptions</li>
</ul>
<h2>Risks no longer mentioned:</h2>
<ul>
<li>....</li>
</ul>
<h2>New risks that have emerged:</h2>
<ul>
<li>....</li>
</ul>
The summary should be in basic HTML as shown here, so that we can display it directly without having to parse Markdown.
`;
const llm = new Anthropic({
apiKey: ANTHROPIC_API_KEY,
model: "claude-3-7-sonnet-latest"
});
let response = await llm.complete({
prompt: prompt,
});
const summary = response.text
console.log(summary);
return NextResponse.json({ summary });
} catch (error) {
console.error("Error generating summary:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Error generating summary" },
{ status: 500 }
);
}
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { signIn } from "next-auth/react";
import { FcGoogle } from "react-icons/fc";
export default function SignIn() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>
<div className="mt-8 space-y-6">
<button
onClick={() => signIn("google", { callbackUrl: "/" })}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<span className="absolute left-0 inset-y-0 flex items-center pl-3">
<FcGoogle className="h-5 w-5" />
</span>
Sign in with Google
</button>
</div>
</div>
</div>
);
}
+258
View File
@@ -3,6 +3,17 @@
:root {
--background: #ffffff;
--foreground: #171717;
--primary: #2563EB;
--primary-hover: #1D4ED8;
--gray-light: #F9FAFB;
--gray-medium: #D1D5DB;
--gray-dark: #4B5563;
--success-light: #DCFCE7;
--success-dark: #166534;
--error-light: #FEE2E2;
--error-dark: #991B1B;
--warning-light: #FEF9C3;
--warning-dark: #854D0E;
}
@theme inline {
@@ -24,3 +35,250 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Layout */
.container {
max-width: 80rem;
margin: 0 auto;
padding: 2rem;
}
/* Auth page */
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.auth-button {
padding: 0.5rem 1rem;
background-color: var(--primary);
color: white;
border-radius: 0.375rem;
text-decoration: none;
}
.auth-button:hover {
background-color: var(--primary-hover);
}
/* Document processing page */
.document-page {
min-height: 100vh;
padding: 2rem;
}
.document-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.document-title {
font-size: 1.5rem;
font-weight: 700;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.user-name {
color: var(--gray-dark);
}
.user-avatar {
width: 2rem;
height: 2rem;
border-radius: 9999px;
}
.document-dropzone {
border: 2px dashed var(--gray-medium);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
cursor: pointer;
margin-bottom: 2rem;
}
.document-dropzone-active {
border-color: var(--primary);
background-color: #EFF6FF;
}
.document-table-container {
overflow-x: auto;
}
.document-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.document-table th,
.document-table td {
padding: 0.75rem;
border: 1px solid #e2e8f0;
text-align: left;
}
.document-table th {
background-color: #f8fafc;
font-weight: 600;
}
.nested-table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
.nested-table th,
.nested-table td {
padding: 0.5rem;
border: 1px solid #e2e8f0;
text-align: left;
}
.nested-table th {
background-color: #f1f5f9;
font-weight: 500;
}
.nested-table td[data-full-description] {
cursor: help;
}
.status-badge {
display: inline-flex;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 9999px;
}
.status-badge.completed {
background-color: var(--success-light);
color: var(--success-dark);
}
.status-badge.error {
background-color: var(--error-light);
color: var(--error-dark);
}
.status-badge.processing {
background-color: var(--warning-light);
color: var(--warning-dark);
}
.json-result {
font-size: 0.75rem;
font-family: monospace;
}
.summarize-button {
position: relative;
padding: 0.5rem 2.5rem;
background-color: var(--primary);
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.summarize-button:hover {
background-color: var(--primary-hover);
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid #ffffff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.json-details {
background-color: #f5f5f5;
padding: 1rem;
border-radius: 4px;
max-width: 600px;
max-height: 400px;
overflow: auto;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
word-wrap: break-word;
margin-top: 0.5rem;
}
details {
cursor: pointer;
}
details summary {
color: #666;
font-size: 0.9rem;
}
details summary:hover {
color: #333;
}
.summary-box {
background-color: var(--gray-light);
padding: 1.5rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.summary-box h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 1rem 0;
}
.summary-box ul {
margin-left: 1.5rem;
padding-left: 1rem;
}
.summary-box li {
list-style-type: disc;
margin: 0.5rem 0;
}
.logout-button {
background-color: #dc2626;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-left: 16px;
transition: background-color 0.2s;
}
.logout-button:hover {
background-color: #b91c1c;
}
+4 -3
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "10K multi-year Risk Summarizer",
description: "",
};
export default function RootLayout({
@@ -27,7 +28,7 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Providers>{children}</Providers>
</body>
</html>
);
+285 -95
View File
@@ -1,103 +1,293 @@
import Image from "next/image";
"use client";
import { useSession } from "next-auth/react";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import "./globals.css";
interface Document {
id: string;
filename: string;
status: "processing" | "completed" | "error";
jobId?: string;
result?: any;
}
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
const { data: session, status } = useSession();
const [documents, setDocuments] = useState<Document[]>([]);
const [summary, setSummary] = useState<string>("");
const [isSummarizing, setIsSummarizing] = useState(false);
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const newDocuments = acceptedFiles.map((file) => ({
id: Math.random().toString(36).substr(2, 9),
filename: file.name,
status: "processing" as const,
}));
setDocuments((prev) => [...prev, ...newDocuments]);
// Process each file
for (const file of acceptedFiles) {
try {
const formData = new FormData();
formData.append("file", file);
const response = await axios.post("/api/process", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const jobId = response.data.jobId;
// Update document with jobId
setDocuments((prev) =>
prev.map((doc) =>
doc.filename === file.name ? { ...doc, jobId } : doc
)
);
// Poll for results
const pollInterval = setInterval(async () => {
try {
const resultResponse = await axios.get(`/api/status?jobId=${jobId}`);
const { status, error } = resultResponse.data;
if (status === "SUCCESS") {
clearInterval(pollInterval);
// Fetch the actual results from the result endpoint
const resultsResponse = await axios.get(`/api/result?jobId=${jobId}`);
setDocuments((prev) =>
prev.map((doc) =>
doc.jobId === jobId
? { ...doc, status: "completed", result: resultsResponse.data.result }
: doc
)
);
} else if (status === "FAILED" || error) {
clearInterval(pollInterval);
setDocuments((prev) =>
prev.map((doc) =>
doc.jobId === jobId ? { ...doc, status: "error" } : doc
)
);
}
} catch (error) {
clearInterval(pollInterval);
setDocuments((prev) =>
prev.map((doc) =>
doc.jobId === jobId ? { ...doc, status: "error" } : doc
)
);
}
}, 2000); // Poll every 2 seconds
// Cleanup interval after 5 minutes
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
} catch (error) {
setDocuments((prev) =>
prev.map((doc) =>
doc.filename === file.name ? { ...doc, status: "error" } : doc
)
);
}
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"application/pdf": [".pdf"],
"application/msword": [".doc"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
".docx",
],
},
});
if (status === "loading") {
return <div>Loading...</div>;
}
if (!session) {
return (
<div className="auth-page">
<a href="/auth/signin" className="auth-button">
Sign in to continue
</a>
</div>
);
}
return (
<main className="document-page">
<div className="container">
<div className="document-header">
<h1 className="document-title">Document Processing</h1>
<div className="user-info">
<span className="user-name">
Welcome, {session.user?.name}
</span>
<img
src={session.user?.image || ""}
alt="Profile"
className="user-avatar"
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
<button
onClick={() => window.location.href = '/api/auth/signout'}
className="logout-button"
>
Logout
</button>
</div>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<div
{...getRootProps()}
className={`document-dropzone ${isDragActive ? "document-dropzone-active" : ""}`}
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop the files here ...</p>
) : (
<p>Drag and drop files here, or click to select files</p>
)}
</div>
<div>
<h2 className="document-title">Processing Status</h2>
<div className="document-table-container">
<table className="document-table">
<thead>
<tr>
<th>Filename</th>
<th>Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id}>
<td>{doc.filename}</td>
<td>
<span className={`status-badge ${doc.status}`}>
{doc.status}
</span>
</td>
<td>
{doc.status === "completed" && doc.result && (
<details>
<summary>View JSON</summary>
<pre className="json-details">
{JSON.stringify(doc.result, null, 2)}
</pre>
</details>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{documents.some(doc => doc.status === "completed" && doc.result) && (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="document-title">Risks</h2>
<button
onClick={async () => {
const risksByYear = documents
.filter(doc => doc.status === "completed" && doc.result)
.reduce((acc, doc) => {
const year = doc.result.data.filingInfo.fiscalYear;
acc[year] = doc.result.data.keyRisks.map((risk: any) => ({
category: risk.category,
description: risk.description
}));
return acc;
}, {} as Record<string, Array<{ category: string; description: string }>>);
try {
setIsSummarizing(true);
const response = await axios.post('/api/summarize', {
years: risksByYear
});
setSummary(response.data.summary);
} catch (error) {
console.error('Error summarizing risks:', error);
} finally {
setIsSummarizing(false);
}
}}
className="summarize-button"
disabled={isSummarizing}
>
Summarize
{isSummarizing && (
<span className="spinner" />
)}
</button>
</div>
{summary && (
<div className="summary-box" dangerouslySetInnerHTML={{ __html: summary }} />
)}
<div className="document-table-container">
<table className="document-table">
<thead>
<tr>
<th>Fiscal Year</th>
<th>Risks</th>
</tr>
</thead>
<tbody>
{documents
.filter(doc => doc.status === "completed" && doc.result)
.sort((a, b) => {
const yearA = parseInt(a.result.data.filingInfo.fiscalYear);
const yearB = parseInt(b.result.data.filingInfo.fiscalYear);
return yearB - yearA;
})
.map(doc => (
<tr key={doc.id}>
<td>{doc.result.data.filingInfo.fiscalYear}</td>
<td>
<table className="nested-table">
<thead>
<tr>
<th>Category</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{doc.result.data.keyRisks.map((risk: any, index: number) => (
<tr key={index}>
<td>{risk.category}</td>
<td
title={risk.description}
data-full-description={risk.description}
>
{risk.description.length > 100
? `${risk.description.substring(0, 100)}...`
: risk.description}
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode } from "react";
const queryClient = new QueryClient();
export function Providers({ children }: { children: ReactNode }) {
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</SessionProvider>
);
}