feat: support for uploading files to the server (#310)

* feat: support for uploading files to the server

* feat: field Internationalization

* refactor: encapsulation attachment-related requests

* feat: support for getting a list of attachments that have been uploaded for a session

* feat: the session displays the number and list of uploaded files

* feat: internalization

* feat: wrapping the Checkbox component

* feat: add checkbox

* feat: support for deleting uploaded files

* feat: support for selecting uploaded files

* refactor: optimize the display of file icons

* refactor: hide file uploads when there is no sessionId
This commit is contained in:
ayangweb
2025-03-28 13:50:14 +08:00
committed by GitHub
parent d2eed4a1c4
commit d9dea0ea38
23 changed files with 859 additions and 74 deletions

View File

@@ -18,6 +18,7 @@
"release-beta": "release-it --preRelease=beta --preReleaseBase=1" "release-beta": "release-it --preRelease=beta --preReleaseBase=1"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@tauri-apps/api": "^2.4.0", "@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-autostart": "~2.2.0", "@tauri-apps/plugin-autostart": "~2.2.0",

59
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@ant-design/icons':
specifier: ^6.0.0
version: 6.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@headlessui/react': '@headlessui/react':
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -206,6 +209,23 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
'@ant-design/colors@8.0.0':
resolution: {integrity: sha512-6YzkKCw30EI/E9kHOIXsQDHmMvTllT8STzjMb4K2qzit33RW2pqCJP0sk+hidBntXxE+Vz4n1+RvCTfBw6OErw==}
'@ant-design/fast-color@3.0.0':
resolution: {integrity: sha512-eqvpP7xEDm2S7dUzl5srEQCBTXZMmY3ekf97zI+M2DHOYyKdJGH0qua0JACHTqbkRnD/KHFQP9J1uMJ/XWVzzA==}
engines: {node: '>=8.x'}
'@ant-design/icons-svg@4.4.2':
resolution: {integrity: sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==}
'@ant-design/icons@6.0.0':
resolution: {integrity: sha512-o0aCCAlHc1o4CQcapAwWzHeaW2x9F49g7P3IDtvtNXgHowtRWYb7kiubt8sQPFvfVIVU/jLw2hzeSlNt0FU+Uw==}
engines: {node: '>=8'}
peerDependencies:
react: '>=16.0.0'
react-dom: '>=16.0.0'
'@antfu/install-pkg@1.0.0': '@antfu/install-pkg@1.0.0':
resolution: {integrity: sha512-xvX6P/lo1B3ej0OsaErAjqgFYzYVcJpamjLAFLYh9vRJngBrMoUG7aVnrGTeqM7yxbyTD5p3F2+0/QUEh8Vzhw==} resolution: {integrity: sha512-xvX6P/lo1B3ej0OsaErAjqgFYzYVcJpamjLAFLYh9vRJngBrMoUG7aVnrGTeqM7yxbyTD5p3F2+0/QUEh8Vzhw==}
@@ -864,6 +884,12 @@ packages:
resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@rc-component/util@1.2.1':
resolution: {integrity: sha512-AUVu6jO+lWjQnUOOECwu8iR0EdElQgWW5NBv5vP/Uf9dWbAX3udhMutRlkVXjuac2E40ghkFy+ve00mc/3Fymg==}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
'@react-aria/focus@3.20.1': '@react-aria/focus@3.20.1':
resolution: {integrity: sha512-lgYs+sQ1TtBrAXnAdRBQrBo0/7o5H6IrfDxec1j+VRpcXL0xyk0xPq+m3lZp8typzIghqDgpnKkJ5Jf4OrzPIw==} resolution: {integrity: sha512-lgYs+sQ1TtBrAXnAdRBQrBo0/7o5H6IrfDxec1j+VRpcXL0xyk0xPq+m3lZp8typzIghqDgpnKkJ5Jf4OrzPIw==}
peerDependencies: peerDependencies:
@@ -1506,6 +1532,9 @@ packages:
resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==}
engines: {node: '>=8'} engines: {node: '>=8'}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
cli-boxes@3.0.0: cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2898,6 +2927,9 @@ packages:
react-native: react-native:
optional: true optional: true
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-markdown@9.1.0: react-markdown@9.1.0:
resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==}
peerDependencies: peerDependencies:
@@ -3519,6 +3551,23 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.8 '@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25 '@jridgewell/trace-mapping': 0.3.25
'@ant-design/colors@8.0.0':
dependencies:
'@ant-design/fast-color': 3.0.0
'@ant-design/fast-color@3.0.0': {}
'@ant-design/icons-svg@4.4.2': {}
'@ant-design/icons@6.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@ant-design/colors': 8.0.0
'@ant-design/icons-svg': 4.4.2
'@rc-component/util': 1.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
classnames: 2.5.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@antfu/install-pkg@1.0.0': '@antfu/install-pkg@1.0.0':
dependencies: dependencies:
package-manager-detector: 0.2.11 package-manager-detector: 0.2.11
@@ -4089,6 +4138,12 @@ snapshots:
'@pnpm/network.ca-file': 1.0.2 '@pnpm/network.ca-file': 1.0.2
config-chain: 1.1.13 config-chain: 1.1.13
'@rc-component/util@1.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is: 18.3.1
'@react-aria/focus@3.20.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@react-aria/focus@3.20.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@react-aria/interactions': 3.24.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-aria/interactions': 3.24.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4716,6 +4771,8 @@ snapshots:
ci-info@4.2.0: {} ci-info@4.2.0: {}
classnames@2.5.1: {}
cli-boxes@3.0.0: {} cli-boxes@3.0.0: {}
cli-cursor@5.0.0: cli-cursor@5.0.0:
@@ -6385,6 +6442,8 @@ snapshots:
optionalDependencies: optionalDependencies:
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-is@18.3.1: {}
react-markdown@9.1.0(@types/react@18.3.19)(react@18.3.1): react-markdown@9.1.0(@types/react@18.3.19)(react@18.3.1):
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4

