feat: chat support for uploading files (#229)

* feat: chat support for uploading files

* refactor: out of focus hidden window

* refactor: filtering files larger than 100M

* refactor: displayed in the chat content area

* refactor: hide window when out of focus
This commit is contained in:
ayangweb
2025-03-03 17:54:00 +08:00
committed by GitHub
parent fbe20df1f9
commit 5d7c252a8f
11 changed files with 1119 additions and 253 deletions

View File

@@ -29,11 +29,13 @@
"axios": "^1.7.7", "axios": "^1.7.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"filesize": "^10.1.6",
"i18next": "^23.16.2", "i18next": "^23.16.2",
"i18next-browser-languagedetector": "^8.0.3", "i18next-browser-languagedetector": "^8.0.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.461.0", "lucide-react": "^0.461.0",
"mermaid": "^11.4.0", "mermaid": "^11.4.0",
"nanoid": "^5.1.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.5.1", "react-hotkeys-hook": "^4.5.1",
@@ -46,6 +48,7 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tauri-plugin-fs-pro-api": "^2.3.1",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"zustand": "^5.0.0" "zustand": "^5.0.0"

29
pnpm-lock.yaml generated
View File

@@ -56,6 +56,9 @@ importers:
dotenv: dotenv:
specifier: ^16.4.7 specifier: ^16.4.7
version: 16.4.7 version: 16.4.7
filesize:
specifier: ^10.1.6
version: 10.1.6
i18next: i18next:
specifier: ^23.16.2 specifier: ^23.16.2
version: 23.16.2 version: 23.16.2
@@ -71,6 +74,9 @@ importers:
mermaid: mermaid:
specifier: ^11.4.0 specifier: ^11.4.0
version: 11.4.0 version: 11.4.0
nanoid:
specifier: ^5.1.2
version: 5.1.2
react: react:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.3.1 version: 18.3.1
@@ -107,6 +113,9 @@ importers:
remark-math: remark-math:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
tauri-plugin-fs-pro-api:
specifier: ^2.3.1
version: 2.3.1
use-debounce: use-debounce:
specifier: ^10.0.4 specifier: ^10.0.4
version: 10.0.4(react@18.3.1) version: 10.0.4(react@18.3.1)
@@ -1879,6 +1888,10 @@ packages:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'} engines: {node: '>=18'}
filesize@10.1.6:
resolution: {integrity: sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==}
engines: {node: '>= 10.4.0'}
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2556,6 +2569,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@5.1.2:
resolution: {integrity: sha512-b+CiXQCNMUGe0Ri64S9SXFcP9hogjAJ2Rd6GdVxhPLRm7mhGaM7VgOvCAJ1ZshfHbqVDI3uqTI5C8/GaKuLI7g==}
engines: {node: ^18 || >=20}
hasBin: true
netmask@2.0.2: netmask@2.0.2:
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
@@ -3087,6 +3105,9 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
tauri-plugin-fs-pro-api@2.3.1:
resolution: {integrity: sha512-fx/zITX9MWoDZ603FKWSybluZqJUEOvHU+H6kj3iRJNyoGFHoNkajpQbiK5cu81spQbGBlP9sV2HkaCI07gQ+Q==}
thenify-all@1.6.0: thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -5047,6 +5068,8 @@ snapshots:
dependencies: dependencies:
is-unicode-supported: 2.1.0 is-unicode-supported: 2.1.0
filesize@10.1.6: {}
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
@@ -5963,6 +5986,8 @@ snapshots:
nanoid@3.3.7: {} nanoid@3.3.7: {}
nanoid@5.1.2: {}
netmask@2.0.2: {} netmask@2.0.2: {}
new-github-release-url@2.0.0: new-github-release-url@2.0.0:
@@ -6602,6 +6627,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
tauri-plugin-fs-pro-api@2.3.1:
dependencies:
'@tauri-apps/api': 2.2.0
thenify-all@1.6.0: thenify-all@1.6.0:
dependencies: dependencies:
thenify: 3.3.1 thenify: 3.3.1

808
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,7 @@ tauri-plugin-fs = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-drag = "2" tauri-plugin-drag = "2"
tauri-plugin-fs-pro = "2"
tokio-native-tls = "0.3" # For wss connections tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

View File

@@ -62,6 +62,8 @@
} }
], ],
"deny": [] "deny": []
} },
"dialog:default",
"fs-pro:default"
] ]
} }

