mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-07-30 19:22:28 +00:00
real local upload path
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLTextAreaElement>) => void;
|
||||
onSend: () => void;
|
||||
onAttachmentFiles: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
attachments: UploadAttachment[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onVoiceToggle?: () => void;
|
||||
onStop: () => void;
|
||||
canSend: boolean;
|
||||
@@ -1179,6 +1205,42 @@ const AgentChatComposer = memo(function AgentChatComposer({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{attachments.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((attachment) => {
|
||||
const isImage = attachment.contentType.startsWith("image/");
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="relative overflow-hidden rounded-lg border border-border/70 bg-card/90"
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
className="h-16 w-16 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center px-2 text-center font-mono text-[10px] text-muted-foreground">
|
||||
File
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-black"
|
||||
aria-label={`Remove attachment ${attachment.name}`}
|
||||
onClick={() => onRemoveAttachment(attachment.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/85 to-transparent px-1 py-0.5 text-[10px] text-white">
|
||||
{attachment.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{voiceStatusText || voiceError || attachmentStatus ? (
|
||||
<div
|
||||
className={`mb-2 rounded-md border px-2.5 py-1.5 font-mono text-[10px] tracking-[0.02em] ${
|
||||
@@ -1197,7 +1259,7 @@ const AgentChatComposer = memo(function AgentChatComposer({
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept=".txt,.md,.markdown,.json,.js,.jsx,.ts,.tsx,.py,.rb,.go,.rs,.java,.kt,.sql,.html,.css,.xml,.yaml,.yml,.csv,.log,text/*,application/json,application/xml"
|
||||
accept=".txt,.md,.markdown,.json,.js,.jsx,.ts,.tsx,.py,.rb,.go,.rs,.java,.kt,.sql,.html,.css,.xml,.yaml,.yml,.csv,.log,.png,.jpg,.jpeg,.gif,.webp,.pdf,text/*,application/json,application/xml,image/*,application/pdf"
|
||||
onChange={onAttachmentFiles}
|
||||
/>
|
||||
<textarea
|
||||
@@ -1214,8 +1276,8 @@ const AgentChatComposer = memo(function AgentChatComposer({
|
||||
type="button"
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
disabled={!canSend}
|
||||
aria-label="Attach text files"
|
||||
title="Attach text files"
|
||||
aria-label="Attach files"
|
||||
title="Attach files"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
@@ -1300,6 +1362,7 @@ export const AgentChatPanel = ({
|
||||
const [renameDraft, setRenameDraft] = useState(agent.name);
|
||||
const [renameError, setRenameError] = useState<string | null>(null);
|
||||
const [attachmentStatus, setAttachmentStatus] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<UploadAttachment[]>([]);
|
||||
const draftRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -1345,6 +1408,8 @@ export const AgentChatPanel = ({
|
||||
};
|
||||
plainDraftRef.current = agent.draft;
|
||||
setDraftValue(agent.draft);
|
||||
setAttachments([]);
|
||||
setAttachmentStatus(null);
|
||||
return;
|
||||
}
|
||||
if (agent.draft === plainDraftRef.current) return;
|
||||
@@ -1391,15 +1456,18 @@ export const AgentChatPanel = ({
|
||||
(message: string) => {
|
||||
if (!canSend) return;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return;
|
||||
const attachmentBlocks = attachments.map(buildUploadedAttachmentPromptBlock);
|
||||
const finalMessage = [trimmed, ...attachmentBlocks].filter(Boolean).join("\n\n").trim();
|
||||
if (!finalMessage) return;
|
||||
plainDraftRef.current = "";
|
||||
setDraftValue("");
|
||||
setAttachments([]);
|
||||
setAttachmentStatus(null);
|
||||
onDraftChange("");
|
||||
scrollToBottomNextOutputRef.current = true;
|
||||
onSend(trimmed);
|
||||
onSend(finalMessage);
|
||||
},
|
||||
[canSend, onDraftChange, onSend]
|
||||
[attachments, canSend, onDraftChange, onSend]
|
||||
);
|
||||
|
||||
const chatItems = useMemo(
|
||||
@@ -1453,7 +1521,7 @@ export const AgentChatPanel = ({
|
||||
() => resolveEmptyChatIntroMessage(agent.agentId, agent.sessionEpoch),
|
||||
[agent.agentId, agent.sessionEpoch]
|
||||
);
|
||||
const sendDisabled = !canSend || !draftValue.trim();
|
||||
const sendDisabled = !canSend || (!draftValue.trim() && attachments.length === 0);
|
||||
|
||||
const handleComposerChange = useCallback(
|
||||
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -1485,31 +1553,43 @@ export const AgentChatPanel = ({
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
if (files.length === 0) return;
|
||||
const unsupported = files.filter((file) => !isTextAttachmentFile(file));
|
||||
const supported = files.filter((file) => isTextAttachmentFile(file));
|
||||
const oversized = files.filter((file) => file.size > MAX_UPLOAD_BYTES);
|
||||
const supported = files.filter((file) => file.size <= MAX_UPLOAD_BYTES);
|
||||
if (supported.length === 0) {
|
||||
setAttachmentStatus("Only text/code/markdown-style files can be attached right now.");
|
||||
setAttachmentStatus("All selected files exceeded the 10 MB upload limit.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blocks = await Promise.all(
|
||||
const uploaded = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const rawText = await file.text();
|
||||
const normalizedText = rawText.trim();
|
||||
const clippedText =
|
||||
normalizedText.length > MAX_ATTACHMENT_TEXT_CHARS
|
||||
? `${normalizedText.slice(0, MAX_ATTACHMENT_TEXT_CHARS).trimEnd()}\n[Truncated]`
|
||||
: normalizedText;
|
||||
return buildAttachmentPromptBlock(file.name, clippedText);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload.error === "string"
|
||||
? payload.error
|
||||
: `Failed to upload ${file.name}.`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return {
|
||||
id: String(payload.id ?? ""),
|
||||
name: String(payload.name ?? file.name),
|
||||
url: String(payload.url ?? ""),
|
||||
contentType: String(payload.contentType ?? file.type ?? "application/octet-stream"),
|
||||
extractedText:
|
||||
typeof payload.extractedText === "string" ? payload.extractedText : undefined,
|
||||
} satisfies UploadAttachment;
|
||||
})
|
||||
);
|
||||
const nextValue = [draftValue.trim(), ...blocks].filter(Boolean).join("\n\n");
|
||||
plainDraftRef.current = nextValue;
|
||||
setDraftValue(nextValue);
|
||||
onDraftChange(nextValue);
|
||||
const statusParts = [`Attached ${supported.length} file${supported.length === 1 ? "" : "s"}.`];
|
||||
if (unsupported.length > 0) {
|
||||
statusParts.push(`${unsupported.length} unsupported file${unsupported.length === 1 ? "" : "s"} skipped.`);
|
||||
setAttachments((current) => [...current, ...uploaded]);
|
||||
const statusParts = [`Uploaded ${uploaded.length} file${uploaded.length === 1 ? "" : "s"}.`];
|
||||
if (oversized.length > 0) {
|
||||
statusParts.push(`${oversized.length} oversized file${oversized.length === 1 ? "" : "s"} skipped.`);
|
||||
}
|
||||
setAttachmentStatus(statusParts.join(" "));
|
||||
scrollToBottomNextOutputRef.current = true;
|
||||
@@ -1519,9 +1599,13 @@ export const AgentChatPanel = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
[draftValue, onDraftChange]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachments((current) => current.filter((attachment) => attachment.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleVoiceToggle = useCallback(
|
||||
() => {
|
||||
if (!canSend && voiceState !== "recording") return;
|
||||
@@ -1775,6 +1859,8 @@ export const AgentChatPanel = ({
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
onSend={handleComposerSend}
|
||||
onAttachmentFiles={handleAttachmentFiles}
|
||||
attachments={attachments}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
onVoiceToggle={handleVoiceToggle}
|
||||
onStop={onStopRun}
|
||||
canSend={canSend}
|
||||
|
||||
Reference in New Issue
Block a user