18
src-tauri/Cargo.lock generated
View File

@@ -777,6 +777,7 @@ dependencies = [
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-tungstenite 0.20.1", "tokio-tungstenite 0.20.1",
"tokio-util",
"tungstenite 0.24.0", "tungstenite 0.24.0",
"url", "url",
"walkdir", "walkdir",
@@ -3240,6 +3241,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -4782,6 +4793,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess",
"native-tls", "native-tls",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
@@ -6736,6 +6748,12 @@ dependencies = [
"unic-common", "unic-common",
] ]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.18"

View File

@@ -47,7 +47,7 @@ tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] }
hyper = { version = "0.14", features = ["client"] } hyper = { version = "0.14", features = ["client"] }
reqwest = "0.12.12" reqwest = { version = "0.12", features = ["json", "multipart"] }
futures = "0.3.31" futures = "0.3.31"
ordered-float = { version = "4.6.0", default-features = false } ordered-float = { version = "4.6.0", default-features = false }
lazy_static = "1.5.0" lazy_static = "1.5.0"
@@ -68,6 +68,7 @@ url = "2.5.2"
http = "1.1.0" http = "1.1.0"
tungstenite = "0.24.0" tungstenite = "0.24.0"
env_logger = "0.11.5" env_logger = "0.11.5"
tokio-util = "0.7.14"
[target."cfg(target_os = \"macos\")".dependencies] [target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }

View File