View File

@@ -1,14 +1,13 @@
mod assistant;
mod autostart; mod autostart;
mod common; mod common;
mod local; mod local;
mod search; mod search;
mod server; mod server;
mod setup;
mod shortcut; mod shortcut;
mod util; mod util;
mod setup;
mod assistant;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource; // use crate::common::traits::SearchSource;
use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL}; use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
@@ -80,7 +79,9 @@ pub fn run() {
)) ))
.plugin(tauri_plugin_theme::init(ctx.config_mut())) .plugin(tauri_plugin_theme::init(ctx.config_mut()))
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_store::Builder::default().build()); .plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs_pro::init());
// Conditional compilation for macOS // Conditional compilation for macOS
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]

View File

@@ -22,6 +22,7 @@ import { Sidebar } from "@/components/Assistant/Sidebar";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { IServer } from "@/stores/appStore"; import { IServer } from "@/stores/appStore";
import FileList from "../Search/FileList";
interface ChatAIProps { interface ChatAIProps {
isTransitioned: boolean; isTransitioned: boolean;
@@ -91,6 +92,7 @@ const ChatAI = memo(
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen); const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
const [chats, setChats] = useState<Chat[]>([]); const [chats, setChats] = useState<Chat[]>([]);
const sourceDataIds = useSearchStore((state) => state.sourceDataIds); const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
const uploadFiles = useChatStore((state) => state.uploadFiles);
useEffect(() => { useEffect(() => {
activeChatProp && setActiveChat(activeChatProp); activeChatProp && setActiveChat(activeChatProp);
@@ -641,7 +643,6 @@ const ChatAI = memo(
/> />
</div> </div>
)} )}
<ChatHeader <ChatHeader
onCreateNewChat={clearChat} onCreateNewChat={clearChat}
onOpenChatAI={openChatAI} onOpenChatAI={openChatAI}
@@ -653,90 +654,92 @@ const ChatAI = memo(
activeChat={activeChat} activeChat={activeChat}
reconnect={reconnect} reconnect={reconnect}
/> />
{/* Chat messages */} {/* Chat messages */}
<div className="w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative"> <div className="flex flex-col h-full justify-between overflow-hidden">
<ChatMessage <div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
key={"greetings"}
message={{
_id: "greetings",
_source: {
type: "assistant",
message: t("assistant.chat.greetings"),
},
}}
/>
{activeChat?.messages?.map((message, index) => (
<ChatMessage <ChatMessage
key={message._id + index} key={"greetings"}
message={message}
isTyping={false}
onResend={handleSendMessage}
/>
))}
{(query_intent ||
fetch_source ||
pick_source ||
deep_read ||
think ||
response) &&
activeChat?._id ? (
<ChatMessage
key={"current"}
message={{ message={{
_id: "current", _id: "greetings",
_source: { _source: {
type: "assistant", type: "assistant",
message: "", message: t("assistant.chat.greetings"),
question: Question,
}, },
}} }}
onResend={handleSendMessage}
isTyping={!curChatEnd}
query_intent={query_intent}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
/> />
) : null} {activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
message={message}
isTyping={false}
onResend={handleSendMessage}
/>
))}
{(query_intent ||
fetch_source ||
pick_source ||
deep_read ||
think ||
response) &&
activeChat?._id ? (
<ChatMessage
key={"current"}
message={{
_id: "current",
_source: {
type: "assistant",
message: "",
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={!curChatEnd}
query_intent={query_intent}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
/>
) : null}
{timedoutShow ? (
<ChatMessage
key={"timedout"}
message={{
_id: "timedout",
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
{errorShow ? (
<ChatMessage
key={"error"}
message={{
_id: "error",
_source: {
type: "assistant",
message: t("assistant.chat.error"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
{timedoutShow ? ( {uploadFiles.length > 0 && (
<ChatMessage <div className="max-h-[120px] overflow-auto p-2 border-t border-[#E6E6E6] cl">
key={"timedout"} <FileList />
message={{ </div>
_id: "timedout", )}
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
{errorShow ? (
<ChatMessage
key={"error"}
message={{
_id: "error",
_source: {
type: "assistant",
message: t("assistant.chat.error"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,53 @@
import { useChatStore } from "@/stores/chatStore";
import { convertFileSrc } from "@tauri-apps/api/core";
import { filesize } from "filesize";
import { X } from "lucide-react";
const FileList = () => {
const uploadFiles = useChatStore((state) => state.uploadFiles);
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
const deleteFile = (id: string) => {
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => {
const { id, icon, name, extname, size } = file;
return (
<div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center rounded-sm bg-black/10 p-1">
<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);
}}
>
<X className="size-[10px] text-white" />
</div>
<img src={convertFileSrc(icon)} className="size-[40px]" />
<div className="flex-1 flex flex-col justify-between">
<div className="truncate">{name}</div>
<div className="text-xs text-black/60">
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
export default FileList;

View File

@@ -32,6 +32,7 @@ import { metaOrCtrlKey } from "@/utils/keyboardUtils";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import TypeIcon from "@/components/Common/Icons/TypeIcon"; import TypeIcon from "@/components/Common/Icons/TypeIcon";
import { isArray } from "lodash-es"; import { isArray } from "lodash-es";
import InputExtra from "./InputExtra";
interface ChatInputProps { interface ChatInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
@@ -448,8 +449,10 @@ export default function ChatInput({
> >
{isChatMode ? ( {isChatMode ? (
<div className="flex gap-2 text-xs text-[#333] dark:text-[#d8d8d8]"> <div className="flex gap-2 text-xs text-[#333] dark:text-[#d8d8d8]">
<InputExtra />
<button <button
className={`h-5 px-2 inline-flex items-center border rounded-[10px] transition-colors relative ${ className={`h-5 px-2 inline-flex justify-center items-center gap-1 border rounded-[10px] transition-colors relative ${
isDeepThinkActive isDeepThinkActive
? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]" ? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]"
: "border-[#262727]" : "border-[#262727]"
@@ -457,22 +460,25 @@ export default function ChatInput({
onClick={DeepThinkClick} onClick={DeepThinkClick}
> >
<Brain <Brain
className={`w-3 h-3 mr-1 ${ className={`size-3 ${
isDeepThinkActive isDeepThinkActive
? "text-[#0072FF] dark:text-[#0072FF]" ? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white" : "text-[#333] dark:text-white"
}`} }`}
/> />
<span {isDeepThinkActive && (
className={ <span
isDeepThinkActive ? "text-[#0072FF]" : "dark:text-white" className={
} isDeepThinkActive ? "text-[#0072FF]" : "dark:text-white"
> }
{t("search.input.deepThink")} >
</span> {t("search.input.deepThink")}
</span>
)}
</button> </button>
<div <div
className={`h-5 px-2 inline-flex items-center border rounded-[10px] transition-colors relative cursor-pointer ${ className={`h-5 px-2 inline-flex items-center justify-center gap-1 border rounded-[10px] transition-colors relative cursor-pointer ${
isSearchActive isSearchActive
? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]" ? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]"
: "border-[#262727]" : "border-[#262727]"
@@ -480,112 +486,119 @@ export default function ChatInput({
onClick={SearchClick} onClick={SearchClick}
> >
<Globe <Globe
className={`w-3 h-3 mr-1 ${ className={`size-3 ${
isSearchActive isSearchActive
? "text-[#0072FF] dark:text-[#0072FF]" ? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white" : "text-[#333] dark:text-white"
}`} }`}
/> />
<span {isSearchActive && (
className={ <>
isSearchActive ? "text-[#0072FF]" : "dark:text-white" <span
} className={
> isSearchActive ? "text-[#0072FF]" : "dark:text-white"
{t("search.input.search")} }
</span>
<Popover>
<PopoverButton className={clsx("flex items-center")}>
<ChevronDownIcon
className={clsx("size-4", [
isSearchActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white",
])}
/>
</PopoverButton>
<PopoverPanel
anchor="top start"
className="min-w-[220px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
>
<div
className="text-sm px-[12px] py-[18px]"
onClick={(e) => {
e.stopPropagation();
}}
> >
<div className="flex justify-between mb-[18px]"> {t("search.input.search")}
<span>{t("search.input.searchPopover.title")}</span> </span>
<Popover>
<PopoverButton className={clsx("flex items-center")}>
<ChevronDownIcon
className={clsx("size-4", [
isSearchActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white",
])}
/>
</PopoverButton>
<PopoverPanel
anchor="top start"
className="min-w-[220px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
>
<div <div
onClick={async () => { className="text-sm px-[12px] py-[18px]"
setIsRefreshDataSource(true); onClick={(e) => {
e.stopPropagation();
getDataSourceList();
setTimeout(() => {
setIsRefreshDataSource(false);
}, 1000);
}} }}
className="size-[24px] flex justify-center items-center rounded-lg border border-black/10 dark:border-white/10"
> >
<RefreshCw <div className="flex justify-between mb-[18px]">
className={`size-3 text-[#0287FF] transition-transform duration-1000 ${ <span>{t("search.input.searchPopover.title")}</span>
isRefreshDataSource ? "animate-spin" : ""
}`}
/>
</div>
</div>
<ul className="flex flex-col gap-[16px]">
{state.dataSourceList?.map((item, index) => {
const { id, name } = item;
const isAll = index === 0; <div
onClick={async () => {
setIsRefreshDataSource(true);
return ( getDataSourceList();
<li
key={id} setTimeout(() => {
className="flex justify-between items-center" setIsRefreshDataSource(false);
}, 1000);
}}
className="size-[24px] flex justify-center items-center rounded-lg border border-black/10 dark:border-white/10"
> >
<div className="flex items-center gap-[8px]"> <RefreshCw
{isAll ? ( className={`size-3 text-[#0287FF] transition-transform duration-1000 ${
<Layers className="size-[16px] text-[#0287FF]" /> isRefreshDataSource ? "animate-spin" : ""
) : ( }`}
<TypeIcon item={item} className="size-[16px]" /> />
)} </div>
</div>
<ul className="flex flex-col gap-[16px]">
{state.dataSourceList?.map((item, index) => {
const { id, name } = item;
<span>{isAll ? t(name) : name}</span> const isAll = index === 0;
</div>
<Checkbox return (
checked={ <li
isAll key={id}
? sourceDataIds.length === className="flex justify-between items-center"
state.dataSourceList.length - 1 >
: sourceDataIds?.includes(id) <div className="flex items-center gap-[8px]">
} {isAll ? (
onChange={(value) => <Layers className="size-[16px] text-[#0287FF]" />
onSelectDataSource(id, value, isAll) ) : (
} <TypeIcon
className="group size-[14px] rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition" item={item}
> className="size-[16px]"
{isAll && ( />
<div className="size-full flex items-center justify-center group-data-[checked]:hidden"> )}
<div className="size-[6px] bg-[#2F54EB]"></div>
<span>{isAll ? t(name) : name}</span>
</div> </div>
)}
<CheckIcon className="hidden size-[12px] text-white group-data-[checked]:block" /> <Checkbox
</Checkbox> checked={
</li> isAll
); ? sourceDataIds.length ===
})} state.dataSourceList.length - 1
</ul> : sourceDataIds?.includes(id)
</div> }
</PopoverPanel> onChange={(value) =>
</Popover> onSelectDataSource(id, value, isAll)
}
className="group size-[14px] rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition"
>
{isAll && (
<div className="size-full flex items-center justify-center group-data-[checked]:hidden">
<div className="size-[6px] bg-[#2F54EB]"></div>
</div>
)}
<CheckIcon className="hidden size-[12px] text-white group-data-[checked]:block" />
</Checkbox>
</li>
);
})}
</ul>
</div>
</PopoverPanel>
</Popover>
</>
)}
</div> </div>
{/*<button*/} {/*<button*/}

View File

@@ -0,0 +1,88 @@
import { Plus } from "lucide-react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { open } from "@tauri-apps/plugin-dialog";
import { find, isNil } from "lodash-es";
import { useChatStore } from "@/stores/chatStore";
import { metadata, icon } from "tauri-plugin-fs-pro-api";
import { nanoid } from "nanoid";
import Tooltip from "../Common/Tooltip";
const InputExtra = () => {
const uploadFiles = useChatStore((state) => state.uploadFiles);
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
const uploadFile = async () => {
const selectedFiles = await open({
multiple: true,
});
if (isNil(selectedFiles)) return;
const files: typeof uploadFiles = [];
for await (const path of selectedFiles) {
if (find(uploadFiles, { path })) continue;
const stat = await metadata(path);
if (stat.size / 1024 / 1024 > 100) {
continue;
}
files.push({
...stat,
id: nanoid(),
path,
icon: await icon(path),
});
}
console.log("files", files);
setUploadFiles([...uploadFiles, ...files]);
};
const menuItems = [
{
label: "上传文件",
event: uploadFile,
},
// {
// label: "截取屏幕截图",
// event: () => {},
// },
];
return (
<Menu>
<MenuButton>
<Tooltip content="支持截图、上传文件,最多 50个单个文件最大 100 MB。">
<div className="group h-5 px-2 flex justify-center items-center border rounded-[10px] transition-colors relative border-[#262727] hover:bg-[rgba(0,114,255,0.3)] hover:border-[rgba(0,114,255,0.3)]">
<Plus className="size-3 text-[#333] dark:text-white group-hover:text-[#0072FF] hover:dark:text-[#0072FF]" />
</div>
</Tooltip>
</MenuButton>
<MenuItems
anchor="bottom start"
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
>
{menuItems.map((item) => {
const { label, event } = item;
return (
<MenuItem key={label}>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={event}
>
{label}
</div>
</MenuItem>
);
})}
</MenuItems>
</Menu>
);
};
export default InputExtra;

View File

@@ -3,6 +3,13 @@ import {
persist, persist,
// createJSONStorage // createJSONStorage
} from "zustand/middleware"; } from "zustand/middleware";
import { Metadata } from "tauri-plugin-fs-pro-api";
interface UploadFile extends Metadata {
id: string;
path: string;
icon: string;
}
export type IChatStore = { export type IChatStore = {
curChatEnd: boolean; curChatEnd: boolean;
@@ -13,6 +20,8 @@ export type IChatStore = {
setConnected: (value: boolean) => void; setConnected: (value: boolean) => void;
messages: string; messages: string;
setMessages: (value: string | ((prev: string) => string)) => void; setMessages: (value: string | ((prev: string) => string)) => void;
uploadFiles: UploadFile[];
setUploadFiles: (value: UploadFile[]) => void;
}; };
export const useChatStore = create<IChatStore>()( export const useChatStore = create<IChatStore>()(
@@ -29,6 +38,10 @@ export const useChatStore = create<IChatStore>()(
set((state) => ({ set((state) => ({
messages: typeof value === "function" ? value(state.messages) : value, messages: typeof value === "function" ? value(state.messages) : value,
})), })),
uploadFiles: [],
setUploadFiles: (uploadFiles: UploadFile[]) => {
return set(() => ({ uploadFiles }));
},
}), }),
{ {
name: "chat-state", name: "chat-state",