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>,
}
#[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)]
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<GetAttachmentResponse, String> {
pub async fn get_attachment(server_id: String, session_id: String) -> Result<Value, String> {
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::<GetAttachmentResponse>(&body)
serde_json::from_str::<Value>(&body)
.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 { 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,6 +39,7 @@ const FileList = (props: FileListProps) => {
if (uploaded) continue;
try {
const attachmentIds: any = await platformAdapter.commands(
"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, {
uploaded: true,
attachmentId: attachmentIds[0],
});
}
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 (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => {
const { id, name, extname, size, uploaded, attachmentId } = file;
const {
id,
name,
extname,
size,
uploaded,
attachmentId,
uploadFailed,
failedMessage,
} = file;
return (
<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]">
{attachmentId && (
{(uploadFailed || attachmentId) && (
<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 "
onClick={() => {
deleteFile(id, attachmentId);
deleteFile(file);
}}
>
<X className="size-[10px] text-white" />
@@ -94,7 +116,13 @@ const FileList = (props: FileListProps) => {
{name}
</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 ? (
<div className="flex gap-2">
{extname && <span>{extname}</span>}
@@ -106,6 +134,8 @@ const FileList = (props: FileListProps) => {
<span>{t("assistant.fileList.uploading")}</span>
)}
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -1,14 +1,14 @@
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 {
@@ -16,8 +16,8 @@ interface SessionFileProps {
}
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);
@@ -37,11 +37,15 @@ const SessionFile = (props: SessionFileProps) => {
const getUploadedFiles = async () => {
if (isTauri) {
console.log("sessionId", sessionId);
const response: any = await platformAdapter.commands("get_attachment", {
serverId,
sessionId,
});
console.log("get_attachment response", response);
setUploadedFiles(response?.hits?.hits ?? []);
} else {
}
@@ -89,10 +93,9 @@ const SessionFile = (props: SessionFileProps) => {
setVisible(true);
}}
>
<Files className="size-5 text-white"/>
<Files className="size-5 text-white" />
<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]">
<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]">
{uploadedFiles?.length}
</div>
</div>
@@ -128,7 +131,7 @@ const SessionFile = (props: SessionFileProps) => {
</div>
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6 p-0">
{uploadedFiles?.map((item) => {
const {id, name, icon, size} = item._source;
const { id, name, icon, size } = item._source;
return (
<li
@@ -136,7 +139,7 @@ const SessionFile = (props: SessionFileProps) => {
className="flex items-center justify-between min-h-12 px-2 rounded-[4px] bg-[#ededed] dark:bg-[#202126]"
>
<div className="flex items-center gap-2">
<FileIcon extname={icon}/>
<FileIcon extname={icon} />
<div>
<div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]">
@@ -145,7 +148,7 @@ const SessionFile = (props: SessionFileProps) => {
<div className="text-xs text-[#999]">
<span>{icon}</span>
<span className="pl-2">
{filesize(size, {standard: "jedec", spacer: ""})}
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</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 = [],
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}
/>
</div>
);

View File

@@ -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<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 = ({
@@ -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,8 +171,8 @@ const InputControls = ({
>
{isChatMode ? (
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
{/* {sessionId && (
<InputExtra
{currentSessionId && (
<InputUpload
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
@@ -163,12 +183,12 @@ const InputControls = ({
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
)} */}
)}
{source?.type === "deep_think" && source?.config?.visible && (
<button
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,
}
@@ -213,13 +233,14 @@ const InputControls = ({
getMCPByServer={getMCPByServer}
/>
{!(source?.datasource?.enabled && source?.datasource?.visible) &&
{!currentSessionId &&
!(source?.datasource?.enabled && source?.datasource?.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]">
<Copyright />
</div>
) : null}
)}
</div>
) : (
<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 { ChevronRight, Plus } from "lucide-react";
import {
@@ -35,7 +35,7 @@ interface MenuItem {
clickEvent?: (event: MouseEvent) => void;
}
interface InputExtraProps {
interface InputUploadProps {
checkScreenPermission: () => Promise<boolean>;
requestScreenPermission: () => void;
getScreenMonitors: () => Promise<any[]>;
@@ -49,7 +49,8 @@ interface InputExtraProps {
getFileIcon: (path: string, size: number) => Promise<string>;
}
const InputExtra = ({
const InputUpload: FC<InputUploadProps> = (props) => {
const {
checkScreenPermission,
requestScreenPermission,
getScreenMonitors,
@@ -59,20 +60,11 @@ const InputExtra = ({
openFileDialog,
getFileMetadata,
getFileIcon,
}: InputExtraProps) => {
} = 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<State>({
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,11 +178,10 @@ const InputExtra = ({
return (
<Menu>
<MenuButton className="size-6">
<Tooltip content="支持截图、上传文件,最多 50个单个文件最大 100 MB。">
<div className="size-full flex justify-center items-center rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<MenuButton className="flex p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip content={t("search.input.uploadFileHints.tooltip")}>
<Plus
className={clsx("size-5", {
className={clsx("size-3 scale-[1.3]", {
hidden: modifierKeyPressed,
})}
/>
@@ -203,7 +196,6 @@ const InputExtra = ({
>
{addFile}
</div>
</div>
</Tooltip>
</MenuButton>
@@ -280,4 +272,4 @@ const InputExtra = ({
);
};
export default InputExtra;
export default InputUpload;

View File

@@ -166,7 +166,7 @@ export default function MCPPopover({
return (
<div
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,
}

View File

@@ -172,7 +172,7 @@ export default function SearchPopover({
return (
<div
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,
}

View File

@@ -3,12 +3,12 @@ import platformAdapter from "@/utils/platformAdapter";
import { useTauriFocus } from "./useTauriFocus";
export function useWindowEvents() {
const isPinned = useAppStore((state) => 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);
}

View File

@@ -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": {

View File

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

View File

@@ -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 {