소스 검색

feat(app): disallow duplicate attachments (#39464)

Aarav Sareen 1 개월 전
부모
커밋
eca5e68a5e

+ 1 - 0
packages/app/src/components/prompt-input-v2.tsx

@@ -368,6 +368,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
           title: language.t("prompt.toast.pasteUnsupported.title"),
           description: language.t("prompt.toast.pasteUnsupported.description"),
         }),
+      duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
       onError: (error) =>
         showToast({
           variant: "error",

+ 1 - 0
packages/app/src/i18n/ar.ts

@@ -277,6 +277,7 @@ export const dict = {
   "prompt.action.send": "إرسال",
   "prompt.action.stop": "توقف",
   "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
+  "prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
   "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
   "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
   "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",

+ 1 - 0
packages/app/src/i18n/br.ts

@@ -277,6 +277,7 @@ export const dict = {
   "prompt.action.send": "Enviar",
   "prompt.action.stop": "Parar",
   "prompt.toast.pasteUnsupported.title": "Anexo não suportado",
+  "prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado",
   "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
   "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
   "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",

+ 1 - 0
packages/app/src/i18n/bs.ts

@@ -298,6 +298,7 @@ export const dict = {
   "prompt.action.stop": "Zaustavi",
 
   "prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
+  "prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
   "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
   "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
   "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",

+ 1 - 0
packages/app/src/i18n/da.ts

@@ -296,6 +296,7 @@ export const dict = {
   "prompt.action.stop": "Stop",
 
   "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
+  "prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
   "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
   "prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
   "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",

+ 1 - 0
packages/app/src/i18n/de.ts

@@ -282,6 +282,7 @@ export const dict = {
   "prompt.action.send": "Senden",
   "prompt.action.stop": "Stopp",
   "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
+  "prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen",
   "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
   "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
   "prompt.toast.modelAgentRequired.description":

+ 1 - 0
packages/app/src/i18n/en.ts

@@ -301,6 +301,7 @@ export const dict = {
 
   "prompt.toast.pasteUnsupported.title": "Unsupported attachment",
   "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
+  "prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
   "prompt.toast.modelAgentRequired.title": "Select an agent and model",
   "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
   "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",

+ 1 - 0
packages/app/src/i18n/es.ts

@@ -297,6 +297,7 @@ export const dict = {
   "prompt.action.stop": "Detener",
 
   "prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
+  "prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido",
   "prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
   "prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
   "prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",

+ 1 - 0
packages/app/src/i18n/fr.ts

@@ -277,6 +277,7 @@ export const dict = {
   "prompt.action.send": "Envoyer",
   "prompt.action.stop": "Arrêter",
   "prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
+  "prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé",
   "prompt.toast.pasteUnsupported.description":
     "Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
   "prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",

+ 1 - 0
packages/app/src/i18n/ja.ts

@@ -276,6 +276,7 @@ export const dict = {
   "prompt.action.send": "送信",
   "prompt.action.stop": "停止",
   "prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
+  "prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています",
   "prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
   "prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
   "prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",

+ 1 - 0
packages/app/src/i18n/ko.ts

@@ -264,6 +264,7 @@ export const dict = {
   "prompt.action.send": "전송",
   "prompt.action.stop": "중지",
   "prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
+  "prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다",
   "prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
   "prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
   "prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",

+ 1 - 0
packages/app/src/i18n/no.ts

@@ -288,6 +288,7 @@ export const dict = {
   "prompt.action.stop": "Stopp",
 
   "prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
+  "prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp",
   "prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
   "prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
   "prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",

+ 1 - 0
packages/app/src/i18n/pl.ts

@@ -278,6 +278,7 @@ export const dict = {
   "prompt.action.send": "Wyślij",
   "prompt.action.stop": "Zatrzymaj",
   "prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
+  "prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany",
   "prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
   "prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
   "prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",

+ 1 - 0
packages/app/src/i18n/ru.ts

@@ -297,6 +297,7 @@ export const dict = {
   "prompt.action.stop": "Остановить",
 
   "prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
+  "prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен",
   "prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
   "prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
   "prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",

+ 1 - 0
packages/app/src/i18n/th.ts

@@ -297,6 +297,7 @@ export const dict = {
   "prompt.action.stop": "หยุด",
 
   "prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
+  "prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว",
   "prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
   "prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
   "prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",

+ 1 - 0
packages/app/src/i18n/tr.ts

@@ -302,6 +302,7 @@ export const dict = {
   "prompt.action.stop": "Durdur",
 
   "prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
+  "prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi",
   "prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
   "prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
   "prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",

+ 1 - 0
packages/app/src/i18n/uk.ts

@@ -299,6 +299,7 @@ export const dict = {
   "prompt.action.stop": "Зупинити",
 
   "prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
+  "prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено",
   "prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
   "prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
   "prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",

+ 1 - 0
packages/app/src/i18n/zh.ts

@@ -316,6 +316,7 @@ export const dict = {
   "prompt.action.send": "发送",
   "prompt.action.stop": "停止",
   "prompt.toast.pasteUnsupported.title": "不支持的附件",
+  "prompt.toast.attachmentDuplicate.title": "此文件已上传",
   "prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
   "prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
   "prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",

+ 1 - 0
packages/app/src/i18n/zht.ts

@@ -297,6 +297,7 @@ export const dict = {
   "prompt.action.stop": "停止",
 
   "prompt.toast.pasteUnsupported.title": "不支援的附件",
+  "prompt.toast.attachmentDuplicate.title": "此檔案已上傳",
   "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
   "prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
   "prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",

+ 86 - 0
packages/app/test-browser/prompt-attachments.test.ts

@@ -1,7 +1,10 @@
 import { describe, expect, test } from "bun:test"
 import { createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
 import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
 import { createPromptState } from "@/context/prompt"
+import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments"
+import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types"
 
 describe("prompt attachment session ownership", () => {
   test("adds an asynchronously read image to the session where the read started", async () => {
@@ -83,6 +86,89 @@ describe("prompt attachment session ownership", () => {
       dispose()
     })
   })
+
+})
+
+test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => {
+  await createRoot(async (dispose) => {
+    const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
+    const duplicate = Promise.withResolvers<void>()
+    const files = [
+      new File(["hello"], "clipboard-1.txt", { type: "text/plain" }),
+      new File(["hello"], "clipboard-2.txt", { type: "text/plain" }),
+    ]
+    const attachments = createPromptInputV2Attachments({
+      capture: () => ({
+        current: () => state.prompt,
+        cursor: () => 0,
+        set: (prompt) => setState("prompt", prompt),
+      }),
+      editor: () => document.createElement("div"),
+      focusEditor: () => undefined,
+      addPart: () => false,
+      setDraggingType: () => undefined,
+      directory: () => "/",
+      isDialogActive: () => false,
+      warn: () => undefined,
+      duplicate: duplicate.resolve,
+      onError: () => undefined,
+      readClipboardImage: async () => files.shift() ?? null,
+    })
+    const event = {
+      clipboardData: { items: [], getData: () => "" },
+      preventDefault: () => undefined,
+      stopPropagation: () => undefined,
+    } as unknown as ClipboardEvent
+
+    await attachments.handlePaste(event)
+    await attachments.handlePaste(event)
+    await duplicate.promise
+
+    expect(state.prompt).toHaveLength(1)
+    dispose()
+  })
+})
+
+test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => {
+  await createRoot(async (dispose) => {
+    const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
+    const duplicates: string[] = []
+    const attachments = createPromptInputV2Attachments({
+      capture: () => ({
+        current: () => state.prompt,
+        cursor: () => 0,
+        set: (prompt) => setState("prompt", prompt),
+      }),
+      editor: () => document.createElement("div"),
+      focusEditor: () => undefined,
+      addPart: () => false,
+      setDraggingType: () => undefined,
+      directory: () => "/",
+      isDialogActive: () => false,
+      warn: () => undefined,
+      duplicate: () => duplicates.push("duplicate"),
+      onError: () => undefined,
+      getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`),
+    })
+    const first = new File(["first"], "a.txt", { type: "text/plain" })
+    const second = new File(["second"], "b.txt", { type: "text/plain" })
+
+    await attachments.addAttachments([first, second])
+    await attachments.addAttachments([first, second])
+    expect(state.prompt).toHaveLength(2)
+    expect(duplicates).toEqual(["duplicate", "duplicate"])
+
+    await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })])
+    expect(state.prompt).toHaveLength(3)
+
+    await attachments.addAttachments([
+      new File(["same"], "browser.txt", { type: "text/plain" }),
+      new File(["same"], "browser.txt", { type: "text/plain" }),
+    ])
+    expect(state.prompt).toHaveLength(4)
+    expect(duplicates).toHaveLength(3)
+    dispose()
+  })
 })
 
 function images(prompt: ReturnType<typeof createPromptState>) {

+ 19 - 3
packages/session-ui/src/v2/components/prompt-input/attachments.ts

@@ -74,6 +74,7 @@ export type PromptInputV2AttachmentConfig = {
   directory: () => string
   isDialogActive: () => boolean
   warn: () => void
+  duplicate: () => void
   onError: (error: unknown) => void
   readClipboardImage?: () => Promise<File | null>
   getPathForFile?: (file: File) => string
@@ -94,7 +95,7 @@ export function createPromptInputV2Attachments(
     if (!editor) return
     return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
   }
-  const add = async (file: File, toast = true, target = capture()) => {
+  const add = async (file: File, toast = true, target = capture(), clipboard = false) => {
     if (!target) return false
     const mime = await attachmentMime(file)
     if (!mime) {
@@ -103,11 +104,26 @@ export function createPromptInputV2Attachments(
     }
     const url = await dataUrl(file, mime)
     if (!url) return false
+    const sourcePath = input.getPathForFile?.(file) || undefined
+    // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
+    // clipboard content is matched on bytes alone.
+    const duplicate = target.prompt
+      .current()
+      .some(
+        (part) =>
+          part.type === "image" &&
+          part.dataUrl === url &&
+          (sourcePath ? part.sourcePath === sourcePath : !part.sourcePath && (clipboard || part.filename === file.name)),
+      )
+    if (duplicate) {
+      input.duplicate()
+      return true
+    }
     const attachment: PromptInputV2Attachment = {
       type: "image",
       id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2),
       filename: file.name,
-      sourcePath: input.getPathForFile?.(file) || undefined,
+      sourcePath,
       mime,
       dataUrl: url,
     }
@@ -141,7 +157,7 @@ export function createPromptInputV2Attachments(
     const plainText = clipboardData.getData("text/plain") ?? ""
     if (input.readClipboardImage && !plainText) {
       const file = await input.readClipboardImage()
-      if (file && (await add(file, true, target))) return
+      if (file && (await add(file, true, target, true))) return
     }
     if (!plainText) return
     const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText