From 3aed3a0df4a14d354d860f544d715cac874cbc77 Mon Sep 17 00:00:00 2001 From: ayangweb <75017711+ayangweb@users.noreply.github.com> Date: Wed, 2 Apr 2025 14:03:40 +0800 Subject: [PATCH] feat: history added search and action menus (#322) * feat: history added search and action menus * refactor: refinement of the dark theme * feat: add renamed input box style * feat: internalization * refactor: optimize the bright theme style * refactor: change dark theme style * feat: added api for deleting and modifying conversations * feat: supported search * feat: support for modifying the title * feat: support for deleting sessions * refactor: remove popup internationalization --- package.json | 1 + pnpm-lock.yaml | 3 + src-tauri/src/assistant/mod.rs | 68 ++++- src-tauri/src/lib.rs | 2 + src/commands/servers.ts | 30 ++- src/components/Assistant/ChatSidebar.tsx | 2 +- src/components/Common/HistoryList/index.tsx | 274 ++++++++++++++++++++ src/locales/en/translation.json | 24 ++ src/locales/zh/translation.json | 24 ++ src/pages/chat/index.tsx | 86 ++++-- tailwind.config.js | 18 +- 11 files changed, 484 insertions(+), 48 deletions(-) create mode 100644 src/components/Common/HistoryList/index.tsx diff --git a/package.json b/package.json index 4baf1c94..4f9013cd 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@wavesurfer/react": "^1.0.9", "ahooks": "^3.8.4", "clsx": "^2.1.1", + "dayjs": "^1.11.13", "dotenv": "^16.4.7", "filesize": "^10.1.6", "i18next": "^23.16.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed95434..b5e5deff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 dotenv: specifier: ^16.4.7 version: 16.4.7 diff --git a/src-tauri/src/assistant/mod.rs b/src-tauri/src/assistant/mod.rs index 3e93fe46..205b7c19 100644 --- a/src-tauri/src/assistant/mod.rs +++ b/src-tauri/src/assistant/mod.rs @@ -12,6 +12,7 @@ pub async fn chat_history( server_id: String, from: u32, size: u32, + query: Option, ) -> Result { let mut query_params: HashMap = HashMap::new(); if from > 0 { @@ -21,6 +22,10 @@ pub async fn chat_history( query_params.insert("size".to_string(), size.into()); } + if let Some(query) = query { + query_params.insert("query".to_string(), query.into()); + } + let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params)) .await .map_err(|e| format!("Error get sessions: {}", e))?; @@ -135,9 +140,10 @@ pub async fn new_chat( let mut headers = HashMap::new(); headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into()); - let response = HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body) - .await - .map_err(|e| format!("Error sending message: {}", e))?; + let response = + HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body) + .await + .map_err(|e| format!("Error sending message: {}", e))?; if response.status().as_u16() < 200 || response.status().as_u16() >= 400 { return Err("Failed to send message".to_string()); @@ -174,10 +180,58 @@ pub async fn send_message( headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into()); let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap()); - let response = - HttpClient::advanced_post(&server_id, path.as_str(), Some(headers), query_params, Some(body)) - .await - .map_err(|e| format!("Error cancel session: {}", e))?; + let response = HttpClient::advanced_post( + &server_id, + path.as_str(), + Some(headers), + query_params, + Some(body), + ) + .await + .map_err(|e| format!("Error cancel session: {}", e))?; handle_raw_response(response).await? } + +#[tauri::command] +pub async fn delete_session_chat(server_id: String, session_id: String) -> Result { + let response = + HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?; + + if response.status().is_success() { + Ok(true) + } else { + Err(format!("Delete failed with status: {}", response.status())) + } +} + +#[tauri::command] +pub async fn update_session_chat( + server_id: String, + session_id: String, + title: Option, + context: Option>, +) -> Result { + let mut body = HashMap::new(); + if let Some(title) = title { + body.insert("title".to_string(), Value::String(title)); + } + if let Some(context) = context { + body.insert( + "context".to_string(), + Value::Object(context.into_iter().collect()), + ); + } + + let response = HttpClient::put( + &server_id, + &format!("/chat/{}", session_id), + None, + None, + Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())), + ) + .await + .map_err(|e| format!("Error updating session: {}", e))?; + + Ok(response.status().is_success()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 219cfc7d..1372c234 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,6 +120,8 @@ pub fn run() { assistant::open_session_chat, assistant::close_session_chat, assistant::cancel_session_chat, + assistant::delete_session_chat, + assistant::update_session_chat, // server::get_coco_server_datasources, // server::get_coco_server_connectors, server::websocket::connect_to_server, diff --git a/src/commands/servers.ts b/src/commands/servers.ts index dd18d430..852194e0 100644 --- a/src/commands/servers.ts +++ b/src/commands/servers.ts @@ -1,6 +1,12 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invoke } from "@tauri-apps/api/core"; -import { ServerTokenResponse, Server, Connector, DataSource, GetResponse } from "@/types/commands" +import { + ServerTokenResponse, + Server, + Connector, + DataSource, + GetResponse, +} from "@/types/commands"; export function get_server_token(id: string): Promise { return invoke(`get_server_token`, { id }); @@ -70,15 +76,18 @@ export function chat_history({ serverId, from = 0, size = 20, + query = "", }: { serverId: string; from?: number; size?: number; + query?: string; }): Promise { return invoke(`chat_history`, { serverId, from, size, + query, }); } @@ -179,4 +188,19 @@ export function send_message({ message, queryParams, }); -} \ No newline at end of file +} + +export const delete_session_chat = (serverId: string, sessionId: string) => { + return invoke(`delete_session_chat`, { serverId, sessionId }); +}; + +export const update_session_chat = (payload: { + serverId: string; + sessionId: string; + title?: string; + context?: { + attachments?: string[]; + }; +}): Promise => { + return invoke("update_session_chat", payload); +}; diff --git a/src/components/Assistant/ChatSidebar.tsx b/src/components/Assistant/ChatSidebar.tsx index 39d39b70..775bc58f 100644 --- a/src/components/Assistant/ChatSidebar.tsx +++ b/src/components/Assistant/ChatSidebar.tsx @@ -41,4 +41,4 @@ export const ChatSidebar: React.FC = ({ /> ); -}; \ No newline at end of file +}; diff --git a/src/components/Common/HistoryList/index.tsx b/src/components/Common/HistoryList/index.tsx new file mode 100644 index 00000000..4449d56d --- /dev/null +++ b/src/components/Common/HistoryList/index.tsx @@ -0,0 +1,274 @@ +import { Chat } from "@/components/Assistant/types"; +import { + Description, + Dialog, + DialogPanel, + DialogTitle, + Input, + Menu, + MenuButton, + MenuItem, + MenuItems, +} from "@headlessui/react"; +import { debounce, groupBy, isNil } from "lodash-es"; +import { FC, useMemo, useState } from "react"; +import dayjs from "dayjs"; +import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; +import clsx from "clsx"; +import { Ellipsis, Pencil, RefreshCcw, Search, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +dayjs.extend(isSameOrAfter); + +interface HistoryListProps { + list: Chat[]; + active?: Chat; + onSearch: (keyword: string) => void; + onRefresh: () => void; + onSelect: (chat: Chat) => void; + onRename: (chat: Chat, title: string) => void; + onRemove: (chatId: string) => void; +} + +const HistoryList: FC = (props) => { + const { list, active, onSearch, onRefresh, onSelect, onRename, onRemove } = + props; + const { t } = useTranslation(); + const [isEdit, setIsEdit] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const sortedList = useMemo(() => { + if (isNil(list)) return {}; + + const now = dayjs(); + + return groupBy(list, (chat) => { + const date = dayjs(chat._source?.updated); + + if (date.isSame(now, "day")) { + return "history_list.date.today"; + } + + if (date.isSame(now.subtract(1, "day"), "day")) { + return "history_list.date.yesterday"; + } + + if (date.isSameOrAfter(now.subtract(7, "day"), "day")) { + return "history_list.date.last7Days"; + } + + if (date.isSameOrAfter(now.subtract(30, "day"), "day")) { + return "history_list.date.last30Days"; + } + + return date.format("YYYY-MM"); + }); + }, [list]); + + const menuItems = [ + // { + // label: "history_list.menu.share", + // icon: Share2, + // onClick: () => {}, + // }, + { + label: "history_list.menu.rename", + icon: Pencil, + onClick: () => { + setIsEdit(true); + }, + }, + { + label: "history_list.menu.delete", + icon: Trash2, + iconColor: "#FF2018", + onClick: () => { + setIsOpen(true); + }, + }, + ]; + + const debouncedSearch = useMemo(() => { + return debounce((value: string) => onSearch(value), 500); + }, [onSearch]); + + return ( +
+
+
+ + + { + debouncedSearch(event.target.value); + }} + /> +
+ +
+ +
+
+ +
+ {Object.entries(sortedList).map(([label, list]) => { + return ( +
+ {t(label)} + +
    + {list.map((item) => { + const { _id, _source } = item; + + const isActive = _id === active?._id; + const title = _source?.title ?? _id; + + return ( +
  • { + if (!isActive) { + setIsEdit(false); + } + + onSelect(item); + }} + > +
    + +
    + {isEdit && isActive ? ( + { + if (event.key !== "Enter") return; + + onRename(item, event.currentTarget.value); + + setIsEdit(false); + }} + onBlur={(event) => { + onRename(item, event.target.value); + + setIsEdit(false); + }} + /> + ) : ( + {title} + )} + + + {isActive && !isEdit && ( + + + + )} + + + {menuItems.map((menuItem) => { + const { + label, + icon: Icon, + iconColor, + onClick, + } = menuItem; + + return ( + + + + ); + })} + + +
    +
  • + ); + })} +
+
+ ); + })} +
+ + setIsOpen(false)} + className="relative z-1000" + > +
+ +
+ + {t("history_list.delete_modal.title")} + + + {t("history_list.delete_modal.description", { + replace: [active?._source?.title || active?._id], + })} + +
+ +
+ + +
+
+
+
+
+ ); +}; + +export default HistoryList; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 13470386..fe2923f2 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -294,5 +294,29 @@ }, "error": { "message": "Sorry, there is an error in your Coco App. Please contact the administrator." + }, + "history_list": { + "search": { + "placeholder": "Search" + }, + "date": { + "today": "Today", + "yesterday": "Yesterday", + "last7Days": "Last 7 Days", + "last30Days": "Last 30 Days" + }, + "menu": { + "share": "Share", + "rename": "Rename", + "delete": "Delete" + }, + "delete_modal": { + "title": "Delete chat?", + "description": "This will delete \"{{0}}\"", + "button": { + "delete": "Delete", + "cancel": "Cancel" + } + } } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 0da51dc3..047bfdf2 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -293,5 +293,29 @@ }, "error": { "message": "抱歉,Coco 应用出现了错误。请联系管理员。" + }, + "history_list": { + "search": { + "placeholder": "搜索" + }, + "date": { + "today": "今天", + "yesterday": "昨天", + "last7Days": "最近 7 天", + "last30Days": "最近 30 天" + }, + "menu": { + "share": "分享", + "rename": "重命名", + "delete": "删除" + }, + "delete_modal": { + "title": "删除聊天?", + "description": "这将删除“{{0}}”", + "button": { + "delete": "删除", + "cancel": "取消" + } + } } } diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index dbdf4fad..93cd462a 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -15,8 +15,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { metadata, icon } from "tauri-plugin-fs-pro-api"; import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat"; -import { Sidebar } from "@/components/Assistant/Sidebar"; -import type { Chat } from "@/components/Assistant/types"; +import type { Chat as typeChat } from "@/components/Assistant/types"; import { useConnectStore } from "@/stores/connectStore"; import InputBox from "@/components/Search/InputBox"; import { @@ -25,8 +24,11 @@ import { close_session_chat, open_session_chat, get_datasources_by_server, + delete_session_chat, + update_session_chat, } from "@/commands"; -import { DataSource } from "@/types/commands" +import { DataSource } from "@/types/commands"; +import HistoryList from "@/components/Common/HistoryList"; interface ChatProps {} @@ -35,8 +37,8 @@ export default function Chat({}: ChatProps) { const chatAIRef = useRef(null); - const [chats, setChats] = useState([]); - const [activeChat, setActiveChat] = useState(); + const [chats, setChats] = useState([]); + const [activeChat, setActiveChat] = useState(); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const isTyping = false; @@ -44,12 +46,13 @@ export default function Chat({}: ChatProps) { const [isSearchActive, setIsSearchActive] = useState(false); const [isDeepThinkActive, setIsDeepThinkActive] = useState(false); + const [keyword, setKeyword] = useState(""); const isChatPage = true; useEffect(() => { getChatHistory(); - }, []); + }, [keyword]); const getChatHistory = async () => { try { @@ -57,6 +60,7 @@ export default function Chat({}: ChatProps) { serverId: currentService?.id, from: 0, size: 20, + query: keyword, }); response = JSON.parse(response || ""); console.log("_history", response); @@ -72,24 +76,24 @@ export default function Chat({}: ChatProps) { } }; - const deleteChat = (chatId: string) => { - setChats((prev) => prev.filter((chat) => chat._id !== chatId)); - if (activeChat?._id === chatId) { - const remainingChats = chats.filter((chat) => chat._id !== chatId); - if (remainingChats.length > 0) { - setActiveChat(remainingChats[0]); - } else { - chatAIRef.current?.init(""); - } - } - }; + // const deleteChat = (chatId: string) => { + // setChats((prev) => prev.filter((chat) => chat._id !== chatId)); + // if (activeChat?._id === chatId) { + // const remainingChats = chats.filter((chat) => chat._id !== chatId); + // if (remainingChats.length > 0) { + // setActiveChat(remainingChats[0]); + // } else { + // chatAIRef.current?.init(""); + // } + // } + // }; const handleSendMessage = async (content: string) => { setInput(content); chatAIRef.current?.init(content); }; - const chatHistory = async (chat: Chat) => { + const chatHistory = async (chat: typeChat) => { try { let response: any = await session_chat_history({ serverId: currentService?.id, @@ -100,7 +104,7 @@ export default function Chat({}: ChatProps) { response = JSON.parse(response || ""); console.log("id_history", response); const hits = response?.hits?.hits || []; - const updatedChat: Chat = { + const updatedChat: typeChat = { ...chat, messages: hits, }; @@ -203,6 +207,33 @@ export default function Chat({}: ChatProps) { return icon(path, size); }, []); + const handleSearch = (keyword: string) => { + setKeyword(keyword); + }; + + const handleRename = async (chat: typeChat, title: string) => { + if (!currentService?.id) return; + + console.log("chat", chat); + console.log("title", title); + + await update_session_chat({ + serverId: currentService.id, + sessionId: chat?._id, + title, + }); + + getChatHistory(); + }; + + const handleDelete = async (id: string) => { + if (!currentService?.id) return; + + await delete_session_chat(currentService.id, id); + + getChatHistory(); + }; + return (
@@ -213,15 +244,14 @@ export default function Chat({}: ChatProps) { isSidebarOpen ? "translate-x-0" : "-translate-x-full" } transition-transform duration-300 ease-in-out md:translate-x-0 md:static md:block bg-gray-100 dark:bg-gray-800`} > - { - chatAIRef.current?.clearChat(); - }} - onSelectChat={onSelectChat} - onDeleteChat={deleteChat} - fetchChatHistory={getChatHistory} +
) : null} diff --git a/tailwind.config.js b/tailwind.config.js index 1cc6353f..dd095c49 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -23,8 +23,8 @@ export default { }, animation: { "fade-in": "fade-in 0.2s ease-in-out", - 'typing': 'typing 1.5s ease-in-out infinite', - 'shake': 'shake 0.5s ease-in-out', + typing: "typing 1.5s ease-in-out infinite", + shake: "shake 0.5s ease-in-out", }, keyframes: { "fade-in": { @@ -32,15 +32,15 @@ export default { "100%": { opacity: "1" }, }, typing: { - '0%': { opacity: '0.3' }, - '50%': { opacity: '1' }, - '100%': { opacity: '0.3' }, + "0%": { opacity: "0.3" }, + "50%": { opacity: "1" }, + "100%": { opacity: "0.3" }, }, shake: { - '0%, 100%': { transform: 'rotate(0deg)' }, - '25%': { transform: 'rotate(-20deg)' }, - '75%': { transform: 'rotate(20deg)' } - } + "0%, 100%": { transform: "rotate(0deg)" }, + "25%": { transform: "rotate(-20deg)" }, + "75%": { transform: "rotate(20deg)" }, + }, }, boxShadow: { "window-custom": "0px 16px 32px 0px rgba(0,0,0,0.3)",