@@ -124,7 +124,10 @@ pub fn run() {
// server::get_coco_server_connectors, // server::get_coco_server_connectors,
server::websocket::connect_to_server, server::websocket::connect_to_server,
server::websocket::disconnect, server::websocket::disconnect,
get_app_search_source get_app_search_source,
server::attachment::upload_attachment,
server::attachment::get_attachment,
server::attachment::delete_attachment,
]) ])
.setup(|app| { .setup(|app| {
let registry = SearchSourceRegistry::default(); let registry = SearchSourceRegistry::default();

View File

@@ -0,0 +1,151 @@
use super::servers::{get_server_by_id, get_server_token};
use crate::server::http_client::HttpClient;
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::HashMap, path::PathBuf};
use tauri::command;
use tokio::fs::File;
use tokio_util::codec::{BytesCodec, FramedRead};
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadAttachmentResponse {
pub acknowledged: bool,
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: String,
pub _id: String,
pub _score: f64,
pub _source: AttachmentSource,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHits {
pub total: Value,
pub max_score: f64,
pub hits: Vec<AttachmentHit>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetAttachmentResponse {
pub took: u32,
pub timed_out: bool,
pub _shards: Value,
pub hits: AttachmentHits,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteAttachmentResponse {
pub _id: String,
pub result: String,
}
#[command]
pub async fn upload_attachment(
server_id: String,
session_id: String,
file_paths: Vec<PathBuf>,
) -> Result<UploadAttachmentResponse, String> {
let mut form = Form::new();
for file_path in file_paths {
let file = File::open(&file_path)
.await
.map_err(|err| err.to_string())?;
let stream = FramedRead::new(file, BytesCodec::new());
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or("Invalid filename")?;
let part =
Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name.to_string());
form = form.part("files", part);
}
let server = get_server_by_id(&server_id).ok_or("Server not found")?;
let url = HttpClient::join_url(&server.endpoint, &format!("chat/{}/_upload", session_id));
let token = get_server_token(&server_id).await?;
let mut headers = HashMap::new();
if let Some(token) = token {
headers.insert("X-API-TOKEN".to_string(), token.access_token);
}
let client = reqwest::Client::new();
let response = client
.post(url)
.multipart(form)
.headers((&headers).try_into().map_err(|err| format!("{}", err))?)
.send()
.await
.map_err(|err| err.to_string())?;
if response.status().is_success() {
let result = response
.json::<UploadAttachmentResponse>()
.await
.map_err(|err| err.to_string())?;
Ok(result)
} else {
Err(format!("Upload failed with status: {}", response.status()))
}
}
#[command]
pub async fn get_attachment(
server_id: String,
session_id: String,
) -> Result<GetAttachmentResponse, String> {
let mut query_params = HashMap::new();
query_params.insert("session".to_string(), serde_json::Value::String(session_id));
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params)).await?;
if response.status().is_success() {
response
.json::<GetAttachmentResponse>()
.await
.map_err(|e| e.to_string())
} else {
Err(format!("Request failed with status: {}", response.status()))
}
}
#[command]
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
let response =
HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None).await?;
if response.status().is_success() {
response
.json::<DeleteAttachmentResponse>()
.await
.map_err(|e| e.to_string())?
.result
.eq("deleted")
.then_some(true)
.ok_or("Delete operation was not successful".to_string())
} else {
Err(format!("Delete failed with status: {}", response.status()))
}
}

View File

@@ -1,10 +1,11 @@
//! This file contains Rust APIs related to Coco Server management. //! This file contains Rust APIs related to Coco Server management.
pub mod attachment;
pub mod auth; pub mod auth;
pub mod servers;
pub mod connector; pub mod connector;
pub mod datasource; pub mod datasource;
pub mod http_client; pub mod http_client;
pub mod profile; pub mod profile;
pub mod search; pub mod search;
pub mod servers;
pub mod websocket; pub mod websocket;

73
src/api/attachment.ts Normal file
View File

@@ -0,0 +1,73 @@
import { invoke } from "@tauri-apps/api/core";
interface UploadAttachmentPayload {
serverId: string;
sessionId: string;
filePaths: string[];
}
interface UploadAttachmentResponse {
acknowledged: boolean;
attachments: string[];
}
type GetAttachmentPayload = Omit<UploadAttachmentPayload, "filePaths">;
export interface AttachmentHit {
_index: string;
_type: string;
_id: string;
_score: number;
_source: {
id: string;
created: string;
updated: string;
session: string;
name: string;
icon: string;
url: string;
size: number;
};
}
interface GetAttachmentResponse {
took: number;
timed_out: boolean;
_shards: {
total: number;
successful: number;
skipped: number;
failed: number;
};
hits: {
total: {
value: number;
relation: string;
};
max_score: number;
hits: AttachmentHit[];
};
}
interface DeleteAttachmentPayload {
serverId: string;
id: string;
}
export const uploadAttachment = async (payload: UploadAttachmentPayload) => {
const response = await invoke<UploadAttachmentResponse>("upload_attachment", {
...payload,
});
if (response?.acknowledged) {
return response.attachments;
}
};
export const getAttachment = (payload: GetAttachmentPayload) => {
return invoke<GetAttachmentResponse>("get_attachment", { ...payload });
};
export const deleteAttachment = (payload: DeleteAttachmentPayload) => {
return invoke<boolean>("delete_attachment", { ...payload });
};

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage"; import { ChatMessage } from "@/components/ChatMessage";
@@ -7,6 +7,8 @@ import FileList from "@/components/Assistant/FileList";
import { useChatScroll } from "@/hooks/useChatScroll"; import { useChatScroll } from "@/hooks/useChatScroll";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import type { Chat, IChunkData } from "./types"; import type { Chat, IChunkData } from "./types";
import SessionFile from "./SessionFile";
import { useConnectStore } from "@/stores/connectStore";
interface ChatContentProps { interface ChatContentProps {
activeChat?: Chat; activeChat?: Chat;
@@ -41,12 +43,21 @@ export const ChatContent = ({
handleSendMessage, handleSendMessage,
getFileUrl, getFileUrl,
}: ChatContentProps) => { }: ChatContentProps) => {
const sessionId = useConnectStore((state) => state.currentSessionId);
const setCurrentSessionId = useConnectStore((state) => {
return state.setCurrentSessionId;
});
useEffect(() => {
setCurrentSessionId(activeChat?._id);
}, [activeChat]);
const { t } = useTranslation(); const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles); const uploadFiles = useChatStore((state) => state.uploadFiles);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef); const { scrollToBottom } = useChatScroll(messagesEndRef);
useEffect(() => { useEffect(() => {
@@ -69,7 +80,7 @@ export const ChatContent = ({
}, [scrollToBottom]); }, [scrollToBottom]);
return ( return (
<div className="flex flex-col h-full justify-between overflow-hidden"> <div className="relative 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"> <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">
<Greetings /> <Greetings />
@@ -143,11 +154,13 @@ export const ChatContent = ({
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{uploadFiles.length > 0 && ( {sessionId && uploadFiles.length > 0 && (
<div className="max-h-[120px] overflow-auto p-2"> <div key={sessionId} className="max-h-[120px] overflow-auto p-2">
<FileList getFileUrl={getFileUrl}/> <FileList sessionId={sessionId} getFileUrl={getFileUrl} />
</div> </div>
)} )}
{sessionId && <SessionFile sessionId={sessionId} />}
</div> </div>
); );
}; };

View File

@@ -1,42 +1,88 @@
import { useEffect, useMemo } from "react";
import { filesize } from "filesize"; import { filesize } from "filesize";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import { isImage } from "@/utils"; import { isImage } from "@/utils";
import { useConnectStore } from "@/stores/connectStore";
import { deleteAttachment, uploadAttachment } from "@/api/attachment";
import FileIcon from "../Common/Icons/FileIcon";
interface FileListProps { interface FileListProps {
sessionId: string;
getFileUrl: (path: string) => string; getFileUrl: (path: string) => string;
} }
const FileList = ({ getFileUrl }: FileListProps) => { const FileList = (props: FileListProps) => {
const { sessionId } = props;
const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles); const uploadFiles = useChatStore((state) => state.uploadFiles);
const setUploadFiles = useChatStore((state) => state.setUploadFiles); const setUploadFiles = useChatStore((state) => state.setUploadFiles);
const currentService = useConnectStore((state) => state.currentService);
const deleteFile = (id: string) => { const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
return () => {
setUploadFiles([]);
};
}, []);
useAsyncEffect(async () => {
if (uploadFiles.length === 0) return;
for await (const item of uploadFiles) {
const { uploaded, path } = item;
if (uploaded) continue;
const attachmentIds = await uploadAttachment({
serverId,
sessionId,
filePaths: [path],
});
if (!attachmentIds) continue;
Object.assign(item, {
uploaded: true,
attachmentId: attachmentIds[0],
});
setUploadFiles(uploadFiles);
}
}, [uploadFiles]);
const deleteFile = async (id: string, attachmentId: string) => {
setUploadFiles(uploadFiles.filter((file) => file.id !== id)); setUploadFiles(uploadFiles.filter((file) => file.id !== id));
deleteAttachment({ serverId, id: attachmentId });
}; };
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, path, icon, name, extname, size } = file; const { id, name, extname, size, uploaded, attachmentId } = 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]">
<div {attachmentId && (
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 " <div
onClick={() => { 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 "
deleteFile(id); onClick={() => {
}} deleteFile(id, attachmentId);
> }}
<X className="size-[10px] text-white" /> >
</div> <X className="size-[10px] text-white" />
</div>
)}
<img <FileIcon extname={extname} />
src={getFileUrl(isImage(path) ? path : icon)}
className="size-[40px]"
/>
<div className="flex flex-col justify-between overflow-hidden"> <div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-[#333333] dark:text-[#D8D8D8]"> <div className="truncate text-[#333333] dark:text-[#D8D8D8]">
@@ -44,12 +90,16 @@ const FileList = ({ getFileUrl }: FileListProps) => {
</div> </div>
<div className="text-xs text-[#999999]"> <div className="text-xs text-[#999999]">
<div className="flex gap-2"> {uploaded ? (
{extname && <span>{extname}</span>} <div className="flex gap-2">
<span> {extname && <span>{extname}</span>}
{filesize(size, { standard: "jedec", spacer: "" })} <span>
</span> {filesize(size, { standard: "jedec", spacer: "" })}
</div> </span>
</div>
) : (
<span>{t("assistant.fileList.uploading")}</span>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,160 @@
import {
AttachmentHit,
deleteAttachment,
getAttachment,
} from "@/api/attachment";
import { useConnectStore } from "@/stores/connectStore";
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 Checkbox from "../Common/Checkbox";
import FileIcon from "../Common/Icons/FileIcon";
interface SessionFileProps {
sessionId: string;
}
const SessionFile = (props: SessionFileProps) => {
const { sessionId } = props;
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]);
const [visible, setVisible] = useState(false);
const [checkList, setCheckList] = useState<string[]>([]);
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
setUploadedFiles([]);
getUploadedFiles();
}, [sessionId]);
const getUploadedFiles = async () => {
const response = await getAttachment({ serverId, sessionId });
setUploadedFiles(response.hits.hits);
};
const handleDelete = async (id: string) => {
const result = await deleteAttachment({ serverId, id });
if (!result) return;
getUploadedFiles();
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckList(uploadedFiles.map((item) => item._source.id));
} else {
setCheckList([]);
}
};
const handleCheck = (checked: boolean, id: string) => {
if (checked) {
setCheckList([...checkList, id]);
} else {
setCheckList(checkList.filter((item) => item !== id));
}
};
return (
<div
className={clsx("select-none", {
hidden: uploadedFiles.length === 0,
})}
>
<div
className="absolute top-4 right-4 flex items-center justify-center size-8 rounded-lg bg-[#0072FF] cursor-pointer"
onClick={() => {
setVisible(true);
}}
>
<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]">
{uploadedFiles.length}
</div>
</div>
<div
className={clsx(
"absolute inset-0 flex flex-col p-4 bg-white dark:bg-black",
{
hidden: !visible,
}
)}
>
<X
className="absolute top-4 right-4 size-5 text-[#999] cursor-pointer"
onClick={() => {
setVisible(false);
}}
/>
<div className="mb-2 text-sm text-[#333] dark:text-[#D8D8D8] font-bold">
{t("assistant.sessionFile.title")}
</div>
<div className="flex items-center justify-between pr-2">
<span className="text-sm text-[#999]">
{t("assistant.sessionFile.description")}
</span>
<Checkbox
indeterminate
checked={checkList.length === uploadedFiles.length}
onChange={handleCheckAll}
/>
</div>
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6">
{uploadedFiles.map((item) => {
const { id, name, icon, size } = item._source;
return (
<li
key={id}
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} />
<div>
<div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs text-[#999]">
<span>{icon}</span>
<span className="pl-2">
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Trash2
className="size-4 text-[#999] cursor-pointer"
onClick={() => handleDelete(id)}
/>
<Checkbox
checked={checkList.includes(id)}
onChange={(checked) => handleCheck(checked, id)}
/>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
};
export default SessionFile;

View File

@@ -67,6 +67,13 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
const recordedUrl = URL.createObjectURL(blob); const recordedUrl = URL.createObjectURL(blob);
console.log("recorded:", recordedUrl); console.log("recorded:", recordedUrl);
// 获取文件大小(单位:字节)
const fileSizeInBytes = blob.size;
// 转换为 MB保留两位小数
const fileSizeInMB = (fileSizeInBytes / (1024 * 1024)).toFixed(2);
console.log("recorded:", recordedUrl, `size: ${fileSizeInMB}MB`);
// setAudioUrl(recordedUrl); // setAudioUrl(recordedUrl);
}); });

