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

29
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -1,14 +1,13 @@
mod assistant;
mod autostart;
mod common;
mod local;
mod search;
mod server;
mod setup;
mod shortcut;
mod util;
mod setup;
mod assistant;
use crate::common::register::SearchSourceRegistry;
// use crate::common::traits::SearchSource;
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_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
#[cfg(target_os = "macos")]

View File

@@ -22,6 +22,7 @@ import { Sidebar } from "@/components/Assistant/Sidebar";
import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore";
import { IServer } from "@/stores/appStore";
import FileList from "../Search/FileList";
interface ChatAIProps {
isTransitioned: boolean;
@@ -91,6 +92,7 @@ const ChatAI = memo(
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
const [chats, setChats] = useState<Chat[]>([]);
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
const uploadFiles = useChatStore((state) => state.uploadFiles);
useEffect(() => {
activeChatProp && setActiveChat(activeChatProp);
@@ -641,7 +643,6 @@ const ChatAI = memo(
/>
</div>
)}
<ChatHeader
onCreateNewChat={clearChat}
onOpenChatAI={openChatAI}
@@ -653,9 +654,9 @@ const ChatAI = memo(
activeChat={activeChat}
reconnect={reconnect}
/>
{/* 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">
<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">
<ChatMessage
key={"greetings"}
message={{
@@ -666,7 +667,6 @@ const ChatAI = memo(
},
}}
/>
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
@@ -675,7 +675,6 @@ const ChatAI = memo(
onResend={handleSendMessage}
/>
))}
{(query_intent ||
fetch_source ||
pick_source ||
@@ -703,7 +702,6 @@ const ChatAI = memo(
response={response}
/>
) : null}
{timedoutShow ? (
<ChatMessage
key={"timedout"}
@@ -719,7 +717,6 @@ const ChatAI = memo(
isTyping={false}
/>
) : null}
{errorShow ? (
<ChatMessage
key={"error"}
@@ -735,9 +732,15 @@ const ChatAI = memo(
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
{uploadFiles.length > 0 && (
<div className="max-h-[120px] overflow-auto p-2 border-t border-[#E6E6E6] cl">
<FileList />
</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 TypeIcon from "@/components/Common/Icons/TypeIcon";
import { isArray } from "lodash-es";
import InputExtra from "./InputExtra";
interface ChatInputProps {
onSend: (message: string) => void;
@@ -448,8 +449,10 @@ export default function ChatInput({
>
{isChatMode ? (
<div className="flex gap-2 text-xs text-[#333] dark:text-[#d8d8d8]">
<InputExtra />
<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
? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]"
: "border-[#262727]"
@@ -457,12 +460,13 @@ export default function ChatInput({
onClick={DeepThinkClick}
>
<Brain
className={`w-3 h-3 mr-1 ${
className={`size-3 ${
isDeepThinkActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white"
}`}
/>
{isDeepThinkActive && (
<span
className={
isDeepThinkActive ? "text-[#0072FF]" : "dark:text-white"
@@ -470,9 +474,11 @@ export default function ChatInput({
>
{t("search.input.deepThink")}
</span>
)}
</button>
<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
? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]"
: "border-[#262727]"
@@ -480,13 +486,15 @@ export default function ChatInput({
onClick={SearchClick}
>
<Globe
className={`w-3 h-3 mr-1 ${
className={`size-3 ${
isSearchActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white"
}`}
/>
{isSearchActive && (
<>
<span
className={
isSearchActive ? "text-[#0072FF]" : "dark:text-white"
@@ -553,7 +561,10 @@ export default function ChatInput({
{isAll ? (
<Layers className="size-[16px] text-[#0287FF]" />
) : (
<TypeIcon item={item} className="size-[16px]" />
<TypeIcon
item={item}
className="size-[16px]"
/>
)}
<span>{isAll ? t(name) : name}</span>
@@ -586,6 +597,8 @@ export default function ChatInput({
</div>
</PopoverPanel>
</Popover>
</>
)}
</div>
{/*<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,
// createJSONStorage
} from "zustand/middleware";
import { Metadata } from "tauri-plugin-fs-pro-api";
interface UploadFile extends Metadata {
id: string;
path: string;
icon: string;
}
export type IChatStore = {
curChatEnd: boolean;
@@ -13,6 +20,8 @@ export type IChatStore = {
setConnected: (value: boolean) => void;
messages: string;
setMessages: (value: string | ((prev: string) => string)) => void;
uploadFiles: UploadFile[];
setUploadFiles: (value: UploadFile[]) => void;
};
export const useChatStore = create<IChatStore>()(
@@ -29,6 +38,10 @@ export const useChatStore = create<IChatStore>()(
set((state) => ({
messages: typeof value === "function" ? value(state.messages) : value,
})),
uploadFiles: [],
setUploadFiles: (uploadFiles: UploadFile[]) => {
return set(() => ({ uploadFiles }));
},
}),
{
name: "chat-state",