diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts index f93adff..2aa682d 100644 --- a/src/app/api/files/upload/route.ts +++ b/src/app/api/files/upload/route.ts @@ -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 = { + ".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"); diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx index a6c1fa6..4a2f34f 100644 --- a/src/features/agents/components/AgentChatPanel.tsx +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -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] ); diff --git a/src/features/agents/operations/chatSendOperation.ts b/src/features/agents/operations/chatSendOperation.ts index 31675f5..64897bc 100644 --- a/src/features/agents/operations/chatSendOperation.ts +++ b/src/features/agents/operations/chatSendOperation.ts @@ -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 } @@ -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 { 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, }); diff --git a/src/features/agents/operations/useChatInteractionController.ts b/src/features/agents/operations/useChatInteractionController.ts index c692e0e..8348e33 100644 --- a/src/features/agents/operations/useChatInteractionController.ts +++ b/src/features/agents/operations/useChatInteractionController.ts @@ -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; }; +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; + handleSend: (agentId: string, sessionKey: string, message: string, attachments?: RuntimeAttachment[]) => Promise; removeQueuedMessage: (agentId: string, index: number) => void; handleNewSession: (agentId: string) => Promise; handleStopRun: (agentId: string, sessionKey: string) => Promise; @@ -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), }); }, diff --git a/src/features/agents/screens/AgentsPageScreen.tsx b/src/features/agents/screens/AgentsPageScreen.tsx index 394aa32..2995969 100644 --- a/src/features/agents/screens/AgentsPageScreen.tsx +++ b/src/features/agents/screens/AgentsPageScreen.tsx @@ -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) => diff --git a/src/features/office/screens/OfficeScreen.tsx b/src/features/office/screens/OfficeScreen.tsx index 1867511..11356da 100644 --- a/src/features/office/screens/OfficeScreen.tsx +++ b/src/features/office/screens/OfficeScreen.tsx @@ -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, ]); diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index a3040e9..2011106 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -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, diff --git a/src/lib/runtime/types.ts b/src/lib/runtime/types.ts index 2398566..8947baf 100644 --- a/src/lib/runtime/types.ts +++ b/src/lib/runtime/types.ts @@ -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"