diff --git a/package.json b/package.json index 0aa64fcf..98e4f1b6 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "release-beta": "release-it --preRelease=beta --preReleaseBase=1" }, "dependencies": { + "@ant-design/icons": "^6.0.0", "@headlessui/react": "^2.2.0", "@tauri-apps/api": "^2.4.0", "@tauri-apps/plugin-autostart": "~2.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eef8bd92..eed95434 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: 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': specifier: ^2.2.0 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==} 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': resolution: {integrity: sha512-xvX6P/lo1B3ej0OsaErAjqgFYzYVcJpamjLAFLYh9vRJngBrMoUG7aVnrGTeqM7yxbyTD5p3F2+0/QUEh8Vzhw==} @@ -864,6 +884,12 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} 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': resolution: {integrity: sha512-lgYs+sQ1TtBrAXnAdRBQrBo0/7o5H6IrfDxec1j+VRpcXL0xyk0xPq+m3lZp8typzIghqDgpnKkJ5Jf4OrzPIw==} peerDependencies: @@ -1506,6 +1532,9 @@ packages: resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} engines: {node: '>=8'} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -2898,6 +2927,9 @@ packages: react-native: optional: true + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-markdown@9.1.0: resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} peerDependencies: @@ -3519,6 +3551,23 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@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': dependencies: package-manager-detector: 0.2.11 @@ -4089,6 +4138,12 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 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)': dependencies: '@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: {} + classnames@2.5.1: {} + cli-boxes@3.0.0: {} cli-cursor@5.0.0: @@ -6385,6 +6442,8 @@ snapshots: optionalDependencies: 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): dependencies: '@types/hast': 3.0.4 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c50fd5a3..ef6ba170 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -777,6 +777,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-tungstenite 0.20.1", + "tokio-util", "tungstenite 0.24.0", "url", "walkdir", @@ -3240,6 +3241,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "minimal-lexical" version = "0.2.1" @@ -4782,6 +4793,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -6736,6 +6748,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.18" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4a4670cb..f6cb5f40 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,7 +47,7 @@ tokio-native-tls = "0.3" # For wss connections tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] } hyper = { version = "0.14", features = ["client"] } -reqwest = "0.12.12" +reqwest = { version = "0.12", features = ["json", "multipart"] } futures = "0.3.31" ordered-float = { version = "4.6.0", default-features = false } lazy_static = "1.5.0" @@ -68,6 +68,7 @@ url = "2.5.2" http = "1.1.0" tungstenite = "0.24.0" env_logger = "0.11.5" +tokio-util = "0.7.14" [target."cfg(target_os = \"macos\")".dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad132f56..ce25fef5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -124,7 +124,10 @@ pub fn run() { // server::get_coco_server_connectors, server::websocket::connect_to_server, 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| { let registry = SearchSourceRegistry::default(); diff --git a/src-tauri/src/server/attachment.rs b/src-tauri/src/server/attachment.rs new file mode 100644 index 00000000..5b05336c --- /dev/null +++ b/src-tauri/src/server/attachment.rs @@ -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, +} + +#[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, +} + +#[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, +) -> Result { + 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::() + .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 { + 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::() + .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 { + let response = + HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None).await?; + + if response.status().is_success() { + response + .json::() + .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())) + } +} diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index f07e127c..5e84b995 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -1,10 +1,11 @@ //! This file contains Rust APIs related to Coco Server management. +pub mod attachment; pub mod auth; -pub mod servers; pub mod connector; pub mod datasource; pub mod http_client; pub mod profile; pub mod search; +pub mod servers; pub mod websocket; diff --git a/src/api/attachment.ts b/src/api/attachment.ts new file mode 100644 index 00000000..2c4300d8 --- /dev/null +++ b/src/api/attachment.ts @@ -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; + +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("upload_attachment", { + ...payload, + }); + + if (response?.acknowledged) { + return response.attachments; + } +}; + +export const getAttachment = (payload: GetAttachmentPayload) => { + return invoke("get_attachment", { ...payload }); +}; + +export const deleteAttachment = (payload: DeleteAttachmentPayload) => { + return invoke("delete_attachment", { ...payload }); +}; diff --git a/src/components/Assistant/ChatContent.tsx b/src/components/Assistant/ChatContent.tsx index 73e99bff..84c91d0b 100644 --- a/src/components/Assistant/ChatContent.tsx +++ b/src/components/Assistant/ChatContent.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { ChatMessage } from "@/components/ChatMessage"; @@ -7,6 +7,8 @@ import FileList from "@/components/Assistant/FileList"; import { useChatScroll } from "@/hooks/useChatScroll"; import { useChatStore } from "@/stores/chatStore"; import type { Chat, IChunkData } from "./types"; +import SessionFile from "./SessionFile"; +import { useConnectStore } from "@/stores/connectStore"; interface ChatContentProps { activeChat?: Chat; @@ -41,12 +43,21 @@ export const ChatContent = ({ handleSendMessage, getFileUrl, }: ChatContentProps) => { + const sessionId = useConnectStore((state) => state.currentSessionId); + const setCurrentSessionId = useConnectStore((state) => { + return state.setCurrentSessionId; + }); + + useEffect(() => { + setCurrentSessionId(activeChat?._id); + }, [activeChat]); + const { t } = useTranslation(); const uploadFiles = useChatStore((state) => state.uploadFiles); const messagesEndRef = useRef(null); - + const { scrollToBottom } = useChatScroll(messagesEndRef); useEffect(() => { @@ -69,7 +80,7 @@ export const ChatContent = ({ }, [scrollToBottom]); return ( -
+
@@ -143,11 +154,13 @@ export const ChatContent = ({
- {uploadFiles.length > 0 && ( -
- + {sessionId && uploadFiles.length > 0 && ( +
+
)} + + {sessionId && }
); }; diff --git a/src/components/Assistant/FileList.tsx b/src/components/Assistant/FileList.tsx index b4170b40..59b341d6 100644 --- a/src/components/Assistant/FileList.tsx +++ b/src/components/Assistant/FileList.tsx @@ -1,42 +1,88 @@ +import { useEffect, useMemo } from "react"; import { filesize } from "filesize"; import { X } from "lucide-react"; +import { useAsyncEffect } from "ahooks"; +import { useTranslation } from "react-i18next"; import { useChatStore } from "@/stores/chatStore"; import { isImage } from "@/utils"; +import { useConnectStore } from "@/stores/connectStore"; +import { deleteAttachment, uploadAttachment } from "@/api/attachment"; +import FileIcon from "../Common/Icons/FileIcon"; interface FileListProps { + sessionId: 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 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)); + + deleteAttachment({ serverId, id: attachmentId }); }; return (
{uploadFiles.map((file) => { - const { id, path, icon, name, extname, size } = file; + const { id, name, extname, size, uploaded, attachmentId } = file; return (
-
{ - deleteFile(id); - }} - > - -
+ {attachmentId && ( +
{ + deleteFile(id, attachmentId); + }} + > + +
+ )} - +
@@ -44,12 +90,16 @@ const FileList = ({ getFileUrl }: FileListProps) => {
-
- {extname && {extname}} - - {filesize(size, { standard: "jedec", spacer: "" })} - -
+ {uploaded ? ( +
+ {extname && {extname}} + + {filesize(size, { standard: "jedec", spacer: "" })} + +
+ ) : ( + {t("assistant.fileList.uploading")} + )}
diff --git a/src/components/Assistant/SessionFile.tsx b/src/components/Assistant/SessionFile.tsx new file mode 100644 index 00000000..5ef589f7 --- /dev/null +++ b/src/components/Assistant/SessionFile.tsx @@ -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([]); + const [visible, setVisible] = useState(false); + const [checkList, setCheckList] = useState([]); + + 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 ( +
+
{ + setVisible(true); + }} + > + + +
+ {uploadedFiles.length} +
+
+ +
+ { + setVisible(false); + }} + /> + +
+ {t("assistant.sessionFile.title")} +
+
+ + {t("assistant.sessionFile.description")} + + + +
+
    + {uploadedFiles.map((item) => { + const { id, name, icon, size } = item._source; + + return ( +
  • +
    + + +
    +
    + {name} +
    +
    + {icon} + + {filesize(size, { standard: "jedec", spacer: "" })} + +
    +
    +
    + +
    + handleDelete(id)} + /> + + handleCheck(checked, id)} + /> +
    +
  • + ); + })} +
+
+
+ ); +}; + +export default SessionFile; diff --git a/src/components/AudioRecording/index.tsx b/src/components/AudioRecording/index.tsx index bec47a93..c63182d2 100644 --- a/src/components/AudioRecording/index.tsx +++ b/src/components/AudioRecording/index.tsx @@ -67,6 +67,13 @@ const AudioRecording: FC = (props) => { const recordedUrl = URL.createObjectURL(blob); 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); }); diff --git a/src/components/Common/Checkbox/index.tsx b/src/components/Common/Checkbox/index.tsx new file mode 100644 index 00000000..72241036 --- /dev/null +++ b/src/components/Common/Checkbox/index.tsx @@ -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 ( + + {indeterminate && ( +
+
+
+ )} + + +
+ ); +}; + +export default Checkbox; diff --git a/src/components/Common/Icons/FileIcon/AudioIcon.tsx b/src/components/Common/Icons/FileIcon/AudioIcon.tsx new file mode 100644 index 00000000..e94c954d --- /dev/null +++ b/src/components/Common/Icons/FileIcon/AudioIcon.tsx @@ -0,0 +1,21 @@ +const AudioIcon = () => { + return ( + + audio + + + + + ); +}; + +export default AudioIcon; diff --git a/src/components/Common/Icons/FileIcon/VideoIcon.tsx b/src/components/Common/Icons/FileIcon/VideoIcon.tsx new file mode 100644 index 00000000..b7924f4d --- /dev/null +++ b/src/components/Common/Icons/FileIcon/VideoIcon.tsx @@ -0,0 +1,21 @@ +const VideoIcon = () => { + return ( + + video + + + + + ); +}; + +export default VideoIcon; diff --git a/src/components/Common/Icons/FileIcon/index.tsx b/src/components/Common/Icons/FileIcon/index.tsx new file mode 100644 index 00000000..0dde1258 --- /dev/null +++ b/src/components/Common/Icons/FileIcon/index.tsx @@ -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 = (props) => { + const { extname, className } = props; + + const presetFileIcons = [ + { + icon: , + color: "#22b35e", + extnames: ["xlsx", "xls", "csv", "xlsm", "xltx", "xltm", "xlsb"], + }, + { + icon: , + color: "#13c2c2", + extnames: [ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "svg", + "ico", + "tiff", + "raw", + "heic", + "psd", + "ai", + ], + }, + { + icon: , + color: "#722ed1", + extnames: ["md", "mdx", "markdown", "mdown", "mkd", "mkdn"], + }, + { + icon: , + color: "#ff4d4f", + extnames: ["pdf", "xps", "oxps"], + }, + { + icon: , + color: "#d04423", + extnames: [ + "ppt", + "pptx", + "pps", + "ppsx", + "pot", + "potx", + "pptm", + "potm", + "ppsm", + ], + }, + { + icon: , + color: "#1677ff", + extnames: ["doc", "docx", "dot", "dotx", "docm", "dotm", "rtf", "odt"], + }, + { + icon: , + color: "#fab714", + extnames: [ + "zip", + "rar", + "7z", + "tar", + "gz", + "bz2", + "xz", + "tgz", + "iso", + "dmg", + ], + }, + { + icon: , + color: "#7b61ff", + extnames: [ + "mp4", + "avi", + "mov", + "wmv", + "flv", + "mkv", + "webm", + "m4v", + "mpeg", + "mpg", + "3gp", + "rmvb", + "ts", + ], + }, + { + icon: , + 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 [, "#8c8c8c"]; + }, [extname]); + + return ( +
+ {icon} +
+ ); +}; + +export default FileIcon; diff --git a/src/components/Search/InputBox.tsx b/src/components/Search/InputBox.tsx index b9528d6a..1bf061c4 100644 --- a/src/components/Search/InputBox.tsx +++ b/src/components/Search/InputBox.tsx @@ -14,6 +14,8 @@ import SearchPopover from "./SearchPopover"; import AudioRecording from "../AudioRecording"; import { hide_coco } from "@/commands"; import { DataSource } from "@/types/commands"; +import InputExtra from "./InputExtra"; +import { useConnectStore } from "@/stores/connectStore"; interface ChatInputProps { onSend: (message: string) => void; @@ -60,16 +62,16 @@ export default function ChatInput({ isChatPage = false, getDataSourcesByServer, setupWindowFocusListener, -}: // checkScreenPermission, -// requestScreenPermission, -// getScreenMonitors, -// getScreenWindows, -// captureMonitorScreenshot, -// captureWindowScreenshot, -// openFileDialog, -// getFileMetadata, -// getFileIcon, -ChatInputProps) { + checkScreenPermission, + requestScreenPermission, + getScreenMonitors, + getScreenWindows, + captureMonitorScreenshot, + captureWindowScreenshot, + openFileDialog, + getFileMetadata, + getFileIcon, +}: ChatInputProps) { const { t } = useTranslation(); const showTooltip = useAppStore( @@ -85,6 +87,8 @@ ChatInputProps) { (state: { setSourceData: any }) => state.setSourceData ); + const sessionId = useConnectStore((state) => state.currentSessionId); + useEffect(() => { return () => { changeInput(""); @@ -360,17 +364,19 @@ ChatInputProps) { > {isChatMode ? (
- {/* */} + {sessionId && ( + + )}
); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9db31b30..3c15618e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -233,6 +233,14 @@ "source": { "fetch_source": "Found {{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": { diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 90ca9dcc..b14839c2 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -233,6 +233,14 @@ "source": { "fetch_source": "找到 {{count}} 个结果", "pick_source": "{{count}} 个结果" + }, + "fileList": { + "uploading": "上传中...", + "uploaded": "已上传" + }, + "sessionFile": { + "title": "对话中的文件", + "description": "只有选中的文件才会参与当前对话" } }, "cloud": { diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 280af7c0..86152322 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -9,6 +9,8 @@ interface UploadFile extends Metadata { id: string; path: string; icon: string; + uploaded?: boolean; + attachmentId?: string; } export type IChatStore = { diff --git a/src/stores/connectStore.ts b/src/stores/connectStore.ts index 8781d5ef..01429570 100644 --- a/src/stores/connectStore.ts +++ b/src/stores/connectStore.ts @@ -21,6 +21,8 @@ export type IConnectStore = { setDatasourceData: (datasourceData: any[], key: string) => void; connectionTimeout: number; setConnectionTimeout: (connectionTimeout: number) => void; + currentSessionId?: string; + setCurrentSessionId: (currentSessionId?: string) => void; }; export const useConnectStore = create()( @@ -80,6 +82,9 @@ export const useConnectStore = create()( setConnectionTimeout: (connectionTimeout: number) => { return set(() => ({ connectionTimeout })); }, + setCurrentSessionId(currentSessionId) { + return set(() => ({ currentSessionId })); + }, }), { name: "connect-store", diff --git a/src/utils/platformAdapter.ts b/src/utils/platformAdapter.ts index efb33c4e..c119e9b3 100644 --- a/src/utils/platformAdapter.ts +++ b/src/utils/platformAdapter.ts @@ -1,5 +1,7 @@ import { useState } from "react"; 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 { "language-changed": { @@ -51,9 +53,9 @@ export interface PlatformAdapter { getScreenshotableWindows: () => Promise; captureMonitorScreenshot: (id: number) => Promise; captureWindowScreenshot: (id: number) => Promise; - openFileDialog: (options: { - multiple: boolean; - }) => Promise; + openFileDialog: ( + options: OpenDialogOptions + ) => Promise; getFileMetadata: (path: string) => Promise; getFileIcon: (path: string, size: number) => Promise; checkUpdate: () => Promise; @@ -124,8 +126,7 @@ export const createTauriAdapter = (): PlatformAdapter => { convertFileSrc(path: string): string { if (isTauri()) { - const { convertFileSrc } = require("@tauri-apps/api/core"); - return convertFileSrc(path); + return tauriConvertFileSrc(path); } return path; }, @@ -215,7 +216,7 @@ export const createTauriAdapter = (): PlatformAdapter => { return ""; }, - async openFileDialog(options: { multiple: boolean }) { + async openFileDialog(options: OpenDialogOptions) { if (isTauri()) { const { open } = await import("@tauri-apps/plugin-dialog"); return open(options); @@ -391,7 +392,7 @@ export const createWebAdapter = (): PlatformAdapter => { return ""; }, - async openFileDialog(options: { multiple: boolean }): Promise { + async openFileDialog(options: OpenDialogOptions): Promise { console.log("Web mode simulated open file dialog", options); return null; },