From 0c2d58bd25110eb0d09be3c97e5e6cabb3c3259d Mon Sep 17 00:00:00 2001 From: gsknnft Date: Thu, 9 Apr 2026 10:43:24 -0400 Subject: [PATCH] real local upload path --- src/app/api/files/[file]/route.ts | 63 ++++++++ src/app/api/files/upload/route.ts | 93 ++++++++++++ .../agents/components/AgentChatPanel.tsx | 138 ++++++++++++++---- 3 files changed, 268 insertions(+), 26 deletions(-) create mode 100644 src/app/api/files/[file]/route.ts create mode 100644 src/app/api/files/upload/route.ts diff --git a/src/app/api/files/[file]/route.ts b/src/app/api/files/[file]/route.ts new file mode 100644 index 0000000..380df5d --- /dev/null +++ b/src/app/api/files/[file]/route.ts @@ -0,0 +1,63 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { NextResponse } from "next/server"; + +import { resolveStateDir } from "@/lib/clawdbot/paths"; + +export const runtime = "nodejs"; + +const uploadsDir = () => path.join(resolveStateDir(), "claw3d", "uploads"); + +const contentTypeFromName = (fileName: string): string => { + const ext = path.extname(fileName).toLowerCase(); + switch (ext) { + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".pdf": + return "application/pdf"; + case ".md": + case ".markdown": + return "text/markdown; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + case ".csv": + return "text/csv; charset=utf-8"; + default: + return "text/plain; charset=utf-8"; + } +}; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ file: string }> } +) { + try { + const { file } = await params; + const safeFile = path.basename(file); + const targetPath = path.join(uploadsDir(), safeFile); + const resolvedUploads = path.resolve(uploadsDir()); + const resolvedTarget = path.resolve(targetPath); + if (!resolvedTarget.startsWith(`${resolvedUploads}${path.sep}`) && resolvedTarget !== resolvedUploads) { + return NextResponse.json({ error: "Invalid file path." }, { status: 400 }); + } + + const bytes = await fs.readFile(resolvedTarget); + return new Response(new Blob([Uint8Array.from(bytes)], { type: contentTypeFromName(safeFile) }), { + headers: { + "Content-Type": contentTypeFromName(safeFile), + "Cache-Control": "no-store", + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "File not found."; + return NextResponse.json({ error: message }, { status: 404 }); + } +} diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts new file mode 100644 index 0000000..f93adff --- /dev/null +++ b/src/app/api/files/upload/route.ts @@ -0,0 +1,93 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { NextResponse } from "next/server"; + +import { resolveStateDir } from "@/lib/clawdbot/paths"; + +export const runtime = "nodejs"; + +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; +const ALLOWED_CONTENT_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "text/plain", + "text/markdown", + "text/csv", + "application/json", + "application/xml", + "text/xml", + "application/pdf", +]); + +const TEXT_CONTENT_TYPES = [ + "text/", + "application/json", + "application/xml", +]; + +const sanitizeFilename = (input: string): string => { + const cleaned = input.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + return cleaned || "upload"; +}; + +const uploadsDir = () => path.join(resolveStateDir(), "claw3d", "uploads"); + +const isTextContentType = (contentType: string): boolean => + TEXT_CONTENT_TYPES.some((prefix) => contentType.startsWith(prefix)); + +export async function POST(request: Request) { + try { + const formData = await request.formData(); + const file = formData.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file uploaded." }, { status: 400 }); + } + if (file.size <= 0) { + return NextResponse.json({ error: "Uploaded file is empty." }, { status: 400 }); + } + if (file.size > MAX_UPLOAD_BYTES) { + return NextResponse.json({ error: "File exceeds 10 MB limit." }, { status: 400 }); + } + + const contentType = file.type.trim().toLowerCase(); + if (!contentType || !ALLOWED_CONTENT_TYPES.has(contentType) && !contentType.startsWith("text/")) { + return NextResponse.json({ error: `Unsupported file type: ${contentType || "(unknown)"}` }, { status: 400 }); + } + + const fileId = crypto.randomBytes(8).toString("hex"); + const safeName = sanitizeFilename(file.name || "upload"); + const storedName = `${fileId}-${safeName}`; + const targetDir = uploadsDir(); + const targetPath = path.join(targetDir, storedName); + + await fs.mkdir(targetDir, { recursive: true }); + const bytes = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(targetPath, bytes); + + let extractedText: string | undefined; + if (isTextContentType(contentType)) { + const normalizedText = bytes.toString("utf8").trim(); + if (normalizedText) { + extractedText = + normalizedText.length > 12_000 + ? `${normalizedText.slice(0, 12_000).trimEnd()}\n[Truncated]` + : normalizedText; + } + } + + return NextResponse.json({ + id: fileId, + name: file.name || safeName, + url: `/api/files/${storedName}`, + contentType, + extractedText, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Upload failed."; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx index 4d13c51..a6c1fa6 100644 --- a/src/features/agents/components/AgentChatPanel.tsx +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -89,6 +89,15 @@ const TEXT_ATTACHMENT_EXTENSIONS = new Set([ "log", ]); const MAX_ATTACHMENT_TEXT_CHARS = 12_000; +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +type UploadAttachment = { + id: string; + name: string; + url: string; + contentType: string; + extractedText?: string; +}; const isTextAttachmentFile = (file: File): boolean => { const mime = file.type.trim().toLowerCase(); @@ -113,6 +122,19 @@ const buildAttachmentPromptBlock = (fileName: string, content: string): string = `[End attached reference: ${fileName}]`, ].join("\n"); +const buildUploadedAttachmentPromptBlock = (attachment: UploadAttachment): string => { + const lines = [ + `[Attached file: ${attachment.name}]`, + `URL: ${attachment.url}`, + `Content-Type: ${attachment.contentType}`, + ]; + if (attachment.extractedText) { + lines.push("", attachment.extractedText); + } + lines.push(`[End attached file: ${attachment.name}]`); + return lines.join("\n"); +}; + const stableStringHash = (value: string): number => { let hash = 0; for (let i = 0; i < value.length; i += 1) { @@ -931,6 +953,8 @@ const AgentChatComposer = memo(function AgentChatComposer({ onKeyDown, onSend, onAttachmentFiles, + attachments, + onRemoveAttachment, onVoiceToggle, onStop, canSend, @@ -963,6 +987,8 @@ const AgentChatComposer = memo(function AgentChatComposer({ onKeyDown: (event: KeyboardEvent) => void; onSend: () => void; onAttachmentFiles: (event: ChangeEvent) => void; + attachments: UploadAttachment[]; + onRemoveAttachment: (id: string) => void; onVoiceToggle?: () => void; onStop: () => void; canSend: boolean; @@ -1179,6 +1205,42 @@ const AgentChatComposer = memo(function AgentChatComposer({ ) : null} + {attachments.length > 0 ? ( +
+ {attachments.map((attachment) => { + const isImage = attachment.contentType.startsWith("image/"); + return ( +
+ {isImage ? ( + {attachment.name} + ) : ( +
+ File +
+ )} + +
+ {attachment.name} +
+
+ ); + })} +
+ ) : null} {voiceStatusText || voiceError || attachmentStatus ? (