refactor: AI conversation rendering logic (#216)

* chore: chat message

* chore: new chat id

* chore: chat message

* chore: chat messages

* chore: think messages display

* chore: chat message

* chore: chat message

* refactor: the message logic

* chore: add useCallback params

* chore: add ThinkingSegment

* chore: add QueryIntent

* chore: loading & text

* chore: add JSON

* chore: add

* chore: source

* chore: add ws-error listen

* chore: style

* fix: ws reconnect

* chore: ws

* chore: ws

* refactor: AI conversation rendering logic

* chore: update note
This commit is contained in:
BiggerRain
2025-03-02 21:44:21 +08:00
committed by GitHub
parent 9fd56457d8
commit 31aa8587ba
30 changed files with 1236 additions and 523 deletions

View File

@@ -30,6 +30,7 @@ Information about release notes of Coco Server is provided here.
- Refactoring assistant api #195 - Refactoring assistant api #195
- Refactor: remove websocket_session_id from message request #206 - Refactor: remove websocket_session_id from message request #206
- Refactor: the display of search results and the logic of creating new chats #207 - Refactor: the display of search results and the logic of creating new chats #207
- Refactor: AI conversation rendering logic #216
- Refresh all server's info on purpose, get the actual health info #225 - Refresh all server's info on purpose, get the actual health info #225

View File

@@ -52,15 +52,15 @@ where
.await .await
.map_err(|e| format!("Failed to parse JSON: {}", e))?; .map_err(|e| format!("Failed to parse JSON: {}", e))?;
// dbg!(&body);
let search_response: SearchResponse<T> = serde_json::from_value(body) let search_response: SearchResponse<T> = serde_json::from_value(body)
.map_err(|e| format!("Failed to deserialize search response: {}", e))?; .map_err(|e| format!("Failed to deserialize search response: {}", e))?;
Ok(search_response) Ok(search_response)
} }
pub async fn parse_search_hits<T>( pub async fn parse_search_hits<T>(response: Response) -> Result<Vec<SearchHit<T>>, Box<dyn Error>>
response: Response,
) -> Result<Vec<SearchHit<T>>, Box<dyn Error>>
where where
T: for<'de> Deserialize<'de> + std::fmt::Debug, T: for<'de> Deserialize<'de> + std::fmt::Debug,
{ {
@@ -69,13 +69,15 @@ where
Ok(response.hits.hits) Ok(response.hits.hits)
} }
pub async fn parse_search_results<T>( pub async fn parse_search_results<T>(response: Response) -> Result<Vec<T>, Box<dyn Error>>
response: Response,
) -> Result<Vec<T>, Box<dyn Error>>
where where
T: for<'de> Deserialize<'de> + std::fmt::Debug, T: for<'de> Deserialize<'de> + std::fmt::Debug,
{ {
Ok(parse_search_hits(response).await?.into_iter().map(|hit| hit._source).collect()) Ok(parse_search_hits(response)
.await?
.into_iter()
.map(|hit| hit._source)
.collect())
} }
pub async fn parse_search_results_with_score<T>( pub async fn parse_search_results_with_score<T>(
@@ -84,7 +86,11 @@ pub async fn parse_search_results_with_score<T>(
where where
T: for<'de> Deserialize<'de> + std::fmt::Debug, T: for<'de> Deserialize<'de> + std::fmt::Debug,
{ {
Ok(parse_search_hits(response).await?.into_iter().map(|hit| (hit._source, hit._score)).collect()) Ok(parse_search_hits(response)
.await?
.into_iter()
.map(|hit| (hit._source, hit._score))
.collect())
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -107,8 +113,8 @@ impl SearchQuery {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct QuerySource { pub struct QuerySource {
pub r#type: String, //coco-server/local/ etc. pub r#type: String, //coco-server/local/ etc.
pub id: String, //coco server's id pub id: String, //coco server's id
pub name: String, //coco server's name, local computer name, etc. pub name: String, //coco server's name, local computer name, etc.
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -139,4 +145,3 @@ pub struct MultiSourceQueryResponse {
pub hits: Vec<QueryHits>, pub hits: Vec<QueryHits>,
pub total_hits: usize, pub total_hits: usize,
} }

View File

@@ -128,7 +128,7 @@ pub async fn connect_to_server(
msg = ws.next() => { msg = ws.next() => {
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
println!("Received message: {}", text); //println!("Received message: {}", text);
let _ = app_handle_clone.emit("ws-message", text); let _ = app_handle_clone.emit("ws-message", text);
}, },
Some(Err(WsError::ConnectionClosed)) => { Some(Err(WsError::ConnectionClosed)) => {
@@ -169,7 +169,6 @@ pub async fn connect_to_server(
#[tauri::command] #[tauri::command]
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> { pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
// Send cancellation signal // Send cancellation signal
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() { if let Some(cancel_tx) = state.cancel_tx.lock().await.take() {
let _ = cancel_tx.send(()).await; let _ = cancel_tx.send(()).await;

View File

@@ -13,14 +13,15 @@ import { useTranslation } from "react-i18next";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { ChatMessage } from "./ChatMessage"; import { ChatMessage } from "@/components/ChatMessage";
import type { Chat } from "./types"; import type { Chat, IChunkData } from "./types";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import { useWindows } from "@/hooks/useWindows"; import { useWindows } from "@/hooks/useWindows";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { Sidebar } from "@/components/Assistant/Sidebar"; import { Sidebar } from "@/components/Assistant/Sidebar";
import { useConnectStore } from "@/stores/connectStore"; import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { IServer } from "@/stores/appStore";
interface ChatAIProps { interface ChatAIProps {
isTransitioned: boolean; isTransitioned: boolean;
@@ -72,24 +73,16 @@ const ChatAI = memo(
const { createWin } = useWindows(); const { createWin } = useWindows();
const { const { curChatEnd, setCurChatEnd, connected, setConnected } =
curChatEnd, useChatStore();
setCurChatEnd,
connected,
setConnected,
messages,
setMessages,
} = useChatStore();
const currentService = useConnectStore((state) => state.currentService); const currentService = useConnectStore((state) => state.currentService);
const [activeChat, setActiveChat] = useState<Chat>(); const [activeChat, setActiveChat] = useState<Chat>();
const [isTyping, setIsTyping] = useState(false);
const [timedoutShow, setTimedoutShow] = useState(false); const [timedoutShow, setTimedoutShow] = useState(false);
const [errorShow, setErrorShow] = useState(false); const [errorShow, setErrorShow] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [curMessage, setCurMessage] = useState("");
const curChatEndRef = useRef(curChatEnd); const curChatEndRef = useRef(curChatEnd);
curChatEndRef.current = curChatEnd; curChatEndRef.current = curChatEnd;
@@ -103,14 +96,12 @@ const ChatAI = memo(
activeChatProp && setActiveChat(activeChatProp); activeChatProp && setActiveChat(activeChatProp);
}, [activeChatProp]); }, [activeChatProp]);
const handleMessageChunk = useCallback((chunk: string) => { const reconnect = async (server?: IServer) => {
setCurMessage((prev) => prev + chunk); server = server || currentService;
}, []); if (!server?.id) return;
const reconnect = async () => {
if (!currentService?.id) return;
try { try {
await invoke("connect_to_server", { id: currentService?.id }); console.log("reconnect", 1111111, server.id);
await invoke("connect_to_server", { id: server.id });
setConnected(true); setConnected(true);
} catch (error) { } catch (error) {
console.error("Failed to connect:", error); console.error("Failed to connect:", error);
@@ -119,88 +110,177 @@ const ChatAI = memo(
const messageTimeoutRef = useRef<NodeJS.Timeout>(); const messageTimeoutRef = useRef<NodeJS.Timeout>();
const [Question, setQuestion] = useState<string>("");
const [query_intent, setQuery_intent] = useState<IChunkData>();
const deal_query_intent = useCallback((data: IChunkData) => {
setQuery_intent((prev: IChunkData | undefined): IChunkData => {
if (!prev) return data;
return {
...prev,
message_chunk: prev.message_chunk + data.message_chunk,
};
});
}, []);
const [fetch_source, setFetch_source] = useState<IChunkData>();
const deal_fetch_source = useCallback((data: IChunkData) => {
setFetch_source(data);
}, []);
const [pick_source, setPick_source] = useState<IChunkData>();
const deal_pick_source = useCallback((data: IChunkData) => {
setPick_source((prev: IChunkData | undefined): IChunkData => {
if (!prev) return data;
return {
...prev,
message_chunk: prev.message_chunk + data.message_chunk,
};
});
}, []);
const [deep_read, setDeep_read] = useState<IChunkData>();
const deal_deep_read = useCallback((data: IChunkData) => {
setDeep_read((prev: IChunkData | undefined): IChunkData => {
if (!prev) return data;
return {
...prev,
message_chunk: prev.message_chunk + "&" + data.message_chunk,
};
});
}, []);
const [think, setThink] = useState<IChunkData>();
const deal_think = useCallback((data: IChunkData) => {
setThink((prev: IChunkData | undefined): IChunkData => {
if (!prev) return data;
return {
...prev,
message_chunk: prev.message_chunk + data.message_chunk,
};
});
}, []);
const [response, setResponse] = useState<IChunkData>();
const deal_response = useCallback((data: IChunkData) => {
setResponse((prev: IChunkData | undefined): IChunkData => {
if (!prev) return data;
return {
...prev,
message_chunk: prev.message_chunk + data.message_chunk,
};
});
}, []);
const clearCurrentChat = useCallback(() => {
setQuery_intent(undefined);
setFetch_source(undefined);
setPick_source(undefined);
setDeep_read(undefined);
setThink(undefined);
setResponse(undefined);
}, []);
const dealMsg = useCallback( const dealMsg = useCallback(
(msg: string) => { (msg: string) => {
// console.log("msg:", msg);
if (messageTimeoutRef.current) { if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current); clearTimeout(messageTimeoutRef.current);
} }
if (msg.includes("PRIVATE")) { if (!msg.includes("PRIVATE")) return;
messageTimeoutRef.current = setTimeout(() => {
if (!curChatEnd && isTyping) {
console.log("AI response timeout");
setTimedoutShow(true);
cancelChat();
}
}, 30000);
if (msg.includes("assistant finished output")) { messageTimeoutRef.current = setTimeout(() => {
if (messageTimeoutRef.current) { if (!curChatEnd) {
clearTimeout(messageTimeoutRef.current); console.log("AI response timeout");
} setTimedoutShow(true);
console.log("AI finished output"); cancelChat();
setCurChatEnd(true);
} else {
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
// console.log("cleanedData", cleanedData);
const chunkData = JSON.parse(cleanedData);
// console.log("msg1:", chunkData, curIdRef.current);
if (chunkData.reply_to_message === curIdRef.current) {
handleMessageChunk(chunkData.message_chunk);
return chunkData.message_chunk;
}
} catch (error) {
console.error("parse error:", error);
}
} }
}, 30000);
if (msg.includes("assistant finished output")) {
clearTimeout(messageTimeoutRef.current);
console.log("AI finished output");
setCurChatEnd(true);
return;
}
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
const chunkData = JSON.parse(cleanedData);
if (chunkData.reply_to_message !== curIdRef.current) return;
// ['query_intent', 'fetch_source', 'pick_source', 'deep_read', 'think', 'response'];
if (chunkData.chunk_type === "query_intent") {
deal_query_intent(chunkData);
} else if (chunkData.chunk_type === "fetch_source") {
deal_fetch_source(chunkData);
} else if (chunkData.chunk_type === "pick_source") {
deal_pick_source(chunkData);
} else if (chunkData.chunk_type === "deep_read") {
deal_deep_read(chunkData);
} else if (chunkData.chunk_type === "think") {
deal_think(chunkData);
} else if (chunkData.chunk_type === "response") {
deal_response(chunkData);
}
} catch (error) {
console.error("parse error:", error);
} }
}, },
[curChatEnd, isTyping] [curChatEnd]
); );
useEffect(() => { useEffect(() => {
const unlisten = listen("ws-message", (event) => { if (curChatEnd) {
const data = dealMsg(String(event.payload)); simulateAssistantResponse();
if (data) { }
setMessages((prev) => prev + data); }, [curChatEnd]);
}
}); useEffect(() => {
let unlisten_error = null;
if (connected) {
setErrorShow(false);
unlisten_error = listen("ws-error", (event) => {
console.error("WebSocket error:", event.payload);
setConnected(false);
setErrorShow(true);
});
}
return () => { return () => {
unlisten.then((fn) => fn()); unlisten_error?.then((fn) => fn());
}; };
}, []); }, [connected]);
const assistantMessage = useMemo(() => { useEffect(() => {
if (!activeChat?._id || (!curMessage && !messages)) return null; let unlisten_message = null;
return { if (connected) {
_id: activeChat._id, setErrorShow(false);
_source: { unlisten_message = listen("ws-message", (event) => {
type: "assistant", dealMsg(String(event.payload));
message: curMessage || messages, });
}, }
return () => {
unlisten_message?.then((fn) => fn());
}; };
}, [activeChat?._id, curMessage, messages]); }, [dealMsg, connected]);
const updatedChat = useMemo(() => { const updatedChat = useMemo(() => {
if (!activeChat?._id || !assistantMessage) return null; if (!activeChat?._id) return null;
return { return {
...activeChat, ...activeChat,
messages: [...(activeChat.messages || []), assistantMessage], messages: [...(activeChat.messages || [])],
}; };
}, [activeChat, assistantMessage]); }, [activeChat]);
const simulateAssistantResponse = useCallback(() => { const simulateAssistantResponse = useCallback(() => {
if (!updatedChat) return; if (!updatedChat) return;
console.log("updatedChat:", updatedChat); console.log("updatedChat:", updatedChat);
setActiveChat(updatedChat); setActiveChat(updatedChat);
setMessages("");
setCurMessage("");
setIsTyping(false);
}, [updatedChat]); }, [updatedChat]);
useEffect(() => { useEffect(() => {
@@ -271,7 +351,15 @@ const ChatAI = memo(
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [activeChat?.messages, isTyping, curMessage]); }, [
activeChat?.messages,
query_intent?.message_chunk,
fetch_source?.message_chunk,
pick_source?.message_chunk,
deep_read?.message_chunk,
think?.message_chunk,
response?.message_chunk,
]);
const clearChat = () => { const clearChat = () => {
console.log("clearChat"); console.log("clearChat");
@@ -281,43 +369,47 @@ const ChatAI = memo(
clearChatPage && clearChatPage(); clearChatPage && clearChatPage();
}; };
const createNewChat = useCallback(async (value: string = "") => { const createNewChat = useCallback(
setTimedoutShow(false); async (value: string = "") => {
setErrorShow(false); setTimedoutShow(false);
chatClose(); setErrorShow(false);
try { chatClose();
console.log("sourceDataIds", sourceDataIds); clearCurrentChat();
let response: any = await invoke("new_chat", { setQuestion(value);
serverId: currentService?.id, try {
message: value, // console.log("sourceDataIds", sourceDataIds);
queryParams: { let response: any = await invoke("new_chat", {
search: isSearchActive, serverId: currentService?.id,
deep_thinking: isDeepThinkActive, message: value,
datasource: sourceDataIds.join(","), queryParams: {
}, search: isSearchActive,
}); deep_thinking: isDeepThinkActive,
console.log("_new", response); datasource: sourceDataIds.join(","),
const newChat: Chat = response; },
curIdRef.current = response?.payload?.id; });
console.log("_new", response);
const newChat: Chat = response;
curIdRef.current = response?.payload?.id;
newChat._source = { newChat._source = {
message: value, message: value,
}; };
const updatedChat: Chat = { const updatedChat: Chat = {
...newChat, ...newChat,
messages: [newChat], messages: [newChat],
}; };
changeInput && changeInput(""); changeInput && changeInput("");
console.log("updatedChat2", updatedChat); console.log("updatedChat2", updatedChat);
setActiveChat(updatedChat); setActiveChat(updatedChat);
setIsTyping(true); setCurChatEnd(false);
setCurChatEnd(false); } catch (error) {
} catch (error) { setErrorShow(true);
setErrorShow(true); console.error("Failed to fetch user data:", error);
console.error("Failed to fetch user data:", error); }
} },
}, []); [isSearchActive, isDeepThinkActive]
);
const init = (value: string) => { const init = (value: string) => {
if (!curChatEnd) return; if (!curChatEnd) return;
@@ -332,10 +424,14 @@ const ChatAI = memo(
async (content: string, newChat?: Chat) => { async (content: string, newChat?: Chat) => {
newChat = newChat || activeChat; newChat = newChat || activeChat;
if (!newChat?._id || !content) return; if (!newChat?._id || !content) return;
setQuestion(content);
await chatHistory(newChat);
setTimedoutShow(false); setTimedoutShow(false);
setErrorShow(false); setErrorShow(false);
clearCurrentChat();
try { try {
console.log("sourceDataIds", sourceDataIds); // console.log("sourceDataIds", sourceDataIds);
let response: any = await invoke("send_message", { let response: any = await invoke("send_message", {
serverId: currentService?.id, serverId: currentService?.id,
sessionId: newChat?._id, sessionId: newChat?._id,
@@ -358,7 +454,6 @@ const ChatAI = memo(
changeInput && changeInput(""); changeInput && changeInput("");
console.log("updatedChat2", updatedChat); console.log("updatedChat2", updatedChat);
setActiveChat(updatedChat); setActiveChat(updatedChat);
setIsTyping(true);
setCurChatEnd(false); setCurChatEnd(false);
} catch (error) { } catch (error) {
setErrorShow(true); setErrorShow(true);
@@ -383,12 +478,7 @@ const ChatAI = memo(
}; };
const cancelChat = async () => { const cancelChat = async () => {
if (curMessage || messages) {
simulateAssistantResponse();
}
setCurChatEnd(true); setCurChatEnd(true);
setIsTyping(false);
if (!activeChat?._id) return; if (!activeChat?._id) return;
try { try {
let response: any = await invoke("cancel_session_chat", { let response: any = await invoke("cancel_session_chat", {
@@ -427,10 +517,7 @@ const ChatAI = memo(
clearTimeout(messageTimeoutRef.current); clearTimeout(messageTimeoutRef.current);
} }
chatClose(); chatClose();
setMessages("");
setCurMessage("");
setActiveChat(undefined); setActiveChat(undefined);
setIsTyping(false);
setCurChatEnd(true); setCurChatEnd(true);
scrollToBottom.cancel(); scrollToBottom.cancel();
}; };
@@ -564,6 +651,7 @@ const ChatAI = memo(
}} }}
isSidebarOpen={isSidebarOpenChat} isSidebarOpen={isSidebarOpenChat}
activeChat={activeChat} activeChat={activeChat}
reconnect={reconnect}
/> />
{/* Chat messages */} {/* Chat messages */}
@@ -577,32 +665,42 @@ const ChatAI = memo(
message: t("assistant.chat.greetings"), message: t("assistant.chat.greetings"),
}, },
}} }}
isTyping={false}
/> />
{activeChat?.messages?.map((message, index) => ( {activeChat?.messages?.map((message, index) => (
<ChatMessage <ChatMessage
key={message._id + index} key={message._id + index}
message={message} message={message}
isTyping={ isTyping={false}
isTyping && onResend={handleSendMessage}
index === (activeChat.messages?.length || 0) - 1 &&
message._source?.type === "assistant"
}
/> />
))} ))}
{!curChatEnd && activeChat?._id ? ( {(query_intent ||
fetch_source ||
pick_source ||
deep_read ||
think ||
response) &&
activeChat?._id ? (
<ChatMessage <ChatMessage
key={"last"} key={"current"}
message={{ message={{
_id: activeChat?._id, _id: "current",
_source: { _source: {
type: "assistant", type: "assistant",
message: curMessage, message: "",
question: Question,
}, },
}} }}
onResend={handleSendMessage}
isTyping={!curChatEnd} isTyping={!curChatEnd}
query_intent={query_intent}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
/> />
) : null} ) : null}
@@ -614,8 +712,10 @@ const ChatAI = memo(
_source: { _source: {
type: "assistant", type: "assistant",
message: t("assistant.chat.timedout"), message: t("assistant.chat.timedout"),
question: Question,
}, },
}} }}
onResend={handleSendMessage}
isTyping={false} isTyping={false}
/> />
) : null} ) : null}
@@ -628,8 +728,10 @@ const ChatAI = memo(
_source: { _source: {
type: "assistant", type: "assistant",
message: t("assistant.chat.error"), message: t("assistant.chat.error"),
question: Question,
}, },
}} }}
onResend={handleSendMessage}
isTyping={false} isTyping={false}
/> />
) : null} ) : null}

View File

@@ -38,6 +38,7 @@ interface ChatHeaderProps {
setIsSidebarOpen: () => void; setIsSidebarOpen: () => void;
isSidebarOpen: boolean; isSidebarOpen: boolean;
activeChat: Chat | undefined; activeChat: Chat | undefined;
reconnect: (server?: IServer) => void;
} }
export function ChatHeader({ export function ChatHeader({
@@ -45,6 +46,7 @@ export function ChatHeader({
onOpenChatAI, onOpenChatAI,
setIsSidebarOpen, setIsSidebarOpen,
activeChat, activeChat,
reconnect,
}: ChatHeaderProps) { }: ChatHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,7 +54,7 @@ export function ChatHeader({
const isPinned = useAppStore((state) => state.isPinned); const isPinned = useAppStore((state) => state.isPinned);
const setIsPinned = useAppStore((state) => state.setIsPinned); const setIsPinned = useAppStore((state) => state.setIsPinned);
const { setConnected, setMessages } = useChatStore(); const { connected, setConnected, setMessages } = useChatStore();
const [serverList, setServerList] = useState<IServer[]>([]); const [serverList, setServerList] = useState<IServer[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
@@ -68,8 +70,17 @@ export function ChatHeader({
); );
// console.log("list_coco_servers", enabledServers); // console.log("list_coco_servers", enabledServers);
setServerList(enabledServers); setServerList(enabledServers);
if (resetSelection && enabledServers.length > 0 && !currentService) {
switchServer(enabledServers[enabledServers.length - 1]); if (resetSelection && enabledServers.length > 0) {
const currentServiceExists = enabledServers.some(
server => server.id === currentService?.id
);
if (currentServiceExists) {
switchServer(currentService);
} else {
switchServer(enabledServers[enabledServers.length - 1]);
}
} }
}) })
.catch((err: any) => { .catch((err: any) => {
@@ -79,10 +90,17 @@ export function ChatHeader({
useEffect(() => { useEffect(() => {
fetchServers(true); fetchServers(true);
return () => {
// Cleanup logic if needed
disconnect();
};
}, []); }, []);
const disconnect = async () => { const disconnect = async () => {
if (!connected) return;
try { try {
console.log("disconnect", 33333333);
await invoke("disconnect"); await invoke("disconnect");
setConnected(false); setConnected(false);
} catch (error) { } catch (error) {
@@ -90,15 +108,6 @@ export function ChatHeader({
} }
}; };
const connect = async (server: IServer) => {
try {
await invoke("connect_to_server", { id: server.id });
setConnected(true);
} catch (error) {
console.error("Failed to connect:", error);
}
};
const switchServer = async (server: IServer) => { const switchServer = async (server: IServer) => {
try { try {
// Switch UI first, then switch server connection // Switch UI first, then switch server connection
@@ -108,7 +117,7 @@ export function ChatHeader({
onCreateNewChat(); onCreateNewChat();
// //
await disconnect(); await disconnect();
await connect(server); reconnect && reconnect(server);
} catch (error) { } catch (error) {
console.error("switchServer:", error); console.error("switchServer:", error);
} }
@@ -185,7 +194,7 @@ export function ChatHeader({
<div> <div>
<h2 className="text-sm font-medium text-gray-900 dark:text-gray-100"> <h2 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{activeChat?.title || activeChat?._id} {activeChat?._source?.title || activeChat?._id}
</h2> </h2>
</div> </div>

View File

@@ -1,133 +0,0 @@
import { Brain, ChevronDown, ChevronUp } from "lucide-react";
import { useState, memo } from "react";
import { useTranslation } from "react-i18next";
import type { Message } from "./types";
import Markdown from "./Markdown";
import { formatThinkingMessage } from "@/utils/index";
import logoImg from "@/assets/icon.svg";
import { SourceResult } from "./SourceResult";
// import { ThinkingSteps } from "./ThinkingSteps";
interface ChatMessageProps {
message: Message;
isTyping?: boolean;
}
export const ChatMessage = memo(function ChatMessage({
message,
isTyping,
}: ChatMessageProps) {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const isAssistant = message._source?.type === "assistant";
const segments = formatThinkingMessage(message._source?.message || "");
return (
<div
className={`py-8 flex ${isAssistant ? "justify-start" : "justify-end"}`}
>
<div
className={`max-w-3xl px-4 sm:px-6 lg:px-8 flex gap-4 ${
isAssistant ? "" : "flex-row-reverse"
}`}
>
<div
className={`flex-1 space-y-2 ${
isAssistant ? "text-left" : "text-right"
}`}
>
<p className="flex items-center gap-4 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
{isAssistant ? (
<img
src={logoImg}
className="w-6 h-6"
alt={t("assistant.message.logo")}
/>
) : null}
{isAssistant ? t("assistant.message.aiName") : ""}
</p>
<div className="prose dark:prose-invert prose-sm max-w-none">
<div className="text-[#333] dark:text-[#d8d8d8] leading-relaxed">
{isAssistant ? (
<>
{/* <ThinkingSteps currentStep={4} /> */}
{segments.map((segment, index) => (
<span key={index}>
{segment.isSource ? (
<SourceResult text={segment.text} />
) : segment.isThinking ? (
<div className="space-y-2 mb-3 w-full">
<button
onClick={() =>
setIsThinkingExpanded((prev) => !prev)
}
className="inline-flex items-center gap-2 px-2 py-1 rounded-xl transition-colors border border-[#E6E6E6] dark:border-[#272626]"
>
{isTyping ? (
<>
<Brain className="w-4 h-4 animate-pulse text-[#999999]" />
<span className="text-xs text-[#999999] italic">
{t("assistant.message.thinking")}
</span>
</>
) : (
<>
<Brain className="w-4 h-4 text-[#999999]" />
<span className="text-xs text-[#999999]">
{t("assistant.message.thoughtTime")}
</span>
</>
)}
{segment.thinkContent &&
(isThinkingExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
))}
</button>
{isThinkingExpanded && segment.thinkContent && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
{segment.thinkContent.split("\n").map(
(paragraph, idx) =>
paragraph.trim() && (
<p key={idx} className="text-sm">
{paragraph}
</p>
)
)}
</div>
</div>
)}
</div>
) : segment.text ? (
<div className="space-y-4">
<Markdown
key={`${index}-${isTyping ? "loading" : "done"}`}
content={segment.text}
loading={isTyping}
onDoubleClickCapture={() => {}}
/>
</div>
) : null}
</span>
))}
{isTyping && (
<span className="inline-block w-1.5 h-4 ml-0.5 -mb-0.5 bg-current animate-pulse" />
)}
</>
) : (
<div className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8]">
{message._source?.message || ""}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
});

View File

@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { MessageSquare, Plus } from "lucide-react"; import { MessageSquare, Plus } from "lucide-react";
import type { Chat } from "./types"; import type { Chat } from "./types";
interface SidebarProps { interface SidebarProps {
chats: Chat[]; chats: Chat[];
activeChat: Chat | undefined; activeChat: Chat | undefined;

View File

@@ -12,54 +12,24 @@ import { OpenURLWithBrowser } from "@/utils/index";
interface SourceResultProps { interface SourceResultProps {
text: string; text: string;
prefix?: string;
data?: any[];
total?: string;
type?: string;
} }
interface SourceItem { export const SourceResult = ({
url?: string; prefix,
title?: string; data,
category?: string; total,
source?: { type,
name: string; }: SourceResultProps) => {
};
}
export function SourceResult({ text }: SourceResultProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isSourceExpanded, setIsSourceExpanded] = useState(false); const [isSourceExpanded, setIsSourceExpanded] = useState(false);
if (!text?.includes("<Source")) {
return null;
}
const getSourceData = (): SourceItem[] => {
try {
// console.log("Raw text:", text);
const sourceRegex = /<Source.*?total=(\d+)>\s*(.*?)\s*<\/Source>/s;
const sourceMatch = text.match(sourceRegex);
if (!sourceMatch) {
// console.log("No source match found");
return [];
}
const jsonContent = sourceMatch[2];
// console.log("Extracted JSON:", jsonContent);
const parsedData = JSON.parse(jsonContent);
return parsedData;
} catch (error) {
console.error("Failed to parse source data:", error);
return [];
}
};
const totalResults = text.match(/total=["']?(\d+)["']?/)?.[1] || "0";
const sourceData = getSourceData();
return ( return (
<div <div
className={`mt-2 ${ className={`mt-2 mb-2 w-[98%] ${
isSourceExpanded isSourceExpanded
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]" ? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
: "" : ""
@@ -67,53 +37,58 @@ export function SourceResult({ text }: SourceResultProps) {
> >
<button <button
onClick={() => setIsSourceExpanded((prev) => !prev)} onClick={() => setIsSourceExpanded((prev) => !prev)}
className={`inline-flex justify-between items-center gap-2 px-2 py-1 rounded-xl transition-colors ${ className={`inline-flex justify-between items-center gap-2 px-2 py-1 rounded-xl transition-colors whitespace-nowrap ${
isSourceExpanded isSourceExpanded
? "w-full" ? "w-full"
: "border border-[#E6E6E6] dark:border-[#272626]" : "border border-[#E6E6E6] dark:border-[#272626]"
}`} }`}
> >
<div className="flex gap-2"> <div className="flex-1 min-w-0 flex items-center gap-2">
<Search className="w-4 h-4 text-[#999999] dark:text-[#999999]" /> <Search className="w-4 h-4 text-[#38C200] flex-shrink-0" />
<span className="text-xs text-[#999999] dark:text-[#999999]"> <span className="text-xs text-[#999999]">
{t("assistant.source.foundResults", { {t(`assistant.message.steps.${type}`, {
count: Number(totalResults), count: Number(total),
})} })}
</span> </span>
</div> </div>
{isSourceExpanded ? ( {isSourceExpanded ? (
<ChevronUp className="w-4 h-4 text-[#999999] dark:text-[#999999]" /> <ChevronUp className="w-4 h-4 text-[#999999]" />
) : ( ) : (
<ChevronDown className="w-4 h-4 text-[#999999] dark:text-[#999999]" /> <ChevronDown className="w-4 h-4 text-[#999999]" />
)} )}
</button> </button>
{isSourceExpanded && ( {isSourceExpanded && (
<div className=""> <>
{sourceData.map((item, idx) => ( {prefix && (
<div className="px-3 py-2 bg-[#F7F7F7] dark:bg-[#1E1E1E] text-[#666666] dark:text-[#A3A3A3] text-xs leading-relaxed border-b border-[#E6E6E6] dark:border-[#272626]">
{prefix}
</div>
)}
{data?.map((item, idx) => (
<div <div
key={idx} key={idx}
onClick={() => item.url && OpenURLWithBrowser(item.url)} onClick={() => item.url && OpenURLWithBrowser(item.url)}
className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors" className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors"
> >
<div className="flex-1 min-w-0 flex items-center gap-2"> <div className="w-full flex items-center gap-2">
<div className="flex-1 min-w-0 flex items-center gap-1"> <div className="w-[75%] flex items-center gap-1">
<Globe className="w-3 h-3" /> <Globe className="w-3 h-3 flex-shrink-0" />
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]"> <div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
{item.title || item.category} {item.title || item.category}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="w-[25%] flex items-center justify-end gap-2">
<span className="text-xs text-[#999999] dark:text-[#999999]"> <span className="text-xs text-[#999999] dark:text-[#999999] truncate">
{item.source?.name} {item.source?.name}
</span> </span>
<SquareArrowOutUpRight className="w-3 h-3 text-[#999999] dark:text-[#999999]" /> <SquareArrowOutUpRight className="w-3 h-3 text-[#999999] dark:text-[#999999] flex-shrink-0" />
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </>
)} )}
</div> </div>
); );
} };

View File

@@ -1,84 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Brain, ChevronDown, ChevronUp } from "lucide-react";
interface ThinkingStepsProps {
currentStep?: number;
}
export const ThinkingSteps = ({ currentStep = 4 }: ThinkingStepsProps) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(true);
const steps = [
"Understand the query",
"Retrieve documents",
"Intelligent pre-selection",
"Deep reading",
];
return (
<div
className={`mt-2 ${
isExpanded
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
: ""
}`}
>
<button
onClick={() => setIsExpanded((prev) => !prev)}
className={`inline-flex justify-between items-center gap-2 px-2 py-1 rounded-xl transition-colors ${
isExpanded
? "w-full"
: "border border-[#E6E6E6] dark:border-[#272626]"
}`}
>
<div className="flex gap-2">
<Brain className="w-4 h-4 text-[#999999]" />
<span className="text-xs text-[#999999]">
{t("assistant.message.thinking")}
</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-[#999999]" />
) : (
<ChevronDown className="w-4 h-4 text-[#999999]" />
)}
</button>
{isExpanded && (
<div className="p-2 space-y-2">
{steps.map((step, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0"
>
<div
className={`w-4 h-4 rounded-full ${
index < currentStep - 1
? "bg-[#22C493]"
: "border-2 border-[#0072FF]"
} flex items-center justify-center flex-shrink-0`}
>
{index < currentStep - 1 ? (
<span className="text-white text-xs"></span>
) : (
<span className="text-[#0072FF] text-xs animate-pulse">
</span>
)}
</div>
<span className="text-xs text-[#333333] dark:text-[#D8D8D8]">
{t(
`assistant.message.steps.${step
.toLowerCase()
.replace(/\s+/g, "_")}`
)}
</span>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -1,8 +0,0 @@
.markdown-content p {
margin: 1em 0;
line-height: 1.6;
}
.markdown-content strong {
font-weight: bold;
}

View File

@@ -12,6 +12,8 @@ export interface ISource {
session_id?: string; session_id?: string;
type?: string; type?: string;
message?: any; message?: any;
title?: string;
question?: string;
} }
export interface Chat { export interface Chat {
_id: string; _id: string;
@@ -25,3 +27,14 @@ export interface Chat {
payload?: string; payload?: string;
[key: string]: any; [key: string]: any;
} }
export interface IChunkData {
session_id: string;
message_id: string;
message_type: string;
reply_to_message: string;
chunk_sequence: number;
chunk_type: string;
message_chunk: string;
[key: string]: any;
}

View File

@@ -0,0 +1,98 @@
import { ChevronDown, ChevronUp, Loader, BadgeCheck } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
interface DeepReadeProps {
ChunkData?: IChunkData;
}
export const DeepRead = ({ ChunkData }: DeepReadeProps) => {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [loading, setLoading] = useState(true);
const [prevContent, setPrevContent] = useState("");
const [Data, setData] = useState<string[]>([]);
useEffect(() => {
if (!ChunkData?.message_chunk) return;
const timerID = setTimeout(() => {
if (ChunkData.message_chunk === prevContent) {
setLoading(false);
clearTimeout(timerID);
}
}, 500);
setPrevContent(ChunkData.message_chunk);
return () => {
timerID && clearTimeout(timerID);
};
}, [ChunkData?.message_chunk, prevContent, loading]);
useEffect(() => {
if (!ChunkData?.message_chunk) return;
try {
if (ChunkData.message_chunk.includes("&")) {
const contentArray = ChunkData.message_chunk.split("&").filter(Boolean);
setData(contentArray);
} else {
setData([ChunkData.message_chunk]);
}
} catch (e) {
console.error("Failed to parse query data:", e);
}
}, [ChunkData?.message_chunk]);
// Must be after hooks
if (!ChunkData) return null;
return (
<div className="space-y-2 mb-3 w-full">
<button
onClick={() => setIsThinkingExpanded((prev) => !prev)}
className="inline-flex items-center gap-2 px-2 py-1 rounded-xl transition-colors border border-[#E6E6E6] dark:border-[#272626]"
>
{loading ? (
<>
<Loader className="w-4 h-4 animate-spin text-[#1990FF]" />
<span className="text-xs text-[#999999] italic">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
</span>
</>
) : (
<>
<BadgeCheck className="w-4 h-4 text-[#38C200]" />
<span className="text-xs text-[#999999]">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`, {
count: Number(Data.length),
})}
</span>
</>
)}
{isThinkingExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => (
<div key={item} className="flex flex-col gap-2">
<div className="text-xs text-[#999999] dark:text-[#808080]">
- {item}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,130 @@
import {
Search,
ChevronUp,
ChevronDown,
SquareArrowOutUpRight,
Globe,
} from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { OpenURLWithBrowser } from "@/utils/index";
import type { IChunkData } from "@/components/Assistant/types";
interface FetchSourceProps {
ChunkData?: IChunkData;
}
interface ISourceData {
category: string;
icon: string;
id: string;
size: number;
source: {
type: string;
name: string;
id: string;
};
summary: string;
thumbnail: string;
title: string;
updated: string | null;
url: string;
}
export const FetchSource = ({ ChunkData }: FetchSourceProps) => {
const { t } = useTranslation();
const [isSourceExpanded, setIsSourceExpanded] = useState(false);
const [total, setTotal] = useState(0);
const [data, setData] = useState<ISourceData[]>([]);
useEffect(() => {
if (!ChunkData?.message_chunk) return;
try {
const match = ChunkData.message_chunk.match(
/\u003cPayload total=(\d+)\u003e/
);
if (match) {
setTotal(Number(match[1]));
}
const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
if (jsonMatch) {
const jsonData = JSON.parse(jsonMatch[0]);
setData(jsonData);
}
} catch (e) {
console.error("Failed to parse fetch source data:", e);
}
}, [ChunkData?.message_chunk]);
// Must be after hooks
if (!ChunkData) return null;
return (
<div
className={`mt-2 mb-2 w-[98%] ${
isSourceExpanded
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
: ""
}`}
>
<button
onClick={() => setIsSourceExpanded((prev) => !prev)}
className={`inline-flex justify-between items-center gap-2 px-2 py-1 rounded-xl transition-colors whitespace-nowrap ${
isSourceExpanded
? "w-full"
: "border border-[#E6E6E6] dark:border-[#272626]"
}`}
>
<div className="flex-1 min-w-0 flex items-center gap-2">
<Search className="w-4 h-4 text-[#38C200] flex-shrink-0" />
<span className="text-xs text-[#999999]">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`, {
count: Number(total),
})}
</span>
</div>
{isSourceExpanded ? (
<ChevronUp className="w-4 h-4 text-[#999999]" />
) : (
<ChevronDown className="w-4 h-4 text-[#999999]" />
)}
</button>
{isSourceExpanded && (
<>
{/* {prefix && (
<div className="px-3 py-2 bg-[#F7F7F7] dark:bg-[#1E1E1E] text-[#666666] dark:text-[#A3A3A3] text-xs leading-relaxed border-b border-[#E6E6E6] dark:border-[#272626]">
{prefix}
</div>
)} */}
{data?.map((item, idx) => (
<div
key={idx}
onClick={() => item.url && OpenURLWithBrowser(item.url)}
className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors"
>
<div className="w-full flex items-center gap-2">
<div className="w-[75%] flex items-center gap-1">
<Globe className="w-3 h-3 flex-shrink-0" />
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
{item.title || item.category}
</div>
</div>
<div className="w-[25%] flex items-center justify-end gap-2">
<span className="text-xs text-[#999999] dark:text-[#999999] truncate">
{item.source?.name}
</span>
<SquareArrowOutUpRight className="w-3 h-3 text-[#999999] dark:text-[#999999] flex-shrink-0" />
</div>
</div>
</div>
))}
</>
)}
</div>
);
};

View File

@@ -9,7 +9,7 @@ import RehypeHighlight from "rehype-highlight";
import mermaid from "mermaid"; import mermaid from "mermaid";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { copyToClipboard, useWindowSize } from "../../utils"; import { copyToClipboard, useWindowSize } from "@/utils";
import "./markdown.css"; import "./markdown.css";
import "./highlight.css"; import "./highlight.css";

View File

@@ -0,0 +1,152 @@
import {
Check,
Copy,
ThumbsUp,
ThumbsDown,
Volume2,
RotateCcw,
} from "lucide-react";
import { useState } from "react";
interface MessageActionsProps {
content: string;
question?: string;
onResend?: () => void;
}
export const MessageActions = ({
content,
question,
onResend,
}: MessageActionsProps) => {
const [copied, setCopied] = useState(false);
const [liked, setLiked] = useState(false);
const [disliked, setDisliked] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [isResending, setIsResending] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
const timerID = setTimeout(() => {
setCopied(false);
clearTimeout(timerID);
}, 2000);
} catch (err) {
console.error("copy error:", err);
}
};
const handleLike = () => {
setLiked(!liked);
setDisliked(false);
};
const handleDislike = () => {
setDisliked(!disliked);
setLiked(false);
};
const handleSpeak = () => {
if ("speechSynthesis" in window) {
if (isSpeaking) {
window.speechSynthesis.cancel();
setIsSpeaking(false);
return;
}
const utterance = new SpeechSynthesisUtterance(content);
utterance.lang = "zh-CN";
utterance.onend = () => {
setIsSpeaking(false);
};
setIsSpeaking(true);
window.speechSynthesis.speak(utterance);
}
};
const handleResend = () => {
if (onResend) {
setIsResending(true);
onResend();
const timerID = setTimeout(() => {
setIsResending(false);
clearTimeout(timerID);
}, 1000);
}
};
return (
<div className="flex items-center gap-1 mt-2">
<button
onClick={handleCopy}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
>
{copied ? (
<Check className="w-4 h-4 text-[#38C200] dark:text-[#38C200]" />
) : (
<Copy className="w-4 h-4 text-[#666666] dark:text-[#A3A3A3]" />
)}
</button>
<button
onClick={handleLike}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
liked ? "animate-shake" : ""
}`}
>
<ThumbsUp
className={`w-4 h-4 ${
liked
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
/>
</button>
<button
onClick={handleDislike}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
disliked ? "animate-shake" : ""
}`}
>
<ThumbsDown
className={`w-4 h-4 ${
disliked
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
/>
</button>
<button
onClick={handleSpeak}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
>
<Volume2
className={`w-4 h-4 ${
isSpeaking
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
/>
</button>
{question && (
<button
onClick={handleResend}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
isResending ? "animate-spin" : ""
}`}
>
<RotateCcw
className={`w-4 h-4 ${
isResending
? "text-[#1990FF] dark:text-[#1990FF]"
: "text-[#666666] dark:text-[#A3A3A3]"
}`}
/>
</button>
)}
</div>
);
};

View File

@@ -0,0 +1,139 @@
import { ChevronDown, ChevronUp, Loader, BadgeCheck } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
interface PickSourceProps {
ChunkData?: IChunkData;
}
interface IData {
explain: string;
id: string;
title: string;
}
export const PickSource = ({ ChunkData }: PickSourceProps) => {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [loading, setLoading] = useState(true);
const [prevContent, setPrevContent] = useState("");
const [Data, setData] = useState<IData[]>([]);
useEffect(() => {
if (!ChunkData?.message_chunk) return;
const timerID = setTimeout(() => {
if (ChunkData.message_chunk === prevContent) {
const cleanContent = ChunkData.message_chunk?.replace(/^"|"$/g, "");
const match = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/);
if (match && match[1]) {
const data = JSON.parse(match[1].trim());
if (
Array.isArray(data) &&
data.every((item) => item.id && item.title && item.explain)
) {
setData(data);
}
}
setLoading(false);
clearTimeout(timerID);
}
}, 500);
setPrevContent(ChunkData.message_chunk);
return () => {
timerID && clearTimeout(timerID);
};
}, [ChunkData?.message_chunk, prevContent]);
useEffect(() => {
if (!ChunkData?.message_chunk) return;
try {
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
if (allMatches) {
for (let i = allMatches.length - 1; i >= 0; i--) {
try {
const jsonString = allMatches[i].replace(/<JSON>|<\/JSON>/g, "");
const data = JSON.parse(jsonString);
if (
Array.isArray(data) &&
data.every((item) => item.id && item.title && item.explain)
) {
setData(data);
break;
}
} catch (e) {
continue;
}
}
}
} catch (e) {
console.error("Failed to parse pick source data:", e);
}
}, [ChunkData?.message_chunk]);
// Must be after hooks
if (!ChunkData) return null;
return (
<div className="space-y-2 mb-3 w-full">
<button
onClick={() => setIsThinkingExpanded((prev) => !prev)}
className="inline-flex items-center gap-2 px-2 py-1 rounded-xl transition-colors border border-[#E6E6E6] dark:border-[#272626]"
>
{loading ? (
<>
<Loader className="w-4 h-4 animate-spin text-[#1990FF]" />
<span className="text-xs text-[#999999] italic">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`, {
count: Data?.length,
})}
</span>
</>
) : (
<>
<BadgeCheck className="w-4 h-4 text-[#38C200]" />
<span className="text-xs text-[#999999]">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`, {
count: Data?.length,
})}
</span>
</>
)}
{isThinkingExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-3 text-xs">
{Data?.map((item) => (
<div
key={item.id}
className="p-3 rounded-lg border border-[#E6E6E6] dark:border-[#272626] bg-white dark:bg-[#1E1E1E] hover:bg-gray-50 dark:hover:bg-[#2C2C2C] transition-colors"
>
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-[#333333] dark:text-[#D8D8D8]">
{item.title}
</div>
<div className="text-xs text-[#666666] dark:text-[#A3A3A3] line-clamp-2">
{item.explain}
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,148 @@
import { ChevronDown, ChevronUp, Loader, BadgeCheck } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
interface QueryIntentProps {
ChunkData?: IChunkData;
}
interface IQueryData {
category: string;
intent: string;
query: string[];
keyword: string[];
}
export const QueryIntent = ({ ChunkData }: QueryIntentProps) => {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [loading, setLoading] = useState(true);
const [prevContent, setPrevContent] = useState("");
const [Data, setData] = useState<IQueryData | null>(null);
useEffect(() => {
if (!ChunkData?.message_chunk) return;
const timerID = setTimeout(() => {
if (ChunkData.message_chunk === prevContent) {
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
if (allMatches) {
const lastMatch = allMatches[allMatches.length - 1];
const jsonString = lastMatch.replace(/<JSON>|<\/JSON>/g, "");
const data = JSON.parse(jsonString);
setData(data);
}
setLoading(false);
clearTimeout(timerID);
}
}, 500);
setPrevContent(ChunkData.message_chunk);
return () => {
timerID && clearTimeout(timerID);
};
}, [ChunkData?.message_chunk, prevContent, loading]);
useEffect(() => {
if (!ChunkData?.message_chunk) return;
try {
} catch (e) {
console.error("Failed to parse query data:", e);
}
}, [ChunkData?.message_chunk]);
// Must be after hooks
if (!ChunkData) return null;
return (
<div className="space-y-2 mb-3 w-full">
<button
onClick={() => setIsThinkingExpanded((prev) => !prev)}
className="inline-flex items-center gap-2 px-2 py-1 rounded-xl transition-colors border border-[#E6E6E6] dark:border-[#272626]"
>
{loading ? (
<>
<Loader className="w-4 h-4 animate-spin text-[#1990FF]" />
<span className="text-xs text-[#999999] italic">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
</span>
</>
) : (
<>
<BadgeCheck className="w-4 h-4 text-[#38C200]" />
<span className="text-xs text-[#999999]">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
</span>
</>
)}
{isThinkingExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
<div className="mb-4 space-y-2 text-xs">
{Data?.keyword ? (
<div className="flex gap-1">
<span className="text-[#999999]">
{t("assistant.message.steps.keywords")}
</span>
<div className="flex flex-wrap gap-1">
{Data?.keyword?.map((keyword, index) => (
<span
key={index}
className="text-[#333333] dark:text-[#D8D8D8]"
>
{keyword}
{index < 2 && "、"}
</span>
))}
</div>
</div>
) : null}
{Data?.category ? (
<div className="flex items-center gap-1">
<span className="text-[#999999]">
{t("assistant.message.steps.questionType")}
</span>
<span className="text-[#333333] dark:text-[#D8D8D8]">
{Data?.category}
</span>
</div>
) : null}
{Data?.intent ? (
<div className="flex items-start gap-1">
<span className="text-[#999999]">
{t("assistant.message.steps.userIntent")}
</span>
<div className="flex-1 text-[#333333] dark:text-[#D8D8D8]">
{Data?.intent}
</div>
</div>
) : null}
{Data?.query ? (
<div className="flex items-start gap-1">
<span className="text-[#999999]">
{t("assistant.message.steps.relatedQuestions")}
</span>
<div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]">
{Data?.query?.map((question) => (
<span key={question}>- {question}</span>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { Brain, ChevronDown, ChevronUp } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { IChunkData } from "@/components/Assistant/types";
interface ThinkProps {
ChunkData?: IChunkData;
}
export const Think = ({ ChunkData }: ThinkProps) => {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [loading, setLoading] = useState(true);
const [Data, setData] = useState("");
useEffect(() => {
if (!ChunkData?.message_chunk) return;
const timerID = setTimeout(() => {
if (ChunkData.message_chunk === Data) {
setLoading(false);
clearTimeout(timerID);
}
}, 500);
setData(ChunkData?.message_chunk);
return () => {
timerID && clearTimeout(timerID);
};
}, [ChunkData?.message_chunk, Data, loading]);
// Must be after hooks
if (!ChunkData) return null;
return (
<div className="space-y-2 mb-3 w-full">
<button
onClick={() => setIsThinkingExpanded((prev) => !prev)}
className="inline-flex items-center gap-2 px-2 py-1 rounded-xl transition-colors border border-[#E6E6E6] dark:border-[#272626]"
>
{loading ? (
<>
<Brain className="w-4 h-4 animate-pulse text-[#999999]" />
<span className="text-xs text-[#999999] italic">
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
</span>
</>
) : (
<>
<Brain className="w-4 h-4 text-[#38C200]" />
<span className="text-xs text-[#999999]">
{t("assistant.message.steps.thoughtTime")}
</span>
</>
)}
{isThinkingExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{isThinkingExpanded && (
<div className="pl-2 border-l-2 border-[e5e5e5]">
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
{Data?.split("\n").map(
(paragraph, idx) =>
paragraph.trim() && (
<p key={idx} className="text-sm">
{paragraph}
</p>
)
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,114 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import logoImg from "@/assets/icon.svg";
import type { Message, IChunkData } from "@/components/Assistant/types";
import { QueryIntent } from "./QueryIntent";
import { FetchSource } from "./FetchSource";
import { PickSource } from "./PickSource";
import { DeepRead } from "./DeepRead";
import { Think } from "./Think";
import { MessageActions } from "./MessageActions";
import Markdown from "./Markdown";
interface ChatMessageProps {
message: Message;
isTyping?: boolean;
query_intent?: IChunkData;
fetch_source?: IChunkData;
pick_source?: IChunkData;
deep_read?: IChunkData;
think?: IChunkData;
response?: IChunkData;
onResend?: (value: string) => void;
}
export const ChatMessage = memo(function ChatMessage({
message,
isTyping,
query_intent,
fetch_source,
pick_source,
deep_read,
think,
response,
onResend,
}: ChatMessageProps) {
const { t } = useTranslation();
const isAssistant = message?._source?.type === "assistant";
const messageContent = message?._source?.message || "";
const question = message?._source?.question || "";
console.log(11111, messageContent);
const renderContent = () => {
if (!isAssistant) {
return (
<div className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8]">
{messageContent}
</div>
);
}
return (
<>
<QueryIntent ChunkData={query_intent} />
<FetchSource ChunkData={fetch_source} />
<PickSource ChunkData={pick_source} />
<DeepRead ChunkData={deep_read} />
<Think ChunkData={think} />
<Markdown
content={messageContent || response?.message_chunk || ""}
loading={isTyping}
onDoubleClickCapture={() => {}}
/>
{isTyping && (
<div className="inline-block w-1.5 h-5 ml-0.5 -mb-0.5 bg-[#666666] dark:bg-[#A3A3A3] rounded-sm animate-typing" />
)}
{isTyping === false &&
(messageContent || response?.message_chunk) && (
<MessageActions
content={messageContent || response?.message_chunk || ""}
question={question}
onResend={() => {
onResend && onResend(question);
}}
/>
)}
</>
);
};
return (
<div
className={`py-8 flex ${isAssistant ? "justify-start" : "justify-end"}`}
>
<div
className={`px-4 flex gap-4 ${
isAssistant ? "w-full" : "flex-row-reverse"
}`}
>
<div
className={`space-y-2 ${isAssistant ? "text-left" : "text-right"}`}
>
<p className="flex items-center gap-4 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
{isAssistant ? (
<img
src={logoImg}
className="w-6 h-6"
alt={t("assistant.message.logo")}
/>
) : null}
{isAssistant ? t("assistant.message.aiName") : ""}
</p>
<div className="prose dark:prose-invert prose-sm max-w-none">
<div className="text-[#333] dark:text-[#d8d8d8] leading-relaxed">
{renderContent()}
</div>
</div>
</div>
</div>
</div>
);
});

View File

@@ -93,7 +93,7 @@
.markdown-body { .markdown-body {
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
margin: 0; margin: 16px 0 0;
color: var(--color-fg-default); color: var(--color-fg-default);
background-color: var(--color-canvas-default); background-color: var(--color-canvas-default);
font-size: 14px; font-size: 14px;

View File

@@ -1,7 +1,7 @@
import { useOSKeyPress } from "@/hooks/useOSKeyPress"; import { useOSKeyPress } from "@/hooks/useOSKeyPress";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { copyToClipboard, OpenURLWithBrowser } from "@/utils"; import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
import { isMac, isWin } from "@/utils/platform"; import { isMac } from "@/utils/platform";
import { import {
useClickAway, useClickAway,
useCreation, useCreation,

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { CircleAlert, Bolt, X, ArrowBigRight } from "lucide-react"; import { CircleAlert, Bolt, X, ArrowBigRight } from "lucide-react";
import { isNil } from "lodash-es";
import { useUnmount } from "ahooks";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import ThemedIcon from "@/components/Common/Icons/ThemedIcon"; import ThemedIcon from "@/components/Common/Icons/ThemedIcon";
@@ -8,8 +10,6 @@ import TypeIcon from "@/components/Common/Icons/TypeIcon";
import SearchListItem from "./SearchListItem"; import SearchListItem from "./SearchListItem";
import { metaOrCtrlKey, isMetaOrCtrlKey } from "@/utils/keyboardUtils"; import { metaOrCtrlKey, isMetaOrCtrlKey } from "@/utils/keyboardUtils";
import { OpenURLWithBrowser } from "@/utils/index"; import { OpenURLWithBrowser } from "@/utils/index";
import { isNil, isPlainObject } from "lodash-es";
import { useUnmount } from "ahooks";
type ISearchData = Record<string, any[]>; type ISearchData = Record<string, any[]>;

View File

@@ -1,10 +1,4 @@
import { import { ArrowDown01, Command, CornerDownLeft } from "lucide-react";
ArrowDown01,
Command,
CornerDownLeft,
Pin,
PinOff,
} from "lucide-react";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
@@ -14,6 +8,8 @@ import { useSearchStore } from "@/stores/searchStore";
import TypeIcon from "@/components/Common/Icons/TypeIcon"; import TypeIcon from "@/components/Common/Icons/TypeIcon";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { isMac } from "@/utils/platform"; import { isMac } from "@/utils/platform";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
interface FooterProps { interface FooterProps {
isChat: boolean; isChat: boolean;
@@ -65,17 +61,12 @@ export default function Footer({}: FooterProps) {
version: process.env.VERSION || "v1.0.0", version: process.env.VERSION || "v1.0.0",
})} })}
</span> </span>
<button <button
onClick={togglePin} onClick={togglePin}
className={`rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 ${ className={`${isPinned ? "text-blue-500" : ""}`}
isPinned ? "text-blue-500" : ""
}`}
> >
{isPinned ? ( {isPinned ? <PinIcon /> : <PinOffIcon />}
<Pin className="h-3 w-3" />
) : (
<PinOff className="h-3 w-3" />
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -262,8 +262,10 @@ export default function ChatInput({
const [countdown, setCountdown] = useState(5); const [countdown, setCountdown] = useState(5);
useEffect(() => { useEffect(() => {
if (!isChatMode) return;
if (connected) return; if (connected) return;
if (countdown <= 0) { if (countdown <= 0) {
console.log("ReconnectClick", 111111);
ReconnectClick(); ReconnectClick();
return; return;
} }
@@ -273,7 +275,7 @@ export default function ChatInput({
}, 1000); }, 1000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [countdown, connected]); }, [countdown, connected, isChatMode]);
const ReconnectClick = () => { const ReconnectClick = () => {
setCountdown(5); setCountdown(5);

View File

@@ -131,14 +131,20 @@
"message": { "message": {
"logo": "Coco AI Logo", "logo": "Coco AI Logo",
"aiName": "Coco AI", "aiName": "Coco AI",
"thinking": "AI is thinking...",
"thoughtTime": "Thought for a few seconds",
"thinkingButton": "View thinking process", "thinkingButton": "View thinking process",
"steps": { "steps": {
"understand_the_query": "Understand the query", "query_intent": "Understand the query",
"retrieve_documents": "Retrieve documents", "source_zero": "Searching for relevant documents",
"intelligent_pre_selection": "Intelligent pre-selection", "fetch_source": "Retrieve {{count}} documents",
"deep_reading": "Deep reading" "pick_source": "Intelligent pick {{count}} results",
"deep_read": "Deep reading",
"think": "AI is thinking...",
"thoughtTime": "Thought for a few seconds",
"keywords": "Keywords",
"questionType": "Query Type",
"userIntent": "User Intent",
"relatedQuestions": "Query",
"informationSeeking": "Information Seeking"
} }
}, },
"sidebar": { "sidebar": {
@@ -148,7 +154,8 @@
"untitledChat": "Untitled Chat" "untitledChat": "Untitled Chat"
}, },
"source": { "source": {
"foundResults": "Found {{count}} results" "fetch_source": "Found {{count}} results",
"pick_source": "{{count}} results"
} }
}, },
"cloud": { "cloud": {

View File

@@ -131,14 +131,20 @@
"message": { "message": {
"logo": "Coco AI 图标", "logo": "Coco AI 图标",
"aiName": "Coco AI", "aiName": "Coco AI",
"thinking": "AI 正在思考...",
"thoughtTime": "思考了数秒",
"thinkingButton": "查看思考过程", "thinkingButton": "查看思考过程",
"steps": { "steps": {
"understand_the_query": "理解查询", "query_intent": "理解查询",
"retrieve_documents": "检索文档", "source_zero": "正在搜索相关文档",
"intelligent_pre_selection": "智能预选", "fetch_source": "检索 {{count}} 份文档",
"deep_reading": "深度阅读" "pick_source": "智能预选 {{count}} 个结果",
"deep_read": "深度阅读,",
"think": "AI 正在思考...",
"thoughtTime": "思考了数秒",
"keywords": "关键词",
"questionType": "查询类型",
"userIntent": "用户意图",
"relatedQuestions": "查询",
"informationSeeking": "信息查询"
} }
}, },
"sidebar": { "sidebar": {
@@ -148,7 +154,8 @@
"untitledChat": "未命名对话" "untitledChat": "未命名对话"
}, },
"source": { "source": {
"foundResults": "找到 {{count}} 个结果" "fetch_source": "找到 {{count}} 个结果",
"pick_source": "{{count}} 个结果"
} }
}, },
"cloud": { "cloud": {

View File

@@ -1,4 +1,4 @@
import React from "react"; // import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
@@ -9,9 +9,9 @@ import "./i18n";
import "./main.css"; import "./main.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> //<React.StrictMode>
<ThemeProvider> <ThemeProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ThemeProvider> </ThemeProvider>
</React.StrictMode> // </React.StrictMode>
); );

View File

@@ -90,49 +90,4 @@ export const formatter = {
}, },
}; };
export const formatThinkingMessage = (message: string) => {
const segments: Array<{
text: string;
isThinking: boolean;
thinkContent: string;
isSource?: boolean;
}> = [];
if (!message) return segments;
const sourceRegex = /(<Source.*?>.*?<\/Source>)/gs;
const parts = message.split(sourceRegex);
parts.forEach(part => {
if (part.startsWith('<Source')) {
segments.push({
text: part,
isThinking: false,
thinkContent: '',
isSource: true
});
} else {
const thinkParts = part.split(/(<think>.*?<\/think>)/s);
thinkParts.forEach(thinkPart => {
if (thinkPart.startsWith('<think>')) {
const content = thinkPart.replace(/<\/?think>/g, '');
segments.push({
text: '',
isThinking: true,
thinkContent: content.trim()
});
} else if (thinkPart.trim()) {
segments.push({
text: thinkPart.trim(),
isThinking: false,
thinkContent: ''
});
}
});
}
});
return segments;
};

View File

@@ -23,12 +23,24 @@ export default {
}, },
animation: { animation: {
"fade-in": "fade-in 0.2s ease-in-out", "fade-in": "fade-in 0.2s ease-in-out",
'typing': 'typing 1.5s ease-in-out infinite',
'shake': 'shake 0.5s ease-in-out',
}, },
keyframes: { keyframes: {
"fade-in": { "fade-in": {
"0%": { opacity: "0" }, "0%": { opacity: "0" },
"100%": { opacity: "1" }, "100%": { opacity: "1" },
}, },
typing: {
'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)' }
}
}, },
boxShadow: { boxShadow: {
"window-custom": "0px 16px 32px 0px rgba(0,0,0,0.3)", "window-custom": "0px 16px 32px 0px rgba(0,0,0,0.3)",