fixed file upload, MIME integgration

This commit is contained in:
gsknnft
2026-04-09 13:41:11 -04:00
parent 0c2d58bd25
commit 8040ef735f
8 changed files with 116 additions and 21 deletions
+50 -3
View File
@@ -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) =>
+17 -3
View File
@@ -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,
]);
+2 -1
View File
@@ -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,
+7
View File
@@ -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"