AI 應用元件

AI 附件托盤

可掛在任何輸入框上方的附件托盤:拖入/貼上檔案成縮圖 chip、上傳進度環、失敗重試、類型圖示、超限提示與拖曳重排

獨立於對話輸入框的附件列,可以掛在任何 textarea 上方。把檔案拖進托盤、貼上截圖,或按「+」選取,都會變成一個 chip:圖片顯示縮圖,其他檔案依 MIME/副檔名顯示對應的類型圖示(PDF、文件、試算表、程式碼、音訊、影片、壓縮檔)+中間截斷的檔名+大小。上傳中在縮圖上疊一圈 SVG 進度環與百分比,失敗時紅框加重試鈕,游標移上去才出現移除 ×。chip 用 spring 彈入、收縮退場,拖曳可以重排;超過數量或大小上限時只顯示提示 chip,不會把檔案交給 onAdd。

把這個效果裝進你的專案

在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。

npx webberui@latest add ai-attachment-tray

還沒有專案,或安裝遇到問題?看快速開始 →

AI Attachment Tray 是用 React、Tailwind CSS 與 Motion 做的檔案附件托盤(file attachment chips),常見於 AI 聊天輸入框上方:支援拖放、貼上截圖與檔案選取,用 SVG 進度環顯示上傳進度,並以 Motion 的 Reorder 拖曳重排。

Playground

即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。

6
10
編輯中的呼叫預設屬性
<AiAttachmentTray/>

完整範例包含預覽的排版與資料。先安裝元件,再把範例檔貼入你的專案。

適用情境

  • AI 聊天或客服對話框,讓使用者在提問前附上圖片、PDF 或試算表。
  • 工單、意見回饋表單裡需要附上螢幕截圖,直接貼上就能加入。
  • Email 或訊息撰寫區,顯示附件清單並允許調整順序與移除。
  • 需要逐檔回報上傳進度並支援失敗重試的上傳流程。
  • 不適合:以大面積拖放區為主的上傳頁,改用 File Dropzone。

安裝

npx webberui@latest add https://webberui.com/r/ai-attachment-tray.json

或在 components.json 設定 registries 後,改用 @webberui/ai-attachment-tray 安裝。

使用

受控模式:由父層持有清單、負責上傳,並把進度寫回 files。

import * as React from "react";
import {
  AiAttachmentTray,
  type AiAttachmentFile,
} from "@/components/ui/ai-attachment-tray";

function Composer() {
  const [files, setFiles] = React.useState<AiAttachmentFile[]>([]);

  const upload = (id: string, file: File) => {
    // 呼叫你的上傳 API,並在進度事件裡更新 progress;完成改 status 為 "done",失敗改 "error"
    uploadToServer(file, {
      onProgress: (p) =>
        setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, progress: p * 100 } : f))),
    })
      .then(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "done" } : f))))
      .catch(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "error" } : f))));
  };

  return (
    <div className="rounded-2xl border p-2">
      <AiAttachmentTray
        files={files}
        maxFiles={6}
        maxSizeMB={10}
        accept="image/*,.pdf,.docx,.xlsx"
        onAdd={(incoming) => {
          const entries = incoming.map<AiAttachmentFile>((file) => ({
            id: crypto.randomUUID(),
            name: file.name,
            size: file.size,
            type: file.type,
            status: "uploading",
            progress: 0,
            previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
          }));
          setFiles((prev) => [...prev, ...entries]);
          entries.forEach((entry, i) => upload(entry.id, incoming[i]));
        }}
        onRemove={(id) => setFiles((prev) => prev.filter((f) => f.id !== id))}
        onRetry={(id) =>
          setFiles((prev) =>
            prev.map((f) => (f.id === id ? { ...f, status: "uploading", progress: 0 } : f)),
          )
        }
        onReorder={(ids) =>
          setFiles((prev) =>
            ids.flatMap((id) => prev.filter((f) => f.id === id)),
          )
        }
      />
      <textarea rows={2} placeholder="問點什麼……" className="w-full bg-transparent p-2" />
    </div>
  );
}

不傳 files 時為非受控模式:元件自己維護清單(新增的檔案直接標為 done,圖片自動產生縮圖),適合只需要收集檔案、送出時再一次上傳的場景。

<AiAttachmentTray
  defaultFiles={[]}
  onChange={(files) => console.log(files.map((f) => f.name))}
  onAdd={(incoming) => console.log("通過檢查的檔案", incoming)}
/>

範例

用 XMLHttpRequest 回報上傳進度

fetch 無法取得上傳進度,上面「使用」範例裡的 uploadToServer 可以用 XMLHttpRequest 實作;onProgress 回傳 0–1,寫回 progress 時乘上 100。