View File

@@ -0,0 +1,34 @@
import {
CheckboxProps as HeadlessCheckboxProps,
Checkbox as HeadlessCheckbox,
} from "@headlessui/react";
import clsx from "clsx";
import { CheckIcon } from "lucide-react";
interface CheckboxProps extends HeadlessCheckboxProps {
indeterminate?: boolean;
}
const Checkbox = (props: CheckboxProps) => {
const { indeterminate, className, ...rest } = props;
return (
<HeadlessCheckbox
{...rest}
className={clsx(
"group size-4 rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition cursor-pointer",
className
)}
>
{indeterminate && (
<div className="size-full flex items-center justify-center group-data-[checked]:hidden">
<div className="size-2 bg-[#2F54EB]"></div>
</div>
)}
<CheckIcon className="hidden size-[14px] text-white group-data-[checked]:block" />
</HeadlessCheckbox>
);
};
export default Checkbox;

View File

@@ -0,0 +1,21 @@
const AudioIcon = () => {
return (
<svg
width="1em"
height="1em"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<title>audio</title>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<path
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M10.7315824,7.11216117 C10.7428131,7.15148751 10.7485063,7.19218979 10.7485063,7.23309113 L10.7485063,8.07742614 C10.7484199,8.27364959 10.6183424,8.44607275 10.4296853,8.50003683 L8.32984514,9.09986306 L8.32984514,11.7071803 C8.32986605,12.5367078 7.67249692,13.217028 6.84345686,13.2454634 L6.79068592,13.2463395 C6.12766108,13.2463395 5.53916361,12.8217001 5.33010655,12.1924966 C5.1210495,11.563293 5.33842118,10.8709227 5.86959669,10.4741173 C6.40077221,10.0773119 7.12636292,10.0652587 7.67042486,10.4442027 L7.67020842,7.74937024 L7.68449368,7.74937024 C7.72405122,7.59919041 7.83988806,7.48101083 7.98924584,7.4384546 L10.1880418,6.81004755 C10.42156,6.74340323 10.6648954,6.87865515 10.7315824,7.11216117 Z M9.60714286,1.31785714 L12.9678571,4.67857143 L9.60714286,4.67857143 L9.60714286,1.31785714 Z"
fill="currentColor"
/>
</g>
</svg>
);
};
export default AudioIcon;

