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:
@@ -51,6 +51,12 @@ interface Conversation {
|
|||||||
user_id?: string;
|
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 {
|
interface QueryResponse {
|
||||||
response: string;
|
response: string;
|
||||||
}
|
}
|
||||||
@@ -182,16 +188,16 @@ class ConversationService {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadImage(
|
async uploadAttachment(
|
||||||
file: File,
|
file: File,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<{ image_key: string }> {
|
): Promise<UploadResult> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("conversation_id", conversationId);
|
formData.append("conversation_id", conversationId);
|
||||||
|
|
||||||
const response = await userService.fetchWithAuth(
|
const response = await userService.fetchWithAuth(
|
||||||
`${this.conversationBaseUrl}/upload-image`,
|
`${this.conversationBaseUrl}/upload`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -200,8 +206,8 @@ class ConversationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Failed to upload image");
|
throw new UploadError(data.error || "Failed to upload file");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
@@ -224,11 +230,15 @@ class ConversationService {
|
|||||||
onEvent: SSEEventCallback,
|
onEvent: SSEEventCallback,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
imageKey?: string,
|
imageKey?: string,
|
||||||
|
fileKey?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const body: Record<string, string> = { query, conversation_id };
|
const body: Record<string, string> = { query, conversation_id };
|
||||||
if (imageKey) {
|
if (imageKey) {
|
||||||
body.image_key = imageKey;
|
body.image_key = imageKey;
|
||||||
}
|
}
|
||||||
|
if (fileKey) {
|
||||||
|
body.file_key = fileKey;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await userService.fetchWithAuth(
|
const response = await userService.fetchWithAuth(
|
||||||
`${this.conversationBaseUrl}/stream-query`,
|
`${this.conversationBaseUrl}/stream-query`,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AnswerBubble } from "./AnswerBubble";
|
|||||||
import { ActivityTimeline } from "./ActivityTimeline";
|
import { ActivityTimeline } from "./ActivityTimeline";
|
||||||
import { ThinkingBubble } from "./ThinkingBubble";
|
import { ThinkingBubble } from "./ThinkingBubble";
|
||||||
import { MessageInput } from "./MessageInput";
|
import { MessageInput } from "./MessageInput";
|
||||||
|
import { DropOverlay } from "./DropOverlay";
|
||||||
import { SidebarContent } from "./SidebarContent";
|
import { SidebarContent } from "./SidebarContent";
|
||||||
import { AdminPanel } from "./AdminPanel";
|
import { AdminPanel } from "./AdminPanel";
|
||||||
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
||||||
@@ -84,8 +85,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
|||||||
streaming,
|
streaming,
|
||||||
thinking,
|
thinking,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
pendingImage,
|
pendingAttachment,
|
||||||
setPendingImage,
|
attachFile,
|
||||||
|
clearAttachment,
|
||||||
|
attachmentError,
|
||||||
|
clearAttachmentError,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
error,
|
error,
|
||||||
@@ -141,9 +145,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
|||||||
[deleteConversation],
|
[deleteConversation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
|
|
||||||
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("access_token");
|
localStorage.removeItem("access_token");
|
||||||
localStorage.removeItem("refresh_token");
|
localStorage.removeItem("refresh_token");
|
||||||
@@ -223,7 +224,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
|||||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||||
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||||
@@ -302,9 +304,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
|||||||
onToggleSimbaMode={handleToggleSimbaMode}
|
onToggleSimbaMode={handleToggleSimbaMode}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onStop={stopGeneration}
|
onStop={stopGeneration}
|
||||||
pendingImage={pendingImage}
|
pendingAttachment={pendingAttachment}
|
||||||
onImageSelect={handleImageSelect}
|
onAttach={attachFile}
|
||||||
onClearImage={handleClearImage}
|
onClearAttachment={clearAttachment}
|
||||||
|
attachmentError={attachmentError}
|
||||||
|
onDismissAttachmentError={clearAttachmentError}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,9 +435,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
|||||||
onToggleSimbaMode={handleToggleSimbaMode}
|
onToggleSimbaMode={handleToggleSimbaMode}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onStop={stopGeneration}
|
onStop={stopGeneration}
|
||||||
pendingImage={pendingImage}
|
pendingAttachment={pendingAttachment}
|
||||||
onImageSelect={handleImageSelect}
|
onAttach={attachFile}
|
||||||
onClearImage={handleClearImage}
|
onClearAttachment={clearAttachment}
|
||||||
|
attachmentError={attachmentError}
|
||||||
|
onDismissAttachmentError={clearAttachmentError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from "react";
|
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 { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
ATTACHMENT_ACCEPT,
|
||||||
|
attachmentKind,
|
||||||
|
fileExtension,
|
||||||
|
formatBytes,
|
||||||
|
} from "../lib/attachments";
|
||||||
import { Textarea } from "./ui/textarea";
|
import { Textarea } from "./ui/textarea";
|
||||||
|
|
||||||
type MessageInputProps = {
|
type MessageInputProps = {
|
||||||
@@ -12,9 +18,11 @@ type MessageInputProps = {
|
|||||||
query: string;
|
query: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
pendingImage: File | null;
|
pendingAttachment: File | null;
|
||||||
onImageSelect: (file: File) => void;
|
onAttach: (file: File) => void;
|
||||||
onClearImage: () => void;
|
onClearAttachment: () => void;
|
||||||
|
attachmentError: string | null;
|
||||||
|
onDismissAttachmentError: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,15 +36,23 @@ export const MessageInput = React.memo(
|
|||||||
onToggleSimbaMode,
|
onToggleSimbaMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
onStop,
|
onStop,
|
||||||
pendingImage,
|
pendingAttachment,
|
||||||
onImageSelect,
|
onAttach,
|
||||||
onClearImage,
|
onClearAttachment,
|
||||||
|
attachmentError,
|
||||||
|
onDismissAttachmentError,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
}: MessageInputProps) => {
|
}: MessageInputProps) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(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(
|
const previewUrl = useMemo(
|
||||||
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
||||||
[pendingImage],
|
[pendingImage],
|
||||||
@@ -59,7 +75,7 @@ export const MessageInput = React.memo(
|
|||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
onImageSelect(file);
|
onAttach(file);
|
||||||
}
|
}
|
||||||
// Reset so the same file can be re-selected
|
// Reset so the same file can be re-selected
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
@@ -69,19 +85,21 @@ export const MessageInput = React.memo(
|
|||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
for (const item of items) {
|
for (const item of Array.from(items)) {
|
||||||
if (item.type.startsWith("image/")) {
|
if (item.type.startsWith("image/")) {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) {
|
if (file) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onImageSelect(file);
|
onAttach(file);
|
||||||
return;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -102,7 +120,8 @@ export const MessageInput = React.memo(
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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} />
|
<X size={12} />
|
||||||
@@ -111,6 +130,56 @@ export const MessageInput = React.memo(
|
|||||||
</div>
|
</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 */}
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -128,7 +197,7 @@ export const MessageInput = React.memo(
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept={ATTACHMENT_ACCEPT}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
@@ -150,11 +219,13 @@ export const MessageInput = React.memo(
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Image attach button */}
|
{/* Attach button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
aria-label="Attach a file"
|
||||||
|
title="Attach an image, PDF, TXT, MD or CSV"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
|
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
|
||||||
isLoading
|
isLoading
|
||||||
@@ -162,7 +233,7 @@ export const MessageInput = React.memo(
|
|||||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ImagePlus size={16} />
|
<Paperclip size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { FileSpreadsheet, FileText } from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { fileExtension, splitAttachedFile } from "../lib/attachments";
|
||||||
import { usePresignedUrl } from "../hooks/usePresignedUrl";
|
import { usePresignedUrl } from "../hooks/usePresignedUrl";
|
||||||
|
|
||||||
type QuestionBubbleProps = {
|
type QuestionBubbleProps = {
|
||||||
@@ -9,6 +11,8 @@ type QuestionBubbleProps = {
|
|||||||
|
|
||||||
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
|
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
|
||||||
const { imageUrl, imageError } = usePresignedUrl(image_key);
|
const { imageUrl, imageError } = usePresignedUrl(image_key);
|
||||||
|
const { body, fileName } = splitAttachedFile(text);
|
||||||
|
const FileIcon = fileName && fileExtension(fileName) === "csv" ? FileSpreadsheet : FileText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end message-enter">
|
<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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
|||||||
import { useMatch } from "react-router-dom";
|
import { useMatch } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
conversationService,
|
conversationService,
|
||||||
|
UploadError,
|
||||||
type ToolInput,
|
type ToolInput,
|
||||||
} from "../api/conversationService";
|
} from "../api/conversationService";
|
||||||
import type { ArtifactRef } from "../api/artifactTypes";
|
import type { ArtifactRef } from "../api/artifactTypes";
|
||||||
|
import { validateAttachment, withAttachedFile } from "../lib/attachments";
|
||||||
import type { Conversation } from "./useConversations";
|
import type { Conversation } from "./useConversations";
|
||||||
|
|
||||||
export type ToolStatus = "running" | "ok" | "error";
|
export type ToolStatus = "running" | "ok" | "error";
|
||||||
@@ -63,7 +65,9 @@ export function useChat({
|
|||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [messagesLoading, setMessagesLoading] = 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 [error, setError] = useState<string | null>(null);
|
||||||
const [stalled, setStalled] = useState(false);
|
const [stalled, setStalled] = useState(false);
|
||||||
const [streaming, setStreaming] = useState(false);
|
const [streaming, setStreaming] = useState(false);
|
||||||
@@ -164,7 +168,7 @@ export function useChat({
|
|||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (query: string, simbaMode: boolean) => {
|
async (query: string, simbaMode: boolean) => {
|
||||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
if ((!query.trim() && !pendingAttachment) || isLoading) return;
|
||||||
|
|
||||||
let activeId = conversationId;
|
let activeId = conversationId;
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
@@ -174,7 +178,7 @@ export function useChat({
|
|||||||
activeId = created.id;
|
activeId = created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageFile = pendingImage;
|
const attachment = pendingAttachment;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setStalled(false);
|
setStalled(false);
|
||||||
@@ -185,7 +189,8 @@ export function useChat({
|
|||||||
streamingActiveRef.current = false;
|
streamingActiveRef.current = false;
|
||||||
setStreaming(false);
|
setStreaming(false);
|
||||||
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
|
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
|
||||||
setPendingImage(null);
|
setPendingAttachment(null);
|
||||||
|
setAttachmentError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (simbaMode) {
|
if (simbaMode) {
|
||||||
@@ -203,19 +208,27 @@ export function useChat({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let imageKey: string | undefined;
|
let imageKey: string | undefined;
|
||||||
if (imageFile) {
|
let fileKey: string | undefined;
|
||||||
const uploadResult = await conversationService.uploadImage(
|
if (attachment) {
|
||||||
imageFile,
|
const uploadResult = await conversationService.uploadAttachment(
|
||||||
|
attachment,
|
||||||
activeId,
|
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) => {
|
updateMessages((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
for (let i = updated.length - 1; i >= 0; i--) {
|
for (let i = updated.length - 1; i >= 0; i--) {
|
||||||
const msg = updated[i];
|
const msg = updated[i];
|
||||||
if (msg.speaker === "user") {
|
if (msg.speaker === "user") {
|
||||||
updated[i] = { ...msg, image_key: imageKey };
|
updated[i] = { ...msg, ...patch };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,10 +355,13 @@ export function useChat({
|
|||||||
},
|
},
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
imageKey,
|
imageKey,
|
||||||
|
fileKey,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
// User cancelled — not an error to surface.
|
// User cancelled — not an error to surface.
|
||||||
|
} else if (err instanceof UploadError) {
|
||||||
|
if (isMountedRef.current) setError(`Couldn't attach that file: ${err.message}`);
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to send query:", err);
|
console.error("Failed to send query:", err);
|
||||||
if (
|
if (
|
||||||
@@ -383,7 +399,7 @@ export function useChat({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
pendingImage,
|
pendingAttachment,
|
||||||
isLoading,
|
isLoading,
|
||||||
conversationId,
|
conversationId,
|
||||||
createConversation,
|
createConversation,
|
||||||
@@ -400,6 +416,19 @@ export function useChat({
|
|||||||
|
|
||||||
const clearError = useCallback(() => setError(null), []);
|
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 retryStalled = useCallback(() => {
|
||||||
const q = stalledQueryRef.current;
|
const q = stalledQueryRef.current;
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
@@ -415,8 +444,11 @@ export function useChat({
|
|||||||
streaming,
|
streaming,
|
||||||
thinking,
|
thinking,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
pendingImage,
|
pendingAttachment,
|
||||||
setPendingImage,
|
attachFile,
|
||||||
|
clearAttachment,
|
||||||
|
attachmentError,
|
||||||
|
clearAttachmentError,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -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"
|
// (all v1 kinds) | "broken" (GET /api/artifacts/<id> 500s) | "mystery"
|
||||||
// (unknown kind → JSON fallback)
|
// (unknown kind → JSON fallback)
|
||||||
// anything else: two sequential calls, then a streamed answer
|
// anything else: two sequential calls, then a streamed answer
|
||||||
// Conversations "seeded" (timeline) and "everything" (one saved tool message
|
// any query sent with a file_key: reads the attached file (by name)
|
||||||
// per artifact kind) exist up front.
|
// 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
|
// artifacts/<kind>.json holds one ToolArtifact per v1 kind, matching what the
|
||||||
// backend tools emit. Any other *.json in this directory with `kind` and
|
// 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).
|
// `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();
|
const artifacts = new Map();
|
||||||
/** @type {Map<string, {id, name, created_at, updated_at, messages: any[]}>} */
|
/** @type {Map<string, {id, name, created_at, updated_at, messages: any[]}>} */
|
||||||
const conversations = new Map();
|
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) {
|
function addArtifact(conversationId, tool, a) {
|
||||||
const record = {
|
const record = {
|
||||||
@@ -278,8 +290,15 @@ const LABELS = {
|
|||||||
};
|
};
|
||||||
const label = (tool) => LABELS[tool] ?? `Using ${tool}…`;
|
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();
|
seed();
|
||||||
seedEverything();
|
seedEverything();
|
||||||
|
seedAttachment();
|
||||||
loadFixtureArtifacts();
|
loadFixtureArtifacts();
|
||||||
|
|
||||||
/** Artifact-panel scenarios: one tool call per artifact, by keyword. */
|
/** 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
|
* Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded
|
||||||
* into contract-shaped events; ctx persists tool messages and artifacts.
|
* into contract-shaped events; ctx persists tool messages and artifacts.
|
||||||
*/
|
*/
|
||||||
function scenario(query) {
|
function scenario(query, file) {
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
const answer = (text) =>
|
const answer = (text) =>
|
||||||
text.match(/\S+\s*/g).map((w) => [60, { type: "content", delta: w }]);
|
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")) {
|
if (q.includes("plain")) {
|
||||||
return [[300, { type: "thinking" }], ...answer("Meow. Nothing to look up for that one.")];
|
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) {
|
async function streamQuery(req, res, body) {
|
||||||
const c = conversations.get(body.conversation_id) ?? newConversation(body.conversation_id);
|
const c = conversations.get(body.conversation_id) ?? newConversation(body.conversation_id);
|
||||||
pushMessage(c, { speaker: "user", text: body.query, image_key: body.image_key ?? null });
|
let saved = body.query ?? "";
|
||||||
if (c.name === "New Conversation") c.name = body.query.slice(0, 40);
|
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, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
@@ -377,7 +414,7 @@ async function streamQuery(req, res, body) {
|
|||||||
|
|
||||||
const runs = new Map(); // key -> {run_id, tool, started}
|
const runs = new Map(); // key -> {run_id, tool, started}
|
||||||
let text = "";
|
let text = "";
|
||||||
for (const [delay, step] of scenario(body.query)) {
|
for (const [delay, step] of scenario(body.query ?? "", file)) {
|
||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
if (step.start) {
|
if (step.start) {
|
||||||
@@ -428,24 +465,100 @@ const json = (res, status, body) => {
|
|||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
};
|
};
|
||||||
|
|
||||||
const readBody = (req) =>
|
async function readRaw(req) {
|
||||||
new Promise((resolve) => {
|
const chunks = [];
|
||||||
let raw = "";
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
req.on("data", (chunk) => (raw += chunk));
|
return Buffer.concat(chunks);
|
||||||
req.on("end", () => {
|
}
|
||||||
try {
|
|
||||||
resolve(raw ? JSON.parse(raw) : {});
|
/** Minimal multipart/form-data parser: {fields, files: {name: {filename, type, data}}}. */
|
||||||
} catch {
|
function parseMultipart(buf, contentType) {
|
||||||
resolve({});
|
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 });
|
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) {
|
async function api(req, res, url) {
|
||||||
const p = url.pathname.replace(/\/+$/, "") || "/";
|
const p = url.pathname.replace(/\/+$/, "") || "/";
|
||||||
const m = req.method;
|
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") {
|
if (p === "/api/user/me") {
|
||||||
return json(res, 200, { id: "u1", username: "ryan", email: "ryan@example.com", is_admin: true });
|
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.startsWith("/api/scheduled")) return json(res, 200, []);
|
||||||
|
|
||||||
if (p === "/api/conversation/stream-query" && m === "POST") {
|
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") {
|
if (p === "/api/conversation/query" && m === "POST") {
|
||||||
return json(res, 200, { response: "meow (mock)" });
|
return json(res, 200, { response: "meow (mock)" });
|
||||||
}
|
}
|
||||||
if (p === "/api/conversation/upload-image" || p === "/api/conversation/upload") {
|
if (p === "/api/conversation/upload" && m === "POST") return upload(res, form);
|
||||||
return json(res, 200, { kind: "image", image_key: `mock/${uuid()}.png` });
|
if (p === "/api/conversation/upload-image") {
|
||||||
|
return json(res, 200, { image_key: `mock/${uuid()}.png` });
|
||||||
}
|
}
|
||||||
if (p.startsWith("/api/conversation/image/")) {
|
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 (p === "/api/conversation") {
|
||||||
if (m === "POST") return json(res, 200, summary(newConversation()));
|
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 });
|
return json(res, 200, { success: true });
|
||||||
}
|
}
|
||||||
if (m === "PATCH") {
|
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, summary(c));
|
||||||
}
|
}
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
@@ -547,6 +662,13 @@ http
|
|||||||
.createServer(async (req, res) => {
|
.createServer(async (req, res) => {
|
||||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
try {
|
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);
|
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
|
||||||
return serveStatic(res, decodeURIComponent(url.pathname));
|
return serveStatic(res, decodeURIComponent(url.pathname));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user