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
This commit is contained in:
ayangweb
2025-04-02 14:03:40 +08:00
committed by GitHub
parent 569a61841c
commit 3aed3a0df4
11 changed files with 484 additions and 48 deletions

View File

@@ -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",

3
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -12,6 +12,7 @@ pub async fn chat_history<R: Runtime>(
server_id: String,
from: u32,
size: u32,
query: Option<String>,
) -> Result<String, String> {
let mut query_params: HashMap<String, Value> = HashMap::new();
if from > 0 {
@@ -21,6 +22,10 @@ pub async fn chat_history<R: Runtime>(
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<R: Runtime>(
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<R: Runtime>(
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<bool, String> {
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<String>,
context: Option<HashMap<String, Value>>,
) -> Result<bool, String> {
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())
}

View File

@@ -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,

View File

@@ -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<ServerTokenResponse> {
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<string> {
return invoke(`chat_history`, {
serverId,
from,
size,
query,
});
}
@@ -179,4 +188,19 @@ export function send_message({
message,
queryParams,
});
}
}
export const delete_session_chat = (serverId: string, sessionId: string) => {
return invoke<boolean>(`delete_session_chat`, { serverId, sessionId });
};
export const update_session_chat = (payload: {
serverId: string;
sessionId: string;
title?: string;
context?: {
attachments?: string[];
};
}): Promise<boolean> => {
return invoke<boolean>("update_session_chat", payload);
};

View File

@@ -41,4 +41,4 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
/>
</div>
);
};
};

View File

@@ -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<HistoryListProps> = (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 (
<div
className={clsx(
"h-full overflow-auto px-3 py-2 text-sm bg-[#F3F4F6] dark:bg-[#1F2937]"
)}
>
<div className="flex gap-1 children:h-8">
<div className="flex-1 flex items-center gap-2 px-2 rounded-lg border transition border-[#E6E6E6] bg-[#F8F9FA] dark:bg-[#2B3444] dark:border-[#343D4D] focus-within:border-[#0061FF]">
<Search className="size-4 text-[#6B7280]" />
<Input
className="w-full bg-transparent outline-none"
placeholder={t("history_list.search.placeholder")}
onChange={(event) => {
debouncedSearch(event.target.value);
}}
/>
</div>
<div
className="size-8 flex items-center justify-center rounded-lg border text-[#0072FF] border-[#E6E6E6] bg-[#F3F4F6] dark:border-[#343D4D] dark:bg-[#1F2937] hover:bg-[#F8F9FA] dark:hover:bg-[#353F4D] cursor-pointer transition"
onClick={onRefresh}
>
<RefreshCcw className="size-4" />
</div>
</div>
<div className="mt-6">
{Object.entries(sortedList).map(([label, list]) => {
return (
<div key={label}>
<span className="text-xs text-[#999] px-3">{t(label)}</span>
<ul>
{list.map((item) => {
const { _id, _source } = item;
const isActive = _id === active?._id;
const title = _source?.title ?? _id;
return (
<li
key={_id}
className={clsx(
"flex items-center mt-1 h-10 rounded-lg cursor-pointer hover:bg-[#F8F9FA] dark:hover:bg-[#353F4D] transition",
{
"!bg-[#E5E7EB] dark:!bg-[#2B3444]": isActive,
}
)}
onClick={() => {
if (!isActive) {
setIsEdit(false);
}
onSelect(item);
}}
>
<div
className={clsx("w-1 h-6 rounded-sm bg-[#0072FF]", {
"opacity-0": _id !== active?._id,
})}
/>
<div className="flex-1 flex items-center justify-between gap-2 px-2 overflow-hidden">
{isEdit && isActive ? (
<Input
defaultValue={title}
className="flex-1 -mx-px outline-none bg-transparent border border-[#0061FF] rounded-[4px]"
onKeyDown={(event) => {
if (event.key !== "Enter") return;
onRename(item, event.currentTarget.value);
setIsEdit(false);
}}
onBlur={(event) => {
onRename(item, event.target.value);
setIsEdit(false);
}}
/>
) : (
<span className="truncate">{title}</span>
)}
<Menu>
{isActive && !isEdit && (
<MenuButton>
<Ellipsis className="size-4 text-[#979797]" />
</MenuButton>
)}
<MenuItems
anchor="bottom"
className="flex flex-col rounded-lg shadow-md z-100 bg-white dark:bg-[#202126] p-1 border border-black/2 dark:border-white/10"
>
{menuItems.map((menuItem) => {
const {
label,
icon: Icon,
iconColor,
onClick,
} = menuItem;
return (
<MenuItem key={label}>
<button
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
onClick={() => onClick()}
>
<Icon
className="size-4"
style={{
color: iconColor,
}}
/>
<span>{t(label)}</span>
</button>
</MenuItem>
);
})}
</MenuItems>
</Menu>
</div>
</li>
);
})}
</ul>
</div>
);
})}
</div>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-1000"
>
<div className="fixed inset-0 flex items-center justify-center w-screen">
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-xl rounded-lg">
<div className="flex flex-col gap-3">
<DialogTitle className="text-base font-bold text-[#333]">
{t("history_list.delete_modal.title")}
</DialogTitle>
<Description className="text-sm text-[#333]">
{t("history_list.delete_modal.description", {
replace: [active?._source?.title || active?._id],
})}
</Description>
</div>
<div className="flex gap-4 self-end">
<button
className="h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg"
onClick={() => setIsOpen(false)}
>
{t("history_list.delete_modal.button.cancel")}
</button>
<button
className="h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg"
onClick={() => {
if (!active?._id) return;
onRemove(active._id);
setIsOpen(false);
}}
>
{t("history_list.delete_modal.button.delete")}
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</div>
);
};
export default HistoryList;

View File

@@ -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"
}
}
}
}

View File

@@ -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": "取消"
}
}
}
}

View File

@@ -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<ChatAIRef>(null);
const [chats, setChats] = useState<Chat[]>([]);
const [activeChat, setActiveChat] = useState<Chat>();
const [chats, setChats] = useState<typeChat[]>([]);
const [activeChat, setActiveChat] = useState<typeChat>();
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 (
<div className="h-screen">
<div className="h-[100%] flex">
@@ -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`}
>
<Sidebar
chats={chats}
activeChat={activeChat}
onNewChat={() => {
chatAIRef.current?.clearChat();
}}
onSelectChat={onSelectChat}
onDeleteChat={deleteChat}
fetchChatHistory={getChatHistory}
<HistoryList
list={chats}
active={activeChat}
onSearch={handleSearch}
onRefresh={getChatHistory}
onSelect={onSelectChat}
onRename={handleRename}
onRemove={handleDelete}
/>
</div>
) : null}

View File

@@ -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)",