View File

@@ -0,0 +1,21 @@
const VideoIcon = () => {
return (
<svg
width="1em"
height="1em"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<title>video</title>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<path
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M12.9678571,4.67857143 L9.60714286,1.31785714 L9.60714286,4.67857143 L12.9678571,4.67857143 Z M10.5379461,10.3101106 L6.68957555,13.0059749 C6.59910784,13.0693494 6.47439406,13.0473861 6.41101953,12.9569184 C6.3874624,12.9232903 6.37482581,12.8832269 6.37482581,12.8421686 L6.37482581,7.45043999 C6.37482581,7.33998304 6.46436886,7.25043999 6.57482581,7.25043999 C6.61588409,7.25043999 6.65594753,7.26307658 6.68957555,7.28663371 L10.5379461,9.98249803 C10.6284138,10.0458726 10.6503772,10.1705863 10.5870027,10.2610541 C10.5736331,10.2801392 10.5570312,10.2967411 10.5379461,10.3101106 Z"
fill="currentColor"
/>
</g>
</svg>
);
};
export default VideoIcon;

View File

@@ -0,0 +1,154 @@
import {
FileExcelFilled,
FileImageFilled,
FileMarkdownFilled,
FilePdfFilled,
FilePptFilled,
FileTextFilled,
FileWordFilled,
FileZipFilled,
} from "@ant-design/icons";
import AudioIcon from "./AudioIcon";
import VideoIcon from "./VideoIcon";
import { cloneElement, FC, useMemo } from "react";
import clsx from "clsx";
interface FileIconProps {
extname: string;
className?: string;
}
const FileIcon: FC<FileIconProps> = (props) => {
const { extname, className } = props;
const presetFileIcons = [
{
icon: <FileExcelFilled />,
color: "#22b35e",
extnames: ["xlsx", "xls", "csv", "xlsm", "xltx", "xltm", "xlsb"],
},
{
icon: <FileImageFilled />,
color: "#13c2c2",
extnames: [
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"svg",
"ico",
"tiff",
"raw",
"heic",
"psd",
"ai",
],
},
{
icon: <FileMarkdownFilled />,
color: "#722ed1",
extnames: ["md", "mdx", "markdown", "mdown", "mkd", "mkdn"],
},
{
icon: <FilePdfFilled />,
color: "#ff4d4f",
extnames: ["pdf", "xps", "oxps"],
},
{
icon: <FilePptFilled />,
color: "#d04423",
extnames: [
"ppt",
"pptx",
"pps",
"ppsx",
"pot",
"potx",
"pptm",
"potm",
"ppsm",
],
},
{
icon: <FileWordFilled />,
color: "#1677ff",
extnames: ["doc", "docx", "dot", "dotx", "docm", "dotm", "rtf", "odt"],
},
{
icon: <FileZipFilled />,
color: "#fab714",
extnames: [
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"tgz",
"iso",
"dmg",
],
},
{
icon: <VideoIcon />,
color: "#7b61ff",
extnames: [
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
"mpeg",
"mpg",
"3gp",
"rmvb",
"ts",
],
},
{
icon: <AudioIcon />,
color: "#eb2f96",
extnames: [
"mp3",
"wav",
"flac",
"ape",
"aac",
"ogg",
"wma",
"m4a",
"opus",
"ac3",
"mid",
"midi",
],
},
];
const [icon, iconColor] = useMemo(() => {
for (const item of presetFileIcons) {
const { icon, color, extnames } = item;
if (extnames.includes(extname)) {
return [icon, color];
}
}
return [<FileTextFilled key="defaultIcon" />, "#8c8c8c"];
}, [extname]);
return (
<div className={clsx("text-3xl", className)} style={{ color: iconColor }}>
{icon}
</div>
);
};
export default FileIcon;