export function uploadToServer(
  file: File,
  { onProgress }: { onProgress: (ratio: number) => void },
) {
  return new Promise<void>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const body = new FormData();
    body.append("file", file);
    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(String(xhr.status))));
    xhr.onerror = () => reject(new Error("network"));
    xhr.open("POST", "/api/attachments");
    xhr.send(body);
  });
}

顯示拒絕原因

檔案未通過檢查時,托盤本身會顯示提示 chip;若還想在別處記錄或顯示訊息,可以依 onReject 的 reason("count"、"size"、"type")分別處理。

import {
  AiAttachmentTray,
  type AiAttachmentRejectReason,
} from "@/components/ui/ai-attachment-tray";

const messages: Record<AiAttachmentRejectReason, string> = {
  count: "附件數量已達上限",
  size: "檔案超過 10 MB",
  type: "只接受圖片與 PDF",
};

<AiAttachmentTray
  maxFiles={4}
  maxSizeMB={10}
  accept="image/*,.pdf"
  onReject={(file, reason) => console.warn(file.name, messages[reason])}
/>

精簡模式與自訂文字

compact 縮小縮圖、改為單行文字,適合手機或側邊欄;labels 只需覆寫要改的欄位。同一頁有多個托盤時,用 listenPaste={false} 關掉其中幾個的貼上監聽,避免一次貼上被多個托盤接收。

<AiAttachmentTray
  compact
  showSize={false}
  listenPaste={false}
  hint="拖曳檔案到這裡"
  labels={{ tooMany: "最多只能附 {max} 個檔案", failed: "傳送失敗" }}
  maxFiles={3}
/>

Props

Prop型別預設值說明
filesAiAttachmentFile[]—受控的附件清單(順序即顯示順序);不提供時為非受控模式
defaultFilesAiAttachmentFile[][]非受控模式的初始附件清單
onChange(files: AiAttachmentFile[]) => void—非受控模式下清單變動(新增/移除/重排)後回呼完整清單
onAdd(files: File[]) => void—使用者拖入、貼上或選取的檔案通過檢查時回呼;受控模式由父層據此建立項目並上傳
onRemove(id: string) => void—按下移除鈕(或在 chip 上按 Delete)時回呼
onRetry(id: string) => void—上傳失敗的 chip 按下重試時回呼
onReorder(ids: string[]) => void—拖曳或鍵盤重排後回呼新的 id 順序;受控模式未提供時停用重排
onReject(file: File, reason: AiAttachmentRejectReason) => void—檔案未通過數量/大小/類型檢查時回呼
maxFilesnumber—附件數量上限;超過時顯示提示 chip 且不把多出的檔案交給 onAdd
maxSizeMBnumber—單檔大小上限(MB)
acceptstring—接受的檔案類型,同原生 accept(如 "image/*,.pdf");拖放與貼上也套用
compactbooleanfalse精簡模式:更小的縮圖與單行文字
showSizebooleantrue是否在 chip 上顯示檔案大小
showAddButtonbooleantrue是否顯示「+」新增按鈕(開啟原生檔案選取)
listenPastebooleantrue是否監聽 document 的 paste 事件;貼上帶檔案的內容時視同拖入
hintstring—托盤空白時的提示文字;等同 labels.hint 的捷徑
labelsPartial<AiAttachmentTrayLabels>—自訂文字(部分覆寫即可)
disabledbooleanfalse停用所有互動(拖放、貼上、移除、重排)
classNamestring—透傳到最外層容器

AiAttachmentFile

欄位型別預設值說明
idstring—唯一識別碼;移除、重試、重排都以此為準
namestring—檔名(含副檔名)
sizenumber—檔案大小(bytes)
typestring—MIME 類型(如 image/png);空字串時改由副檔名推斷
status"uploading" | "done" | "error"—上傳狀態
progressnumber—上傳進度 0–100;status 為 uploading 且未提供時顯示不定進度環
previewUrlstring—圖片縮圖來源(data URL 或 blob URL);只對圖片類型生效

AiAttachmentTrayLabels

欄位型別預設值說明
hintstring"拖曳、貼上或點「+」加入附件"托盤沒有附件時的提示文字
dropHintstring"放開以加入附件"拖曳檔案懸停在托盤上時的提示文字
addstring"加入附件"「+」新增按鈕的無障礙名稱
removestring"移除"移除鈕無障礙名稱的前綴,後面接檔名
retrystring"重試"重試按鈕文字
uploadingstring"上傳中"上傳中的進度環無障礙名稱
failedstring"上傳失敗"上傳失敗時 chip 上的說明文字
tooManystring"最多 {max} 個附件"超過附件數量上限的提示;{max} 會被替換成上限
tooLargestring"單檔上限 {size}"超過單檔大小上限的提示;{size} 會被替換成上限
unsupportedstring"不支援的檔案類型"檔案類型不在 accept 範圍內的提示
liststring"附件"附件列表(ul)的無障礙名稱
reorderHintstring"左右方向鍵切換附件;Shift 加方向鍵調整順序;Delete 移除"每個 chip 的鍵盤操作說明(僅供螢幕閱讀器)
movedstring"{name} 已移到第 {index} 位,共 {total} 個"鍵盤重排後的播報;{name}、{index}、{total} 會被替換
removedstring"已移除 {name}"移除後的播報;{name} 會被替換

