adds attachment support

This commit is contained in:
2026-05-02 19:21:06 -07:00
parent 11e6875de9
commit 38da3cea72
15 changed files with 949 additions and 67 deletions

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { AuthScreen } from "@/components/auth/auth-screen";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
import {
@@ -20,6 +21,8 @@ import {
runCompletionStream,
runSearchStream,
suggestChatTitle,
getMessageAttachments,
type ChatAttachment,
type ModelCatalogResponse,
type Provider,
type ChatDetail,
@@ -102,6 +105,65 @@ const TRANSCRIPT_BOTTOM_GAP = 20;
const REPLY_SCROLL_BUFFER_MIN = 288;
const REPLY_SCROLL_BUFFER_MAX = 576;
const REPLY_SCROLL_BUFFER_VIEWPORT_RATIO = 0.52;
const MAX_CHAT_ATTACHMENTS = 8;
const MAX_IMAGE_ATTACHMENT_BYTES = 6 * 1024 * 1024;
const MAX_TEXT_ATTACHMENT_BYTES = 8 * 1024 * 1024;
const MAX_TEXT_ATTACHMENT_CHARS = 200_000;
const CHAT_FILE_ACCEPT =
".png,.jpg,.jpeg,.txt,.md,.markdown,.csv,.tsv,.json,.jsonl,.xml,.yaml,.yml,.html,.htm,.css,.js,.jsx,.ts,.tsx,.py,.rb,.java,.c,.cc,.cpp,.h,.hpp,.go,.rs,.sh,.sql,.log,.toml,.ini,.cfg,.conf,.swift,.kt,.m,.mm";
const TEXT_ATTACHMENT_EXTENSIONS = new Set([
".txt",
".md",
".markdown",
".csv",
".tsv",
".json",
".jsonl",
".xml",
".yaml",
".yml",
".html",
".htm",
".css",
".js",
".jsx",
".ts",
".tsx",
".py",
".rb",
".java",
".c",
".cc",
".cpp",
".h",
".hpp",
".go",
".rs",
".sh",
".sql",
".log",
".toml",
".ini",
".cfg",
".conf",
".swift",
".kt",
".m",
".mm",
]);
const TEXT_ATTACHMENT_MIME_TYPES = new Set([
"application/json",
"application/ld+json",
"application/sql",
"application/toml",
"application/x-httpd-php",
"application/x-javascript",
"application/x-sh",
"application/xml",
"application/yaml",
"application/x-yaml",
"image/svg+xml",
]);
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
const providerModels = catalog[provider]?.models ?? [];
@@ -117,6 +179,103 @@ function getReplyScrollBufferHeight() {
);
}
function getFileExtension(filename: string) {
const index = filename.lastIndexOf(".");
return index >= 0 ? filename.slice(index).toLowerCase() : "";
}
function createAttachmentId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function inferImageMimeType(file: File) {
if (file.type === "image/png" || file.type === "image/jpeg") return file.type;
const extension = getFileExtension(file.name);
if (extension === ".png") return "image/png";
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
return null;
}
function isTextLikeFile(file: File) {
const mimeType = file.type.toLowerCase();
if (mimeType.startsWith("text/")) return true;
if (TEXT_ATTACHMENT_MIME_TYPES.has(mimeType)) return true;
return TEXT_ATTACHMENT_EXTENSIONS.has(getFileExtension(file.name));
}
function arrayBufferToBase64(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = "";
for (let index = 0; index < bytes.length; index += chunkSize) {
const chunk = bytes.subarray(index, index + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
async function buildChatAttachment(file: File): Promise<ChatAttachment> {
const imageMimeType = inferImageMimeType(file);
if (imageMimeType) {
if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
throw new Error(`Image '${file.name}' exceeds the 6 MB upload limit.`);
}
const base64 = arrayBufferToBase64(await file.arrayBuffer());
return {
kind: "image",
id: createAttachmentId(),
filename: file.name,
mimeType: imageMimeType,
sizeBytes: file.size,
dataUrl: `data:${imageMimeType};base64,${base64}`,
};
}
if (!isTextLikeFile(file)) {
throw new Error(`Unsupported file type for '${file.name}'. Use PNG/JPEG images or text-based files.`);
}
if (file.size > MAX_TEXT_ATTACHMENT_BYTES) {
throw new Error(`Text file '${file.name}' exceeds the 8 MB upload limit.`);
}
const normalizedText = (await file.text()).replace(/\r\n/g, "\n").replace(/\u0000/g, "");
const truncated = normalizedText.length > MAX_TEXT_ATTACHMENT_CHARS;
return {
kind: "text",
id: createAttachmentId(),
filename: file.name,
mimeType: file.type || "text/plain",
sizeBytes: file.size,
text: truncated ? normalizedText.slice(0, MAX_TEXT_ATTACHMENT_CHARS) : normalizedText,
truncated,
};
}
function buildAttachmentSummary(attachments: ChatAttachment[]) {
if (!attachments.length) return "";
const filenames = attachments.map((attachment) => attachment.filename).join(", ");
return attachments.length === 1 ? filenames : `Attached: ${filenames}`;
}
function getFilesFromDataTransfer(dataTransfer: DataTransfer | null) {
if (!dataTransfer) return [];
const fromItems = Array.from(dataTransfer.items ?? [])
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file instanceof File);
if (fromItems.length) return fromItems;
return Array.from(dataTransfer.files ?? []);
}
function hasFileTransfer(dataTransfer: DataTransfer | null) {
if (!dataTransfer) return false;
return Array.from(dataTransfer.types ?? []).includes("Files") || getFilesFromDataTransfer(dataTransfer).length > 0;
}
function loadStoredModelPreferences() {
if (typeof window === "undefined") return EMPTY_MODEL_PREFERENCES;
try {
@@ -347,8 +506,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) {
if (chat.title?.trim()) return chat.title.trim();
const firstUserMessage = messages?.find((m) => m.role === "user")?.content.trim();
if (firstUserMessage) return firstUserMessage.slice(0, 48);
const firstUserMessage = messages?.find((message) => message.role === "user");
const firstUserText = firstUserMessage?.content.trim();
if (firstUserText) return firstUserText.slice(0, 48);
const firstUserAttachments = firstUserMessage ? getMessageAttachments(firstUserMessage.metadata) : [];
const attachmentSummary = buildAttachmentSummary(firstUserAttachments);
if (attachmentSummary) return attachmentSummary.slice(0, 48);
return "New chat";
}
@@ -448,6 +611,8 @@ export default function App() {
const [isStartingSearchChat, setIsStartingSearchChat] = useState(false);
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
const [composer, setComposer] = useState("");
const [pendingAttachments, setPendingAttachments] = useState<ChatAttachment[]>([]);
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
const [provider, setProvider] = useState<Provider>("openai");
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
@@ -460,6 +625,9 @@ export default function App() {
const transcriptContainerRef = useRef<HTMLDivElement>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
const selectedItemRef = useRef<SidebarSelection | null>(null);
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
const searchRunAbortRef = useRef<AbortController | null>(null);
@@ -518,6 +686,10 @@ export default function App() {
textarea.style.height = `${textarea.scrollHeight}px`;
}, [composer]);
useEffect(() => {
pendingAttachmentsRef.current = pendingAttachments;
}, [pendingAttachments]);
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
const filteredSidebarItems = useMemo(() => {
const query = sidebarQuery.trim().toLowerCase();
@@ -540,6 +712,7 @@ export default function App() {
setDraftKind(null);
setPendingChatState(null);
setComposer("");
setPendingAttachments([]);
setError(null);
};
@@ -767,6 +940,16 @@ export default function App() {
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
const isSearchRunning = isSending && isSearchMode;
const isSendingActiveChat = isChatReplyStreamingInView;
useEffect(() => {
if (isSearchMode && pendingAttachments.length) {
setPendingAttachments([]);
}
if (isSearchMode) {
dragDepthRef.current = 0;
setIsComposerDropActive(false);
}
}, [isSearchMode, pendingAttachments.length]);
const displayMessages = useMemo(() => {
if (!pendingChatState) return messages.filter(isDisplayableMessage);
if (pendingChatState.chatId) {
@@ -837,6 +1020,7 @@ export default function App() {
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
setPendingAttachments([]);
setIsMobileSidebarOpen(false);
};
@@ -847,6 +1031,7 @@ export default function App() {
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
setPendingAttachments([]);
setIsMobileSidebarOpen(false);
};
@@ -899,7 +1084,88 @@ export default function App() {
};
}, [contextMenu]);
const handleSendChat = async (content: string) => {
const handleOpenAttachmentPicker = () => {
fileInputRef.current?.click();
};
const handleRemovePendingAttachment = (attachmentId: string) => {
setPendingAttachments((current) => current.filter((attachment) => attachment.id !== attachmentId));
};
const appendPendingAttachments = async (files: File[]) => {
if (!files.length) return;
if (isSearchMode) {
setError("Attachments are only available in chat mode.");
return;
}
setError(null);
try {
const attachments = await Promise.all(files.map((file) => buildChatAttachment(file)));
if (pendingAttachmentsRef.current.length + attachments.length > MAX_CHAT_ATTACHMENTS) {
throw new Error(`You can attach up to ${MAX_CHAT_ATTACHMENTS} files per message.`);
}
setPendingAttachments((current) => current.concat(attachments));
focusComposer();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
}
};
const handleFileSelection = async (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = "";
await appendPendingAttachments(files);
};
const handleComposerPaste = async (event: ClipboardEvent) => {
const files = getFilesFromDataTransfer(event.clipboardData);
if (!files.length) return;
event.preventDefault();
await appendPendingAttachments(files);
};
const handleComposerDragEnter = (event: DragEvent) => {
if (!hasFileTransfer(event.dataTransfer)) return;
event.preventDefault();
if (isSearchMode) return;
dragDepthRef.current += 1;
setIsComposerDropActive(true);
};
const handleComposerDragOver = (event: DragEvent) => {
if (!hasFileTransfer(event.dataTransfer)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = isSearchMode ? "none" : "copy";
}
if (!isSearchMode) {
setIsComposerDropActive(true);
}
};
const handleComposerDragLeave = (event: DragEvent) => {
if (!hasFileTransfer(event.dataTransfer)) return;
event.preventDefault();
if (isSearchMode) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsComposerDropActive(false);
}
};
const handleComposerDrop = async (event: DragEvent) => {
if (!hasFileTransfer(event.dataTransfer)) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsComposerDropActive(false);
await appendPendingAttachments(getFilesFromDataTransfer(event.dataTransfer));
};
const handleSendChat = async (content: string, attachments: ChatAttachment[]) => {
pendingReplyScrollRef.current = true;
expandTranscriptTailSpacer(getReplyScrollBufferHeight());
@@ -909,7 +1175,7 @@ export default function App() {
role: "user",
content,
name: null,
metadata: null,
metadata: attachments.length ? { attachments } : null,
};
const optimisticAssistantMessage: Message = {
@@ -965,13 +1231,15 @@ export default function App() {
...baseChat.messages
.filter((message) => !isToolCallLogMessage(message))
.map((message) => ({
role: message.role,
content: message.content,
...(message.name ? { name: message.name } : {}),
role: message.role,
content: message.content,
...(message.name ? { name: message.name } : {}),
...(getMessageAttachments(message.metadata).length ? { attachments: getMessageAttachments(message.metadata) } : {}),
})),
{
role: "user",
content,
...(attachments.length ? { attachments } : {}),
},
];
@@ -984,7 +1252,8 @@ export default function App() {
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
pendingTitleGenerationRef.current.add(chatId);
void suggestChatTitle({ chatId, content })
const titleSeed = content || buildAttachmentSummary(attachments) || "Uploaded files";
void suggestChatTitle({ chatId, content: titleSeed })
.then((updatedChat) => {
setChats((current) =>
current.map((chat) => {
@@ -1232,6 +1501,7 @@ export default function App() {
setDraftKind(null);
setPendingChatState(null);
setComposer("");
setPendingAttachments([]);
setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting];
@@ -1265,9 +1535,15 @@ export default function App() {
const handleSend = async () => {
const content = composer.trim();
if (!content || isSending) return;
const attachments = pendingAttachments;
if ((!content && !attachments.length) || isSending) return;
if (isSearchMode && attachments.length) {
setError("Attachments are only available in chat mode.");
return;
}
setComposer("");
setPendingAttachments([]);
setError(null);
setIsSending(true);
@@ -1275,7 +1551,7 @@ export default function App() {
if (isSearchMode) {
await handleSendSearch(content);
} else {
await handleSendChat(content);
await handleSendChat(content, attachments);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -1286,6 +1562,8 @@ export default function App() {
}
if (!isSearchMode) {
setComposer(content);
setPendingAttachments(attachments);
setPendingChatState(null);
}
@@ -1519,7 +1797,42 @@ export default function App() {
</div>
<footer className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20">
<div className="pointer-events-auto mx-auto max-w-4xl rounded-2xl border border-violet-300/30 bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20">
<div
className={cn(
"pointer-events-auto mx-auto max-w-4xl rounded-2xl border bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20 transition",
isComposerDropActive
? "border-cyan-300/70 shadow-cyan-500/20"
: "border-violet-300/30"
)}
onDragEnter={handleComposerDragEnter}
onDragOver={handleComposerDragOver}
onDragLeave={handleComposerDragLeave}
onDrop={(event) => {
void handleComposerDrop(event);
}}
>
<input
ref={fileInputRef}
type="file"
multiple
accept={CHAT_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
void handleFileSelection(event);
}}
/>
{!isSearchMode && pendingAttachments.length ? (
<div className="px-2 pb-2 pt-1">
<ChatAttachmentList attachments={pendingAttachments} onRemove={handleRemovePendingAttachment} />
</div>
) : null}
{!isSearchMode && isComposerDropActive ? (
<div className="px-3 pb-2">
<div className="rounded-xl border border-dashed border-cyan-300/55 bg-cyan-300/8 px-4 py-3 text-sm text-cyan-100">
Drop files to attach them
</div>
</div>
) : null}
<Textarea
id="composer-input"
rows={1}
@@ -1530,6 +1843,9 @@ export default function App() {
textarea.style.height = `${textarea.scrollHeight}px`;
setComposer(textarea.value);
}}
onPaste={(event) => {
void handleComposerPaste(event);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
@@ -1542,7 +1858,24 @@ export default function App() {
/>
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
<Button className="h-10 w-10 rounded-lg" onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
{!isSearchMode ? (
<Button
className="h-10 w-10 rounded-lg"
onClick={handleOpenAttachmentPicker}
size="icon"
variant="secondary"
disabled={isSending || pendingAttachments.length >= MAX_CHAT_ATTACHMENTS}
aria-label="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
) : null}
<Button
className="h-10 w-10 rounded-lg"
onClick={() => void handleSend()}
size="icon"
disabled={isSending || (!composer.trim() && !pendingAttachments.length)}
>
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
</Button>
</div>

View File

@@ -0,0 +1,103 @@
import { FileText, Image as ImageIcon, X } from "lucide-preact";
import type { ChatAttachment } from "@/lib/api";
import { cn } from "@/lib/utils";
type Props = {
attachments: ChatAttachment[];
tone?: "composer" | "user" | "assistant";
onRemove?: (id: string) => void;
};
function getTextPreview(value: string) {
const normalized = value.replace(/\r/g, "").trim();
if (!normalized) return "(empty file)";
return normalized.length <= 280 ? normalized : `${normalized.slice(0, 280).trimEnd()}...`;
}
function getSurfaceClasses(tone: Props["tone"]) {
if (tone === "user") {
return "border-white/12 bg-black/16 text-fuchsia-50";
}
if (tone === "assistant") {
return "border-violet-300/16 bg-violet-400/8 text-violet-50";
}
return "border-violet-300/18 bg-background/40 text-violet-50";
}
export function ChatAttachmentList({ attachments, tone = "composer", onRemove }: Props) {
if (!attachments.length) return null;
const surfaceClasses = getSurfaceClasses(tone);
return (
<div className="space-y-2">
{attachments.map((attachment) => {
const isImage = attachment.kind === "image";
return (
<div key={attachment.id} className={cn("overflow-hidden rounded-xl border", surfaceClasses)}>
{isImage ? (
<div className="grid gap-0 md:grid-cols-[minmax(0,220px)_minmax(0,1fr)]">
<div className="border-b border-white/10 bg-black/10 md:border-b-0 md:border-r">
<img src={attachment.dataUrl} alt={attachment.filename} className="block max-h-56 w-full object-cover" />
</div>
<div className="flex min-w-0 flex-col gap-2 p-3">
<div className="flex items-start gap-2">
<span className="mt-0.5 rounded-md border border-white/12 bg-white/5 p-1.5">
<ImageIcon className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.filename}</p>
<p className="text-xs text-muted-foreground">{attachment.mimeType}</p>
</div>
{onRemove ? (
<button
type="button"
className="rounded-md border border-white/10 p-1 text-muted-foreground transition hover:bg-white/8 hover:text-foreground"
onClick={() => onRemove(attachment.id)}
aria-label={`Remove ${attachment.filename}`}
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</div>
</div>
) : (
<div className="p-3">
<div className="flex items-start gap-2">
<span className="mt-0.5 rounded-md border border-white/12 bg-white/5 p-1.5">
<FileText className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.filename}</p>
<p className="text-xs text-muted-foreground">
{attachment.mimeType}
{attachment.truncated ? " · truncated" : ""}
</p>
</div>
{onRemove ? (
<button
type="button"
className="rounded-md border border-white/10 p-1 text-muted-foreground transition hover:bg-white/8 hover:text-foreground"
onClick={() => onRemove(attachment.id)}
aria-label={`Remove ${attachment.filename}`}
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<pre className="mt-2 overflow-x-auto rounded-lg border border-white/8 bg-black/16 p-3 text-xs leading-5 text-inherit whitespace-pre-wrap">
{getTextPreview(attachment.text)}
</pre>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils";
import type { Message } from "@/lib/api";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { getMessageAttachments, type Message } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { Globe2, Link2, Wrench } from "lucide-preact";
@@ -68,28 +69,30 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const isUser = message.role === "user";
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
const attachments = getMessageAttachments(message.metadata);
return (
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%]",
"max-w-[85%] space-y-3",
isUser
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50"
)}
>
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
{isPendingAssistant ? (
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
</span>
) : (
) : message.content.trim() ? (
<MarkdownContent
markdown={message.content}
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
/>
)}
) : null}
</div>
</div>
);

View File

@@ -90,6 +90,27 @@ export type SearchDetail = {
results: SearchResultItem[];
};
export type ChatImageAttachment = {
kind: "image";
id: string;
filename: string;
mimeType: "image/png" | "image/jpeg";
sizeBytes: number;
dataUrl: string;
};
export type ChatTextAttachment = {
kind: "text";
id: string;
filename: string;
mimeType: string;
sizeBytes: number;
text: string;
truncated?: boolean;
};
export type ChatAttachment = ChatImageAttachment | ChatTextAttachment;
export type SearchRunRequest = {
query?: string;
title?: string;
@@ -103,6 +124,7 @@ export type CompletionRequestMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
attachments?: ChatAttachment[];
};
export type Provider = "openai" | "anthropic" | "xai";
@@ -251,6 +273,49 @@ export async function deleteSearch(searchId: string) {
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
}
export function getMessageAttachments(metadata: unknown): ChatAttachment[] {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
const attachments = (metadata as Record<string, unknown>).attachments;
if (!Array.isArray(attachments)) return [];
const parsed: ChatAttachment[] = [];
for (const entry of attachments) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const record = entry as Record<string, unknown>;
const kind = record.kind;
const id = typeof record.id === "string" ? record.id : "";
const filename = typeof record.filename === "string" ? record.filename : "";
const mimeType = typeof record.mimeType === "string" ? record.mimeType : "";
const sizeBytes = typeof record.sizeBytes === "number" ? record.sizeBytes : 0;
if (kind === "image" && typeof record.dataUrl === "string" && (mimeType === "image/png" || mimeType === "image/jpeg")) {
parsed.push({
kind,
id,
filename,
mimeType,
sizeBytes,
dataUrl: record.dataUrl,
} satisfies ChatImageAttachment);
continue;
}
if (kind === "text" && typeof record.text === "string") {
parsed.push({
kind,
id,
filename,
mimeType,
sizeBytes,
text: record.text,
truncated: record.truncated === true,
} satisfies ChatTextAttachment);
}
}
return parsed;
}
type RunSearchStreamHandlers = {
onSearchResults?: (payload: { requestId: string | null; results: SearchResultItem[] }) => void;
onSearchError?: (payload: { error: string }) => void;

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}