From 6ff9d65350dcd4d73c2c719396cb32d644326cfe Mon Sep 17 00:00:00 2001 From: hive-lead Date: Tue, 10 Mar 2026 00:46:56 +0000 Subject: [PATCH 1/4] Support uploading any file type, not just images Extends the upload system from image-only to general file uploads (PDFs, text, archives, code, docs, etc.). Images render inline as before; non-image files appear as styled download links with the original filename. Changes: - Media: expand allowed MIME types, add ext_for map, bump limit to 10MB - ChatLive: accept any file type, show file icon + name for non-images - MCP bridge: remove image-only enum, add optional filename param - ToolsController: pass filename option, update error messages - CSS: file preview cards in composer and download link styling - JS: open file download links in new tab (skip lightbox) - Tests: unit tests for all new file types, e2e Playwright tests Co-Authored-By: Claude Opus 4.6 --- assets/css/app.css | 41 +++++ assets/js/app.js | 8 + e2e/file-upload.spec.ts | 168 ++++++++++++++++++ lib/hive/media.ex | 102 +++++++++-- lib/hive_web/controllers/tools_controller.ex | 12 +- lib/hive_web/live/chat_live.ex | 71 ++++++-- sdk/hive_mcp_bridge.js | 11 +- test/hive/media_test.exs | 155 +++++++++++++--- .../controllers/tools_controller_test.exs | 109 +++++++++++- 9 files changed, 612 insertions(+), 65 deletions(-) create mode 100644 e2e/file-upload.spec.ts diff --git a/assets/css/app.css b/assets/css/app.css index 26df2fb..059c654 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1105,6 +1105,26 @@ select { border: 1px solid var(--ui-border); } +.ui-upload-preview__file { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.6rem; + border-radius: 4px; + border: 1px solid var(--ui-border); + background: var(--ui-surface-muted); + font-size: 0.75rem; + color: var(--ui-text-soft); + max-width: 160px; + height: 48px; +} + +.ui-upload-preview__filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .ui-upload-preview__remove { position: absolute; top: -6px; @@ -1122,6 +1142,27 @@ select { cursor: pointer; } +/* File attachment links in messages */ +.ui-markdown a[href^="/uploads/"] { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.6rem; + border-radius: 6px; + border: 1px solid var(--ui-border); + background: var(--ui-surface-muted); + color: var(--ui-text); + font-size: 0.85rem; + text-decoration: none; + transition: background 0.15s; + margin: 0.15rem 0; +} + +.ui-markdown a[href^="/uploads/"]:hover { + background: var(--ui-surface-hover, var(--ui-border)); + text-decoration: none; +} + /* Upload button in composer */ .ui-chat-composer__upload-btn { display: inline-flex; diff --git a/assets/js/app.js b/assets/js/app.js index bf68642..88a06e1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -427,6 +427,14 @@ const Hooks = { return } + // File download links — open in new tab + const fileLink = event.target.closest('.ui-markdown a[href^="/uploads/"]') + if (fileLink && this.el.contains(fileLink) && !fileLink.querySelector("img")) { + event.preventDefault() + window.open(fileLink.href, "_blank") + return + } + const mention = event.target.closest(".ui-mention[data-agent-name]") if (!mention || !this.el.contains(mention)) return diff --git a/e2e/file-upload.spec.ts b/e2e/file-upload.spec.ts new file mode 100644 index 0000000..d147414 --- /dev/null +++ b/e2e/file-upload.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from "@playwright/test"; +import { waitForLiveView } from "./helpers"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +/** + * Create a temporary file with the given content and extension. + * Returns the file path. Caller is responsible for cleanup. + */ +function createTempFile( + name: string, + content: string | Buffer, +): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hive-e2e-")); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content); + return filePath; +} + +test.describe("File upload", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await waitForLiveView(page); + }); + + test("upload button is visible with correct title", async ({ page }) => { + const uploadBtn = page.locator(".ui-chat-composer__upload-btn"); + await expect(uploadBtn).toBeVisible(); + await expect(uploadBtn).toHaveAttribute("title", "Attach file"); + }); + + test("image upload shows thumbnail preview", async ({ page }) => { + // Create a tiny valid PNG (1x1 red pixel) + const pngData = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==", + "base64", + ); + const filePath = createTempFile("test-image.png", pngData); + + try { + // Need an active topic first — click on one if available + const topicItem = page.locator(".ui-topic-item").first(); + if (await topicItem.isVisible({ timeout: 2000 }).catch(() => false)) { + await topicItem.click(); + await page.waitForTimeout(500); + } + + // Set file via the hidden file input + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(filePath); + + // Should show image thumbnail preview + const previews = page.locator(".ui-upload-previews"); + await expect(previews).toBeVisible(); + + const thumb = page.locator(".ui-upload-preview__thumb"); + await expect(thumb).toBeVisible(); + } finally { + fs.unlinkSync(filePath); + fs.rmdirSync(path.dirname(filePath)); + } + }); + + test("non-image file upload shows filename preview", async ({ page }) => { + const filePath = createTempFile( + "test-document.pdf", + "%PDF-1.4 test content for upload testing", + ); + + try { + const topicItem = page.locator(".ui-topic-item").first(); + if (await topicItem.isVisible({ timeout: 2000 }).catch(() => false)) { + await topicItem.click(); + await page.waitForTimeout(500); + } + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(filePath); + + // Should show file icon + filename preview (not image thumbnail) + const previews = page.locator(".ui-upload-previews"); + await expect(previews).toBeVisible(); + + const filePreview = page.locator(".ui-upload-preview__file"); + await expect(filePreview).toBeVisible(); + + const filename = page.locator(".ui-upload-preview__filename"); + await expect(filename).toContainText("test-document.pdf"); + } finally { + fs.unlinkSync(filePath); + fs.rmdirSync(path.dirname(filePath)); + } + }); + + test("can cancel a file upload before sending", async ({ page }) => { + const filePath = createTempFile("cancel-me.txt", "temporary file"); + + try { + const topicItem = page.locator(".ui-topic-item").first(); + if (await topicItem.isVisible({ timeout: 2000 }).catch(() => false)) { + await topicItem.click(); + await page.waitForTimeout(500); + } + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(filePath); + + const previews = page.locator(".ui-upload-previews"); + await expect(previews).toBeVisible(); + + // Click remove button + const removeBtn = page.locator(".ui-upload-preview__remove"); + await removeBtn.click(); + + // Previews should be gone + await expect( + page.locator(".ui-upload-preview__file"), + ).not.toBeVisible(); + } finally { + fs.unlinkSync(filePath); + fs.rmdirSync(path.dirname(filePath)); + } + }); + + test("multiple file types can be attached simultaneously", async ({ + page, + }) => { + const pngData = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==", + "base64", + ); + const imgPath = createTempFile("photo.png", pngData); + const txtPath = createTempFile("notes.txt", "some notes"); + const csvPath = createTempFile("data.csv", "name,age\nAlice,30"); + + try { + const topicItem = page.locator(".ui-topic-item").first(); + if (await topicItem.isVisible({ timeout: 2000 }).catch(() => false)) { + await topicItem.click(); + await page.waitForTimeout(500); + } + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles([imgPath, txtPath, csvPath]); + + // Should show 3 previews: 1 image thumb + 2 file icons + const allPreviews = page.locator(".ui-upload-preview"); + await expect(allPreviews).toHaveCount(3); + + // Image gets a thumbnail + const thumb = page.locator(".ui-upload-preview__thumb"); + await expect(thumb).toHaveCount(1); + + // Non-images get file previews + const filePreviews = page.locator(".ui-upload-preview__file"); + await expect(filePreviews).toHaveCount(2); + } finally { + fs.unlinkSync(imgPath); + fs.unlinkSync(txtPath); + fs.unlinkSync(csvPath); + // Clean up temp dirs + fs.rmdirSync(path.dirname(imgPath)); + fs.rmdirSync(path.dirname(txtPath)); + fs.rmdirSync(path.dirname(csvPath)); + } + }); +}); diff --git a/lib/hive/media.ex b/lib/hive/media.ex index e45ae88..1aa9f95 100644 --- a/lib/hive/media.ex +++ b/lib/hive/media.ex @@ -2,35 +2,115 @@ defmodule Hive.Media do @moduledoc """ Saves uploaded media files to disk and returns public URLs. Files are stored in `priv/static/uploads/` and served via Plug.Static. + Supports images and general file uploads (documents, archives, code, etc.). """ @upload_dir Path.join([:code.priv_dir(:hive) |> to_string(), "static", "uploads"]) - @max_size 5_000_000 - @allowed_types ~w(image/png image/jpeg image/gif image/webp) + @max_size 10_000_000 + + @allowed_types ~w( + image/png image/jpeg image/gif image/webp image/svg+xml + application/pdf + application/zip application/gzip application/x-tar + application/x-7z-compressed application/x-rar-compressed + application/json application/xml + application/msword application/vnd.openxmlformats-officedocument.wordprocessingml.document + application/vnd.ms-excel application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + application/vnd.ms-powerpoint application/vnd.openxmlformats-officedocument.presentationml.presentation + text/plain text/csv text/html text/css text/javascript text/markdown text/xml + audio/mpeg audio/wav audio/ogg + video/mp4 video/webm + application/octet-stream + ) + + @image_types ~w(image/png image/jpeg image/gif image/webp image/svg+xml) + + @ext_map %{ + "image/png" => "png", + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + "application/pdf" => "pdf", + "application/zip" => "zip", + "application/gzip" => "gz", + "application/x-tar" => "tar", + "application/x-7z-compressed" => "7z", + "application/x-rar-compressed" => "rar", + "application/json" => "json", + "application/xml" => "xml", + "application/msword" => "doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx", + "application/vnd.ms-excel" => "xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx", + "application/vnd.ms-powerpoint" => "ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx", + "text/plain" => "txt", + "text/csv" => "csv", + "text/html" => "html", + "text/css" => "css", + "text/javascript" => "js", + "text/markdown" => "md", + "text/xml" => "xml", + "audio/mpeg" => "mp3", + "audio/wav" => "wav", + "audio/ogg" => "ogg", + "video/mp4" => "mp4", + "video/webm" => "webm", + "application/octet-stream" => "bin" + } def upload_dir, do: @upload_dir def ensure_upload_dir, do: File.mkdir_p!(@upload_dir) - def save(data, media_type) when media_type in @allowed_types do + def max_size, do: @max_size + + def allowed_types, do: @allowed_types + + def image_type?(media_type), do: media_type in @image_types + + @doc """ + Saves file data to disk. Returns `{:ok, url}` or `{:error, reason}`. + Optionally accepts an original filename to preserve the extension. + """ + def save(data, media_type, opts \\ []) when media_type in @allowed_types do if byte_size(data) > @max_size do - {:error, "file too large (max 5MB)"} + {:error, "file too large (max #{div(@max_size, 1_000_000)}MB)"} else id = Base.hex_encode32(:crypto.strong_rand_bytes(10), case: :lower, padding: false) - ext = ext_for(media_type) + ext = ext_for(media_type, opts[:filename]) filename = "#{id}.#{ext}" path = Path.join(@upload_dir, filename) + ensure_upload_dir() File.write!(path, data) {:ok, "/uploads/#{filename}"} end end - def save(_data, _media_type) do - {:error, "unsupported media type (allowed: png, jpeg, gif, webp)"} + def save(_data, _media_type, _opts) do + {:error, "unsupported file type"} end - defp ext_for("image/png"), do: "png" - defp ext_for("image/jpeg"), do: "jpg" - defp ext_for("image/gif"), do: "gif" - defp ext_for("image/webp"), do: "webp" + @doc """ + Returns the file extension for a MIME type. + Falls back to extracting from the original filename if provided. + """ + def ext_for(media_type, filename \\ nil) do + case Map.get(@ext_map, media_type) do + nil -> + # Fallback: try to extract from original filename, or use "bin" + if filename do + case Path.extname(filename) do + "." <> ext -> ext + _ -> "bin" + end + else + "bin" + end + + ext -> + ext + end + end end diff --git a/lib/hive_web/controllers/tools_controller.ex b/lib/hive_web/controllers/tools_controller.ex index 6ced3e1..0d5387b 100644 --- a/lib/hive_web/controllers/tools_controller.ex +++ b/lib/hive_web/controllers/tools_controller.ex @@ -289,9 +289,11 @@ defmodule HiveWeb.ToolsController do {:ok, "CLAUDE.md updated at #{path}"} end - defp execute_tool(_agent, "upload_media", %{"data" => base64, "media_type" => media_type}) do + defp execute_tool(_agent, "upload_media", %{"data" => base64, "media_type" => media_type} = params) do + opts = if params["filename"], do: [filename: params["filename"]], else: [] + with {:ok, data} <- Base.decode64(base64), - {:ok, url} <- Hive.Media.save(data, media_type) do + {:ok, url} <- Hive.Media.save(data, media_type, opts) do {:ok, url} else :error -> {:error, "invalid base64 data"} @@ -305,9 +307,11 @@ defmodule HiveWeb.ToolsController do if File.exists?(path) do data = File.read!(path) - {:ok, %{base64: Base.encode64(data), media_type: MIME.from_path(path)}} + media_type = MIME.from_path(path) + + {:ok, %{base64: Base.encode64(data), media_type: media_type}} else - {:error, "image not found"} + {:error, "file not found"} end else {:error, "only /uploads/ URLs are supported"} diff --git a/lib/hive_web/live/chat_live.ex b/lib/hive_web/live/chat_live.ex index 923c078..1945e99 100644 --- a/lib/hive_web/live/chat_live.ex +++ b/lib/hive_web/live/chat_live.ex @@ -62,9 +62,9 @@ defmodule HiveWeb.ChatLive do |> assign(:scratchpad_agent, nil) |> assign(:scratchpad_events, []) |> allow_upload(:media, - accept: ~w(.jpg .jpeg .png .gif .webp), + accept: :any, max_entries: 4, - max_file_size: 5_000_000 + max_file_size: 10_000_000 ) {:ok, socket} @@ -225,7 +225,16 @@ defmodule HiveWeb.ChatLive do style="padding: 0.5rem 0.95rem 0;" >
- <.live_img_preview entry={entry} class="ui-upload-preview__thumb" /> + <%= if image_entry?(entry) do %> + <.live_img_preview entry={entry} class="ui-upload-preview__thumb" /> + <% else %> +
+ <.icon name="hero-document" class="size-5" /> + + {truncate_filename(entry.client_name, 20)} + +
+ <% end %>