refactor: enabling the upload file component (#755)

* refactor: enabling the upload file component

* update
This commit is contained in:
ayangweb
2025-07-10 17:26:44 +08:00
committed by GitHub
parent 5d37420109
commit b17949fe29
13 changed files with 365 additions and 288 deletions

View File

@@ -15,42 +15,6 @@ pub struct UploadAttachmentResponse {
pub attachments: Vec<String>, pub attachments: Vec<String>,
} }
#[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<String>,
pub _id: String,
pub _score: Option<f64>,
pub _source: AttachmentSource,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHits {
pub total: Value,
pub max_score: Option<f64>,
pub hits: Option<Vec<AttachmentHit>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetAttachmentResponse {
pub took: u32,
pub timed_out: bool,
pub _shards: Option<Value>,
pub hits: AttachmentHits,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct DeleteAttachmentResponse { pub struct DeleteAttachmentResponse {
pub _id: String, pub _id: String,
@@ -107,10 +71,7 @@ pub async fn upload_attachment(
} }
#[command] #[command]
pub async fn get_attachment( pub async fn get_attachment(server_id: String, session_id: String) -> Result<Value, String> {
server_id: String,
session_id: String,
) -> Result<GetAttachmentResponse, String> {
let mut query_params = Vec::new(); let mut query_params = Vec::new();
query_params.push(format!("session={}", session_id)); query_params.push(format!("session={}", session_id));
@@ -120,7 +81,7 @@ pub async fn get_attachment(
let body = get_response_body_text(response).await?; let body = get_response_body_text(response).await?;
serde_json::from_str::<GetAttachmentResponse>(&body) serde_json::from_str::<Value>(&body)
.map_err(|e| format!("Failed to parse attachment response: {}", e)) .map_err(|e| format!("Failed to parse attachment response: {}", e))
} }

View File

@@ -4,10 +4,11 @@ import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks"; import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore, UploadFile } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import FileIcon from "../Common/Icons/FileIcon"; import FileIcon from "../Common/Icons/FileIcon";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import Tooltip2 from "../Common/Tooltip2";
interface FileListProps { interface FileListProps {
sessionId: string; sessionId: string;
@@ -17,9 +18,8 @@ interface FileListProps {
const FileList = (props: FileListProps) => { const FileList = (props: FileListProps) => {
const { sessionId } = props; const { sessionId } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles); const { uploadFiles, setUploadFiles } = useChatStore();
const setUploadFiles = useChatStore((state) => state.setUploadFiles); const { currentService } = useConnectStore();
const currentService = useConnectStore((state) => state.currentService);
const serverId = useMemo(() => { const serverId = useMemo(() => {
return currentService.id; return currentService.id;
@@ -39,6 +39,7 @@ const FileList = (props: FileListProps) => {
if (uploaded) continue; if (uploaded) continue;
try {
const attachmentIds: any = await platformAdapter.commands( const attachmentIds: any = await platformAdapter.commands(
"upload_attachment", "upload_attachment",
{ {
@@ -48,20 +49,32 @@ const FileList = (props: FileListProps) => {
} }
); );
if (!attachmentIds) continue; if (!attachmentIds) {
throw new Error("Failed to get attachment id");
} else {
Object.assign(item, { Object.assign(item, {
uploaded: true, uploaded: true,
attachmentId: attachmentIds[0], attachmentId: attachmentIds[0],
}); });
}
setUploadFiles(uploadFiles); setUploadFiles(uploadFiles);
} catch (error) {
Object.assign(item, {
uploadFailed: true,
failedMessage: String(error),
});
}
} }
}, [uploadFiles]); }, [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)); setUploadFiles(uploadFiles.filter((file) => file.id !== id));
if (uploadFailed) return;
platformAdapter.commands("delete_attachment", { platformAdapter.commands("delete_attachment", {
serverId, serverId,
id: attachmentId, id: attachmentId,
@@ -71,16 +84,25 @@ const FileList = (props: FileListProps) => {
return ( return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm"> <div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => { {uploadFiles.map((file) => {
const { id, name, extname, size, uploaded, attachmentId } = file; const {
id,
name,
extname,
size,
uploaded,
attachmentId,
uploadFailed,
failedMessage,
} = file;
return ( return (
<div key={id} className="w-1/3 px-1"> <div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]"> <div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
{attachmentId && ( {(uploadFailed || attachmentId) && (
<div <div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 " className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => { onClick={() => {
deleteFile(id, attachmentId); deleteFile(file);
}} }}
> >
<X className="size-[10px] text-white" /> <X className="size-[10px] text-white" />
@@ -94,7 +116,13 @@ const FileList = (props: FileListProps) => {
{name} {name}
</div> </div>
<div className="text-xs text-[#999999]"> <div className="text-xs">
{uploadFailed && failedMessage ? (
<Tooltip2 content={failedMessage}>
<span className="text-red-500">Upload Failed</span>
</Tooltip2>
) : (
<div className="text-[#999]">
{uploaded ? ( {uploaded ? (
<div className="flex gap-2"> <div className="flex gap-2">
{extname && <span>{extname}</span>} {extname && <span>{extname}</span>}
@@ -106,6 +134,8 @@ const FileList = (props: FileListProps) => {
<span>{t("assistant.fileList.uploading")}</span> <span>{t("assistant.fileList.uploading")}</span>
)} )}
</div> </div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -37,11 +37,15 @@ const SessionFile = (props: SessionFileProps) => {
const getUploadedFiles = async () => { const getUploadedFiles = async () => {
if (isTauri) { if (isTauri) {
console.log("sessionId", sessionId);
const response: any = await platformAdapter.commands("get_attachment", { const response: any = await platformAdapter.commands("get_attachment", {
serverId, serverId,
sessionId, sessionId,
}); });
console.log("get_attachment response", response);
setUploadedFiles(response?.hits?.hits ?? []); setUploadedFiles(response?.hits?.hits ?? []);
} else { } else {
} }
@@ -91,8 +95,7 @@ const SessionFile = (props: SessionFileProps) => {
> >
<Files className="size-5 text-white" /> <Files className="size-5 text-white" />
<div <div className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]">
className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]">
{uploadedFiles?.length} {uploadedFiles?.length}
</div> </div>
</div> </div>

View File

@@ -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<Tooltip2Props> = (props) => {
const { content, children, anchor = "top", ...rest } = props;
const [visible, { setTrue, setFalse }] = useBoolean(false);
return (
<Popover>
<PopoverButton onMouseOver={setTrue} onMouseOut={setFalse}>
{children}
</PopoverButton>
<PopoverPanel
{...rest}
static
anchor={anchor}
className={clsx(
"fixed z-1000 p-2 rounded-md text-xs text-white bg-black/75 hidden",
{
"!block": visible,
}
)}
>
{content}
</PopoverPanel>
</Popover>
);
};
export default Tooltip2;

View File

@@ -71,6 +71,15 @@ export default function ChatInput({
hasModules = [], hasModules = [],
searchPlaceholder, searchPlaceholder,
chatPlaceholder, chatPlaceholder,
checkScreenPermission,
requestScreenPermission,
getScreenMonitors,
getScreenWindows,
captureWindowScreenshot,
captureMonitorScreenshot,
openFileDialog,
getFileMetadata,
getFileIcon,
}: ChatInputProps) { }: ChatInputProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -351,6 +360,15 @@ export default function ChatInput({
isMCPActive={isMCPActive} isMCPActive={isMCPActive}
setIsMCPActive={setIsMCPActive} setIsMCPActive={setIsMCPActive}
changeMode={changeMode} changeMode={changeMode}
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/> />
</div> </div>
); );

View File

@@ -16,7 +16,7 @@ import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore"; import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils"; import { parseSearchQuery, SearchQuery } from "@/utils";
// import InputExtra from "./InputExtra"; import InputUpload from "./InputUpload";
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon"; // import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
interface InputControlsProps { interface InputControlsProps {
@@ -32,6 +32,17 @@ interface InputControlsProps {
searchPlaceholder?: string; searchPlaceholder?: string;
chatPlaceholder?: string; chatPlaceholder?: string;
changeMode?: (isChatMode: boolean) => void; changeMode?: (isChatMode: boolean) => void;
checkScreenPermission: () => Promise<boolean>;
requestScreenPermission: () => void;
getScreenMonitors: () => Promise<any[]>;
getScreenWindows: () => Promise<any[]>;
captureMonitorScreenshot: (id: number) => Promise<string>;
captureWindowScreenshot: (id: number) => Promise<string>;
openFileDialog: (options: {
multiple: boolean;
}) => Promise<string | string[] | null>;
getFileMetadata: (path: string) => Promise<any>;
getFileIcon: (path: string, size: number) => Promise<string>;
} }
const InputControls = ({ const InputControls = ({
@@ -45,12 +56,21 @@ const InputControls = ({
isChatPage, isChatPage,
hasModules, hasModules,
changeMode, changeMode,
checkScreenPermission,
requestScreenPermission,
getScreenMonitors,
getScreenWindows,
captureWindowScreenshot,
captureMonitorScreenshot,
openFileDialog,
getFileMetadata,
getFileIcon,
}: InputControlsProps) => { }: InputControlsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isTauri = useAppStore((state) => state.isTauri); const isTauri = useAppStore((state) => state.isTauri);
const currentAssistant = useConnectStore((state) => state.currentAssistant); const { currentAssistant, currentSessionId } = useConnectStore();
const { modeSwitch, deepThinking } = useShortcutsStore(); const { modeSwitch, deepThinking } = useShortcutsStore();
const source = currentAssistant?._source; const source = currentAssistant?._source;
@@ -151,8 +171,8 @@ const InputControls = ({
> >
{isChatMode ? ( {isChatMode ? (
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]"> <div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
{/* {sessionId && ( {currentSessionId && (
<InputExtra <InputUpload
checkScreenPermission={checkScreenPermission} checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission} requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors} getScreenMonitors={getScreenMonitors}
@@ -163,12 +183,12 @@ const InputControls = ({
getFileMetadata={getFileMetadata} getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon} getFileIcon={getFileIcon}
/> />
)} */} )}
{source?.type === "deep_think" && source?.config?.visible && ( {source?.type === "deep_think" && source?.config?.visible && (
<button <button
className={clsx( className={clsx(
"flex items-center gap-1 py-[3px] pl-1 pr-1.5 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]", "flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
{ {
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive, "!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
} }
@@ -213,13 +233,14 @@ const InputControls = ({
getMCPByServer={getMCPByServer} getMCPByServer={getMCPByServer}
/> />
{!(source?.datasource?.enabled && source?.datasource?.visible) && {!currentSessionId &&
!(source?.datasource?.enabled && source?.datasource?.visible) &&
(source?.type !== "deep_think" || !source?.config?.visible) && (source?.type !== "deep_think" || !source?.config?.visible) &&
!(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) ? ( !(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) && (
<div className="px-[9px]"> <div className="px-[9px]">
<Copyright /> <Copyright />
</div> </div>
) : null} )}
</div> </div>
) : ( ) : (
<div data-tauri-drag-region className="w-28 flex gap-2 relative"> <div data-tauri-drag-region className="w-28 flex gap-2 relative">

View File

@@ -1,4 +1,4 @@
import { Fragment, MouseEvent } from "react"; import { FC, Fragment, MouseEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronRight, Plus } from "lucide-react"; import { ChevronRight, Plus } from "lucide-react";
import { import {
@@ -35,7 +35,7 @@ interface MenuItem {
clickEvent?: (event: MouseEvent) => void; clickEvent?: (event: MouseEvent) => void;
} }
interface InputExtraProps { interface InputUploadProps {
checkScreenPermission: () => Promise<boolean>; checkScreenPermission: () => Promise<boolean>;
requestScreenPermission: () => void; requestScreenPermission: () => void;
getScreenMonitors: () => Promise<any[]>; getScreenMonitors: () => Promise<any[]>;
@@ -49,7 +49,8 @@ interface InputExtraProps {
getFileIcon: (path: string, size: number) => Promise<string>; getFileIcon: (path: string, size: number) => Promise<string>;
} }
const InputExtra = ({ const InputUpload: FC<InputUploadProps> = (props) => {
const {
checkScreenPermission, checkScreenPermission,
requestScreenPermission, requestScreenPermission,
getScreenMonitors, getScreenMonitors,
@@ -59,20 +60,11 @@ const InputExtra = ({
openFileDialog, openFileDialog,
getFileMetadata, getFileMetadata,
getFileIcon, getFileIcon,
}: InputExtraProps) => { } = props;
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles); const { uploadFiles, setUploadFiles } = useChatStore();
const setUploadFiles = useChatStore((state) => state.setUploadFiles); const { withVisibility, addError } = useAppStore();
const withVisibility = useAppStore((state) => state.withVisibility); const { modifierKey, addFile, modifierKeyPressed } = useShortcutsStore();
const modifierKey = useShortcutsStore((state) => {
return state.modifierKey;
});
const addFile = useShortcutsStore((state) => {
return state.addFile;
});
const modifierKeyPressed = useShortcutsStore((state) => {
return state.modifierKeyPressed;
});
const state = useReactive<State>({ const state = useReactive<State>({
screenshotableMonitors: [], screenshotableMonitors: [],
@@ -104,6 +96,8 @@ const InputExtra = ({
const stat = await getFileMetadata(path); const stat = await getFileMetadata(path);
if (stat.size / 1024 / 1024 > 100) { if (stat.size / 1024 / 1024 > 100) {
addError(t("search.input.uploadFileHints.maxSize"));
continue; continue;
} }
@@ -184,11 +178,10 @@ const InputExtra = ({
return ( return (
<Menu> <Menu>
<MenuButton className="size-6"> <MenuButton className="flex p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip content="支持截图、上传文件,最多 50个单个文件最大 100 MB。"> <Tooltip content={t("search.input.uploadFileHints.tooltip")}>
<div className="size-full flex justify-center items-center rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Plus <Plus
className={clsx("size-5", { className={clsx("size-3 scale-[1.3]", {
hidden: modifierKeyPressed, hidden: modifierKeyPressed,
})} })}
/> />
@@ -203,7 +196,6 @@ const InputExtra = ({
> >
{addFile} {addFile}
</div> </div>
</div>
</Tooltip> </Tooltip>
</MenuButton> </MenuButton>
@@ -280,4 +272,4 @@ const InputExtra = ({
); );
}; };
export default InputExtra; export default InputUpload;

View File

@@ -166,7 +166,7 @@ export default function MCPPopover({
return ( return (
<div <div
className={clsx( className={clsx(
"flex items-center gap-1 p-[3px] pr-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer", "flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{ {
"!bg-[rgba(0,114,255,0.3)]": isMCPActive, "!bg-[rgba(0,114,255,0.3)]": isMCPActive,
} }

View File

@@ -172,7 +172,7 @@ export default function SearchPopover({
return ( return (
<div <div
className={clsx( className={clsx(
"flex items-center gap-1 p-[3px] pr-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer", "flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{ {
"!bg-[rgba(0,114,255,0.3)]": isSearchActive, "!bg-[rgba(0,114,255,0.3)]": isSearchActive,
} }

View File

@@ -3,12 +3,12 @@ import platformAdapter from "@/utils/platformAdapter";
import { useTauriFocus } from "./useTauriFocus"; import { useTauriFocus } from "./useTauriFocus";
export function useWindowEvents() { export function useWindowEvents() {
const isPinned = useAppStore((state) => state.isPinned); const { setBlurred } = useAppStore();
const visible = useAppStore((state) => state.visible);
const setBlurred = useAppStore((state) => state.setBlurred);
useTauriFocus({ useTauriFocus({
onBlur() { onBlur() {
const { isPinned, visible } = useAppStore.getState();
if (isPinned || visible) { if (isPinned || visible) {
return setBlurred(true); return setBlurred(true);
} }

View File

@@ -342,6 +342,10 @@
"searchPopover": { "searchPopover": {
"title": "Search Scope", "title": "Search Scope",
"allScope": "All 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": { "main": {

View File

@@ -342,6 +342,10 @@
"searchPopover": { "searchPopover": {
"title": "搜索范围", "title": "搜索范围",
"allScope": "所有范围" "allScope": "所有范围"
},
"uploadFileHints": {
"tooltip": "支持截图、上传文件,最多 50个单个文件最大 100 MB。",
"maxSize": "文件大小不能超过 100 MB。"
} }
}, },
"main": { "main": {

View File

@@ -5,12 +5,14 @@ import {
} from "zustand/middleware"; } from "zustand/middleware";
import { Metadata } from "tauri-plugin-fs-pro-api"; import { Metadata } from "tauri-plugin-fs-pro-api";
interface UploadFile extends Metadata { export interface UploadFile extends Metadata {
id: string; id: string;
path: string; path: string;
icon: string; icon: string;
uploaded?: boolean; uploaded?: boolean;
attachmentId?: string; attachmentId?: string;
uploadFailed?: boolean;
failedMessage?: string;
} }
interface SynthesizeItem { interface SynthesizeItem {