Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)

Expand Down Expand Up @@ -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

Expand Down
35 changes: 28 additions & 7 deletions components/FileAttachments.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
]"
>
<div class="flex items-start gap-3">
<UCheckbox v-model="file.selected" size="sm" class="mt-0.5" />
<UCheckbox v-model="file.selected" class="mt-0.5" />

<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
Expand Down Expand Up @@ -52,18 +52,33 @@
{{ getFileSize(file.file?.size) }}
</span>
</div>

<!-- Processing indicator for this specific file -->
<div
v-if="processingFileName === file.name && processingMessage"
class="flex items-center gap-2 mt-2 text-xs text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 rounded-lg px-2 py-1"
>
<UIcon name="i-heroicons-sparkles" class="w-3 h-3 animate-pulse" />
<span>{{ processingMessage }}</span>
</div>
</div>

<!-- Remove button -->
<!-- Remove button or processing spinner -->
<UButton
:loading="loader"
v-if="processingFileName !== file.name || !processingMessage"
size="xs"
color="gray"
variant="ghost"
icon="i-heroicons-x-mark"
class="opacity-0 group-hover:opacity-100 transition-opacity"
@click="handleRemoveFile(file)"
/>
<div
v-else
class="flex items-center justify-center w-6 h-6"
>
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 animate-spin text-primary-500" />
</div>
</div>
</div>
</div>
Expand All @@ -88,16 +103,22 @@
<script setup>
import { computed } from "vue";

const { loader } = useLoader();

const props = defineProps({
files: {
type: Array,
required: true,
},
processingFileName: {
type: String,
default: "",
},
processingMessage: {
type: String,
default: "",
},
});

const emit = defineEmits(["remove-file"]);
const emit = defineEmits(["remove"]);

const selectedCount = computed(() => {
return props.files.filter((file) => file.selected).length;
Expand Down Expand Up @@ -149,6 +170,6 @@ const getFileSize = (bytes) => {
};

const handleRemoveFile = (file) => {
emit("remove-file", file);
emit("remove", file);
};
</script>
85 changes: 70 additions & 15 deletions components/MessageInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<FileAttachments
v-if="attachedFiles.length > 0"
:files="attachedFiles"
:processing-file-name="processingFileName"
:processing-message="processingMessage"
class="mb-3"
@remove="removeFile"
/>
Expand Down Expand Up @@ -54,6 +56,7 @@
/>
</div>


<!-- Input hints -->
<div
class="flex items-center justify-between mt-2 text-xs text-gray-500 dark:text-gray-400"
Expand Down Expand Up @@ -83,7 +86,7 @@
<input
ref="fileInput"
type="file"
accept=".html,.ts,.htm,.atom,.rss,.md,.markdown,.epub,.xml,.xsl,.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,.js,text/*"
accept=".txt,.js,.ts,.json,.html,.htm,.md,.markdown,.xml,.csv,.css,.scss,.less,.yaml,.yml,.toml,.ini,.conf,.log,.pdf,.doc,.docx,.xls,.xlsx,.pptx,.png,.jpg,.jpeg,.gif,.webp,.bmp,.tiff,text/*"
multiple
class="hidden"
@change="handleFileSelect"
Expand Down Expand Up @@ -112,6 +115,8 @@ const emit = defineEmits(["update:attachedFiles", "send-message"]);

const inputMessage = ref("");
const fileInput = ref(null);
const processingFileName = ref("");
const processingMessage = ref("");

// Quick suggestion prompts
const quickSuggestions = ref([
Expand Down Expand Up @@ -146,18 +151,49 @@ const handleFileSelect = async (event) => {
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);

Expand All @@ -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",
Expand All @@ -222,8 +279,6 @@ const removeFile = async (fileToRemove) => {
color: "red",
icon: "i-heroicons-exclamation-triangle",
});
} finally {
loader.value = false;
}
};

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 14 additions & 4 deletions server/api/threads/[id]/files/index.post.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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.",
Expand All @@ -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",
Expand Down
Loading