adds attachment support
This commit is contained in:
359
web/src/App.tsx
359
web/src/App.tsx
@@ -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>
|
||||
|
||||
103
web/src/components/chat/chat-attachment-list.tsx
Normal file
103
web/src/components/chat/chat-attachment-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user