mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-07-30 11:12:32 +00:00
fixed file upload, MIME integgration
This commit is contained in:
@@ -9,6 +9,36 @@ import { resolveStateDir } from "@/lib/clawdbot/paths";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
const CONTENT_TYPE_BY_EXT: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".markdown": "text/markdown",
|
||||
".csv": "text/csv",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".pdf": "application/pdf",
|
||||
".js": "text/plain",
|
||||
".jsx": "text/plain",
|
||||
".ts": "text/plain",
|
||||
".tsx": "text/plain",
|
||||
".py": "text/plain",
|
||||
".rb": "text/plain",
|
||||
".go": "text/plain",
|
||||
".rs": "text/plain",
|
||||
".java": "text/plain",
|
||||
".kt": "text/plain",
|
||||
".sql": "text/plain",
|
||||
".html": "text/plain",
|
||||
".css": "text/plain",
|
||||
".yaml": "text/plain",
|
||||
".yml": "text/plain",
|
||||
".log": "text/plain",
|
||||
};
|
||||
const ALLOWED_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
@@ -22,6 +52,7 @@ const ALLOWED_CONTENT_TYPES = new Set([
|
||||
"text/xml",
|
||||
"application/pdf",
|
||||
]);
|
||||
const ALLOWED_EXTENSIONS = new Set(Object.keys(CONTENT_TYPE_BY_EXT));
|
||||
|
||||
const TEXT_CONTENT_TYPES = [
|
||||
"text/",
|
||||
@@ -39,6 +70,18 @@ const uploadsDir = () => path.join(resolveStateDir(), "claw3d", "uploads");
|
||||
const isTextContentType = (contentType: string): boolean =>
|
||||
TEXT_CONTENT_TYPES.some((prefix) => contentType.startsWith(prefix));
|
||||
|
||||
const resolveUploadContentType = (file: File): string | null => {
|
||||
const explicit = file.type.trim().toLowerCase();
|
||||
if (explicit && (ALLOWED_CONTENT_TYPES.has(explicit) || explicit.startsWith("text/"))) {
|
||||
return explicit;
|
||||
}
|
||||
const ext = path.extname(file.name || "").trim().toLowerCase();
|
||||
if (!ext || !ALLOWED_EXTENSIONS.has(ext)) {
|
||||
return null;
|
||||
}
|
||||
return CONTENT_TYPE_BY_EXT[ext] ?? null;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
@@ -53,9 +96,13 @@ export async function POST(request: Request) {
|
||||
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 contentType = resolveUploadContentType(file);
|
||||
if (!contentType) {
|
||||
const ext = path.extname(file.name || "").trim().toLowerCase();
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported file type: ${ext || file.type.trim().toLowerCase() || "(unknown)"}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const fileId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
ExecApprovalDecision,
|
||||
PendingExecApproval,
|
||||
} from "@/features/agents/approvals/types";
|
||||
import type { RuntimeAttachment } from "@/lib/runtime/types";
|
||||
import {
|
||||
buildAgentChatRenderBlocks,
|
||||
buildFinalAgentChatItems,
|
||||
@@ -203,7 +204,7 @@ type AgentChatPanelProps = {
|
||||
onToolCallingToggle?: (enabled: boolean) => void;
|
||||
onThinkingTracesToggle?: (enabled: boolean) => void;
|
||||
onDraftChange: (value: string) => void;
|
||||
onSend: (message: string) => void;
|
||||
onSend: (message: string, attachments: RuntimeAttachment[]) => void;
|
||||
onRemoveQueuedMessage?: (index: number) => void;
|
||||
onStopRun: () => void;
|
||||
onAvatarShuffle: () => void;
|
||||
@@ -1456,16 +1457,15 @@ export const AgentChatPanel = ({
|
||||
(message: string) => {
|
||||
if (!canSend) return;
|
||||
const trimmed = message.trim();
|
||||
const attachmentBlocks = attachments.map(buildUploadedAttachmentPromptBlock);
|
||||
const finalMessage = [trimmed, ...attachmentBlocks].filter(Boolean).join("\n\n").trim();
|
||||
if (!finalMessage) return;
|
||||
if (!trimmed && attachments.length === 0) return;
|
||||
const pendingAttachments = attachments.map(({ id: _id, ...rest }) => rest as RuntimeAttachment);
|
||||
plainDraftRef.current = "";
|
||||
setDraftValue("");
|
||||
setAttachments([]);
|
||||
setAttachmentStatus(null);
|
||||
onDraftChange("");
|
||||
scrollToBottomNextOutputRef.current = true;
|
||||
onSend(finalMessage);
|
||||
onSend(trimmed, pendingAttachments);
|
||||
},
|
||||
[attachments, canSend, onDraftChange, onSend]
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { randomUUID } from "@/lib/uuid";
|
||||
import type { TranscriptAppendMeta } from "@/features/agents/state/transcript";
|
||||
import type { RuntimeAttachment } from "@/lib/runtime/types";
|
||||
|
||||
type SendDispatchAction =
|
||||
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
@@ -86,13 +87,15 @@ export async function sendChatMessageViaStudio(params: {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
attachments?: RuntimeAttachment[];
|
||||
clearRunTracking?: (runId: string) => void;
|
||||
echoUserMessage?: boolean;
|
||||
now?: () => number;
|
||||
generateRunId?: () => string;
|
||||
}): Promise<void> {
|
||||
const trimmed = params.message.trim();
|
||||
if (!trimmed) return;
|
||||
const attachments = params.attachments ?? [];
|
||||
if (!trimmed && attachments.length === 0) return;
|
||||
const echoUserMessage = params.echoUserMessage !== false;
|
||||
|
||||
const generateRunId = params.generateRunId ?? (() => randomUUID());
|
||||
@@ -206,6 +209,7 @@ export async function sendChatMessageViaStudio(params: {
|
||||
const sendResult = await params.client.call("chat.send", {
|
||||
sessionKey: params.sessionKey,
|
||||
message: buildAgentInstruction({ message: trimmed }),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createRafBatcher } from "@/lib/dom";
|
||||
import type { RuntimeAttachment } from "@/lib/runtime/types";
|
||||
import {
|
||||
planDraftFlushIntent,
|
||||
planDraftTimerIntent,
|
||||
@@ -23,6 +24,23 @@ type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const buildQueuedAttachmentMessage = (message: string, attachments: RuntimeAttachment[]): string => {
|
||||
const trimmed = message.trim();
|
||||
const attachmentBlocks = attachments.map((attachment) => {
|
||||
const lines = [
|
||||
`[Attached file: ${attachment.name}]`,
|
||||
`URL: ${attachment.url}`,
|
||||
`Content-Type: ${attachment.contentType}`,
|
||||
];
|
||||
if (attachment.extractedText?.trim()) {
|
||||
lines.push("", attachment.extractedText.trim());
|
||||
}
|
||||
lines.push(`[End attached file: ${attachment.name}]`);
|
||||
return lines.join("\n");
|
||||
});
|
||||
return [trimmed, ...attachmentBlocks].filter(Boolean).join("\n\n").trim();
|
||||
};
|
||||
|
||||
export type UseChatInteractionControllerParams = {
|
||||
client: GatewayClientLike;
|
||||
status: GatewayStatus;
|
||||
@@ -43,7 +61,7 @@ export type ChatInteractionController = {
|
||||
stopBusyAgentId: string | null;
|
||||
flushPendingDraft: (agentId: string | null) => void;
|
||||
handleDraftChange: (agentId: string, value: string) => void;
|
||||
handleSend: (agentId: string, sessionKey: string, message: string) => Promise<void>;
|
||||
handleSend: (agentId: string, sessionKey: string, message: string, attachments?: RuntimeAttachment[]) => Promise<void>;
|
||||
removeQueuedMessage: (agentId: string, index: number) => void;
|
||||
handleNewSession: (agentId: string) => Promise<void>;
|
||||
handleStopRun: (agentId: string, sessionKey: string) => Promise<void>;
|
||||
@@ -186,9 +204,11 @@ export function useChatInteractionController(
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (agentId: string, sessionKey: string, message: string) => {
|
||||
async (agentId: string, sessionKey: string, message: string, attachments?: RuntimeAttachment[]) => {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return;
|
||||
const normalizedAttachments = attachments ?? [];
|
||||
const hasAttachments = normalizedAttachments.length > 0;
|
||||
if (!trimmed && !hasAttachments) return;
|
||||
const pendingDraftTimer = pendingDraftTimersRef.current.get(agentId) ?? null;
|
||||
if (pendingDraftTimer !== null) {
|
||||
window.clearTimeout(pendingDraftTimer);
|
||||
@@ -211,7 +231,7 @@ export function useChatInteractionController(
|
||||
params.dispatch({
|
||||
type: "enqueueQueuedMessage",
|
||||
agentId,
|
||||
message: trimmed,
|
||||
message: buildQueuedAttachmentMessage(trimmed, normalizedAttachments),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -224,6 +244,7 @@ export function useChatInteractionController(
|
||||
agentId,
|
||||
sessionKey,
|
||||
message: trimmed,
|
||||
attachments: normalizedAttachments,
|
||||
clearRunTracking: (runId) => params.clearRunTracking(runId),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -864,9 +864,9 @@ const AgentsPageScreen = () => {
|
||||
});
|
||||
});
|
||||
const handleChatSend = useCallback(
|
||||
async (agentId: string, sessionKey: string, message: string) => {
|
||||
async (agentId: string, sessionKey: string, message: string, attachments?: import("@/lib/runtime/types").RuntimeAttachment[]) => {
|
||||
stopVoiceReplyPlayback();
|
||||
await handleSend(agentId, sessionKey, message);
|
||||
await handleSend(agentId, sessionKey, message, attachments);
|
||||
},
|
||||
[handleSend, stopVoiceReplyPlayback]
|
||||
);
|
||||
@@ -1779,11 +1779,12 @@ const AgentsPageScreen = () => {
|
||||
handleThinkingTracesToggle(focusedAgent.agentId, enabled)
|
||||
}
|
||||
onDraftChange={(value) => handleDraftChange(focusedAgent.agentId, value)}
|
||||
onSend={(message) =>
|
||||
onSend={(message, attachments) =>
|
||||
handleChatSend(
|
||||
focusedAgent.agentId,
|
||||
focusedAgent.sessionKey,
|
||||
message
|
||||
message,
|
||||
attachments
|
||||
)
|
||||
}
|
||||
onRemoveQueuedMessage={(index) =>
|
||||
|
||||
@@ -1102,6 +1102,7 @@ export function OfficeScreen({
|
||||
const previousGatewayStatusRef = useRef<"disconnected" | "connecting" | "connected">(
|
||||
"disconnected",
|
||||
);
|
||||
const didAutoNavigateFromLobbyRef = useRef(false);
|
||||
const [floorRosterCache, setFloorRosterCache] = useState(() =>
|
||||
createFloorRosterCache(),
|
||||
);
|
||||
@@ -1162,10 +1163,20 @@ export function OfficeScreen({
|
||||
};
|
||||
}, [settingsCoordinator]);
|
||||
|
||||
// Reset auto-navigate flag when disconnected so the next connection can navigate again.
|
||||
useEffect(() => {
|
||||
const previousStatus = previousGatewayStatusRef.current;
|
||||
previousGatewayStatusRef.current = status;
|
||||
if (previousStatus === "connected" || status !== "connected") return;
|
||||
if (status !== "connected" && status !== "connecting") {
|
||||
didAutoNavigateFromLobbyRef.current = false;
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// Auto-navigate away from lobby when a real adapter connects.
|
||||
// Uses a ref flag instead of previousGatewayStatusRef so the effect can
|
||||
// re-run when detectedAdapterType arrives in a later render (after status
|
||||
// already flipped to "connected").
|
||||
useEffect(() => {
|
||||
if (status !== "connected") return;
|
||||
if (didAutoNavigateFromLobbyRef.current) return;
|
||||
if (activeFloor.kind !== "lobby" || activeFloor.provider !== "demo") return;
|
||||
|
||||
const connectedProvider =
|
||||
@@ -1182,7 +1193,9 @@ export function OfficeScreen({
|
||||
) ?? null;
|
||||
if (!targetFloor || targetFloor.id === activeFloor.id) return;
|
||||
|
||||
didAutoNavigateFromLobbyRef.current = true;
|
||||
setActiveFloorId(targetFloor.id);
|
||||
setSelectedAdapterType(targetFloor.provider as StudioGatewayAdapterType);
|
||||
settingsCoordinator.schedulePatch({ activeFloorId: targetFloor.id }, 0);
|
||||
}, [
|
||||
activeFloor.id,
|
||||
@@ -1190,6 +1203,7 @@ export function OfficeScreen({
|
||||
activeFloor.provider,
|
||||
detectedAdapterType,
|
||||
selectedAdapterType,
|
||||
setSelectedAdapterType,
|
||||
settingsCoordinator,
|
||||
status,
|
||||
]);
|
||||
|
||||
@@ -971,6 +971,7 @@ export const useGatewayConnection = (
|
||||
: "openclaw";
|
||||
setDetectedAdapterType(nextDetectedAdapterType);
|
||||
setHasLastKnownGoodState(nextDetectedAdapterType === selectedAdapterType);
|
||||
// Flush immediately (debounce=0) so lastKnownGood survives a quick refresh.
|
||||
settingsCoordinator.schedulePatch({
|
||||
gateway: {
|
||||
lastKnownGood: {
|
||||
@@ -979,7 +980,7 @@ export const useGatewayConnection = (
|
||||
adapterType: nextDetectedAdapterType,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 0);
|
||||
gatewayDebugLog("connect:success", {
|
||||
selectedAdapterType,
|
||||
detectedAdapterType: nextDetectedAdapterType,
|
||||
|
||||
@@ -6,6 +6,13 @@ import type {
|
||||
GatewayStatus,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export type RuntimeAttachment = {
|
||||
name: string;
|
||||
url: string;
|
||||
contentType: string;
|
||||
extractedText?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeCapability =
|
||||
| "agents"
|
||||
| "sessions"
|
||||
|
||||
Reference in New Issue
Block a user