View File

@@ -14,6 +14,8 @@ import SearchPopover from "./SearchPopover";
import AudioRecording from "../AudioRecording"; import AudioRecording from "../AudioRecording";
import { hide_coco } from "@/commands"; import { hide_coco } from "@/commands";
import { DataSource } from "@/types/commands"; import { DataSource } from "@/types/commands";
import InputExtra from "./InputExtra";
import { useConnectStore } from "@/stores/connectStore";
interface ChatInputProps { interface ChatInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
@@ -60,16 +62,16 @@ export default function ChatInput({
isChatPage = false, isChatPage = false,
getDataSourcesByServer, getDataSourcesByServer,
setupWindowFocusListener, setupWindowFocusListener,
}: // checkScreenPermission, checkScreenPermission,
// requestScreenPermission, requestScreenPermission,
// getScreenMonitors, getScreenMonitors,
// getScreenWindows, getScreenWindows,
// captureMonitorScreenshot, captureMonitorScreenshot,
// captureWindowScreenshot, captureWindowScreenshot,
// openFileDialog, openFileDialog,
// getFileMetadata, getFileMetadata,
// getFileIcon, getFileIcon,
ChatInputProps) { }: ChatInputProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const showTooltip = useAppStore( const showTooltip = useAppStore(
@@ -85,6 +87,8 @@ ChatInputProps) {
(state: { setSourceData: any }) => state.setSourceData (state: { setSourceData: any }) => state.setSourceData
); );
const sessionId = useConnectStore((state) => state.currentSessionId);
useEffect(() => { useEffect(() => {
return () => { return () => {
changeInput(""); changeInput("");
@@ -360,17 +364,19 @@ ChatInputProps) {
> >
{isChatMode ? ( {isChatMode ? (
<div className="flex gap-2 text-sm text-[#333] dark:text-[#d8d8d8]"> <div className="flex gap-2 text-sm text-[#333] dark:text-[#d8d8d8]">
{/* <InputExtra {sessionId && (
checkScreenPermission={checkScreenPermission} <InputExtra
requestScreenPermission={requestScreenPermission} checkScreenPermission={checkScreenPermission}
getScreenMonitors={getScreenMonitors} requestScreenPermission={requestScreenPermission}
getScreenWindows={getScreenWindows} getScreenMonitors={getScreenMonitors}
captureMonitorScreenshot={captureMonitorScreenshot} getScreenWindows={getScreenWindows}
captureWindowScreenshot={captureWindowScreenshot} captureMonitorScreenshot={captureMonitorScreenshot}
openFileDialog={openFileDialog} captureWindowScreenshot={captureWindowScreenshot}
getFileMetadata={getFileMetadata} openFileDialog={openFileDialog}
getFileIcon={getFileIcon} getFileMetadata={getFileMetadata}
/> */} getFileIcon={getFileIcon}
/>
)}
<button <button
className={clsx( className={clsx(

View File

@@ -1,10 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
Checkbox,
Popover,
PopoverButton,
PopoverPanel,
} from "@headlessui/react";
import { import {
ChevronDownIcon, ChevronDownIcon,
RefreshCw, RefreshCw,
@@ -19,6 +14,7 @@ import TypeIcon from "@/components/Common/Icons/TypeIcon";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { DataSource } from "@/types/commands"; import { DataSource } from "@/types/commands";
import Checkbox from "../Common/Checkbox";
interface SearchPopoverProps { interface SearchPopoverProps {
isSearchActive: boolean; isSearchActive: boolean;
@@ -193,19 +189,11 @@ export default function SearchPopover({
dataSourceList.length - 1 dataSourceList.length - 1
: sourceDataIds?.includes(id) : sourceDataIds?.includes(id)
} }
indeterminate={isAll}
onChange={(value) => onChange={(value) =>
onSelectDataSource(id, value, isAll) 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 cursor-pointer" />
>
{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>
</div> </div>
</li> </li>
); );

View File

@@ -233,6 +233,14 @@
"source": { "source": {
"fetch_source": "Found {{count}} results", "fetch_source": "Found {{count}} results",
"pick_source": "{{count}} results" "pick_source": "{{count}} results"
},
"fileList": {
"uploading": "Uploading...",
"uploaded": "Uploaded"
},
"sessionFile": {
"title": "Files in the conversation",
"description": "Only the selected files will participate in the current conversation"
} }
}, },
"cloud": { "cloud": {

View File

@@ -233,6 +233,14 @@
"source": { "source": {
"fetch_source": "找到 {{count}} 个结果", "fetch_source": "找到 {{count}} 个结果",
"pick_source": "{{count}} 个结果" "pick_source": "{{count}} 个结果"
},
"fileList": {
"uploading": "上传中...",
"uploaded": "已上传"
},
"sessionFile": {
"title": "对话中的文件",
"description": "只有选中的文件才会参与当前对话"
} }
}, },
"cloud": { "cloud": {

View File

@@ -9,6 +9,8 @@ interface UploadFile extends Metadata {
id: string; id: string;
path: string; path: string;
icon: string; icon: string;
uploaded?: boolean;
attachmentId?: string;
} }
export type IChatStore = { export type IChatStore = {

View File

@@ -21,6 +21,8 @@ export type IConnectStore = {
setDatasourceData: (datasourceData: any[], key: string) => void; setDatasourceData: (datasourceData: any[], key: string) => void;
connectionTimeout: number; connectionTimeout: number;
setConnectionTimeout: (connectionTimeout: number) => void; setConnectionTimeout: (connectionTimeout: number) => void;
currentSessionId?: string;
setCurrentSessionId: (currentSessionId?: string) => void;
}; };
export const useConnectStore = create<IConnectStore>()( export const useConnectStore = create<IConnectStore>()(
@@ -80,6 +82,9 @@ export const useConnectStore = create<IConnectStore>()(
setConnectionTimeout: (connectionTimeout: number) => { setConnectionTimeout: (connectionTimeout: number) => {
return set(() => ({ connectionTimeout })); return set(() => ({ connectionTimeout }));
}, },
setCurrentSessionId(currentSessionId) {
return set(() => ({ currentSessionId }));
},
}), }),
{ {
name: "connect-store", name: "connect-store",

View File

@@ -1,5 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { convertFileSrc as tauriConvertFileSrc } from "@tauri-apps/api/core";
import type { OpenDialogOptions } from "@tauri-apps/plugin-dialog";
export interface EventPayloads { export interface EventPayloads {
"language-changed": { "language-changed": {
@@ -51,9 +53,9 @@ export interface PlatformAdapter {
getScreenshotableWindows: () => Promise<any[]>; getScreenshotableWindows: () => Promise<any[]>;
captureMonitorScreenshot: (id: number) => Promise<string>; captureMonitorScreenshot: (id: number) => Promise<string>;
captureWindowScreenshot: (id: number) => Promise<string>; captureWindowScreenshot: (id: number) => Promise<string>;
openFileDialog: (options: { openFileDialog: (
multiple: boolean; options: OpenDialogOptions
}) => Promise<string | string[] | null>; ) => Promise<string | string[] | null>;
getFileMetadata: (path: string) => Promise<any>; getFileMetadata: (path: string) => Promise<any>;
getFileIcon: (path: string, size: number) => Promise<string>; getFileIcon: (path: string, size: number) => Promise<string>;
checkUpdate: () => Promise<any>; checkUpdate: () => Promise<any>;
@@ -124,8 +126,7 @@ export const createTauriAdapter = (): PlatformAdapter => {
convertFileSrc(path: string): string { convertFileSrc(path: string): string {
if (isTauri()) { if (isTauri()) {
const { convertFileSrc } = require("@tauri-apps/api/core"); return tauriConvertFileSrc(path);
return convertFileSrc(path);
} }
return path; return path;
}, },
@@ -215,7 +216,7 @@ export const createTauriAdapter = (): PlatformAdapter => {
return ""; return "";
}, },
async openFileDialog(options: { multiple: boolean }) { async openFileDialog(options: OpenDialogOptions) {
if (isTauri()) { if (isTauri()) {
const { open } = await import("@tauri-apps/plugin-dialog"); const { open } = await import("@tauri-apps/plugin-dialog");
return open(options); return open(options);
@@ -391,7 +392,7 @@ export const createWebAdapter = (): PlatformAdapter => {
return ""; return "";
}, },
async openFileDialog(options: { multiple: boolean }): Promise<null> { async openFileDialog(options: OpenDialogOptions): Promise<null> {
console.log("Web mode simulated open file dialog", options); console.log("Web mode simulated open file dialog", options);
return null; return null;
}, },