diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 79f83f05..a1b7e78c 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -21,7 +21,79 @@ export default function MessageItem(props: MessageItemProps) { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) } - const messageParts = () => props.parts ?? props.message.parts + type FilePart = Extract & { + url?: string + mime?: string + filename?: string + } + + const displayParts = () => props.parts ?? props.message.parts + + const fileAttachments = () => + props.message.parts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") + + const getAttachmentName = (part: FilePart) => { + if (part.filename && part.filename.trim().length > 0) { + return part.filename + } + const url = part.url || "" + if (url.startsWith("data:")) { + return "attachment" + } + try { + const parsed = new URL(url) + const segments = parsed.pathname.split("/") + return segments.pop() || "attachment" + } catch (error) { + const fallback = url.split("/").pop() + return fallback && fallback.length > 0 ? fallback : "attachment" + } + } + + const isImageAttachment = (part: FilePart) => { + if (part.mime && typeof part.mime === "string" && part.mime.startsWith("image/")) { + return true + } + return typeof part.url === "string" && part.url.startsWith("data:image/") + } + + const handleAttachmentDownload = async (part: FilePart) => { + const url = part.url + if (!url) return + + const filename = getAttachmentName(part) + const directDownload = (href: string) => { + const anchor = document.createElement("a") + anchor.href = href + anchor.download = filename + anchor.target = "_blank" + anchor.rel = "noopener" + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + } + + if (url.startsWith("data:")) { + directDownload(url) + return + } + + if (url.startsWith("file://")) { + window.open(url, "_blank", "noopener") + return + } + + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch attachment: ${response.status}`) + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) + directDownload(objectUrl) + URL.revokeObjectURL(objectUrl) + } catch (error) { + directDownload(url) + } + } const errorMessage = () => { const info = props.messageInfo @@ -48,7 +120,7 @@ export default function MessageItem(props: MessageItemProps) { return true } - return messageParts().some((part) => partHasRenderableText(part)) + return displayParts().some((part) => partHasRenderableText(part)) } const isGenerating = () => { @@ -141,17 +213,64 @@ export default function MessageItem(props: MessageItemProps) { - {(part) => ( - - )} + + {(part) => ( + + )} + + 0}> +
+ + {(attachment) => { + const name = getAttachmentName(attachment) + const isImage = isImageAttachment(attachment) + return ( +
+ + + + }> + {name} + + {name} + + +
+ {name} +
+
+
+ ) + }} +
+
+
+ +
Sending...
diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 4ed7d407..47d9621a 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -722,7 +722,7 @@ export default function PromptInput(props: PromptInputProps) { const createAndStoreAttachment = (previewUrl?: string) => { const attachment = createFileAttachment(path, filename, mime, undefined, props.instanceFolder) - if (previewUrl && mime.startsWith("image/")) { + if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) { attachment.url = previewUrl } addAttachment(props.instanceId, props.sessionId, attachment) @@ -735,6 +735,13 @@ export default function PromptInput(props: PromptInputProps) { createAndStoreAttachment(result) } reader.readAsDataURL(file) + } else if (mime.startsWith("text/") && typeof FileReader !== "undefined") { + const reader = new FileReader() + reader.onload = () => { + const dataUrl = typeof reader.result === "string" ? reader.result : undefined + createAndStoreAttachment(dataUrl) + } + reader.readAsDataURL(file) } else { createAndStoreAttachment() } diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index b43c1543..ad288471 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -31,6 +31,11 @@ color: var(--text-muted); } +.message-attachments { + @apply flex flex-wrap gap-1.5 pt-2 mt-1; + border-top: 1px solid var(--border-base); +} + .message-error { @apply text-xs mt-1; color: var(--status-error); diff --git a/packages/ui/src/styles/messaging/prompt-input.css b/packages/ui/src/styles/messaging/prompt-input.css index 560c950d..74233b41 100644 --- a/packages/ui/src/styles/messaging/prompt-input.css +++ b/packages/ui/src/styles/messaging/prompt-input.css @@ -132,10 +132,12 @@ display: block; } -.attachment-remove { +.attachment-remove, +.attachment-download { @apply ml-0.5 flex h-4 w-4 items-center justify-center rounded transition-colors; } -.attachment-remove:hover { +.attachment-remove:hover, +.attachment-download:hover { background-color: var(--attachment-chip-ring); }