Skip to content

Commit

Permalink
feat: add chat think (#140)
Browse files Browse the repository at this point in the history
* feat: add chat think

* chore: base url config

* feat: add search icon

* feat: add search result

* chore: search icon & shard icon
  • Loading branch information
RainyNight9 authored Feb 13, 2025
1 parent 975af3b commit 77da5a0
Show file tree
Hide file tree
Showing 12 changed files with 689 additions and 464 deletions.
4 changes: 2 additions & 2 deletions .env
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
COCO_SERVER_URL=http://infini.tpddns.cn:27200 #https://coco.infini.cloud # http://localhost:9000
COCO_SERVER_URL=https://coco.infini.cloud #http://localhost:9000

COCO_WEBSOCKET_URL=ws://infini.tpddns.cn:27200/ws #wss://coco.infini.cloud/ws # ws://localhost:9000/ws
COCO_WEBSOCKET_URL=wss://coco.infini.cloud/ws #ws://localhost:9000/ws
3 changes: 3 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"allow": [
{
"url": "https://coco.infini.cloud"
},
{
"url": "http://localhost:9000"
}
],
"deny": []
Expand Down
3 changes: 0 additions & 3 deletions src/api/tauriFetchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ export const tauriFetch = async <T = any>({
const addLog = useLogStore.getState().addLog;

try {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}")
const endpoint_http = appStore?.state?.endpoint_http
baseURL = endpoint_http || clientEnv.COCO_SERVER_URL
console.log("baseURL", baseURL)

const authStore = JSON.parse(localStorage.getItem("auth-store") || "{}")
Expand Down
16 changes: 7 additions & 9 deletions src/components/Assistant/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface ChatAIProps {
inputValue: string;
isTransitioned: boolean;
changeInput: (val: string) => void;
isSearchActive?: boolean;
}

export interface ChatAIRef {
Expand All @@ -31,7 +32,7 @@ export interface ChatAIRef {
}

const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
({ inputValue, isTransitioned, changeInput }, ref) => {
({ inputValue, isTransitioned, changeInput, isSearchActive }, ref) => {
useImperativeHandle(ref, () => ({
init: init,
cancelChat: cancelChat,
Expand Down Expand Up @@ -59,11 +60,12 @@ const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
const curIdRef = useRef(curId);
curIdRef.current = curId;

console.log("chat useWebSocket", clientEnv.COCO_WEBSOCKET_URL)
// console.log("chat useWebSocket", clientEnv.COCO_WEBSOCKET_URL)
const { messages, setMessages, connected, reconnect } = useWebSocket(
"wss://coco.infini.cloud/ws",
clientEnv.COCO_WEBSOCKET_URL,
(msg) => {
console.log("msg", msg);
// console.log("msg", msg);

if (msg.includes("websocket-session-id")) {
const array = msg.split(" ");
setWebsocketId(array[2]);
Expand Down Expand Up @@ -141,7 +143,6 @@ const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
const response = await tauriFetch({
url: "/chat/_new",
method: "POST",
baseURL: "https://coco.infini.cloud",
});
console.log("_new", response);
const newChat: Chat = response.data;
Expand All @@ -167,13 +168,12 @@ const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
if (!newChat?._id || !content) return;
try {
const response = await tauriFetch({
url: `/chat/${newChat?._id}/_send`,
url: `/chat/${newChat?._id}/_send?search=${isSearchActive}`,
method: "POST",
headers: {
"WEBSOCKET-SESSION-ID": websocketId,
},
body: JSON.stringify({ message: content }),
baseURL: "https://coco.infini.cloud",
});
console.log("_send", response, websocketId);
setCurId(response.data[0]?._id);
Expand All @@ -196,7 +196,6 @@ const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
const response = await tauriFetch({
url: `/chat/${activeChat._id}/_close`,
method: "POST",
baseURL: "https://coco.infini.cloud",
});
console.log("_close", response);
} catch (error) {
Expand All @@ -212,7 +211,6 @@ const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
const response = await tauriFetch({
url: `/chat/${activeChat._id}/_cancel`,
method: "POST",
baseURL: "https://coco.infini.cloud",
});

console.log("_cancel", response);
Expand Down
34 changes: 30 additions & 4 deletions src/components/Assistant/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Library, Mic, Send, Plus } from "lucide-react";
import { Mic, Send, Globe } from "lucide-react";
import {
useState,
type FormEvent,
Expand All @@ -14,14 +14,19 @@ interface ChatInputProps {
disabled: boolean;
curChatEnd: boolean;
disabledChange: () => void;
isSearchActive: boolean;
setIsSearchActive: () => void;
}

export function ChatInput({
onSend,
disabled,
curChatEnd,
disabledChange,
isSearchActive,
setIsSearchActive,
}: ChatInputProps) {

const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);

Expand Down Expand Up @@ -55,7 +60,9 @@ export function ChatInput({
adjustTextareaHeight();
}, [input]);

async function openChatAI() {}
const SearchClick = () => {
setIsSearchActive();
};

return (
<form onSubmit={handleSubmit} className="w-full rounded-xl overflow-hidden">
Expand Down Expand Up @@ -103,16 +110,35 @@ export function ChatInput({
<div className="flex justify-between items-center p-2 rounded-xl overflow-hidden">
<div className="flex gap-1 text-xs text-[#333] dark:text-[#d8d8d8]">
<button
type="button"
className={`inline-flex items-center rounded-lg transition-colors relative py-1 px-[5px]`}
onClick={SearchClick}
>
<Globe
className={`w-4 h-4 mr-1 ${
isSearchActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#000] dark:text-[#d8d8d8]"
}`}
/>
<span className={isSearchActive ? "text-[#0072FF]" : ""}>
Search
</span>
</button>
{/* <button
type="button"
className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors "
onClick={openChatAI}
>
<Library className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
Coco
</button>
<button className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color">
<button
type="button"
className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color">
<Plus className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
Upload
</button>
</button> */}
</div>
</div>
</div>
Expand Down
176 changes: 154 additions & 22 deletions src/components/Assistant/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import { Brain, ChevronDown, ChevronUp, Search, SquareArrowOutUpRight } from "lucide-react";
import { useState, useEffect, useRef } from "react";

import type { Message } from "./types";
import Markdown from "./Markdown";
import { formatThinkingMessage, OpenURLWithBrowser } from "@/utils/index";
import logoImg from "@/assets/icon.svg";

interface ChatMessageProps {
message: Message;
isTyping?: boolean;
}

export function ChatMessage({ message, isTyping }: ChatMessageProps) {
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [isSourceExpanded, setIsSourceExpanded] = useState(false);
const [responseTime, setResponseTime] = useState(0);
const startTimeRef = useRef<number | null>(null);
const hasStartedRef = useRef(false);
const isAssistant = message._source?.type === "assistant";
const segments = formatThinkingMessage(message._source.message);

useEffect(() => {
if (isTyping && !hasStartedRef.current) {
startTimeRef.current = Date.now();
hasStartedRef.current = true;
} else if (!isTyping && hasStartedRef.current && startTimeRef.current) {
const duration = (Date.now() - startTimeRef.current) / 1000;
setResponseTime(duration);
hasStartedRef.current = false;
}
}, [isTyping]);

return (
<div
Expand All @@ -18,38 +40,148 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
isAssistant ? "" : "flex-row-reverse"
}`}
>
{/* <div
className={`flex-shrink-0 h-8 w-8 rounded-lg flex items-center justify-center ${
isAssistant
? "bg-gradient-to-br from-green-400 to-emerald-500"
: "bg-gradient-to-br from-indigo-500 to-purple-500"
}`}
>
{isAssistant ? (
<Bot className="h-5 w-5 text-white" />
) : (
<User className="h-5 w-5 text-white" />
)}
</div> */}

<div
className={`flex-1 space-y-2 ${
isAssistant ? "text-left" : "text-right"
}`}
>
<p className="font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
{isAssistant ? "Summary" : ""}
<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" /> : null}
{isAssistant ? "Coco AI" : ""}
</p>
<div className="prose dark:prose-invert prose-sm max-w-none">
<div className="text-[#333] dark:text-[#d8d8d8] leading-relaxed">
{isAssistant ? (
<>
<Markdown
key={isTyping ? "loading" : "done"}
content={message._source?.message || ""}
loading={isTyping}
onDoubleClickCapture={() => {}}
/>
{segments.map((segment, index) => (
<span key={index}>
{segment.isThinking || segment.thinkContent ? (
<div className="space-y-2 mb-3">
{segment.text?.includes("<Source") && (
<div>
<button
onClick={() =>
setIsSourceExpanded((prev) => !prev)
}
className="inline-flex items-center gap-2 px-2 py-1 bg-gray-100/50 dark:bg-gray-800/50 rounded hover:bg-gray-200/50 dark:hover:bg-gray-700/50 transition-colors"
>
<Search className="w-4 h-4 text-gray-500" />
<span className="text-xs text-gray-500 dark:text-gray-400">
Found{" "}
{segment.text.match(
/total=["']?(\d+)["']?/
)?.[1] || "0"}{" "}
results
</span>
{isSourceExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{isSourceExpanded && (
<div className="mt-2 bg-white dark:bg-[#202126] rounded-lg overflow-hidden border border-gray-100 dark:border-gray-800 shadow-sm">
{(() => {
try {
const sourceMatch = segment.text.match(
/<Source[^>]*>(.*?)<\/Source>/s
);
if (!sourceMatch) return null;
const sourceData = JSON.parse(
sourceMatch[1]
);
return sourceData.map(
(item: any, idx: number) => (
<div
key={idx}
onClick={() => {
if (item.url) {
OpenURLWithBrowser(item.url);
}
}}
className="flex items-center px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-black/10 border-b border-gray-100 dark:border-gray-800 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">
<div className="text-sm text-gray-900 dark:text-[#D8D8D8] truncate font-medium group-hover:text-blue-500 dark:group-hover:text-blue-400">
{item.title || item.category}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 text-xs text-gray-500 dark:text-[#8B8B8B]">
<span>{item.source?.name}</span>
<SquareArrowOutUpRight className="w-3 h-3"/>
</div>
</div>
</div>
)
);
} catch (error) {
console.error(
"Failed to parse source data:",
error
);
return null;
}
})()}
</div>
)}
</div>
)}
<button
onClick={() =>
setIsThinkingExpanded((prev) => !prev)
}
className="inline-flex items-center gap-2 px-2 py-1 bg-gray-100/50 dark:bg-gray-800/50 rounded hover:bg-gray-200/50 dark:hover:bg-gray-700/50 transition-colors"
>
{isTyping ? (
<>
<Brain className="w-4 h-4 animate-pulse text-gray-500" />
<span className="text-xs text-gray-500 dark:text-gray-400 italic">
AI is thinking...
</span>
</>
) : (
<>
<Brain className="w-4 h-4 text-gray-500" />
<span className="text-xs text-gray-500 dark:text-gray-400">
Thought for {responseTime.toFixed(1)} seconds
</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" />
)}
Expand Down
Loading

0 comments on commit 77da5a0

Please # to comment.