Frontend: attach files by picker or drop, with an attachment chip

- pendingImage becomes pendingAttachment (image or PDF/TXT/MD/CSV), uploaded
  through POST /api/conversation/upload and sent as image_key or file_key.
- MessageInput shows an icon/name/size chip for files, an inline error for
  unsupported or oversized files, and a paperclip picker.
- DropOverlay covers the chat area with "Drop to attach" while files are
  dragged over the window; drags without files are ignored.
- QuestionBubble renders a saved trailing "📎 name" as a chip.
- test-fixtures/cowork/mock-server.mjs: dependency-free mock API (static
  dist/, conversations, contract-shaped SSE, uploads, artifacts) for
  Playwright runs without Docker or OIDC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:56:56 -04:00
parent 3e50aecb96
commit 35faeb0fb7
8 changed files with 476 additions and 69 deletions
+15 -5
View File
@@ -51,6 +51,12 @@ interface Conversation {
user_id?: string;
}
export type UploadResult =
| { kind: "image"; image_key: string }
| { kind: "file"; file_key: string; name: string; chars: number };
export class UploadError extends Error {}
interface QueryResponse {
response: string;
}
@@ -182,16 +188,16 @@ class ConversationService {
return await response.json();
}
async uploadImage(
async uploadAttachment(
file: File,
conversationId: string,
): Promise<{ image_key: string }> {
): Promise<UploadResult> {
const formData = new FormData();
formData.append("file", file);
formData.append("conversation_id", conversationId);
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/upload-image`,
`${this.conversationBaseUrl}/upload`,
{
method: "POST",
body: formData,
@@ -200,8 +206,8 @@ class ConversationService {
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to upload image");
const data = await response.json().catch(() => ({}));
throw new UploadError(data.error || "Failed to upload file");
}
return await response.json();
@@ -224,11 +230,15 @@ class ConversationService {
onEvent: SSEEventCallback,
signal?: AbortSignal,
imageKey?: string,
fileKey?: string,
): Promise<void> {
const body: Record<string, string> = { query, conversation_id };
if (imageKey) {
body.image_key = imageKey;
}
if (fileKey) {
body.file_key = fileKey;
}
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/stream-query`,
+18 -12
View File
@@ -6,6 +6,7 @@ import { AnswerBubble } from "./AnswerBubble";
import { ActivityTimeline } from "./ActivityTimeline";
import { ThinkingBubble } from "./ThinkingBubble";
import { MessageInput } from "./MessageInput";
import { DropOverlay } from "./DropOverlay";
import { SidebarContent } from "./SidebarContent";
import { AdminPanel } from "./AdminPanel";
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
@@ -84,8 +85,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
streaming,
thinking,
messagesLoading,
pendingImage,
setPendingImage,
pendingAttachment,
attachFile,
clearAttachment,
attachmentError,
clearAttachmentError,
sendMessage,
stopGeneration,
error,
@@ -141,9 +145,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
[deleteConversation],
);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
const handleLogout = () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
@@ -223,7 +224,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
<div className="relative flex-1 flex flex-col h-full overflow-hidden min-w-0">
<DropOverlay onDrop={attachFile} />
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
<div className="flex items-center gap-2">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
@@ -302,9 +304,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
onToggleSimbaMode={handleToggleSimbaMode}
isLoading={isLoading}
onStop={stopGeneration}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
pendingAttachment={pendingAttachment}
onAttach={attachFile}
onClearAttachment={clearAttachment}
attachmentError={attachmentError}
onDismissAttachmentError={clearAttachmentError}
autoFocus
/>
</div>
@@ -431,9 +435,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
onToggleSimbaMode={handleToggleSimbaMode}
isLoading={isLoading}
onStop={stopGeneration}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
pendingAttachment={pendingAttachment}
onAttach={attachFile}
onClearAttachment={clearAttachment}
attachmentError={attachmentError}
onDismissAttachmentError={clearAttachmentError}
/>
</div>
</footer>
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from "react";
import { Paperclip } from "lucide-react";
const hasFiles = (e: DragEvent) =>
Array.from(e.dataTransfer?.types ?? []).includes("Files");
type DropOverlayProps = {
onDrop: (file: File) => void;
};
/**
* Listens for file drags anywhere in the window and covers its positioned
* parent with a "Drop to attach" target. Drags without files (text, links)
* are ignored so they keep their normal behaviour.
*/
export const DropOverlay = ({ onDrop }: DropOverlayProps) => {
const [active, setActive] = useState(false);
// dragenter/dragleave fire for every element crossed; count to know when
// the drag has really left the window.
const depthRef = useRef(0);
const onDropRef = useRef(onDrop);
onDropRef.current = onDrop;
useEffect(() => {
const handleEnter = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depthRef.current += 1;
setActive(true);
};
const handleOver = (e: DragEvent) => {
if (!hasFiles(e)) return;
// Required for the drop event to fire (and to stop the browser
// navigating to the file).
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
};
const handleLeave = (e: DragEvent) => {
if (!hasFiles(e)) return;
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setActive(false);
};
const handleDrop = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depthRef.current = 0;
setActive(false);
const file = e.dataTransfer?.files?.[0];
if (file) onDropRef.current(file);
};
window.addEventListener("dragenter", handleEnter);
window.addEventListener("dragover", handleOver);
window.addEventListener("dragleave", handleLeave);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleEnter);
window.removeEventListener("dragover", handleOver);
window.removeEventListener("dragleave", handleLeave);
window.removeEventListener("drop", handleDrop);
};
}, []);
if (!active) return null;
return (
<div
className="pointer-events-none absolute inset-0 z-[45] flex items-center justify-center bg-cream/80 backdrop-blur-sm p-3 md:p-5"
data-testid="drop-overlay"
>
<div className="w-full h-full rounded-3xl border-2 border-dashed border-amber-glow/50 bg-amber-pale/70 flex flex-col items-center justify-center gap-3 message-enter">
<div className="w-14 h-14 rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30 text-amber-glow flex items-center justify-center">
<Paperclip size={24} />
</div>
<p
className="text-lg font-bold text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
Drop to attach
</p>
<p className="text-xs text-warm-gray">Images, PDF, TXT, MD or CSV · up to 10 MB</p>
</div>
</div>
);
};
+87 -16
View File
@@ -1,6 +1,12 @@
import React, { useEffect, useMemo, useRef } from "react";
import { ArrowUp, ImagePlus, Square, X } from "lucide-react";
import { ArrowUp, FileSpreadsheet, FileText, Paperclip, Square, X } from "lucide-react";
import { cn } from "../lib/utils";
import {
ATTACHMENT_ACCEPT,
attachmentKind,
fileExtension,
formatBytes,
} from "../lib/attachments";
import { Textarea } from "./ui/textarea";
type MessageInputProps = {
@@ -12,9 +18,11 @@ type MessageInputProps = {
query: string;
isLoading: boolean;
onStop: () => void;
pendingImage: File | null;
onImageSelect: (file: File) => void;
onClearImage: () => void;
pendingAttachment: File | null;
onAttach: (file: File) => void;
onClearAttachment: () => void;
attachmentError: string | null;
onDismissAttachmentError: () => void;
autoFocus?: boolean;
};
@@ -28,15 +36,23 @@ export const MessageInput = React.memo(
onToggleSimbaMode,
isLoading,
onStop,
pendingImage,
onImageSelect,
onClearImage,
pendingAttachment,
onAttach,
onClearAttachment,
attachmentError,
onDismissAttachmentError,
autoFocus,
}: MessageInputProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Create blob URL once per file, revoke on cleanup
const pendingImage =
pendingAttachment && attachmentKind(pendingAttachment) === "image"
? pendingAttachment
: null;
const pendingFile = pendingAttachment && !pendingImage ? pendingAttachment : null;
// Create blob URL once per image, revoke on cleanup
const previewUrl = useMemo(
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
[pendingImage],
@@ -59,7 +75,7 @@ export const MessageInput = React.memo(
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onImageSelect(file);
onAttach(file);
}
// Reset so the same file can be re-selected
e.target.value = "";
@@ -69,19 +85,21 @@ export const MessageInput = React.memo(
if (isLoading) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
e.preventDefault();
onImageSelect(file);
onAttach(file);
return;
}
}
}
};
const canSend = !isLoading && Boolean(query.trim() || pendingImage);
const canSend = !isLoading && Boolean(query.trim() || pendingAttachment);
const FileIcon =
pendingFile && fileExtension(pendingFile.name) === "csv" ? FileSpreadsheet : FileText;
return (
<div
@@ -102,7 +120,8 @@ export const MessageInput = React.memo(
/>
<button
type="button"
onClick={onClearImage}
onClick={onClearAttachment}
aria-label="Remove attachment"
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
>
<X size={12} />
@@ -111,6 +130,56 @@ export const MessageInput = React.memo(
</div>
)}
{/* File chip */}
{pendingFile && (
<div className="px-3 pt-3">
<div
className="relative inline-flex items-center gap-2.5 max-w-full rounded-xl border border-sand bg-cream pl-2 pr-7 py-2 message-enter"
data-testid="attachment-chip"
>
<div className="w-8 h-8 shrink-0 rounded-lg bg-amber-pale text-amber-dark flex items-center justify-center">
<FileIcon size={16} />
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-charcoal truncate max-w-[14rem] sm:max-w-xs">
{pendingFile.name}
</div>
<div className="text-[11px] text-warm-gray">
{fileExtension(pendingFile.name).toUpperCase() || "File"} ·{" "}
{formatBytes(pendingFile.size)}
</div>
</div>
<button
type="button"
onClick={onClearAttachment}
aria-label="Remove attachment"
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full text-warm-gray flex items-center justify-center hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
>
<X size={12} />
</button>
</div>
</div>
)}
{attachmentError && (
<div className="px-3 pt-3">
<div
role="alert"
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600 message-enter"
>
<span>{attachmentError}</span>
<button
type="button"
onClick={onDismissAttachmentError}
className="text-red-400 hover:text-red-600 transition-colors cursor-pointer"
aria-label="Dismiss error"
>
<X size={13} />
</button>
</div>
</div>
)}
{/* Textarea */}
<Textarea
ref={textareaRef}
@@ -128,7 +197,7 @@ export const MessageInput = React.memo(
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept={ATTACHMENT_ACCEPT}
onChange={handleFileChange}
className="hidden"
/>
@@ -150,11 +219,13 @@ export const MessageInput = React.memo(
</span>
</button>
{/* Image attach button */}
{/* Attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
aria-label="Attach a file"
title="Attach an image, PDF, TXT, MD or CSV"
className={cn(
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
isLoading
@@ -162,7 +233,7 @@ export const MessageInput = React.memo(
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<ImagePlus size={16} />
<Paperclip size={16} />
</button>
</div>
@@ -1,5 +1,7 @@
import React from "react";
import { FileSpreadsheet, FileText } from "lucide-react";
import { cn } from "../lib/utils";
import { fileExtension, splitAttachedFile } from "../lib/attachments";
import { usePresignedUrl } from "../hooks/usePresignedUrl";
type QuestionBubbleProps = {
@@ -9,6 +11,8 @@ type QuestionBubbleProps = {
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
const { imageUrl, imageError } = usePresignedUrl(image_key);
const { body, fileName } = splitAttachedFile(text);
const FileIcon = fileName && fileExtension(fileName) === "csv" ? FileSpreadsheet : FileText;
return (
<div className="flex justify-end message-enter">
@@ -32,7 +36,21 @@ export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubblePro
className="max-w-full rounded-xl mb-2"
/>
)}
{text}
{fileName && (
<div
className={cn(
"flex items-center gap-1.5 w-fit max-w-full rounded-lg",
"bg-warm-white/70 border border-leaf-light/70 px-2 py-1",
"text-xs text-leaf-dark",
body && "mb-2",
)}
data-testid="message-attachment"
>
<FileIcon size={13} className="shrink-0" />
<span className="truncate">{fileName}</span>
</div>
)}
{body}
</div>
</div>
);
+44 -12
View File
@@ -2,9 +2,11 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useMatch } from "react-router-dom";
import {
conversationService,
UploadError,
type ToolInput,
} from "../api/conversationService";
import type { ArtifactRef } from "../api/artifactTypes";
import { validateAttachment, withAttachedFile } from "../lib/attachments";
import type { Conversation } from "./useConversations";
export type ToolStatus = "running" | "ok" | "error";
@@ -63,7 +65,9 @@ export function useChat({
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
// One image or text-like file to send with the next message.
const [pendingAttachment, setPendingAttachment] = useState<File | null>(null);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [stalled, setStalled] = useState(false);
const [streaming, setStreaming] = useState(false);
@@ -164,7 +168,7 @@ export function useChat({
const sendMessage = useCallback(
async (query: string, simbaMode: boolean) => {
if ((!query.trim() && !pendingImage) || isLoading) return;
if ((!query.trim() && !pendingAttachment) || isLoading) return;
let activeId = conversationId;
if (!activeId) {
@@ -174,7 +178,7 @@ export function useChat({
activeId = created.id;
}
const imageFile = pendingImage;
const attachment = pendingAttachment;
setError(null);
setStalled(false);
@@ -185,7 +189,8 @@ export function useChat({
streamingActiveRef.current = false;
setStreaming(false);
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
setPendingImage(null);
setPendingAttachment(null);
setAttachmentError(null);
setIsLoading(true);
if (simbaMode) {
@@ -203,19 +208,27 @@ export function useChat({
try {
let imageKey: string | undefined;
if (imageFile) {
const uploadResult = await conversationService.uploadImage(
imageFile,
let fileKey: string | undefined;
if (attachment) {
const uploadResult = await conversationService.uploadAttachment(
attachment,
activeId,
);
imageKey = uploadResult.image_key;
let patch: Partial<TextMessage>;
if (uploadResult.kind === "image") {
imageKey = uploadResult.image_key;
patch = { image_key: imageKey };
} else {
fileKey = uploadResult.file_key;
patch = { text: withAttachedFile(query, uploadResult.name) };
}
updateMessages((prev) => {
const updated = [...prev];
for (let i = updated.length - 1; i >= 0; i--) {
const msg = updated[i];
if (msg.speaker === "user") {
updated[i] = { ...msg, image_key: imageKey };
updated[i] = { ...msg, ...patch };
break;
}
}
@@ -342,10 +355,13 @@ export function useChat({
},
abortController.signal,
imageKey,
fileKey,
);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// User cancelled — not an error to surface.
} else if (err instanceof UploadError) {
if (isMountedRef.current) setError(`Couldn't attach that file: ${err.message}`);
} else {
console.error("Failed to send query:", err);
if (
@@ -383,7 +399,7 @@ export function useChat({
}
},
[
pendingImage,
pendingAttachment,
isLoading,
conversationId,
createConversation,
@@ -400,6 +416,19 @@ export function useChat({
const clearError = useCallback(() => setError(null), []);
const attachFile = useCallback((file: File) => {
const problem = validateAttachment(file);
setAttachmentError(problem);
if (!problem) setPendingAttachment(file);
}, []);
const clearAttachment = useCallback(() => {
setPendingAttachment(null);
setAttachmentError(null);
}, []);
const clearAttachmentError = useCallback(() => setAttachmentError(null), []);
const retryStalled = useCallback(() => {
const q = stalledQueryRef.current;
if (!q) return;
@@ -415,8 +444,11 @@ export function useChat({
streaming,
thinking,
messagesLoading,
pendingImage,
setPendingImage,
pendingAttachment,
attachFile,
clearAttachment,
attachmentError,
clearAttachmentError,
sendMessage,
stopGeneration,
error,
+63
View File
@@ -0,0 +1,63 @@
// What can be attached to a message: images go to the vision model, text-like
// files are extracted server-side and appended to the turn.
export const ATTACHMENT_ACCEPT = "image/*,.pdf,.txt,.md,.markdown,.csv";
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
]);
const FILE_EXTENSIONS = new Set(["pdf", "txt", "md", "markdown", "csv"]);
const FILE_TYPES = new Set([
"application/pdf",
"text/plain",
"text/markdown",
"text/x-markdown",
"text/csv",
]);
export type AttachmentKind = "image" | "file";
export const fileExtension = (name: string) =>
name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
export function attachmentKind(file: File): AttachmentKind | null {
if (IMAGE_TYPES.has(file.type)) return "image";
if (FILE_TYPES.has(file.type) || FILE_EXTENSIONS.has(fileExtension(file.name))) {
return "file";
}
return null;
}
/** Returns an error message if the file can't be attached, else null. */
export function validateAttachment(file: File): string | null {
if (!attachmentKind(file)) {
return `Can't attach “${file.name}”. Use an image, PDF, TXT, MD or CSV file.`;
}
if (file.size > MAX_ATTACHMENT_BYTES) {
return `“${file.name}” is larger than 10 MB.`;
}
return null;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Saved user messages record an attached file as a trailing "📎 name" line.
const ATTACHED_FILE_RE = /(?:^|\n\n)📎 ([^\n]+)$/u;
export const withAttachedFile = (text: string, name: string) =>
text ? `${text}\n\n📎 ${name}` : `📎 ${name}`;
export function splitAttachedFile(text: string): { body: string; fileName: string | null } {
const match = text.match(ATTACHED_FILE_RE);
if (!match) return { body: text, fileName: null };
return { body: text.slice(0, match.index), fileName: match[1] };
}
@@ -20,8 +20,16 @@
// (all v1 kinds) | "broken" (GET /api/artifacts/<id> 500s) | "mystery"
// (unknown kind → JSON fallback)
// anything else: two sequential calls, then a streamed answer
// Conversations "seeded" (timeline) and "everything" (one saved tool message
// per artifact kind) exist up front.
// any query sent with a file_key: reads the attached file (by name)
// Conversations "seeded" (timeline), "everything" (one saved tool message
// per artifact kind) and "attachment" (a saved 📎 file message) exist up front.
//
// Uploads (POST /api/conversation/upload) follow the contract: JPEG/PNG/WebP/
// HEIC -> {kind:"image"}, PDF/TXT/MD/CSV (by MIME or extension) ->
// {kind:"file"}, anything else 415, files named *scan*.pdf 422 (no text
// layer). Uploaded images are served back via /api/conversation/image/<key>.
// GET /__mock/requests returns recorded API request bodies (multipart files
// summarised as {filename, type, size}), newest last.
// artifacts/<kind>.json holds one ToolArtifact per v1 kind, matching what the
// backend tools emit. Any other *.json in this directory with `kind` and
// `data` is loaded as an artifact of "seeded" (`id` defaults to the file name).
@@ -62,6 +70,10 @@ const uuid = () => crypto.randomUUID();
const artifacts = new Map();
/** @type {Map<string, {id, name, created_at, updated_at, messages: any[]}>} */
const conversations = new Map();
/** Uploaded objects by S3-style key: {bytes, type, name} */
const blobs = new Map();
/** Recorded API requests: {method, path, body} */
const requests = [];
function addArtifact(conversationId, tool, a) {
const record = {
@@ -278,8 +290,15 @@ const LABELS = {
};
const label = (tool) => LABELS[tool] ?? `Using ${tool}…`;
function seedAttachment() {
const c = newConversation("attachment", "Vet bill questions");
pushMessage(c, { speaker: "user", text: "How much was the dental cleaning?\n\n📎 vet-invoice-2026-09.pdf" });
pushMessage(c, { speaker: "simba", text: "The dental cleaning on the September invoice was **$412.50**." });
}
seed();
seedEverything();
seedAttachment();
loadFixtureArtifacts();
/** Artifact-panel scenarios: one tool call per artifact, by keyword. */
@@ -304,11 +323,20 @@ function artifactScenario(q) {
* Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded
* into contract-shaped events; ctx persists tool messages and artifacts.
*/
function scenario(query) {
function scenario(query, file) {
const q = query.toLowerCase();
const answer = (text) =>
text.match(/\S+\s*/g).map((w) => [60, { type: "content", delta: w }]);
if (file) {
return [
[200, { type: "thinking_delta", delta: `Reading the attached ${file.name}…` }],
[300, { start: "f1", tool: "simba_search", input: { query: (query || file.name).slice(0, 200) } }],
[600, { end: "f1" }],
...answer(`I read **${file.name}**. It looks like a vet invoice; the dental cleaning came to $412.50.`),
];
}
if (q.includes("plain")) {
return [[300, { type: "thinking" }], ...answer("Meow. Nothing to look up for that one.")];
}
@@ -363,8 +391,17 @@ function scenario(query) {
async function streamQuery(req, res, body) {
const c = conversations.get(body.conversation_id) ?? newConversation(body.conversation_id);
pushMessage(c, { speaker: "user", text: body.query, image_key: body.image_key ?? null });
if (c.name === "New Conversation") c.name = body.query.slice(0, 40);
let saved = body.query ?? "";
let file = null;
if (body.file_key) {
file = blobs.get(body.file_key);
if (!body.file_key.startsWith(`conversations/${c.id}/files/`) || !file) {
return json(res, 400, { error: "invalid file_key" });
}
saved = `${saved}\n\n📎 ${file.name}`.trim();
}
pushMessage(c, { speaker: "user", text: saved, image_key: body.image_key ?? null });
if (c.name === "New Conversation") c.name = (body.query || file?.name || "Attachment").slice(0, 40);
res.writeHead(200, {
"Content-Type": "text/event-stream",
@@ -377,7 +414,7 @@ async function streamQuery(req, res, body) {
const runs = new Map(); // key -> {run_id, tool, started}
let text = "";
for (const [delay, step] of scenario(body.query)) {
for (const [delay, step] of scenario(body.query ?? "", file)) {
await sleep(delay);
if (closed) return;
if (step.start) {
@@ -428,24 +465,100 @@ const json = (res, status, body) => {
res.end(JSON.stringify(body));
};
const readBody = (req) =>
new Promise((resolve) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
try {
resolve(raw ? JSON.parse(raw) : {});
} catch {
resolve({});
}
});
});
async function readRaw(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}
/** Minimal multipart/form-data parser: {fields, files: {name: {filename, type, data}}}. */
function parseMultipart(buf, contentType) {
const boundary = /boundary=(?:"([^"]+)"|([^;]+))/.exec(contentType ?? "");
const out = { fields: {}, files: {} };
if (!boundary) return out;
const delim = Buffer.from(`--${boundary[1] ?? boundary[2]}`);
let pos = buf.indexOf(delim);
while (pos !== -1) {
const start = pos + delim.length + 2; // skip CRLF
const next = buf.indexOf(delim, start);
if (next === -1) break;
const part = buf.subarray(start, next - 2); // strip trailing CRLF
const headerEnd = part.indexOf("\r\n\r\n");
const headers = part.subarray(0, headerEnd).toString("utf8");
const data = part.subarray(headerEnd + 4);
const name = /name="([^"]*)"/.exec(headers)?.[1];
const filename = /filename="([^"]*)"/.exec(headers)?.[1];
if (name && filename !== undefined) {
const type = /Content-Type:\s*([^\r\n]+)/i.exec(headers)?.[1]?.trim() ?? "";
out.files[name] = { filename, type, data };
} else if (name) {
out.fields[name] = data.toString("utf8");
}
pos = next;
}
return out;
}
/** Read the body once: JSON, or multipart form as {fields, files}. */
async function readBody(req) {
const raw = await readRaw(req);
const type = req.headers["content-type"] ?? "";
if (type.startsWith("multipart/form-data")) return { form: parseMultipart(raw, type) };
try {
return raw.length ? JSON.parse(raw.toString("utf8")) : {};
} catch {
return {};
}
}
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]);
const FILE_TYPES = { "application/pdf": "pdf", "text/plain": "txt", "text/markdown": "md", "text/csv": "csv" };
const FILE_EXTS = new Set(["pdf", "txt", "md", "csv"]);
function upload(res, form) {
const file = form?.files.file;
const conversationId = form?.fields.conversation_id;
if (!file || !conversationId) return json(res, 400, { error: "file and conversation_id are required" });
if (!conversations.has(conversationId)) return json(res, 404, { error: "conversation not found" });
if (file.data.length > 10 * 1024 * 1024) return json(res, 413, { error: "File too large" });
const type = file.type.split(";")[0].trim().toLowerCase();
if (IMAGE_TYPES.has(type)) {
const key = `conversations/${conversationId}/${uuid()}.${type.split("/")[1].replace("jpeg", "jpg")}`;
blobs.set(key, { bytes: file.data, type, name: file.filename });
return json(res, 200, { kind: "image", image_key: key });
}
const fromName = path.extname(file.filename).slice(1).toLowerCase();
const ext = FILE_TYPES[type] ?? (FILE_EXTS.has(fromName) ? fromName : null);
if (!ext) {
return json(res, 415, { error: `Unsupported file type: ${type || file.filename}. Allowed: images, PDF, TXT, MD, CSV` });
}
if (ext === "pdf" && /scan/i.test(file.filename)) {
return json(res, 422, { error: "This PDF has no text layer (scanned?), so Simba can't read it yet." });
}
const key = `conversations/${conversationId}/files/${uuid()}.${ext}`;
blobs.set(key, { bytes: file.data, type, name: file.filename });
return json(res, 200, { kind: "file", file_key: key, name: file.filename, chars: ext === "pdf" ? 1834 : file.data.length });
}
const summary = (c) => ({ id: c.id, name: c.name, created_at: c.created_at, updated_at: c.updated_at });
async function api(req, res, url) {
const p = url.pathname.replace(/\/+$/, "") || "/";
const m = req.method;
const body = m === "GET" ? {} : await readBody(req);
const { form } = body;
requests.push({
method: m,
path: url.pathname + url.search,
body: form
? {
...form.fields,
files: Object.fromEntries(
Object.entries(form.files).map(([k, f]) => [k, { filename: f.filename, type: f.type, size: f.data.length }]),
),
}
: body,
});
if (p === "/api/user/me") {
return json(res, 200, { id: "u1", username: "ryan", email: "ryan@example.com", is_admin: true });
@@ -456,16 +569,18 @@ async function api(req, res, url) {
if (p.startsWith("/api/scheduled")) return json(res, 200, []);
if (p === "/api/conversation/stream-query" && m === "POST") {
return streamQuery(req, res, await readBody(req));
return streamQuery(req, res, body);
}
if (p === "/api/conversation/query" && m === "POST") {
return json(res, 200, { response: "meow (mock)" });
}
if (p === "/api/conversation/upload-image" || p === "/api/conversation/upload") {
return json(res, 200, { kind: "image", image_key: `mock/${uuid()}.png` });
if (p === "/api/conversation/upload" && m === "POST") return upload(res, form);
if (p === "/api/conversation/upload-image") {
return json(res, 200, { image_key: `mock/${uuid()}.png` });
}
if (p.startsWith("/api/conversation/image/")) {
return json(res, 200, { url: "/favicon.ico" });
const key = decodeURIComponent(p.slice("/api/conversation/image/".length));
return json(res, 200, { url: blobs.has(key) ? `/__mock/blob/${encodeURIComponent(key)}` : "/favicon.ico" });
}
if (p === "/api/conversation") {
if (m === "POST") return json(res, 200, summary(newConversation()));
@@ -490,7 +605,7 @@ async function api(req, res, url) {
return json(res, 200, { success: true });
}
if (m === "PATCH") {
c.name = (await readBody(req)).name ?? c.name;
c.name = body.name ?? c.name;
return json(res, 200, summary(c));
}
return json(res, 200, {
@@ -547,6 +662,13 @@ http
.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
try {
if (url.pathname === "/__mock/requests") return json(res, 200, requests);
if (url.pathname.startsWith("/__mock/blob/")) {
const blob = blobs.get(decodeURIComponent(url.pathname.slice("/__mock/blob/".length)));
if (!blob) return json(res, 404, { error: "no blob" });
res.writeHead(200, { "Content-Type": blob.type });
return res.end(blob.bytes);
}
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
return serveStatic(res, decodeURIComponent(url.pathname));
} catch (err) {