另外具名匯出 getAttachmentKind(type, name)(由 MIME 與副檔名判斷類型)、truncateMiddle(name, max)(檔名中段截斷)、formatFileSize(bytes)(B/KB/MB/GB 格式化),以及 AiAttachmentStatus、AiAttachmentKind、AiAttachmentRejectReason 型別,方便在父層產生列表或訊息時重複使用同一套邏輯。

細節

  • 類型判斷:先看 MIME(image/*、application/pdf、audio/*、video/*、試算表、壓縮檔、文件、程式碼),再退回副檔名對照表——瀏覽器對 .md/.ts 之類常給空 MIME,副檔名後援才不會全部落到「其他」
  • 進度環:SVG stroke-dashoffset 以 spring 平滑推進,progress 未提供時改為旋轉的短弧表示處理中;環中央顯示整數百分比(精簡模式省略)
  • 拖曳重排:以 motion Reorder.Group/Reorder.Item(axis="x")實作,chip 內的重試與移除鈕會攔下 pointerdown,不會誤觸拖曳;只有一個附件或受控模式未提供 onReorder 時停用拖曳
  • 拖放與貼上:整個托盤是 drop zone,拖曳懸停時虛線邊框加深並顯示 labels.dropHint;拖曳進出子元素會連發 dragenter/dragleave,內部以深度計數避免閃爍。paste 監聽掛在 document 上(只在 effect 中綁定,listenPaste 可關),剪貼簿帶檔案(例如截圖)時才動作
  • 超限處理:accept/maxSizeMB/maxFiles 依序檢查,未通過的檔案不會交給 onAdd,改觸發 onReject 並顯示琥珀色提示 chip(約 3 秒後自動消失);同一批只顯示第一個原因
  • 非受控模式的縮圖:圖片自動 URL.createObjectURL 產生縮圖,移除或卸載時 revoke,不會漏記憶體
  • 移除後的焦點:焦點在被移除的 chip 上時,自動移到相鄰 chip,不會掉回 body

可及性

  • 附件列表為 role="list"(aria-label 取自 labels.list),每個 chip 為可聚焦的 role="listitem",aria-label 含檔名、大小與狀態,並以 aria-describedby 連到鍵盤操作說明
  • 鍵盤:左右方向鍵在 chip 之間移動、Home/End 跳到首尾、Shift+方向鍵調整順序、Delete/Backspace 移除;重排與移除都會透過 aria-live="polite" 播報
  • 進度環帶 role="progressbar"、aria-valuenow/aria-valuetext;移除鈕與重試鈕都有含檔名的 aria-label
  • 移除鈕在精準指標裝置上 hover/聚焦才顯示,觸控裝置永遠可見;所有按鈕皆為 type="button" 並有 focus-visible 外框
  • 使用者系統開啟「減少動態效果」時,chip 進出場改為純淡入淡出、拖曳時不放大、進度環直接跳至目前值、不定進度環停止旋轉

常見問題

可以在 Next.js App Router 使用嗎?

可以。原始碼開頭已有 "use client",在 Server Component 頁面直接 import 即可;document 的 paste 監聽只在 effect 裡綁定,伺服器端渲染時不會存取瀏覽器 API。

元件會幫我上傳檔案嗎?

不會。元件只負責收集、檢查與顯示檔案,通過檢查的 File 會交給 onAdd,上傳由你自己處理;受控模式下把 status 與 progress 寫回 files,托盤就會更新進度環。

縮圖的 blob URL 需要自己釋放嗎?

非受控模式由元件自動建立,並在移除或卸載時 revoke。受控模式的 previewUrl 由你建立,移除項目時請自行呼叫 URL.revokeObjectURL。

鍵盤可以重排或移除附件嗎?

可以。聚焦 chip 後用左右方向鍵切換、Home/End 跳到首尾、Shift+方向鍵調整順序、Delete 或 Backspace 移除,結果會透過 aria-live 播報。受控模式沒有傳 onReorder 時會停用重排。

有哪些工具函式可以重複使用?

getAttachmentKind、truncateMiddle 與 formatFileSize 都有具名匯出,可以在訊息列表或已送出的附件裡沿用同一套檔案類型判斷與檔名、大小格式。