mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 11:37:47 +01:00
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:
@@ -30,6 +30,7 @@ Information about release notes of Coco Server is provided here.
|
||||
- Refactoring assistant api #195
|
||||
- Refactor: remove websocket_session_id from message request #206
|
||||
- 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
|
||||
|
||||
|
||||
|
||||
@@ -52,15 +52,15 @@ where
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
// dbg!(&body);
|
||||
|
||||
let search_response: SearchResponse<T> = serde_json::from_value(body)
|
||||
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
|
||||
|
||||
Ok(search_response)
|
||||
}
|
||||
|
||||
pub async fn parse_search_hits<T>(
|
||||
response: Response,
|
||||
) -> Result<Vec<SearchHit<T>>, Box<dyn Error>>
|
||||
pub async fn parse_search_hits<T>(response: Response) -> Result<Vec<SearchHit<T>>, Box<dyn Error>>
|
||||
where
|
||||
T: for<'de> Deserialize<'de> + std::fmt::Debug,
|
||||
{
|
||||
@@ -69,13 +69,15 @@ where
|
||||
Ok(response.hits.hits)
|
||||
}
|
||||
|
||||
pub async fn parse_search_results<T>(
|
||||
response: Response,
|
||||
) -> Result<Vec<T>, Box<dyn Error>>
|
||||
pub async fn parse_search_results<T>(response: Response) -> Result<Vec<T>, Box<dyn Error>>
|
||||
where
|
||||
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>(
|
||||
@@ -84,7 +86,11 @@ pub async fn parse_search_results_with_score<T>(
|
||||
where
|
||||
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)]
|
||||
@@ -107,8 +113,8 @@ impl SearchQuery {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct QuerySource {
|
||||
pub r#type: String, //coco-server/local/ etc.
|
||||
pub id: String, //coco server's id
|
||||
pub name: String, //coco server's name, local computer name, etc.
|
||||
pub id: String, //coco server's id
|
||||
pub name: String, //coco server's name, local computer name, etc.
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -139,4 +145,3 @@ pub struct MultiSourceQueryResponse {
|
||||
pub hits: Vec<QueryHits>,
|
||||
pub total_hits: usize,
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ pub async fn connect_to_server(
|
||||
msg = ws.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
println!("Received message: {}", text);
|
||||
//println!("Received message: {}", text);
|
||||
let _ = app_handle_clone.emit("ws-message", text);
|
||||
},
|
||||
Some(Err(WsError::ConnectionClosed)) => {
|
||||
@@ -169,7 +169,6 @@ pub async fn connect_to_server(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
|
||||
|
||||
// Send cancellation signal
|
||||
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() {
|
||||
let _ = cancel_tx.send(()).await;
|
||||
|
||||
@@ -13,14 +13,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { debounce } from "lodash-es";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import type { Chat } from "./types";
|
||||
import { ChatMessage } from "@/components/ChatMessage";
|
||||
import type { Chat, IChunkData } from "./types";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useWindows } from "@/hooks/useWindows";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { IServer } from "@/stores/appStore";
|
||||
|
||||
interface ChatAIProps {
|
||||
isTransitioned: boolean;
|
||||
@@ -72,24 +73,16 @@ const ChatAI = memo(
|
||||
|
||||
const { createWin } = useWindows();
|
||||
|
||||
const {
|
||||
curChatEnd,
|
||||
setCurChatEnd,
|
||||
connected,
|
||||
setConnected,
|
||||
messages,
|
||||
setMessages,
|
||||
} = useChatStore();
|
||||
const { curChatEnd, setCurChatEnd, connected, setConnected } =
|
||||
useChatStore();
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const [activeChat, setActiveChat] = useState<Chat>();
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [timedoutShow, setTimedoutShow] = useState(false);
|
||||
const [errorShow, setErrorShow] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [curMessage, setCurMessage] = useState("");
|
||||
|
||||
const curChatEndRef = useRef(curChatEnd);
|
||||
curChatEndRef.current = curChatEnd;
|
||||
|
||||
@@ -103,14 +96,12 @@ const ChatAI = memo(
|
||||
activeChatProp && setActiveChat(activeChatProp);
|
||||
}, [activeChatProp]);
|
||||
|
||||
const handleMessageChunk = useCallback((chunk: string) => {
|
||||
setCurMessage((prev) => prev + chunk);
|
||||
}, []);
|
||||
|
||||
const reconnect = async () => {
|
||||
if (!currentService?.id) return;
|
||||
const reconnect = async (server?: IServer) => {
|
||||
server = server || currentService;
|
||||
if (!server?.id) return;
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error("Failed to connect:", error);
|
||||
@@ -119,88 +110,177 @@ const ChatAI = memo(
|
||||
|
||||
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(
|
||||
(msg: string) => {
|
||||
// console.log("msg:", msg);
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (msg.includes("PRIVATE")) {
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
if (!curChatEnd && isTyping) {
|
||||
console.log("AI response timeout");
|
||||
setTimedoutShow(true);
|
||||
cancelChat();
|
||||
}
|
||||
}, 30000);
|
||||
if (!msg.includes("PRIVATE")) return;
|
||||
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
if (!curChatEnd) {
|
||||
console.log("AI response timeout");
|
||||
setTimedoutShow(true);
|
||||
cancelChat();
|
||||
}
|
||||
}, 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(() => {
|
||||
const unlisten = listen("ws-message", (event) => {
|
||||
const data = dealMsg(String(event.payload));
|
||||
if (data) {
|
||||
setMessages((prev) => prev + data);
|
||||
}
|
||||
});
|
||||
if (curChatEnd) {
|
||||
simulateAssistantResponse();
|
||||
}
|
||||
}, [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 () => {
|
||||
unlisten.then((fn) => fn());
|
||||
unlisten_error?.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
}, [connected]);
|
||||
|
||||
const assistantMessage = useMemo(() => {
|
||||
if (!activeChat?._id || (!curMessage && !messages)) return null;
|
||||
return {
|
||||
_id: activeChat._id,
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: curMessage || messages,
|
||||
},
|
||||
useEffect(() => {
|
||||
let unlisten_message = null;
|
||||
if (connected) {
|
||||
setErrorShow(false);
|
||||
unlisten_message = listen("ws-message", (event) => {
|
||||
dealMsg(String(event.payload));
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
unlisten_message?.then((fn) => fn());
|
||||
};
|
||||
}, [activeChat?._id, curMessage, messages]);
|
||||
}, [dealMsg, connected]);
|
||||
|
||||
const updatedChat = useMemo(() => {
|
||||
if (!activeChat?._id || !assistantMessage) return null;
|
||||
if (!activeChat?._id) return null;
|
||||
return {
|
||||
...activeChat,
|
||||
messages: [...(activeChat.messages || []), assistantMessage],
|
||||
messages: [...(activeChat.messages || [])],
|
||||
};
|
||||
}, [activeChat, assistantMessage]);
|
||||
}, [activeChat]);
|
||||
|
||||
const simulateAssistantResponse = useCallback(() => {
|
||||
if (!updatedChat) return;
|
||||
|
||||
console.log("updatedChat:", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
setMessages("");
|
||||
setCurMessage("");
|
||||
setIsTyping(false);
|
||||
}, [updatedChat]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -271,7 +351,15 @@ const ChatAI = memo(
|
||||
|
||||
useEffect(() => {
|
||||
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 = () => {
|
||||
console.log("clearChat");
|
||||
@@ -281,43 +369,47 @@ const ChatAI = memo(
|
||||
clearChatPage && clearChatPage();
|
||||
};
|
||||
|
||||
const createNewChat = useCallback(async (value: string = "") => {
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
chatClose();
|
||||
try {
|
||||
console.log("sourceDataIds", sourceDataIds);
|
||||
let response: any = await invoke("new_chat", {
|
||||
serverId: currentService?.id,
|
||||
message: value,
|
||||
queryParams: {
|
||||
search: isSearchActive,
|
||||
deep_thinking: isDeepThinkActive,
|
||||
datasource: sourceDataIds.join(","),
|
||||
},
|
||||
});
|
||||
console.log("_new", response);
|
||||
const newChat: Chat = response;
|
||||
curIdRef.current = response?.payload?.id;
|
||||
const createNewChat = useCallback(
|
||||
async (value: string = "") => {
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
chatClose();
|
||||
clearCurrentChat();
|
||||
setQuestion(value);
|
||||
try {
|
||||
// console.log("sourceDataIds", sourceDataIds);
|
||||
let response: any = await invoke("new_chat", {
|
||||
serverId: currentService?.id,
|
||||
message: value,
|
||||
queryParams: {
|
||||
search: isSearchActive,
|
||||
deep_thinking: isDeepThinkActive,
|
||||
datasource: sourceDataIds.join(","),
|
||||
},
|
||||
});
|
||||
console.log("_new", response);
|
||||
const newChat: Chat = response;
|
||||
curIdRef.current = response?.payload?.id;
|
||||
|
||||
newChat._source = {
|
||||
message: value,
|
||||
};
|
||||
const updatedChat: Chat = {
|
||||
...newChat,
|
||||
messages: [newChat],
|
||||
};
|
||||
newChat._source = {
|
||||
message: value,
|
||||
};
|
||||
const updatedChat: Chat = {
|
||||
...newChat,
|
||||
messages: [newChat],
|
||||
};
|
||||
|
||||
changeInput && changeInput("");
|
||||
console.log("updatedChat2", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
setIsTyping(true);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
console.error("Failed to fetch user data:", error);
|
||||
}
|
||||
}, []);
|
||||
changeInput && changeInput("");
|
||||
console.log("updatedChat2", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
console.error("Failed to fetch user data:", error);
|
||||
}
|
||||
},
|
||||
[isSearchActive, isDeepThinkActive]
|
||||
);
|
||||
|
||||
const init = (value: string) => {
|
||||
if (!curChatEnd) return;
|
||||
@@ -332,10 +424,14 @@ const ChatAI = memo(
|
||||
async (content: string, newChat?: Chat) => {
|
||||
newChat = newChat || activeChat;
|
||||
if (!newChat?._id || !content) return;
|
||||
setQuestion(content);
|
||||
await chatHistory(newChat);
|
||||
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
clearCurrentChat();
|
||||
try {
|
||||
console.log("sourceDataIds", sourceDataIds);
|
||||
// console.log("sourceDataIds", sourceDataIds);
|
||||
let response: any = await invoke("send_message", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: newChat?._id,
|
||||
@@ -358,7 +454,6 @@ const ChatAI = memo(
|
||||
changeInput && changeInput("");
|
||||
console.log("updatedChat2", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
setIsTyping(true);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
@@ -383,12 +478,7 @@ const ChatAI = memo(
|
||||
};
|
||||
|
||||
const cancelChat = async () => {
|
||||
if (curMessage || messages) {
|
||||
simulateAssistantResponse();
|
||||
}
|
||||
|
||||
setCurChatEnd(true);
|
||||
setIsTyping(false);
|
||||
if (!activeChat?._id) return;
|
||||
try {
|
||||
let response: any = await invoke("cancel_session_chat", {
|
||||
@@ -427,10 +517,7 @@ const ChatAI = memo(
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
chatClose();
|
||||
setMessages("");
|
||||
setCurMessage("");
|
||||
setActiveChat(undefined);
|
||||
setIsTyping(false);
|
||||
setCurChatEnd(true);
|
||||
scrollToBottom.cancel();
|
||||
};
|
||||
@@ -564,6 +651,7 @@ const ChatAI = memo(
|
||||
}}
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
activeChat={activeChat}
|
||||
reconnect={reconnect}
|
||||
/>
|
||||
|
||||
{/* Chat messages */}
|
||||
@@ -577,32 +665,42 @@ const ChatAI = memo(
|
||||
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"
|
||||
}
|
||||
isTyping={false}
|
||||
onResend={handleSendMessage}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!curChatEnd && activeChat?._id ? (
|
||||
{(query_intent ||
|
||||
fetch_source ||
|
||||
pick_source ||
|
||||
deep_read ||
|
||||
think ||
|
||||
response) &&
|
||||
activeChat?._id ? (
|
||||
<ChatMessage
|
||||
key={"last"}
|
||||
key={"current"}
|
||||
message={{
|
||||
_id: activeChat?._id,
|
||||
_id: "current",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: curMessage,
|
||||
message: "",
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={!curChatEnd}
|
||||
query_intent={query_intent}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
think={think}
|
||||
response={response}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -614,8 +712,10 @@ const ChatAI = memo(
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.timedout"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
@@ -628,8 +728,10 @@ const ChatAI = memo(
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.error"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ChatHeaderProps {
|
||||
setIsSidebarOpen: () => void;
|
||||
isSidebarOpen: boolean;
|
||||
activeChat: Chat | undefined;
|
||||
reconnect: (server?: IServer) => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
@@ -45,6 +46,7 @@ export function ChatHeader({
|
||||
onOpenChatAI,
|
||||
setIsSidebarOpen,
|
||||
activeChat,
|
||||
reconnect,
|
||||
}: ChatHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -52,7 +54,7 @@ export function ChatHeader({
|
||||
const isPinned = useAppStore((state) => state.isPinned);
|
||||
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
||||
|
||||
const { setConnected, setMessages } = useChatStore();
|
||||
const { connected, setConnected, setMessages } = useChatStore();
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -68,8 +70,17 @@ export function ChatHeader({
|
||||
);
|
||||
// console.log("list_coco_servers", 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) => {
|
||||
@@ -79,10 +90,17 @@ export function ChatHeader({
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers(true);
|
||||
|
||||
return () => {
|
||||
// Cleanup logic if needed
|
||||
disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const disconnect = async () => {
|
||||
if (!connected) return;
|
||||
try {
|
||||
console.log("disconnect", 33333333);
|
||||
await invoke("disconnect");
|
||||
setConnected(false);
|
||||
} 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) => {
|
||||
try {
|
||||
// Switch UI first, then switch server connection
|
||||
@@ -108,7 +117,7 @@ export function ChatHeader({
|
||||
onCreateNewChat();
|
||||
//
|
||||
await disconnect();
|
||||
await connect(server);
|
||||
reconnect && reconnect(server);
|
||||
} catch (error) {
|
||||
console.error("switchServer:", error);
|
||||
}
|
||||
@@ -185,7 +194,7 @@ export function ChatHeader({
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{activeChat?.title || activeChat?._id}
|
||||
{activeChat?._source?.title || activeChat?._id}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { MessageSquare, Plus } from "lucide-react";
|
||||
|
||||
import type { Chat } from "./types";
|
||||
|
||||
interface SidebarProps {
|
||||
chats: Chat[];
|
||||
activeChat: Chat | undefined;
|
||||
|
||||
@@ -12,54 +12,24 @@ import { OpenURLWithBrowser } from "@/utils/index";
|
||||
|
||||
interface SourceResultProps {
|
||||
text: string;
|
||||
prefix?: string;
|
||||
data?: any[];
|
||||
total?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface SourceItem {
|
||||
url?: string;
|
||||
title?: string;
|
||||
category?: string;
|
||||
source?: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function SourceResult({ text }: SourceResultProps) {
|
||||
export const SourceResult = ({
|
||||
prefix,
|
||||
data,
|
||||
total,
|
||||
type,
|
||||
}: SourceResultProps) => {
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
<div
|
||||
className={`mt-2 ${
|
||||
className={`mt-2 mb-2 w-[98%] ${
|
||||
isSourceExpanded
|
||||
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
|
||||
: ""
|
||||
@@ -67,53 +37,58 @@ export function SourceResult({ text }: SourceResultProps) {
|
||||
>
|
||||
<button
|
||||
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
|
||||
? "w-full"
|
||||
: "border border-[#E6E6E6] dark:border-[#272626]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Search className="w-4 h-4 text-[#999999] dark:text-[#999999]" />
|
||||
<span className="text-xs text-[#999999] dark:text-[#999999]">
|
||||
{t("assistant.source.foundResults", {
|
||||
count: Number(totalResults),
|
||||
<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.${type}`, {
|
||||
count: Number(total),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
{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
|
||||
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="flex-1 min-w-0 flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
<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="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-[#999999] dark:text-[#999999]">
|
||||
<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]" />
|
||||
<SquareArrowOutUpRight className="w-3 h-3 text-[#999999] dark:text-[#999999] flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
.markdown-content p {
|
||||
margin: 1em 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface ISource {
|
||||
session_id?: string;
|
||||
type?: string;
|
||||
message?: any;
|
||||
title?: string;
|
||||
question?: string;
|
||||
}
|
||||
export interface Chat {
|
||||
_id: string;
|
||||
@@ -25,3 +27,14 @@ export interface Chat {
|
||||
payload?: string;
|
||||
[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;
|
||||
}
|
||||
|
||||
98
src/components/ChatMessage/DeepRead.tsx
Normal file
98
src/components/ChatMessage/DeepRead.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
130
src/components/ChatMessage/FetchSource.tsx
Normal file
130
src/components/ChatMessage/FetchSource.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import RehypeHighlight from "rehype-highlight";
|
||||
import mermaid from "mermaid";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { copyToClipboard, useWindowSize } from "../../utils";
|
||||
import { copyToClipboard, useWindowSize } from "@/utils";
|
||||
|
||||
import "./markdown.css";
|
||||
import "./highlight.css";
|
||||
152
src/components/ChatMessage/MessageActions.tsx
Normal file
152
src/components/ChatMessage/MessageActions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
139
src/components/ChatMessage/PickSource.tsx
Normal file
139
src/components/ChatMessage/PickSource.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
148
src/components/ChatMessage/QueryIntent.tsx
Normal file
148
src/components/ChatMessage/QueryIntent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
78
src/components/ChatMessage/Think.tsx
Normal file
78
src/components/ChatMessage/Think.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
114
src/components/ChatMessage/index.tsx
Normal file
114
src/components/ChatMessage/index.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -93,7 +93,7 @@
|
||||
.markdown-body {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
margin: 0;
|
||||
margin: 16px 0 0;
|
||||
color: var(--color-fg-default);
|
||||
background-color: var(--color-canvas-default);
|
||||
font-size: 14px;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
|
||||
import { isMac, isWin } from "@/utils/platform";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import {
|
||||
useClickAway,
|
||||
useCreation,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { CircleAlert, Bolt, X, ArrowBigRight } from "lucide-react";
|
||||
import { isNil } from "lodash-es";
|
||||
import { useUnmount } from "ahooks";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import ThemedIcon from "@/components/Common/Icons/ThemedIcon";
|
||||
@@ -8,8 +10,6 @@ import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
import { metaOrCtrlKey, isMetaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import { OpenURLWithBrowser } from "@/utils/index";
|
||||
import { isNil, isPlainObject } from "lodash-es";
|
||||
import { useUnmount } from "ahooks";
|
||||
|
||||
type ISearchData = Record<string, any[]>;
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
ArrowDown01,
|
||||
Command,
|
||||
CornerDownLeft,
|
||||
Pin,
|
||||
PinOff,
|
||||
} from "lucide-react";
|
||||
import { ArrowDown01, Command, CornerDownLeft } from "lucide-react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
@@ -14,6 +8,8 @@ import { useSearchStore } from "@/stores/searchStore";
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
|
||||
interface FooterProps {
|
||||
isChat: boolean;
|
||||
@@ -65,17 +61,12 @@ export default function Footer({}: FooterProps) {
|
||||
version: process.env.VERSION || "v1.0.0",
|
||||
})}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={`rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 ${
|
||||
isPinned ? "text-blue-500" : ""
|
||||
}`}
|
||||
className={`${isPinned ? "text-blue-500" : ""}`}
|
||||
>
|
||||
{isPinned ? (
|
||||
<Pin className="h-3 w-3" />
|
||||
) : (
|
||||
<PinOff className="h-3 w-3" />
|
||||
)}
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -262,8 +262,10 @@ export default function ChatInput({
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatMode) return;
|
||||
if (connected) return;
|
||||
if (countdown <= 0) {
|
||||
console.log("ReconnectClick", 111111);
|
||||
ReconnectClick();
|
||||
return;
|
||||
}
|
||||
@@ -273,7 +275,7 @@ export default function ChatInput({
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [countdown, connected]);
|
||||
}, [countdown, connected, isChatMode]);
|
||||
|
||||
const ReconnectClick = () => {
|
||||
setCountdown(5);
|
||||
|
||||
@@ -131,14 +131,20 @@
|
||||
"message": {
|
||||
"logo": "Coco AI Logo",
|
||||
"aiName": "Coco AI",
|
||||
"thinking": "AI is thinking...",
|
||||
"thoughtTime": "Thought for a few seconds",
|
||||
"thinkingButton": "View thinking process",
|
||||
"steps": {
|
||||
"understand_the_query": "Understand the query",
|
||||
"retrieve_documents": "Retrieve documents",
|
||||
"intelligent_pre_selection": "Intelligent pre-selection",
|
||||
"deep_reading": "Deep reading"
|
||||
"query_intent": "Understand the query",
|
||||
"source_zero": "Searching for relevant documents",
|
||||
"fetch_source": "Retrieve {{count}} documents",
|
||||
"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": {
|
||||
@@ -148,7 +154,8 @@
|
||||
"untitledChat": "Untitled Chat"
|
||||
},
|
||||
"source": {
|
||||
"foundResults": "Found {{count}} results"
|
||||
"fetch_source": "Found {{count}} results",
|
||||
"pick_source": "{{count}} results"
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
|
||||
@@ -131,14 +131,20 @@
|
||||
"message": {
|
||||
"logo": "Coco AI 图标",
|
||||
"aiName": "Coco AI",
|
||||
"thinking": "AI 正在思考...",
|
||||
"thoughtTime": "思考了数秒",
|
||||
"thinkingButton": "查看思考过程",
|
||||
"steps": {
|
||||
"understand_the_query": "理解查询",
|
||||
"retrieve_documents": "检索文档",
|
||||
"intelligent_pre_selection": "智能预选",
|
||||
"deep_reading": "深度阅读"
|
||||
"query_intent": "理解查询",
|
||||
"source_zero": "正在搜索相关文档",
|
||||
"fetch_source": "检索 {{count}} 份文档",
|
||||
"pick_source": "智能预选 {{count}} 个结果",
|
||||
"deep_read": "深度阅读,",
|
||||
"think": "AI 正在思考...",
|
||||
"thoughtTime": "思考了数秒",
|
||||
"keywords": "关键词",
|
||||
"questionType": "查询类型",
|
||||
"userIntent": "用户意图",
|
||||
"relatedQuestions": "查询",
|
||||
"informationSeeking": "信息查询"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -148,7 +154,8 @@
|
||||
"untitledChat": "未命名对话"
|
||||
},
|
||||
"source": {
|
||||
"foundResults": "找到 {{count}} 个结果"
|
||||
"fetch_source": "找到 {{count}} 个结果",
|
||||
"pick_source": "{{count}} 个结果"
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
// import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
@@ -9,9 +9,9 @@ import "./i18n";
|
||||
import "./main.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
//<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
// </React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,12 +23,24 @@ 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',
|
||||
},
|
||||
keyframes: {
|
||||
"fade-in": {
|
||||
"0%": { opacity: "0" },
|
||||
"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: {
|
||||
"window-custom": "0px 16px 32px 0px rgba(0,0,0,0.3)",
|
||||
|
||||
Reference in New Issue
Block a user