Files
coco-app/src/components/Assistant/Chat.tsx

419 lines
12 KiB
TypeScript
Raw Normal View History

import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
2025-02-20 15:38:55 +08:00
useMemo,
} from "react";
import { isTauri } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import { debounce } from "lodash-es";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { ChatMessage } from "./ChatMessage";
2025-02-20 15:38:55 +08:00
import type { Chat } from "./types";
import { tauriFetch } from "@/api/tauriFetchClient";
import { useChatStore } from "@/stores/chatStore";
import { useWindows } from "@/hooks/useWindows";
2025-02-20 16:24:53 +08:00
import { ChatHeader } from "./ChatHeader";
import { useAppStore } from "@/stores/appStore";
interface ChatAIProps {
isTransitioned: boolean;
isSearchActive?: boolean;
isDeepThinkActive?: boolean;
2025-02-20 15:38:55 +08:00
isChatPage?: boolean;
activeChatProp?: Chat;
changeInput?: (val: string) => void;
}
export interface ChatAIRef {
init: (value: string) => void;
cancelChat: () => void;
connected: boolean;
reconnect: () => void;
2025-02-20 15:38:55 +08:00
handleSendMessage: (value: string) => void;
}
const ChatAI = memo(
forwardRef<ChatAIRef, ChatAIProps>(
(
2025-02-20 15:38:55 +08:00
{
isTransitioned,
changeInput,
isSearchActive,
isDeepThinkActive,
isChatPage = false,
activeChatProp,
},
ref
) => {
if (!isTransitioned) return null;
const { t } = useTranslation();
useImperativeHandle(ref, () => ({
init: init,
cancelChat: cancelChat,
connected: connected,
reconnect: reconnect,
2025-02-20 15:38:55 +08:00
handleSendMessage: handleSendMessage,
}));
const { createWin } = useWindows();
const { curChatEnd, setCurChatEnd, connected, setConnected, messages, setMessages } =
useChatStore();
const activeServer = useAppStore((state) => state.activeServer);
const [activeChat, setActiveChat] = useState<Chat>();
const [isTyping, setIsTyping] = useState(false);
const [timedoutShow, setTimedoutShow] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [curMessage, setCurMessage] = useState("");
const websocketIdRef = useRef("");
const curChatEndRef = useRef(curChatEnd);
curChatEndRef.current = curChatEnd;
2025-02-20 15:38:55 +08:00
const curIdRef = useRef("");
useEffect(() => {
activeChatProp && setActiveChat(activeChatProp);
}, [activeChatProp]);
const handleMessageChunk = useCallback((chunk: string) => {
setCurMessage((prev) => prev + chunk);
}, []);
const reconnect = async () => {
if (!activeServer?.id) return;
try {
await invoke("connect_to_server", { id: activeServer?.id });
setConnected(true);
} catch (error) {
console.error("Failed to connect:", error);
}
};
const messageTimeoutRef = useRef<NodeJS.Timeout>();
const dealMsg = useCallback(
(msg: string) => {
// console.log("msg:", msg);
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
if (msg.includes("websocket-session-id")) {
const array = msg.split(" ");
websocketIdRef.current = array[2];
return "";
} else if (msg.includes("PRIVATE")) {
messageTimeoutRef.current = setTimeout(() => {
if (!curChatEnd && isTyping) {
console.log("AI response timeout");
setTimedoutShow(true);
cancelChat();
}
}, 30000);
if (msg.includes("assistant finished output")) {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
console.log("AI finished output");
setCurChatEnd(true);
} else {
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
// console.log("cleanedData", cleanedData);
const chunkData = JSON.parse(cleanedData);
if (chunkData.reply_to_message === curIdRef.current) {
handleMessageChunk(chunkData.message_chunk);
return chunkData.message_chunk;
}
} catch (error) {
console.error("parse error:", error);
}
}
}
},
[curChatEnd, isTyping]
);
useEffect(() => {
const unlisten = listen("ws-message", (event) => {
const data = dealMsg(String(event.payload));
if (data) {
setMessages((prev) => prev + data);
}
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
2025-02-20 15:38:55 +08:00
const assistantMessage = useMemo(() => {
if (!activeChat?._id || (!curMessage && !messages)) return null;
return {
_id: activeChat._id,
_source: {
type: "assistant",
message: curMessage || messages,
},
};
2025-02-20 15:38:55 +08:00
}, [activeChat?._id, curMessage, messages]);
2024-11-27 19:41:54 +08:00
2025-02-20 15:38:55 +08:00
const updatedChat = useMemo(() => {
if (!activeChat?._id || !assistantMessage) return null;
return {
...activeChat,
messages: [...(activeChat.messages || []), assistantMessage],
};
2025-02-20 15:38:55 +08:00
}, [activeChat, assistantMessage]);
2025-02-20 15:38:55 +08:00
const simulateAssistantResponse = useCallback(() => {
if (!updatedChat) return;
console.log("updatedChat:", updatedChat);
setActiveChat(updatedChat);
setMessages("");
setCurMessage("");
setIsTyping(false);
2025-02-20 15:38:55 +08:00
}, [updatedChat]);
useEffect(() => {
if (curChatEnd) {
simulateAssistantResponse();
}
}, [curChatEnd]);
const scrollToBottom = useCallback(
debounce(() => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
}, 100),
[]
);
useEffect(() => {
scrollToBottom();
}, [activeChat?.messages, isTyping, curMessage]);
const createNewChat = useCallback(async (value: string = "") => {
chatClose();
try {
const response = await tauriFetch({
url: "/chat/_new",
method: "POST",
});
console.log("_new", response);
const newChat: Chat = response.data;
2025-02-20 15:38:55 +08:00
setActiveChat(newChat);
handleSendMessage(value, newChat);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
}, []);
const init = (value: string) => {
if (!curChatEnd) return;
if (!activeChat?._id) {
createNewChat(value);
} else {
handleSendMessage(value);
}
};
2025-02-20 15:38:55 +08:00
const handleSendMessage = useCallback(
async (content: string, newChat?: Chat) => {
console.log("11111111", isSearchActive, isDeepThinkActive);
2025-02-20 15:38:55 +08:00
newChat = newChat || activeChat;
if (!newChat?._id || !content) return;
setTimedoutShow(false);
try {
const response = await tauriFetch({
url: `/chat/${newChat?._id}/_send?search=${isSearchActive}&deep_thinking=${isDeepThinkActive}`,
method: "POST",
headers: {
"WEBSOCKET-SESSION-ID": websocketIdRef.current,
},
body: JSON.stringify({ message: content }),
});
console.log("_send", response, websocketIdRef.current);
curIdRef.current = response.data[0]?._id;
const updatedChat: Chat = {
...newChat,
messages: [
...(newChat?.messages || []),
...(response.data || []),
],
};
changeInput && changeInput("");
console.log("updatedChat2", updatedChat);
2025-02-20 15:38:55 +08:00
setActiveChat(updatedChat);
setIsTyping(true);
setCurChatEnd(false);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
},
[activeChat, isSearchActive, isDeepThinkActive]
2025-02-20 15:38:55 +08:00
);
const chatClose = async () => {
if (!activeChat?._id) return;
try {
const response = await tauriFetch({
url: `/chat/${activeChat._id}/_close`,
method: "POST",
});
console.log("_close", response);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
const cancelChat = async () => {
if (curMessage || messages) {
simulateAssistantResponse();
}
setCurChatEnd(true);
setIsTyping(false);
if (!activeChat?._id) return;
try {
const response = await tauriFetch({
url: `/chat/${activeChat._id}/_cancel`,
method: "POST",
});
console.log("_cancel", response);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
async function openChatAI() {
if (isTauri()) {
createWin &&
createWin({
label: "chat",
title: "Coco Chat",
dragDropEnabled: true,
center: true,
width: 1000,
height: 800,
alwaysOnTop: false,
skipTaskbar: false,
decorations: true,
closable: true,
url: "/ui/chat",
});
}
}
useEffect(() => {
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
chatClose();
setMessages("");
setCurMessage("");
setActiveChat(undefined);
setIsTyping(false);
setCurChatEnd(true);
scrollToBottom.cancel();
};
}, []);
return (
<div
data-tauri-drag-region
2025-02-20 15:38:55 +08:00
className={`h-full flex flex-col rounded-xl overflow-hidden`}
>
2025-02-20 15:38:55 +08:00
{isChatPage ? null : (
2025-02-20 16:24:53 +08:00
<ChatHeader
onCreateNewChat={createNewChat}
onOpenChatAI={openChatAI}
/>
2025-02-20 15:38:55 +08:00
)}
{/* Chat messages */}
<div className="w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
<ChatMessage
key={"greetings"}
message={{
2025-02-20 15:38:55 +08:00
_id: "greetings",
_source: {
2025-02-20 15:38:55 +08:00
type: "assistant",
message: t("assistant.chat.greetings"),
},
}}
isTyping={false}
/>
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
message={message}
isTyping={
isTyping &&
index === (activeChat.messages?.length || 0) - 1 &&
message._source?.type === "assistant"
}
/>
))}
{!curChatEnd && activeChat?._id ? (
<ChatMessage
key={"last"}
message={{
_id: activeChat?._id,
_source: {
type: "assistant",
message: curMessage,
},
}}
isTyping={!curChatEnd}
/>
) : null}
2025-02-20 15:38:55 +08:00
{timedoutShow ? (
<ChatMessage
key={"timedout"}
message={{
_id: "timedout",
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
},
}}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
</div>
);
}
)
);
export default ChatAI;