diff --git a/app/(main)/chat/page.tsx b/app/(main)/chat/page.tsx index 3d1e651..bda1634 100644 --- a/app/(main)/chat/page.tsx +++ b/app/(main)/chat/page.tsx @@ -16,8 +16,8 @@ import { ChatEmptyState, ChatInput, ChatMessageList, + VoiceInput, } from "@/app/components/chat"; -import VoiceInput from "@/app/components/chat/VoiceInput"; import { useConfigs } from "@/app/hooks"; import { useVoiceChat } from "@/app/hooks/useVoiceChat"; import { @@ -33,6 +33,7 @@ import { ChatMessage, LLMCallRequest, LLMInput, + LLMStructuredInput, SendInput, } from "@/app/lib/types/chat"; import { SavedConfig } from "@/app/lib/types/configs"; @@ -45,7 +46,7 @@ function genId() { } function buildUserMessage(input: SendInput): ChatMessage { - return { + const base: ChatMessage = { id: genId(), role: "user", content: @@ -56,18 +57,49 @@ function buildUserMessage(input: SendInput): ChatMessage { status: "complete", isVoice: input.kind === "audio", }; + if (input.kind === "text" && input.attachments?.length) { + base.attachments = input.attachments.map((a) => ({ + kind: a.kind, + name: a.name, + mimeType: a.mimeType, + previewUrl: `data:${a.mimeType};base64,${a.base64}`, + })); + } + return base; } function buildLLMInput(input: SendInput): LLMInput { - if (input.kind === "text") return input.text.trim(); - return { - type: "audio", - content: { - format: "base64", - value: input.base64, - mime_type: input.mimeType, - }, - }; + if (input.kind === "audio") { + return { + type: "audio", + content: { + format: "base64", + value: input.base64, + mime_type: input.mimeType, + }, + }; + } + const text = input.text.trim(); + const files = input.attachments ?? []; + if (files.length === 0) return text; + const items: LLMStructuredInput[] = []; + if (text) { + items.push({ + type: "text", + content: { format: "text", value: text }, + }); + } + for (const a of files) { + items.push({ + type: a.kind, + content: { + format: "base64", + value: a.base64, + mime_type: a.mimeType, + }, + }); + } + return items; } function buildPayload( @@ -180,6 +212,11 @@ export default function ChatPage() { return () => abortRef.current?.abort(); }, []); + useEffect(() => { + if (!configId || !configVersion) return; + void loadSingleVersion(configId, configVersion); + }, [configId, configVersion, loadSingleVersion]); + const handleNewChat = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; @@ -201,7 +238,13 @@ export default function ChatPage() { const sendMessage = useCallback( async (input: SendInput): Promise => { - if (input.kind === "text" && !input.text.trim()) return null; + if ( + input.kind === "text" && + !input.text.trim() && + !input.attachments?.length + ) { + return null; + } if (!isAuthenticated) { setShowLoginModal(true); @@ -459,7 +502,10 @@ export default function ChatPage() { sendMessage({ kind: "text", text: draft })} + onSend={(attachments) => + sendMessage({ kind: "text", text: draft, attachments }) + } + onAttachmentError={(msg) => toast.error(msg)} isPending={isPending} onStartVoice={handleStartVoice} voiceConfigReady={hasConfig ? voiceConfigReady : undefined} diff --git a/app/components/chat/AttachmentChip.tsx b/app/components/chat/AttachmentChip.tsx new file mode 100644 index 0000000..e07ca8e --- /dev/null +++ b/app/components/chat/AttachmentChip.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { MouseEvent } from "react"; +import { CloseIcon, DocumentFileIcon } from "@/app/components/icons"; + +export interface AttachmentChipFile { + id: string; + kind: "image" | "pdf"; + name: string; + mimeType: string; + previewUrl: string; +} + +interface AttachmentChipProps { + file: AttachmentChipFile; + onRemove: () => void; + onPreview: () => void; +} + +export default function AttachmentChip({ + file, + onRemove, + onPreview, +}: AttachmentChipProps) { + const handleRemove = (e: MouseEvent) => { + e.stopPropagation(); + onRemove(); + }; + if (file.kind === "image") { + return ( +
+ + +
+ ); + } + return ( +
+ + +
+ ); +} diff --git a/app/components/chat/AttachmentPreviewModal.tsx b/app/components/chat/AttachmentPreviewModal.tsx new file mode 100644 index 0000000..3559854 --- /dev/null +++ b/app/components/chat/AttachmentPreviewModal.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Modal } from "@/app/components/ui"; + +export interface PreviewableAttachment { + kind: "image" | "pdf"; + name: string; + mimeType: string; + previewUrl?: string; +} + +interface AttachmentPreviewModalProps { + attachment: PreviewableAttachment | null; + onClose: () => void; +} + +export default function AttachmentPreviewModal({ + attachment, + onClose, +}: AttachmentPreviewModalProps) { + const open = !!attachment; + return ( + +
+ {attachment?.kind === "image" && attachment.previewUrl ? ( +
+ {attachment.name} +
+ ) : attachment?.previewUrl ? ( +