From b17949fe29fc43b5fd25506d1bada1648f4a7fc6 Mon Sep 17 00:00:00 2001 From: ayangweb <75017711+ayangweb@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:26:44 +0800 Subject: [PATCH] refactor: enabling the upload file component (#755) * refactor: enabling the upload file component * update --- src-tauri/src/server/attachment.rs | 43 +-- src/components/Assistant/FileList.tsx | 94 ++++-- src/components/Assistant/SessionFile.tsx | 287 +++++++++--------- src/components/Common/Tooltip2.tsx | 42 +++ src/components/Search/InputBox.tsx | 18 ++ src/components/Search/InputControls.tsx | 67 ++-- .../{InputExtra.tsx => InputUpload.tsx} | 80 +++-- src/components/Search/MCPPopover.tsx | 2 +- src/components/Search/SearchPopover.tsx | 2 +- src/hooks/useWindowEvents.ts | 6 +- src/locales/en/translation.json | 4 + src/locales/zh/translation.json | 4 + src/stores/chatStore.ts | 4 +- 13 files changed, 365 insertions(+), 288 deletions(-) create mode 100644 src/components/Common/Tooltip2.tsx rename src/components/Search/{InputExtra.tsx => InputUpload.tsx} (80%) diff --git a/src-tauri/src/server/attachment.rs b/src-tauri/src/server/attachment.rs index a6f30807..553cd390 100644 --- a/src-tauri/src/server/attachment.rs +++ b/src-tauri/src/server/attachment.rs @@ -15,42 +15,6 @@ pub struct UploadAttachmentResponse { pub attachments: Vec, } -#[derive(Debug, Serialize, Deserialize)] -pub struct AttachmentSource { - pub id: String, - pub created: String, - pub updated: String, - pub session: String, - pub name: String, - pub icon: String, - pub url: String, - pub size: u64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct AttachmentHit { - pub _index: String, - pub _type: Option, - pub _id: String, - pub _score: Option, - pub _source: AttachmentSource, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct AttachmentHits { - pub total: Value, - pub max_score: Option, - pub hits: Option>, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct GetAttachmentResponse { - pub took: u32, - pub timed_out: bool, - pub _shards: Option, - pub hits: AttachmentHits, -} - #[derive(Debug, Serialize, Deserialize)] pub struct DeleteAttachmentResponse { pub _id: String, @@ -107,10 +71,7 @@ pub async fn upload_attachment( } #[command] -pub async fn get_attachment( - server_id: String, - session_id: String, -) -> Result { +pub async fn get_attachment(server_id: String, session_id: String) -> Result { let mut query_params = Vec::new(); query_params.push(format!("session={}", session_id)); @@ -120,7 +81,7 @@ pub async fn get_attachment( let body = get_response_body_text(response).await?; - serde_json::from_str::(&body) + serde_json::from_str::(&body) .map_err(|e| format!("Failed to parse attachment response: {}", e)) } diff --git a/src/components/Assistant/FileList.tsx b/src/components/Assistant/FileList.tsx index 7335158a..b89596ae 100644 --- a/src/components/Assistant/FileList.tsx +++ b/src/components/Assistant/FileList.tsx @@ -4,10 +4,11 @@ import { X } from "lucide-react"; import { useAsyncEffect } from "ahooks"; import { useTranslation } from "react-i18next"; -import { useChatStore } from "@/stores/chatStore"; +import { useChatStore, UploadFile } from "@/stores/chatStore"; import { useConnectStore } from "@/stores/connectStore"; import FileIcon from "../Common/Icons/FileIcon"; import platformAdapter from "@/utils/platformAdapter"; +import Tooltip2 from "../Common/Tooltip2"; interface FileListProps { sessionId: string; @@ -17,9 +18,8 @@ interface FileListProps { const FileList = (props: FileListProps) => { const { sessionId } = props; const { t } = useTranslation(); - const uploadFiles = useChatStore((state) => state.uploadFiles); - const setUploadFiles = useChatStore((state) => state.setUploadFiles); - const currentService = useConnectStore((state) => state.currentService); + const { uploadFiles, setUploadFiles } = useChatStore(); + const { currentService } = useConnectStore(); const serverId = useMemo(() => { return currentService.id; @@ -39,29 +39,42 @@ const FileList = (props: FileListProps) => { if (uploaded) continue; - const attachmentIds: any = await platformAdapter.commands( - "upload_attachment", - { - serverId, - sessionId, - filePaths: [path], + try { + const attachmentIds: any = await platformAdapter.commands( + "upload_attachment", + { + serverId, + sessionId, + filePaths: [path], + } + ); + + if (!attachmentIds) { + throw new Error("Failed to get attachment id"); + } else { + Object.assign(item, { + uploaded: true, + attachmentId: attachmentIds[0], + }); } - ); - if (!attachmentIds) continue; - - Object.assign(item, { - uploaded: true, - attachmentId: attachmentIds[0], - }); - - setUploadFiles(uploadFiles); + setUploadFiles(uploadFiles); + } catch (error) { + Object.assign(item, { + uploadFailed: true, + failedMessage: String(error), + }); + } } }, [uploadFiles]); - const deleteFile = async (id: string, attachmentId: string) => { + const deleteFile = async (file: UploadFile) => { + const { id, uploadFailed, attachmentId } = file; + setUploadFiles(uploadFiles.filter((file) => file.id !== id)); + if (uploadFailed) return; + platformAdapter.commands("delete_attachment", { serverId, id: attachmentId, @@ -71,16 +84,25 @@ const FileList = (props: FileListProps) => { return (
{uploadFiles.map((file) => { - const { id, name, extname, size, uploaded, attachmentId } = file; + const { + id, + name, + extname, + size, + uploaded, + attachmentId, + uploadFailed, + failedMessage, + } = file; return (
- {attachmentId && ( + {(uploadFailed || attachmentId) && (
{ - deleteFile(id, attachmentId); + deleteFile(file); }} > @@ -94,16 +116,24 @@ const FileList = (props: FileListProps) => { {name}
-
- {uploaded ? ( -
- {extname && {extname}} - - {filesize(size, { standard: "jedec", spacer: "" })} - -
+
+ {uploadFailed && failedMessage ? ( + + Upload Failed + ) : ( - {t("assistant.fileList.uploading")} +
+ {uploaded ? ( +
+ {extname && {extname}} + + {filesize(size, { standard: "jedec", spacer: "" })} + +
+ ) : ( + {t("assistant.fileList.uploading")} + )} +
)}
diff --git a/src/components/Assistant/SessionFile.tsx b/src/components/Assistant/SessionFile.tsx index fe6b9a82..6e3cfb0f 100644 --- a/src/components/Assistant/SessionFile.tsx +++ b/src/components/Assistant/SessionFile.tsx @@ -1,174 +1,177 @@ import clsx from "clsx"; -import {filesize} from "filesize"; -import {Files, Trash2, X} from "lucide-react"; -import {useEffect, useMemo, useState} from "react"; -import {useTranslation} from "react-i18next"; +import { filesize } from "filesize"; +import { Files, Trash2, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; -import {useConnectStore} from "@/stores/connectStore"; +import { useConnectStore } from "@/stores/connectStore"; import Checkbox from "@/components/Common/Checkbox"; import FileIcon from "@/components/Common/Icons/FileIcon"; -import {AttachmentHit} from "@/types/commands"; -import {useAppStore} from "@/stores/appStore"; +import { AttachmentHit } from "@/types/commands"; +import { useAppStore } from "@/stores/appStore"; import platformAdapter from "@/utils/platformAdapter"; interface SessionFileProps { - sessionId: string; + sessionId: string; } const SessionFile = (props: SessionFileProps) => { - const {sessionId} = props; - const {t} = useTranslation(); + const { sessionId } = props; + const { t } = useTranslation(); - const isTauri = useAppStore((state) => state.isTauri); - const currentService = useConnectStore((state) => state.currentService); - const [uploadedFiles, setUploadedFiles] = useState([]); - const [visible, setVisible] = useState(false); - const [checkList, setCheckList] = useState([]); + const isTauri = useAppStore((state) => state.isTauri); + const currentService = useConnectStore((state) => state.currentService); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [visible, setVisible] = useState(false); + const [checkList, setCheckList] = useState([]); - const serverId = useMemo(() => { - return currentService.id; - }, [currentService]); + const serverId = useMemo(() => { + return currentService.id; + }, [currentService]); - useEffect(() => { - setUploadedFiles([]); + useEffect(() => { + setUploadedFiles([]); - getUploadedFiles(); - }, [sessionId]); + getUploadedFiles(); + }, [sessionId]); - const getUploadedFiles = async () => { - if (isTauri) { - const response: any = await platformAdapter.commands("get_attachment", { - serverId, - sessionId, - }); + const getUploadedFiles = async () => { + if (isTauri) { + console.log("sessionId", sessionId); - setUploadedFiles(response?.hits?.hits ?? []); - } else { - } - }; + const response: any = await platformAdapter.commands("get_attachment", { + serverId, + sessionId, + }); - const handleDelete = async (id: string) => { - let result; - if (isTauri) { - result = await platformAdapter.commands("delete_attachment", { - serverId, - id, - }); - } else { - } - if (!result) return; + console.log("get_attachment response", response); - getUploadedFiles(); - }; + setUploadedFiles(response?.hits?.hits ?? []); + } else { + } + }; - const handleCheckAll = (checked: boolean) => { - if (checked) { - setCheckList(uploadedFiles?.map((item) => item?._source?.id)); - } else { - setCheckList([]); - } - }; + const handleDelete = async (id: string) => { + let result; + if (isTauri) { + result = await platformAdapter.commands("delete_attachment", { + serverId, + id, + }); + } else { + } + if (!result) return; - const handleCheck = (checked: boolean, id: string) => { - if (checked) { - setCheckList([...checkList, id]); - } else { - setCheckList(checkList.filter((item) => item !== id)); - } - }; + getUploadedFiles(); + }; - return ( -
-
{ - setVisible(true); - }} - > - + const handleCheckAll = (checked: boolean) => { + if (checked) { + setCheckList(uploadedFiles?.map((item) => item?._source?.id)); + } else { + setCheckList([]); + } + }; -
- {uploadedFiles?.length} -
-
+ const handleCheck = (checked: boolean, id: string) => { + if (checked) { + setCheckList([...checkList, id]); + } else { + setCheckList(checkList.filter((item) => item !== id)); + } + }; -
- { - setVisible(false); - }} - /> + return ( +
+
{ + setVisible(true); + }} + > + -
- {t("assistant.sessionFile.title")} -
-
+
+ {uploadedFiles?.length} +
+
+ +
+ { + setVisible(false); + }} + /> + +
+ {t("assistant.sessionFile.title")} +
+
{t("assistant.sessionFile.description")} - -
-
    - {uploadedFiles?.map((item) => { - const {id, name, icon, size} = item._source; - - return ( -
  • -
    - - -
    -
    - {name} -
    -
    - {icon} - - {filesize(size, {standard: "jedec", spacer: ""})} - -
    -
    -
    - -
    - handleDelete(id)} - /> - - handleCheck(checked, id)} - /> -
    -
  • - ); - })} -
-
+
- ); +
    + {uploadedFiles?.map((item) => { + const { id, name, icon, size } = item._source; + + return ( +
  • +
    + + +
    +
    + {name} +
    +
    + {icon} + + {filesize(size, { standard: "jedec", spacer: "" })} + +
    +
    +
    + +
    + handleDelete(id)} + /> + + handleCheck(checked, id)} + /> +
    +
  • + ); + })} +
+
+
+ ); }; export default SessionFile; diff --git a/src/components/Common/Tooltip2.tsx b/src/components/Common/Tooltip2.tsx new file mode 100644 index 00000000..0db6c2ab --- /dev/null +++ b/src/components/Common/Tooltip2.tsx @@ -0,0 +1,42 @@ +import { + Popover, + PopoverButton, + PopoverPanel, + PopoverPanelProps, +} from "@headlessui/react"; +import { useBoolean } from "ahooks"; +import clsx from "clsx"; +import { FC, ReactNode } from "react"; + +interface Tooltip2Props extends PopoverPanelProps { + content: string; + children: ReactNode; +} + +const Tooltip2: FC = (props) => { + const { content, children, anchor = "top", ...rest } = props; + const [visible, { setTrue, setFalse }] = useBoolean(false); + + return ( + + + {children} + + + {content} + + + ); +}; + +export default Tooltip2; diff --git a/src/components/Search/InputBox.tsx b/src/components/Search/InputBox.tsx index 19425157..b2a54b2a 100644 --- a/src/components/Search/InputBox.tsx +++ b/src/components/Search/InputBox.tsx @@ -71,6 +71,15 @@ export default function ChatInput({ hasModules = [], searchPlaceholder, chatPlaceholder, + checkScreenPermission, + requestScreenPermission, + getScreenMonitors, + getScreenWindows, + captureWindowScreenshot, + captureMonitorScreenshot, + openFileDialog, + getFileMetadata, + getFileIcon, }: ChatInputProps) { const { t } = useTranslation(); @@ -351,6 +360,15 @@ export default function ChatInput({ isMCPActive={isMCPActive} setIsMCPActive={setIsMCPActive} changeMode={changeMode} + checkScreenPermission={checkScreenPermission} + requestScreenPermission={requestScreenPermission} + getScreenMonitors={getScreenMonitors} + getScreenWindows={getScreenWindows} + captureMonitorScreenshot={captureMonitorScreenshot} + captureWindowScreenshot={captureWindowScreenshot} + openFileDialog={openFileDialog} + getFileMetadata={getFileMetadata} + getFileIcon={getFileIcon} />
); diff --git a/src/components/Search/InputControls.tsx b/src/components/Search/InputControls.tsx index b5e2c60f..110df76c 100644 --- a/src/components/Search/InputControls.tsx +++ b/src/components/Search/InputControls.tsx @@ -16,7 +16,7 @@ import { useAppStore } from "@/stores/appStore"; import { useSearchStore } from "@/stores/searchStore"; import { useExtensionsStore } from "@/stores/extensionsStore"; import { parseSearchQuery, SearchQuery } from "@/utils"; -// import InputExtra from "./InputExtra"; +import InputUpload from "./InputUpload"; // import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon"; interface InputControlsProps { @@ -32,6 +32,17 @@ interface InputControlsProps { searchPlaceholder?: string; chatPlaceholder?: string; changeMode?: (isChatMode: boolean) => void; + checkScreenPermission: () => Promise; + requestScreenPermission: () => void; + getScreenMonitors: () => Promise; + getScreenWindows: () => Promise; + captureMonitorScreenshot: (id: number) => Promise; + captureWindowScreenshot: (id: number) => Promise; + openFileDialog: (options: { + multiple: boolean; + }) => Promise; + getFileMetadata: (path: string) => Promise; + getFileIcon: (path: string, size: number) => Promise; } const InputControls = ({ @@ -45,12 +56,21 @@ const InputControls = ({ isChatPage, hasModules, changeMode, + checkScreenPermission, + requestScreenPermission, + getScreenMonitors, + getScreenWindows, + captureWindowScreenshot, + captureMonitorScreenshot, + openFileDialog, + getFileMetadata, + getFileIcon, }: InputControlsProps) => { const { t } = useTranslation(); const isTauri = useAppStore((state) => state.isTauri); - const currentAssistant = useConnectStore((state) => state.currentAssistant); + const { currentAssistant, currentSessionId } = useConnectStore(); const { modeSwitch, deepThinking } = useShortcutsStore(); const source = currentAssistant?._source; @@ -151,24 +171,24 @@ const InputControls = ({ > {isChatMode ? (
- {/* {sessionId && ( - - )} */} + {currentSessionId && ( + + )} {source?.type === "deep_think" && source?.config?.visible && (
) : (
diff --git a/src/components/Search/InputExtra.tsx b/src/components/Search/InputUpload.tsx similarity index 80% rename from src/components/Search/InputExtra.tsx rename to src/components/Search/InputUpload.tsx index 1eb41a7a..7bc4323b 100644 --- a/src/components/Search/InputExtra.tsx +++ b/src/components/Search/InputUpload.tsx @@ -1,4 +1,4 @@ -import { Fragment, MouseEvent } from "react"; +import { FC, Fragment, MouseEvent } from "react"; import { useTranslation } from "react-i18next"; import { ChevronRight, Plus } from "lucide-react"; import { @@ -35,7 +35,7 @@ interface MenuItem { clickEvent?: (event: MouseEvent) => void; } -interface InputExtraProps { +interface InputUploadProps { checkScreenPermission: () => Promise; requestScreenPermission: () => void; getScreenMonitors: () => Promise; @@ -49,30 +49,22 @@ interface InputExtraProps { getFileIcon: (path: string, size: number) => Promise; } -const InputExtra = ({ - checkScreenPermission, - requestScreenPermission, - getScreenMonitors, - getScreenWindows, - captureMonitorScreenshot, - captureWindowScreenshot, - openFileDialog, - getFileMetadata, - getFileIcon, -}: InputExtraProps) => { +const InputUpload: FC = (props) => { + const { + checkScreenPermission, + requestScreenPermission, + getScreenMonitors, + getScreenWindows, + captureMonitorScreenshot, + captureWindowScreenshot, + openFileDialog, + getFileMetadata, + getFileIcon, + } = props; const { t, i18n } = useTranslation(); - const uploadFiles = useChatStore((state) => state.uploadFiles); - const setUploadFiles = useChatStore((state) => state.setUploadFiles); - const withVisibility = useAppStore((state) => state.withVisibility); - const modifierKey = useShortcutsStore((state) => { - return state.modifierKey; - }); - const addFile = useShortcutsStore((state) => { - return state.addFile; - }); - const modifierKeyPressed = useShortcutsStore((state) => { - return state.modifierKeyPressed; - }); + const { uploadFiles, setUploadFiles } = useChatStore(); + const { withVisibility, addError } = useAppStore(); + const { modifierKey, addFile, modifierKeyPressed } = useShortcutsStore(); const state = useReactive({ screenshotableMonitors: [], @@ -104,6 +96,8 @@ const InputExtra = ({ const stat = await getFileMetadata(path); if (stat.size / 1024 / 1024 > 100) { + addError(t("search.input.uploadFileHints.maxSize")); + continue; } @@ -184,25 +178,23 @@ const InputExtra = ({ return ( - - -
- + + + -
- {addFile} -
+
+ {addFile}
@@ -280,4 +272,4 @@ const InputExtra = ({ ); }; -export default InputExtra; +export default InputUpload; diff --git a/src/components/Search/MCPPopover.tsx b/src/components/Search/MCPPopover.tsx index 2775cf2f..25698abd 100644 --- a/src/components/Search/MCPPopover.tsx +++ b/src/components/Search/MCPPopover.tsx @@ -166,7 +166,7 @@ export default function MCPPopover({ return (
state.isPinned); - const visible = useAppStore((state) => state.visible); - const setBlurred = useAppStore((state) => state.setBlurred); + const { setBlurred } = useAppStore(); useTauriFocus({ onBlur() { + const { isPinned, visible } = useAppStore.getState(); + if (isPinned || visible) { return setBlurred(true); } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4122518d..17c84826 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -342,6 +342,10 @@ "searchPopover": { "title": "Search Scope", "allScope": "All Scope" + }, + "uploadFileHints": { + "tooltip": "Support screenshots, upload files, up to 50, single file up to 100 MB.", + "maxSize": "The file size cannot exceed 100 MB." } }, "main": { diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 411b79dd..eebee493 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -342,6 +342,10 @@ "searchPopover": { "title": "搜索范围", "allScope": "所有范围" + }, + "uploadFileHints": { + "tooltip": "支持截图、上传文件,最多 50个,单个文件最大 100 MB。", + "maxSize": "文件大小不能超过 100 MB。" } }, "main": { diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index cd3c246b..a2903ae9 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -5,12 +5,14 @@ import { } from "zustand/middleware"; import { Metadata } from "tauri-plugin-fs-pro-api"; -interface UploadFile extends Metadata { +export interface UploadFile extends Metadata { id: string; path: string; icon: string; uploaded?: boolean; attachmentId?: string; + uploadFailed?: boolean; + failedMessage?: string; } interface SynthesizeItem {