diff --git a/README.md b/README.md
index 843ad3d..c38173f 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ A modern chat interface for Anthropic's Claude AI models built with Nuxt.js. Exp
- 💾 Database integration with [Drizzle ORM](https://orm.drizzle.team/)
- 🎨 UI components from [@nuxt/ui](https://ui.nuxt.com/)
- 🤖 AI integration with [@anthropic-ai/sdk](https://www.anthropic.com/)
-- 📝 Text extraction capabilities with [@nosferatu500/textract](https://www.npmjs.com/package/@nosferatu500/textract)
+- 📝 Advanced text extraction using Claude API for PDFs, images, and documents
- ✨ Markdown support with [markdown-it](https://github.com/markdown-it/markdown-it)
- 🎯 Code highlighting with [highlight.js](https://highlightjs.org/)
@@ -69,14 +69,14 @@ DATABASE_URL=./database.db
4. Create a new API key
5. Copy the key and paste it in your `.env` file
-## Parsing PDFs
+## File Processing
-Ensure `poppler-utils` is part of your environment by installing it:
+The application now uses Claude API for advanced text extraction from various file types including:
+- PDFs, Word documents, Excel sheets, PowerPoint presentations
+- Images (PNG, JPEG, GIF, WebP, BMP, TIFF) with OCR capabilities
+- Plain text files (TXT, Markdown, JSON, etc.)
-```bash
-sudo apt update
-sudo apt install poppler-utils
-```
+No additional system dependencies are required - everything is handled through the Claude API.
## ENV
diff --git a/components/FileAttachments.vue b/components/FileAttachments.vue
index aac0bed..a4e3550 100644
--- a/components/FileAttachments.vue
+++ b/components/FileAttachments.vue
@@ -24,7 +24,7 @@
]"
>
-
+
@@ -52,11 +52,20 @@
{{ getFileSize(file.file?.size) }}
+
+
+
+
+ {{ processingMessage }}
+
-
+
+
+
+
@@ -88,16 +103,22 @@
diff --git a/components/MessageInput.vue b/components/MessageInput.vue
index 232ef87..e27e66b 100644
--- a/components/MessageInput.vue
+++ b/components/MessageInput.vue
@@ -9,6 +9,8 @@
@@ -54,6 +56,7 @@
/>
+
{
loader.value = true;
try {
- for (const file of files) {
- // Check file size (limit to 10MB)
- if (file.size > 10 * 1024 * 1024) {
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+
+ // Check file size (limit to 32MB to match Claude API limits)
+ if (file.size > 32 * 1024 * 1024) {
toast.add({
title: "File too large",
- description: `${file.name} is larger than 10MB`,
+ description: `${file.name} is larger than 32MB (Claude API limit)`,
color: "red",
icon: "i-heroicons-exclamation-triangle",
});
continue;
}
+ // Create file entry immediately to show in UI
+ const newFile = {
+ file,
+ name: file.name,
+ selected: true,
+ tokens: 0, // Will be updated after processing
+ id: `temp-${Date.now()}-${Math.random()}`, // Temporary ID
+ isProcessing: true,
+ };
+
+ // Add file to UI immediately
+ emit("update:attachedFiles", [...props.attachedFiles, newFile]);
+
+ // Set which file is currently being processed
+ processingFileName.value = file.name;
+
+ // Update processing message based on file type
+ const fileExt = file.name.split('.').pop()?.toLowerCase();
+ const isImage = file.type.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff'].includes(fileExt);
+ const isDocument = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'pptx'].includes(fileExt);
+
+ if (isImage) {
+ processingMessage.value = `Extracting text using OCR...`;
+ } else if (isDocument) {
+ processingMessage.value = `Processing document and extracting text content...`;
+ } else {
+ processingMessage.value = `Processing with Claude API...`;
+ }
+
const formData = new FormData();
formData.append("file", file);
@@ -166,39 +202,60 @@ const handleFileSelect = async (event) => {
body: formData,
});
- const newFile = {
+ // Update the file with real data from server
+ const updatedFile = {
file,
name: file.name,
selected: true,
tokens: fileReq.file.tokens,
id: fileReq.last_row_id,
+ isProcessing: false,
};
- emit("update:attachedFiles", [...props.attachedFiles, newFile]);
+ // Update the file in the array
+ const updatedFiles = props.attachedFiles.map(f =>
+ f.name === file.name && f.isProcessing ? updatedFile : f
+ );
+
+ emit("update:attachedFiles", updatedFiles);
+ // Success toast with more specific messaging
+ const processType = isImage ? "OCR processed" : isDocument ? "Text extracted" : "Processed";
toast.add({
- title: "File uploaded",
- description: `${file.name} has been processed`,
+ title: "File ready",
+ description: `${file.name} - ${processType} with Claude API (${fileReq.file.tokens} tokens)`,
color: "green",
- icon: "i-heroicons-document-check",
+ icon: "i-heroicons-sparkles",
});
}
} catch (error) {
console.error("Error uploading files:", error);
+ const errorMessage = error.data?.message || error.message || "Could not process files with Claude API";
+
+ // Remove the failed file from the UI
+ const failedFileName = processingFileName.value;
+ if (failedFileName) {
+ const filteredFiles = props.attachedFiles.filter(f =>
+ !(f.name === failedFileName && f.isProcessing)
+ );
+ emit("update:attachedFiles", filteredFiles);
+ }
+
toast.add({
- title: "Upload failed",
- description: "Could not upload one or more files",
+ title: "Processing failed",
+ description: errorMessage,
color: "red",
icon: "i-heroicons-exclamation-triangle",
});
} finally {
fileInput.value.value = ""; // Reset file input
loader.value = false;
+ processingFileName.value = "";
+ processingMessage.value = "";
}
};
const removeFile = async (fileToRemove) => {
- loader.value = true;
try {
await $fetch(`/api/files/${fileToRemove.id}`, {
method: "DELETE",
@@ -222,8 +279,6 @@ const removeFile = async (fileToRemove) => {
color: "red",
icon: "i-heroicons-exclamation-triangle",
});
- } finally {
- loader.value = false;
}
};
diff --git a/package.json b/package.json
index 612e7e4..9618805 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,6 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.31.0",
"@iconify-json/heroicons": "^1.2.1",
- "@nosferatu500/textract": "^3.1.3",
"@nuxt/eslint": "^0.6.1",
"@nuxt/ui": "^2.18.7",
"bcrypt": "^5.1.1",
diff --git a/server/api/threads/[id]/files/index.post.ts b/server/api/threads/[id]/files/index.post.ts
index 3d75f7f..09b76e0 100644
--- a/server/api/threads/[id]/files/index.post.ts
+++ b/server/api/threads/[id]/files/index.post.ts
@@ -1,7 +1,8 @@
import Anthropic from "@anthropic-ai/sdk";
import { parseFile } from "~/server/utils/fileParser";
import db from "~/server/utils/db";
-import { files } from "~/server/database/schema";
+import { files, threads } from "~/server/database/schema";
+import { eq } from "drizzle-orm";
export default defineEventHandler(async (event) => {
try {
@@ -12,7 +13,7 @@ export default defineEventHandler(async (event) => {
const { anthropicKey } = useRuntimeConfig();
if (!anthropicKey || anthropicKey === "your_anthropic_api_key_here") {
- throw createError({
+ return createError({
statusCode: 500,
message:
"Anthropic API key is not configured. Please set the ANTHROPIC_KEY environment variable.",
@@ -27,15 +28,24 @@ export default defineEventHandler(async (event) => {
// Get thread ID from URL parameters
const threadId = event.context.params.id;
+ // Get the thread to access its model
+ const [thread] = await db.select().from(threads).where(eq(threads.id, threadId));
+ if (!thread) {
+ return createError({
+ statusCode: 404,
+ message: "Thread not found",
+ });
+ }
+
const formData = await readMultipartFormData(event);
for (const field of formData) {
if (!field.data || !field.filename) continue;
- const text = await parseFile(field.filename, field.data, field.type);
+ const text = await parseFile(field.filename, field.data, field.type, thread.model);
const tokens = await anthropic.beta.messages.countTokens({
- model: "claude-3-5-sonnet-20241022",
+ model: thread.model,
messages: [
{
role: "user",
diff --git a/server/utils/fileParser.ts b/server/utils/fileParser.ts
index 29898fc..7f6969f 100644
--- a/server/utils/fileParser.ts
+++ b/server/utils/fileParser.ts
@@ -1,110 +1,197 @@
-import textract from "@nosferatu500/textract";
-import { promisify } from "util";
+import Anthropic from "@anthropic-ai/sdk";
-const textractFromBuffer = promisify(textract.fromBufferWithName);
-
-const TEXT_EXTENSIONS = new Set([
+// Plain text files that can be read directly without Claude API
+const PLAIN_TEXT_EXTENSIONS = new Set([
"txt",
"js",
"ts",
"json",
"html",
"htm",
- "atom",
- "rss",
"md",
"markdown",
- "epub",
"xml",
- "xsl",
+ "csv",
+ "css",
+ "scss",
+ "less",
+ "yaml",
+ "yml",
+ "toml",
+ "ini",
+ "conf",
+ "log",
+]);
+
+// File types that Claude API can process for text extraction
+const CLAUDE_SUPPORTED_EXTENSIONS = new Set([
"pdf",
"doc",
"docx",
- "odt",
- "ott",
- "rtf",
"xls",
"xlsx",
- "xlsb",
- "xlsm",
- "xltx",
- "csv",
- "ods",
- "ots",
"pptx",
- "potx",
- "odp",
- "otp",
- "odg",
- "otg",
"png",
"jpg",
"jpeg",
"gif",
- "dxf",
+ "webp",
+ "bmp",
+ "tiff",
]);
-const TEXT_MIMETYPES = new Set([
- "text/html",
- "text/htm",
- "application/atom+xml",
- "application/rss+xml",
- "text/markdown",
- "application/epub+zip",
- "application/xml",
- "text/xml",
+const CLAUDE_SUPPORTED_MIMETYPES = new Set([
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "application/vnd.oasis.opendocument.text",
- "application/rtf",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- "text/csv",
- "application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
- "application/vnd.oasis.opendocument.presentation",
- "application/vnd.oasis.opendocument.graphics",
"image/png",
"image/jpeg",
"image/gif",
- "application/dxf",
- "application/javascript",
+ "image/webp",
+ "image/bmp",
+ "image/tiff",
]);
export async function parseFile(
filename: string,
buffer: Buffer,
mimeType?: string,
+ model: string,
): Promise {
try {
const ext = filename.split(".").pop()?.toLowerCase();
- // Use buffer.toString() for plain text files and specific cases
+ // Handle plain text files directly
if (
mimeType?.startsWith("text/") ||
mimeType === "application/json" ||
mimeType === "application/javascript" ||
- ext === "ts" ||
- ext === "js" ||
- ext === "json"
+ (ext && PLAIN_TEXT_EXTENSIONS.has(ext))
) {
- return buffer.toString();
+ return buffer.toString("utf-8");
}
- // Use textract for supported file types
+ // Use Claude API for supported file types
if (
- TEXT_EXTENSIONS.has(ext) ||
- (mimeType && TEXT_MIMETYPES.has(mimeType))
+ (ext && CLAUDE_SUPPORTED_EXTENSIONS.has(ext)) ||
+ (mimeType && CLAUDE_SUPPORTED_MIMETYPES.has(mimeType))
) {
- const text = await textractFromBuffer(filename, buffer);
- return text;
+ return await extractTextWithClaude(filename, buffer, mimeType, model);
}
- // Default to buffer.toString() if no specific handling is defined
- return buffer.toString();
+ // Fallback to plain text for unsupported types
+ return buffer.toString("utf-8");
} catch (error) {
console.error("Error parsing file:", error);
throw new Error(`Failed to parse file: ${error.message}`);
}
}
+
+// Cache for storing Claude API instances to avoid recreating them
+const claudeInstanceCache = new Map();
+
+function getClaudeInstance(apiKey: string): Anthropic {
+ if (!claudeInstanceCache.has(apiKey)) {
+ claudeInstanceCache.set(apiKey, new Anthropic({ apiKey }));
+ }
+ return claudeInstanceCache.get(apiKey)!;
+}
+
+async function extractTextWithClaude(
+ filename: string,
+ buffer: Buffer,
+ mimeType?: string,
+ model: string,
+): Promise {
+ // Get the API key from runtime config
+ const { anthropicKey } = useRuntimeConfig();
+ // Validate file size (Claude has limits)
+ const maxFileSize = 32 * 1024 * 1024; // 32MB limit
+ if (buffer.length > maxFileSize) {
+ throw new Error(`File too large: ${Math.round(buffer.length / (1024 * 1024))}MB. Maximum size is 32MB.`);
+ }
+
+ const anthropic = getClaudeInstance(anthropicKey);
+
+ // Determine if this is an image file
+ const isImage = mimeType?.startsWith("image/") ||
+ /\.(png|jpe?g|gif|webp|bmp|tiff)$/i.test(filename);
+
+ const base64Data = buffer.toString("base64");
+
+ // Optimize prompts for better text extraction
+ const content = isImage
+ ? [
+ {
+ type: "image" as const,
+ source: {
+ type: "base64" as const,
+ media_type: mimeType || "image/jpeg",
+ data: base64Data,
+ },
+ },
+ {
+ type: "text" as const,
+ text: "Please extract all text content from this image using OCR. If the image contains structured data (like tables), preserve the structure. If no text is found, respond with 'No readable text detected in image'.",
+ },
+ ]
+ : [
+ {
+ type: "document" as const,
+ source: {
+ type: "base64" as const,
+ media_type: mimeType || "application/pdf",
+ data: base64Data,
+ },
+ },
+ {
+ type: "text" as const,
+ text: "Extract all text content from this document. Maintain original formatting, paragraph structure, bullet points, tables, and any other structural elements. Provide clean, readable text while preserving the document's organization.",
+ },
+ ];
+
+ try {
+ const response = await anthropic.messages.create({
+ model: model,
+ max_tokens: 8192, // Increased for larger documents
+ temperature: 0, // Deterministic for text extraction
+ messages: [
+ {
+ role: "user",
+ content,
+ },
+ ],
+ });
+
+ const textContent = response.content
+ .filter((block) => block.type === "text")
+ .map((block) => block.type === "text" ? block.text : "")
+ .join("\n")
+ .trim();
+
+ if (!textContent || textContent === "No readable text detected in image" || textContent === "No text content extracted") {
+ return `[${filename}]: No extractable text content found`;
+ }
+
+ return textContent;
+ } catch (error: unknown) {
+ console.error(`Error extracting text from ${filename}:`, error);
+
+ // Provide more specific error messages
+ const errorObj = error as { status?: number; message?: string };
+ if (errorObj.status === 400) {
+ throw new Error(`Invalid file format or content for ${filename}. Please check the file is not corrupted.`);
+ } else if (errorObj.status === 413) {
+ throw new Error(`File ${filename} is too large for processing. Maximum size is 32MB.`);
+ } else if (errorObj.status === 429) {
+ throw new Error("Rate limit exceeded. Please wait a moment before uploading more files.");
+ } else if (errorObj.status && errorObj.status >= 500) {
+ throw new Error("Claude API is temporarily unavailable. Please try again later.");
+ }
+
+ throw new Error(`Failed to extract text from ${filename}: ${errorObj.message || 'Unknown error'}`);
+ }
+}