chore: UI/UE adjustment

* chore: UI/UE adjustment

* chore: UI/UE adjustment
This commit is contained in:
BiggerRain
2024-11-24 19:25:47 +08:00
committed by GitHub
parent 56ae0f10cd
commit 45108c3abc
49 changed files with 904 additions and 886 deletions

View File

@@ -42,4 +42,6 @@ To start desktop development, run:
```
pnpm tauri dev
pnpm tauri build --bundles app
```

View File

@@ -1,8 +1,8 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/x-icon" href="/icon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Coco</title>
</head>

View File

@@ -18,7 +18,6 @@
"@traptitech/markdown-it-katex": "^3.6.0",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"framer-motion": "^11.11.11",
"highlight.js": "^11.10.0",
"i18next": "^23.16.2",
"lodash": "^4.17.21",

24
pnpm-lock.yaml generated
View File

@@ -32,9 +32,6 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^11.11.11
version: 11.11.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
highlight.js:
specifier: ^11.10.0
version: 11.10.0
@@ -1310,20 +1307,6 @@ packages:
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
framer-motion@11.11.11:
resolution: {integrity: sha512-tuDH23ptJAKUHGydJQII9PhABNJBpB+z0P1bmgKK9QFIssHGlfPd6kxMq00LSKwE27WFsb2z0ovY0bpUyMvfRw==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0
react-dom: ^18.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3420,13 +3403,6 @@ snapshots:
fraction.js@4.3.7: {}
framer-motion@11.11.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
tslib: 2.8.0
optionalDependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
fsevents@2.3.3:
optional: true

BIN
public/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

10
src-tauri/Info.plist Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>Request camera access for WebRTC</string>
<key>NSMicrophoneUsageDescription</key>
<string>Request microphone access for WebRTC</string>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -22,10 +22,10 @@
"create": true,
"decorations": false,
"dragDropEnabled": true,
"focus": false,
"focus": true,
"fullscreen": false,
"height": 90,
"maxHeight": 700,
"height": 600,
"maxHeight": 600,
"minHeight": 90,
"width": 680,
"maxWidth": 680,
@@ -38,7 +38,7 @@
"maximized": false,
"proxyUrl": "http://localhost:2900",
"resizable": false,
"shadow": true,
"shadow": false,
"skipTaskbar": false,
"theme": null,
"title": "Coco AI",
@@ -73,7 +73,25 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"macOS": {
"entitlements": "./Entitlements.plist",
"dmg": {
"background": "assets/dmg-background.png",
"windowSize": {
"width": 800,
"height": 600
},
"windowPosition": {
"x": 400,
"y": 400
},
"applicationFolderPosition": {
"x": 480,
"y": 220
}
}
}
},
"plugins": {
"window": {},

View File

@@ -25,7 +25,7 @@ const AutoResizeTextarea: React.FC<AutoResizeTextareaProps> = ({
<textarea
ref={textareaRef}
className="text-xs flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder="Ask whatever you want....."
placeholder="Ask whatever you want ..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -1,378 +1,272 @@
import { useState, useRef, useEffect } from "react";
import {
useState,
useRef,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import { MessageSquarePlus, PanelLeft } from "lucide-react";
import { motion } from "framer-motion";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { Sidebar } from "./Sidebar";
import type { Chat, Message } from "./types";
import { useTheme } from "../ThemeProvider";
import { tauriFetch } from "../../api/tauriFetchClient";
import { useWebSocket } from "../../hooks/useWebSocket";
import useWindows from "../../hooks/useWindows";
import { useChatStore } from "../../stores/chatStore";
interface ChatAIProps {
changeMode: (isChatMode: boolean) => void;
inputValue: string;
isTransitioned: boolean;
changeInput: (val: string) => void;
}
export default function ChatAI({ changeMode, inputValue }: ChatAIProps) {
const [chats, setChats] = useState<Chat[]>([]);
const [activeChat, setActiveChat] = useState<Chat>();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme();
const { createWin } = useWindows();
export interface ChatAIRef {
init: () => void;
}
const [websocketId, setWebsocketId] = useState("");
const [curMessage, setCurMessage] = useState("");
const [curChatEnd, setCurChatEnd] = useState(true);
const { messages, setMessages } = useWebSocket(
"ws://localhost:2900/ws",
(msg) => {
if (msg.includes("websocket_session_id")) {
const array = msg.split(" ");
setWebsocketId(array[2]);
}
const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
({ inputValue, isTransitioned, changeInput }, ref) => {
useImperativeHandle(ref, () => ({
init: init,
}));
if (msg.includes("PRIVATE")) {
if (msg.includes("assistant finished output")) {
setCurChatEnd(true);
} else {
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
const chunkData = JSON.parse(cleanedData);
setCurMessage((prev) => prev + chunkData.message_chunk);
return chunkData.message_chunk;
} catch (error) {
console.error("JSON Parse error:", error);
}
return "";
const { curChatEnd, setCurChatEnd } = useChatStore();
const [activeChat, setActiveChat] = useState<Chat>();
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { createWin } = useWindows();
const [websocketId, setWebsocketId] = useState("");
const [curMessage, setCurMessage] = useState("");
const { messages, setMessages } = useWebSocket(
"ws://localhost:2900/ws",
(msg) => {
if (msg.includes("websocket_session_id")) {
const array = msg.split(" ");
setWebsocketId(array[2]);
}
if (msg.includes("PRIVATE")) {
if (msg.includes("assistant finished output")) {
setCurChatEnd(true);
} else {
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
const chunkData = JSON.parse(cleanedData);
setCurMessage((prev) => prev + chunkData.message_chunk);
return chunkData.message_chunk;
} catch (error) {
console.error("JSON Parse error:", error);
}
return "";
}
}
return "";
}
);
return "";
}
);
// websocket
useEffect(() => {
if (messages.length === 0 || !activeChat?._id) return;
// websocket
useEffect(() => {
if (messages.length === 0 || !activeChat?._id) return;
const simulateAssistantResponse = () => {
console.log("messages", messages);
const simulateAssistantResponse = () => {
console.log("messages", messages);
const assistantMessage: Message = {
_id: activeChat._id,
_source: {
type: "assistant",
message: messages,
},
};
const assistantMessage: Message = {
_id: activeChat._id,
_source: {
type: "assistant",
message: messages,
},
const updatedChat = {
...activeChat,
messages: [...(activeChat.messages || []), assistantMessage],
};
setMessages("");
setCurMessage("");
setActiveChat(updatedChat);
setTimeout(() => setIsTyping(false), 1000);
};
if (curChatEnd) {
simulateAssistantResponse();
}
}, [messages, curChatEnd]);
const updatedChat = {
...activeChat,
messages: [...(activeChat.messages || []), assistantMessage],
};
setMessages("");
setCurMessage("");
setActiveChat(updatedChat);
setTimeout(() => setIsTyping(false), 1000);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
};
if (curChatEnd) {
simulateAssistantResponse();
}
}, [messages, isTyping, curChatEnd]);
// getChatHistory
useEffect(() => {
getChatHistory();
}, []);
useEffect(() => {
scrollToBottom();
}, [activeChat?.messages, isTyping, curMessage]);
const getChatHistory = async () => {
try {
const response = await tauriFetch({
url: "/chat/_history",
method: "GET",
});
console.log("_history", response);
const hits = response.data?.hits?.hits || [];
setChats(hits);
createNewChat();
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
const createNewChat = async () => {
chatClose();
try {
const response = await tauriFetch({
url: "/chat/_new",
method: "POST",
});
console.log("_new", response);
const newChat: Chat = response.data;
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
};
useEffect(() => {
scrollToBottom();
}, [activeChat?.messages, isTyping, curMessage]);
const createNewChat = async () => {
try {
const response = await tauriFetch({
url: "/chat/_new",
method: "POST",
});
console.log("_new", response);
const newChat: Chat = response.data;
setChats((prev) => [newChat, ...prev]);
setActiveChat(newChat);
setIsSidebarOpen(false);
//
console.log(1111, activeChat, inputValue);
if (inputValue) {
setTimeout(() => {
handleSendMessage(inputValue);
}, 500);
setActiveChat(newChat);
handleSendMessage(inputValue, newChat);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
};
const deleteChat = (chatId: string) => {
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat._id !== chatId);
if (remainingChats.length > 0) {
setActiveChat(remainingChats[0]);
} else {
const init = () => {
if (!activeChat?._id) {
createNewChat();
} else {
handleSendMessage(inputValue);
}
}
};
};
const handleSendMessage = async (content: string) => {
if (!activeChat?._id) return;
try {
const response = await tauriFetch({
url: `/chat/${activeChat?._id}/_send`,
method: "POST",
headers: {
WEBSOCKET_SESSION_ID: websocketId,
},
body: JSON.stringify({ message: content }),
const handleSendMessage = async (content: string, newChat?: Chat) => {
newChat = newChat || activeChat;
if (!newChat?._id || !content) return;
try {
const response = await tauriFetch({
url: `/chat/${newChat?._id}/_send`,
method: "POST",
headers: {
WEBSOCKET_SESSION_ID: websocketId,
},
body: JSON.stringify({ message: content }),
});
console.log("_send", response, websocketId);
const updatedChat: Chat = {
...newChat,
messages: [...(newChat?.messages || []), ...(response.data || [])],
};
changeInput("");
setActiveChat(updatedChat);
setIsTyping(true);
setCurChatEnd(false);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
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 (!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);
}
};
useEffect(() => {
if (curChatEnd) {
cancelChat();
}
}, [curChatEnd]);
async function openChatAI() {
createWin({
label: "chat",
title: "Coco AI",
dragDropEnabled: true,
center: true,
width: 900,
height: 800,
alwaysOnTop: true,
skipTaskbar: true,
decorations: true,
closable: true,
url: "/ui/chat",
});
console.log("_send", response, websocketId);
const updatedChat: Chat = {
...activeChat,
messages: [...(activeChat?.messages || []), ...(response.data || [])],
};
setActiveChat(updatedChat);
setIsTyping(true);
setCurChatEnd(false);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
const chatHistory = async (chat: Chat) => {
try {
const response = await tauriFetch({
url: `/chat/${chat._id}/_history`,
method: "GET",
});
console.log("id_history", response);
const hits = response.data?.hits?.hits || [];
const updatedChat: Chat = {
...chat,
messages: hits,
};
setActiveChat(updatedChat);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
if (!isTransitioned) return null;
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 onSelectChat = async (chat: any) => {
chatClose();
try {
const response = await tauriFetch({
url: `/chat/${chat._id}/_open`,
method: "POST",
});
console.log("_open", response);
chatHistory(response.data);
setIsSidebarOpen(false);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
const cancelChat = async () => {
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() {
createWin({
label: "chat",
title: "Coco AI",
dragDropEnabled: true,
center: true,
width: 900,
height: 800,
alwaysOnTop: true,
skipTaskbar: true,
decorations: true,
closable: true,
url: "/ui/chat",
});
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="h-screen rounded-xl overflow-hidden relative"
>
<div className="h-[calc(100%-100px)] flex rounded-xl overflow-hidden">
{/* Sidebar */}
{isSidebarOpen ? (
<div
className={`fixed inset-y-0 left-0 z-50 w-64 transform ${
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
} transition-transform duration-300 ease-in-out md:translate-x-0 md:static md:block ${
theme === "dark" ? "bg-gray-800" : "bg-gray-100"
}`}
>
{activeChat ? (
<Sidebar
chats={chats}
activeChat={activeChat}
isDark={theme === "dark"}
onNewChat={createNewChat}
onSelectChat={onSelectChat}
onDeleteChat={deleteChat}
/>
) : null}
</div>
) : null}
{/* Main content */}
<div
className={`flex-1 flex flex-col rounded-xl overflow-hidden bg-chat_bg_light dark:bg-chat_bg_dark bg-cover`}
return (
<div
data-tauri-drag-region
className={`h-[500px] flex flex-col rounded-xl overflow-hidden bg-chat_bg_light dark:bg-chat_bg_dark bg-cover`}
>
<header
data-tauri-drag-region
className={`flex items-center justify-between py-2 px-1`}
>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: 0.2 }}
<button
onClick={() => openChatAI()}
className={`p-2 rounded-lg transition-colors text-[#333] dark:text-[#d8d8d8]`}
>
<header className={`flex items-center justify-between py-2 px-1`}>
<button
onClick={() => openChatAI()}
className={`p-2 rounded-lg transition-colors text-[#333] dark:text-[#d8d8d8]`}
>
<PanelLeft className="h-4 w-4" />
</button>
<PanelLeft className="h-4 w-4" />
</button>
{/* <ThemeToggle /> */}
{/* <ThemeToggle /> */}
<button
onClick={() => createNewChat()}
className={`p-2 rounded-lg transition-colors text-[#333] dark:text-[#d8d8d8]`}
>
<MessageSquarePlus className="h-4 w-4" />
</button>
</header>
</motion.div>
{/* Chat messages */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: 0.3 }}
className="flex-1 overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar"
<button
onClick={() => {
createNewChat();
}}
className={`p-2 rounded-lg transition-colors text-[#333] dark:text-[#d8d8d8]`}
>
{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}
{isTyping && (
<div className="flex pt-0 pb-4 pl-20 gap-2 items-center text-gray-500 dark:text-gray-400">
<div className="w-2 h-2 rounded-full bg-current animate-bounce" />
<div className="w-2 h-2 rounded-full bg-current animate-bounce [animation-delay:0.2s]" />
<div className="w-2 h-2 rounded-full bg-current animate-bounce [animation-delay:0.4s]" />
</div>
)}
<div ref={messagesEndRef} />
</motion.div>
<MessageSquarePlus className="h-4 w-4" />
</button>
</header>
{/* Chat messages */}
<div className="overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar">
{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}
<div ref={messagesEndRef} />
</div>
</div>
);
}
);
{/* Input area */}
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
exit={{ y: 100 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className={`mt-2.5 rounded-xl overflow-hidden`}
>
<ChatInput
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={(value) => {
cancelChat();
setIsTyping(value);
}}
changeMode={changeMode}
/>
</motion.div>
</motion.div>
);
}
export default ChatAI;

View File

@@ -19,7 +19,6 @@ interface ChatInputProps {
export function ChatInput({
onSend,
disabled,
disabledChange,
changeMode,
}: ChatInputProps) {
const [input, setInput] = useState("");
@@ -102,7 +101,7 @@ export function ChatInput({
{/* Switch */}
<ChatSwitch
isChat={true}
isChatMode={true}
onChange={(value) => {
changeMode(value);
setInput("");

View File

@@ -1,8 +1,4 @@
import { Bot, User } from "lucide-react";
import { useState } from "react";
import type { Message } from "./types";
// import { TypingAnimation } from "./TypingAnimation";
import { Markdown } from "./Markdown";
interface ChatMessageProps {
@@ -11,7 +7,6 @@ interface ChatMessageProps {
}
export function ChatMessage({ message, isTyping }: ChatMessageProps) {
const [isAnimationComplete, setIsAnimationComplete] = useState(!isTyping);
const isAssistant = message._source?.type === "assistant";
return (
@@ -23,7 +18,7 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
isAssistant ? "" : "flex-row-reverse"
}`}
>
<div
{/* <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"
@@ -35,27 +30,23 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
) : (
<User className="h-5 w-5 text-white" />
)}
</div>
</div> */}
<div
className={`flex-1 space-y-2 ${
isAssistant ? "text-left" : "text-right"
}`}
>
<p className="font-medium text-sm text-gray-900 dark:text-gray-100">
{isAssistant ? "Assistant" : "You"}
<p className="font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
{isAssistant ? "Summary" : ""}
</p>
<div className="prose dark:prose-invert prose-sm max-w-none">
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
<div className="text-[#333] dark:text-[#d8d8d8] leading-relaxed">
{isAssistant ? (
<>
{/* <TypingAnimation
text={message._source?.message || ""}
onComplete={() => setIsAnimationComplete(true)}
/> */}
<Markdown
key={isTyping ? "loading" : "done"}
content={(message._source?.message || "")}
content={message._source?.message || ""}
loading={isTyping}
onDoubleClickCapture={() => {}}
/>
@@ -64,9 +55,11 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
)}
</>
) : (
message._source?.message || ""
<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>
)}
</p>
</div>
</div>
</div>
</div>

View File

@@ -37,8 +37,8 @@ export function Mermaid(props: { code: string }) {
function viewSvgInNewWindow() {
const svg = ref.current?.querySelector("svg");
if (!svg) return;
const text = new XMLSerializer().serializeToString(svg);
const blob = new Blob([text], { type: "image/svg+xml" });
// const text = new XMLSerializer().serializeToString(svg);
// const blob = new Blob([text], { type: "image/svg+xml" });
// view img
// URL.createObjectURL(blob);
}
@@ -63,12 +63,13 @@ export function Mermaid(props: { code: string }) {
}
// 7
export function PreCode(props: { children: any }) {
export function PreCode(props: { children?: any }) {
const ref = useRef<HTMLPreElement>(null);
// const previewRef = useRef<HTMLPreviewHander>(null);
const [mermaidCode, setMermaidCode] = useState("");
const [htmlCode, setHtmlCode] = useState("");
const { height } = useWindowSize();
console.log(htmlCode, height);
const renderArtifacts = useDebouncedCallback(() => {
if (!ref.current) return;
@@ -86,6 +87,7 @@ export function PreCode(props: { children: any }) {
}, 600);
const enableArtifacts = true;
console.log(enableArtifacts);
//Wrap the paragraph for plain-text
useEffect(() => {
@@ -137,7 +139,7 @@ export function PreCode(props: { children: any }) {
}
// 6
function CustomCode(props: { children: any; className?: string }) {
function CustomCode(props: { children?: any; className?: string }) {
const enableCodeFold = false;
const ref = useRef<HTMLPreElement>(null);
@@ -213,13 +215,13 @@ function tryWrapHtmlCode(text: string) {
return text
.replace(
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
(match, quoteStart, lang, newLine, doctype) => {
(match, quoteStart, doctype) => {
return !quoteStart ? "\n```html\n" + doctype : match;
}
)
.replace(
/(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
(match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
(match, bodyEnd, space, htmlEnd, quoteEnd) => {
return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
}
);

View File

@@ -1,10 +1,10 @@
import { MessageSquare, Plus, Trash2 } from "lucide-react";
import { MessageSquare, Plus } from "lucide-react";
import type { Chat } from "./types";
import { useTheme } from "../ThemeProvider";
interface SidebarProps {
chats: Chat[];
activeChat: Chat;
isDark: boolean;
onNewChat: () => void;
onSelectChat: (chat: Chat) => void;
onDeleteChat: (chatId: string) => void;
@@ -14,12 +14,13 @@ interface SidebarProps {
export function Sidebar({
chats,
activeChat,
isDark,
onNewChat,
onSelectChat,
onDeleteChat,
className = "",
}: SidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
return (
<div className={`h-full flex flex-col ${className}`}>
<div className="p-4">
@@ -27,8 +28,8 @@ export function Sidebar({
onClick={onNewChat}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-all ${
isDark
? "bg-gray-700 text-white hover:bg-gray-600 active:bg-gray-500"
: "bg-white text-gray-700 hover:bg-gray-50 active:bg-gray-100 shadow-sm"
? " text-white hover:bg-gray-600 active:bg-gray-500"
: " text-gray-700 hover:bg-gray-50 active:bg-gray-100 shadow-sm"
}`}
>
<Plus
@@ -46,8 +47,8 @@ export function Sidebar({
className={`group relative rounded-lg transition-all ${
activeChat._id === chat._id
? isDark
? "bg-gray-700/50 text-white"
: "bg-white text-gray-900 shadow-sm"
? " text-white"
: " text-gray-900 shadow-sm"
: isDark
? "text-gray-300 hover:bg-gray-700/30"
: "text-gray-600 hover:bg-white/10"

View File

@@ -1,44 +0,0 @@
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import "./index.css";
interface TypingAnimationProps {
text: string;
onComplete?: () => void;
speed?: number;
}
export function TypingAnimation({
text,
onComplete,
speed = 30,
}: TypingAnimationProps) {
const [displayedText, setDisplayedText] = useState("");
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (currentIndex < text.length) {
const timeout = setTimeout(() => {
setDisplayedText((prev) => prev + text[currentIndex]);
setCurrentIndex((prev) => prev + 1);
}, speed);
return () => clearTimeout(timeout);
} else if (onComplete) {
onComplete();
}
}, [currentIndex, text, speed, onComplete]);
// console.log("text", text);
// return <ReactMarkdown>{text}</ReactMarkdown>;
return (
<ReactMarkdown
className="prose"
children={text}
remarkPlugins={[remarkGfm]}
/>
);
}

View File

@@ -1,13 +1,10 @@
import { useState, useRef, useEffect } from "react";
import { PanelRightClose, PanelRightOpen, X } from "lucide-react";
import { motion } from "framer-motion";
// import { ThemeToggle } from "./ThemeToggle";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { Sidebar } from "./Sidebar";
import type { Chat, Message } from "./types";
import { useTheme } from "../ThemeProvider";
import { tauriFetch } from "../../api/tauriFetchClient";
import { useWebSocket } from "../../hooks/useWebSocket";
import useWindows from "../../hooks/useWindows";
@@ -20,7 +17,6 @@ export default function ChatAI({}: ChatAIProps) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme();
const { closeWin } = useWindows();
const [websocketId, setWebsocketId] = useState("");
@@ -214,7 +210,7 @@ export default function ChatAI({}: ChatAIProps) {
console.error("Failed to fetch user data:", error);
}
};
const cancelChat = async () => {
if (!activeChat?._id) return;
try {
@@ -233,28 +229,19 @@ export default function ChatAI({}: ChatAIProps) {
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="h-screen"
>
<div className="h-screen bg-chat_bg_light dark:bg-chat_bg_dark bg-cover">
<div className="h-[100%] flex">
{/* Sidebar */}
{isSidebarOpen ? (
<div
className={`fixed inset-y-0 left-0 z-50 w-64 transform ${
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
} transition-transform duration-300 ease-in-out md:translate-x-0 md:static md:block ${
theme === "dark" ? "bg-gray-800" : "bg-gray-100"
}`}
} transition-transform duration-300 ease-in-out md:translate-x-0 md:static md:block`}
>
{activeChat ? (
<Sidebar
chats={chats}
activeChat={activeChat}
isDark={theme === "dark"}
onNewChat={createNewChat}
onSelectChat={onSelectChat}
onDeleteChat={deleteChat}
@@ -264,51 +251,26 @@ export default function ChatAI({}: ChatAIProps) {
) : null}
{/* Main content */}
<div
className={`flex-1 flex flex-col ${
theme === "dark" ? "bg-gray-900" : "bg-white"
}`}
>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: 0.2 }}
<div className={`flex-1 flex flex-col`}>
<header
className={`flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-800`}
>
<header
className={`flex items-center justify-between p-2 border-b ${
theme === "dark" ? "border-gray-800" : "border-gray-200"
}`}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`rounded-lg transition-colors hover:bg-gray-100 text-gray-600 dark:hover:bg-gray-800 dark:text-gray-300`}
>
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`rounded-lg transition-colors ${
theme === "dark"
? "hover:bg-gray-800 text-gray-300"
: "hover:bg-gray-100 text-gray-600"
}`}
>
{isSidebarOpen ? (
<PanelRightClose className="h-6 w-6" />
) : (
<PanelRightOpen className="h-6 w-6" />
)}
</button>
{isSidebarOpen ? (
<PanelRightClose className="h-6 w-6" />
) : (
<PanelRightOpen className="h-6 w-6" />
)}
</button>
{/* <ThemeToggle /> */}
<X className="cursor-pointer" onClick={closeWindow} />
</header>
</motion.div>
<X className="cursor-pointer" onClick={closeWindow} />
</header>
{/* Chat messages */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: 0.3 }}
className="flex-1 overflow-y-auto custom-scrollbar"
>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
@@ -341,30 +303,22 @@ export default function ChatAI({}: ChatAIProps) {
</div>
)}
<div ref={messagesEndRef} />
</motion.div>
</div>
{/* Input area */}
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
exit={{ y: 100 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className={`border-t p-4 ${
theme === "dark" ? "border-gray-800" : "border-gray-200"
}`}
>
<div className={`border-t p-4 border-gray-200 dark:border-gray-800`}>
<ChatInput
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={(value) => {
cancelChat()
cancelChat();
setIsTyping(value);
}}
changeMode={() => {}}
/>
</motion.div>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { SearchIcon, MessageCircleIcon } from "lucide-react";
import { SearchResults } from "./SearchResults";
@@ -43,12 +42,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
};
return (
<AnimatePresence>
<div>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-20"
>
<div className="w-full max-w-2xl bg-white rounded-lg shadow-2xl">
@@ -88,8 +84,8 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
)}
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,46 @@
import React, { useEffect, useRef } from "react";
interface AutoResizeTextareaProps {
input: string;
setInput: (value: string) => void;
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
}
const AutoResizeTextarea: React.FC<AutoResizeTextareaProps> = ({
input,
setInput,
handleKeyDown,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto"; // Reset height to recalculate
textarea.style.height = `${textarea.scrollHeight}px`; // Adjust based on content
}
}, [input]);
return (
<textarea
ref={textareaRef}
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-xs flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder="Ask whatever you want ..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
style={{
resize: "none", // Prevent manual resize
overflow: "auto", // Enable scrollbars when needed
maxHeight: "4.5rem", // Limit height to 3 rows (3 * 1.5 line-height)
lineHeight: "1.5rem", // Line height to match row height
}}
/>
);
};
export default AutoResizeTextarea;

View File

@@ -1,21 +1,14 @@
import React, { useState } from "react";
import React from "react";
import { Bot, Search } from "lucide-react";
interface ChatSwitchProps {
isChat?: boolean;
onChange?: (isChatMode: boolean) => void;
isChatMode: boolean;
onChange: (isChatMode: boolean) => void;
}
const ChatSwitch: React.FC<ChatSwitchProps> = ({
isChat = false,
onChange,
}) => {
const [isChatMode, setIsChatMode] = useState(isChat);
const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
const handleToggle = () => {
const newMode = !isChatMode;
setIsChatMode(newMode);
onChange?.(newMode);
onChange?.(!isChatMode);
};
return (

View File

@@ -7,8 +7,9 @@ interface DropdownListProps {
isSearchComplete: boolean;
}
function DropdownList({ selected, suggests, isSearchComplete }: DropdownListProps) {
function DropdownList({ selected, suggests }: DropdownListProps) {
const [selectedItem, setSelectedItem] = useState<number | null>(null);
const [showIndex, setShowIndex] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
@@ -22,7 +23,13 @@ function DropdownList({ selected, suggests, isSearchComplete }: DropdownListProp
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const handleKeyDown = (e: KeyboardEvent) => {
console.log(
"handleKeyDown",
e.key,
showIndex,
e.key >= "0" && e.key <= "9" && showIndex
);
if (!suggests.length) return;
if (e.key === "ArrowUp") {
@@ -35,6 +42,9 @@ function DropdownList({ selected, suggests, isSearchComplete }: DropdownListProp
setSelectedItem((prev) =>
prev === null || prev === suggests.length - 1 ? 0 : prev + 1
);
} else if (e.key === "Meta") {
e.preventDefault();
setShowIndex(true);
} else if (e.key === "Enter" && selectedItem !== null) {
const item = suggests[selectedItem];
if (item?._source?.url) {
@@ -43,17 +53,42 @@ function DropdownList({ selected, suggests, isSearchComplete }: DropdownListProp
selected(item);
}
}
if (e.key >= "0" && e.key <= "9" && showIndex) {
console.log(`number ${e.key}`);
const item = suggests[parseInt(e.key, 10)];
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
console.log("handleKeyUp", e.key);
if (!suggests.length) return;
if (!e.metaKey) {
setShowIndex(false);
}
};
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [showIndex]);
useEffect(() => {
if (suggests.length > 0) {
setSelectedItem(0);
if (containerRef.current && isSearchComplete) {
containerRef.current.focus();
}
}
}, [JSON.stringify(suggests), isSearchComplete]);
}, [JSON.stringify(suggests)]);
useEffect(() => {
if (selectedItem !== null && itemRefs.current[selectedItem]) {
@@ -67,53 +102,51 @@ function DropdownList({ selected, suggests, isSearchComplete }: DropdownListProp
return (
<div
ref={containerRef}
className="h-[calc(100vh-100px)] mt-2.5 pb-10 flex flex-col bg-search_bg_light dark:bg-chat_bg_dark bg-cover rounded-xl overflow-hidden focus:outline-none"
data-tauri-drag-region
className="h-[500px] w-full p-2 pb-10 flex flex-col bg-search_bg_light dark:bg-chat_bg_dark bg-cover rounded-xl overflow-y-auto overflow-hidden focus:outline-none"
tabIndex={0}
onKeyDown={handleKeyDown}
>
<div className="flex-1 overflow-y-auto p-2">
{suggests?.map((item, index) => {
const isSelected = selectedItem === index;
return (
<button
key={item._id}
ref={(el) => (itemRefs.current[index] = el)}
onClick={() => {
setSelectedItem(index);
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}}
className={`w-full h-10 px-2 text-sm flex items-center justify-between rounded-lg transition-colors ${
isSelected
? "bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.1)]"
: "hover:bg-[rgba(0,0,0,0.1)] dark:hover:bg-[rgba(255,255,255,0.1)]"
}`}
>
<div className="flex gap-2 items-center">
<img className="w-5 h-5" src={item?._source?.icon} alt="icon" />
<span className="text-[#333] dark:text-[#d8d8d8]">
{item?._source?.source}/{item?._source?.title}
</span>
</div>
<div className="flex gap-2 items-center">
<span className="text-sm text-[#666] dark:text-[#666]">
{item?._source?.type}
</span>
{suggests?.map((item, index) => {
const isSelected = selectedItem === index;
return (
<button
key={item._id}
ref={(el) => (itemRefs.current[index] = el)}
onMouseEnter={() => setSelectedItem(index)}
onClick={() => {
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}}
className={`w-full h-10 px-2 text-sm flex items-center justify-between rounded-lg transition-colors ${
isSelected
? "bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.1)]"
: "hover:bg-[rgba(0,0,0,0.1)] dark:hover:bg-[rgba(255,255,255,0.1)]"
}`}
>
<div className="flex gap-2 items-center">
<img className="w-5 h-5" src={item?._source?.icon} alt="icon" />
<span className="text-[#333] dark:text-[#d8d8d8] truncate w-80 text-left">
{item?._source?.source}/{item?._source?.title}
</span>
</div>
<div className="flex gap-2 items-center relative">
<span className="text-sm text-[#666] dark:text-[#666] truncate w-52 text-right">
{item?._source?.type}
</span>
{showIndex && index < 10 ? (
<div
className={`w-4 h-4 text-xs flex items-center justify-center text-[#e4e5ef] border border-[#e4e5ef] rounded-sm ${
isSelected ? "text-blue-500 dark:bg-white" : ""
}`}
className={`absolute right-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] shadow-[-6px_0px_6px_2px_#e6e6e6] dark:shadow-[-6px_0px_6px_2px_#000] rounded-md`}
>
{index + 1}
{index}
</div>
</div>
</button>
);
})}
</div>
) : null}
</div>
</button>
);
})}
</div>
);
}

View File

@@ -1,60 +1,50 @@
import {
Settings,
LogOut,
// Settings,
// LogOut,
Command,
User,
Home,
ChevronUp,
// User,
// Home,
// ChevronUp,
ArrowDown01,
AppWindowMac,
ArrowDownUp,
// ArrowDownUp,
CornerDownLeft,
} from "lucide-react";
// import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react";
// import { Link } from "react-router-dom";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
const shortcuts = [
{ label: "Quick open", keys: "Tab" },
{ label: "Open", keys: "CornerDownLeft" },
];
const isChatShortcuts = [
{ label: "Go to Search", keys: "⌘ + /" },
{ label: "Open", keys: "⌘ + O" },
];
// import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
interface FooterProps {
isChat: boolean;
name?: string;
}
export const Footer = ({ isChat, name }: FooterProps) => {
async function openWebviewWindowSettings() {
const webview = new WebviewWindow("settings", {
title: "Coco Settings",
dragDropEnabled: true,
center: true,
width: 900,
height: 700,
alwaysOnTop: true,
skipTaskbar: true,
decorations: true,
closable: true,
url: "/ui/settings",
});
webview.once("tauri://created", function () {
console.log("webview created");
});
webview.once("tauri://error", function (e) {
console.log("error creating webview", e);
});
}
export const Footer = ({ name }: FooterProps) => {
// async function openWebviewWindowSettings() {
// const webview = new WebviewWindow("settings", {
// title: "Coco Settings",
// dragDropEnabled: true,
// center: true,
// width: 900,
// height: 700,
// alwaysOnTop: true,
// skipTaskbar: true,
// decorations: true,
// closable: true,
// url: "/ui/settings",
// });
// webview.once("tauri://created", function () {
// console.log("webview created");
// });
// webview.once("tauri://error", function (e) {
// console.log("error creating webview", e);
// });
// }
return (
<div
style={{ zIndex: 999 }}
className="px-4 h-10 fixed bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-xl rounded-t-none overflow-hidden"
data-tauri-drag-region
className="px-4 z-999 mx-[1px] h-10 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-xl rounded-t-none overflow-hidden"
>
<div className="flex items-center">
{

View File

@@ -0,0 +1,156 @@
import {
Library,
Mic,
Send,
Plus,
AudioLines,
Image,
CircleStop,
} from "lucide-react";
import { useRef, type KeyboardEvent } from "react";
import ChatSwitch from "../SearchChat/ChatSwitch";
import AutoResizeTextarea from "./AutoResizeTextarea";
import { useChatStore } from "../../stores/chatStore";
interface ChatInputProps {
onSend: (message: string) => void;
disabled: boolean;
disabledChange: (disabled: boolean) => void;
changeMode: (isChatMode: boolean) => void;
isChatMode: boolean;
inputValue: string;
changeInput: (val: string) => void;
}
export default function ChatInput({
onSend,
disabled,
changeMode,
isChatMode,
inputValue,
changeInput,
}: ChatInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { curChatEnd, setCurChatEnd } = useChatStore();
const handleSubmit = () => {
if (inputValue.trim() && !disabled) {
onSend(inputValue.trim());
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const openChatAI = async () => {
console.log("Chat AI opened.");
};
return (
<div className="w-full rounded-xl overflow-hidden">
<div className="bg-inputbox_bg_light dark:bg-inputbox_bg_dark bg-cover rounded-xl">
<div className="p-[13px] flex items-center dark:text-[#D8D8D8] bg-white dark:bg-[#202126] rounded-xl transition-all">
<div className="flex flex-wrap gap-2 flex-1 items-center">
{isChatMode ? (
<AutoResizeTextarea
input={inputValue}
setInput={changeInput}
handleKeyDown={handleKeyDown}
/>
) : (
<input
ref={inputRef}
type="text"
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-xs leading-6 font-normal flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder="Search whatever you want ..."
value={inputValue}
onChange={(e) => {
onSend(e.target.value);
}}
/>
)}
</div>
{isChatMode ? (
<button
className="p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition-colors"
type="button"
>
<Mic className="w-4 h-4 text-[#999] dark:text-[#999]" />
</button>
) : null}
{isChatMode && curChatEnd ? (
<button
className={`ml-1 p-1 ${
inputValue ? "bg-[#0072FF]" : "bg-[#E4E5F0]"
} rounded-full transition-colors`}
type="submit"
onClick={() => onSend(inputValue.trim())}
>
<Send className="w-4 h-4 text-white hover:text-[#999]" />
</button>
) : null}
{isChatMode && !curChatEnd ? (
<button
className={`ml-1 p-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => setCurChatEnd(true)}
>
<CircleStop className="w-4 h-4 text-white hover:text-[#999]" />
</button>
) : null}
</div>
<div
data-tauri-drag-region
className="flex justify-between items-center p-2 rounded-xl"
>
{isChatMode ? (
<div className="flex gap-1 text-xs text-[#333] dark:text-[#d8d8d8]">
<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">
<Plus className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
Upload
</button>
</div>
) : (
<div className="flex gap-1">
<button
className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors "
onClick={openChatAI}
>
<AudioLines className="w-4 h-4 text-[#000] dark:text-[#d8d8d8]" />
</button>
<button className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color">
<Image className="w-4 h-4 text-[#000] dark:text-[#d8d8d8]" />
</button>
</div>
)}
<ChatSwitch
isChatMode={isChatMode}
onChange={(value) => {
changeMode(value);
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,42 +1,25 @@
import React, { useEffect, useState } from "react";
import {
Mic,
Library,
AudioLines,
SquareChevronLeft,
Send,
Plus,
Image,
} from "lucide-react";
import {
WebviewWindow,
getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import { motion } from "framer-motion";
import { useEffect, useState, useCallback } from "react";
// import {
// WebviewWindow,
// getCurrentWebviewWindow,
// } from "@tauri-apps/api/webviewWindow";
// import { LogicalSize } from "@tauri-apps/api/dpi";
import DropdownList from "./DropdownList";
import { Footer } from "./Footer";
import ChatSwitch from "./ChatSwitch";
import { SearchResults } from "./SearchResults";
import { tauriFetch } from "../../api/tauriFetchClient";
interface Tag {
id: string;
text: string;
}
interface SearchProps {
changeMode: (isChatMode: boolean) => void;
changeInput: (val: string) => void;
isTransitioned: boolean;
isChatMode: boolean;
input: string;
}
function Search({ changeMode, changeInput, isChatMode }: SearchProps) {
const [tags, setTags] = useState<Tag[]>([]);
function Search({ isTransitioned, isChatMode, input }: SearchProps) {
const [suggests, setSuggests] = useState<any[]>([]);
const [isSearchComplete, setIsSearchComplete] = useState(false);
const [input, setInput] = useState("");
const [selectedItem, setSelectedItem] = useState<any>(null);
const getSuggest = async () => {
@@ -45,211 +28,61 @@ function Search({ changeMode, changeInput, isChatMode }: SearchProps) {
url: `/query/_search?query=${input}`,
method: "GET",
});
console.log("_suggest", response);
console.log("_suggest", input, response);
const data = response.data?.hits?.hits || [];
if (data.length > 0) {
await getCurrentWebviewWindow().setSize(new LogicalSize(680, 600));
} else {
await getCurrentWebviewWindow().setSize(new LogicalSize(680, 90));
}
// if (data.length > 0) {
// await getCurrentWebviewWindow().setSize(new LogicalSize(680, 600));
// } else {
// await getCurrentWebviewWindow().setSize(new LogicalSize(680, 90));
// }
setSuggests(data);
setIsSearchComplete(true);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
const getDataList = async () => {
if (isChatMode) {
changeInput(input);
} else {
getSuggest();
// setTags([...tags, { id: Date.now().toString(), text: input.trim() }]);
// setInput("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
getDataList();
}
};
const removeTag = async (tagId: string) => {
const newTag = tags.filter((tag) => tag.id !== tagId);
setTags(newTag);
if (newTag.length === 0) {
await getCurrentWebviewWindow().setSize(new LogicalSize(680, 90));
}
};
async function openChatAI() {
return;
const webview = new WebviewWindow("chat", {
title: "Coco AI",
dragDropEnabled: true,
center: true,
width: 900,
height: 700,
alwaysOnTop: true,
skipTaskbar: true,
decorations: true,
closable: true,
url: "/ui/chat",
});
webview.once("tauri://created", function () {
console.log("webview created");
});
webview.once("tauri://error", function (e) {
console.log("error creating webview", e);
});
function debounce(fn: Function, delay: number) {
let timer: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const debouncedSearch = useCallback(debounce(getSuggest, 300), [input]);
useEffect(() => {
if (selectedItem) {
setTags([]);
}
}, [selectedItem]);
!isChatMode && debouncedSearch();
}, [input]);
if (suggests.length === 0) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0 }}
className={`min-h-screen flex items-start justify-center rounded-xl overflow-hidden`}
<div
className={`absolute w-full transition-opacity duration-500 ${
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
} bottom-0 h-[500px]`}
>
<div className="w-full rounded-xl overflow-hidden">
<div className="bg-inputbox_bg_light dark:bg-inputbox_bg_dark bg-cover rounded-xl">
{/* Search Bar */}
<div className="relative">
<div className="p-[13px] flex items-center dark:text-[#D8D8D8] bg-white dark:bg-[#202126] rounded-xl transition-all">
<div className="flex flex-wrap gap-2 flex-1 h-6 items-center">
{/* {tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center bg-blue-50 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 px-2.5 py-1 rounded-lg text-sm"
>
{tag.text}
<button
onClick={() => removeTag(tag.id)}
className="ml-1.5 text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-400"
>
×
</button>
</span>
))} */}
{!isChatMode && selectedItem ? (
<SquareChevronLeft
className="cursor-pointer text-gray-400 dark:text-gray-500"
onClick={() => setSelectedItem(null)}
/>
) : null}
<input
type="text"
className="text-xs font-normal flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder="Ask whatever you want....."
value={input}
onChange={(e) => {
setInput(e.target.value);
setIsSearchComplete(false);
}}
onKeyDown={handleKeyDown}
/>
</div>
{/* <button className="p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition-colors">
<Mic className="w-3 h-3 text-[#333] dark:text-gray-500" />
</button> */}
{/* <button
className={`ml-1 p-2 ${
input ? "bg-[rgba(66,133,244,1)]" : "bg-[#E4E5F0]"
} rounded-full transition-colors`}
onClick={() => getDataList()}
>
<Send className="w-3 h-3 text-white hover:text-[#333]" />
</button> */}
</div>
</div>
{/* Controls */}
<div className="h-10 px-3 flex justify-between items-center rounded-xl overflow-hidden">
{isChatMode ? (
<div className="flex gap-1 text-xs text-[#101010] dark:text-gray-300">
<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-3 h-3 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">
<Plus className="w-3 h-3 mr-1 text-[#000] dark:text-[#d8d8d8]" />
Upload
</button>
</div>
) : (
<div className="flex gap-1">
<button
className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors "
onClick={openChatAI}
>
<AudioLines className="w-3.5 h-3 text-[#000] dark:text-[#d8d8d8]" />
</button>
<button className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color">
<Image className="w-3 h-3 text-[#000] dark:text-[#d8d8d8]" />
</button>
</div>
)}
{/* Switch */}
<ChatSwitch
isChat={isChatMode}
onChange={(value) => {
changeMode(value);
setInput("");
}}
/>
</div>
</div>
<div
className={`min-h-full w-full flex items-start justify-center rounded-xl overflow-hidden relative`}
>
{/* Search Results Panel */}
{!isChatMode && suggests.length > 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: 0.2 }}
>
<DropdownList
suggests={suggests}
isSearchComplete={isSearchComplete}
selected={(item) => setSelectedItem(item)}
/>
</motion.div>
{suggests.length > 0 ? (
<DropdownList
suggests={suggests}
isSearchComplete={isSearchComplete}
selected={(item) => setSelectedItem(item)}
/>
) : null}
{!isChatMode && selectedItem ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: 0.2 }}
>
<SearchResults />
</motion.div>
{selectedItem ? <SearchResults /> : null}
{suggests.length > 0 || selectedItem ? (
<Footer isChat={false} name={selectedItem?.source} />
) : null}
</div>
{!isChatMode && (suggests.length > 0 || selectedItem) ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ delay: 0.2 }}
>
<Footer isChat={false} name={selectedItem?.source} />
</motion.div>
) : null}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
const TransitionComponent = () => {
const [isTransitioned, setIsTransitioned] = useState(false);
const handleToggle = () => {
setIsTransitioned(!isTransitioned);
};
return (
<div
data-tauri-drag-region
className="w-[680px] h-[600px] mx-auto bg-gray-100 overflow-hidden relative"
>
<div
data-tauri-drag-region
className={`absolute w-full bg-red-500 text-white flex items-center justify-center transition-all duration-500 ${
isTransitioned ? "top-[510px] h-[90px]" : "top-0 h-[90px]"
}`}
>
<button
className="px-4 py-2 bg-white text-black rounded"
onClick={handleToggle}
>
Toggle
</button>
</div>
<div
data-tauri-drag-region
className={`absolute w-full bg-green-500 transition-opacity duration-500 ${
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
} bottom-0 h-[500px]`}
></div>
<div
data-tauri-drag-region
className={`absolute w-full bg-yellow-500 transition-all duration-500 ${
isTransitioned
? "top-0 opacity-100 pointer-events-auto"
: "-top-[510px] opacity-0 pointer-events-none"
} h-[500px]`}
></div>
</div>
);
};
export default TransitionComponent;

View File

@@ -1,44 +1,100 @@
import { useState } from "react";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import { AnimatePresence, LayoutGroup } from "framer-motion";
import { useEffect, useState, useRef } from "react";
// import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
// import { LogicalSize } from "@tauri-apps/api/dpi";
import InputBox from "./InputBox";
import Search from "./Search";
import ChatAI from "../ChatAI/Chat";
import ChatAI, { ChatAIRef } from "../ChatAI/Chat";
export default function SearchChat() {
const chatAIRef = useRef<ChatAIRef>(null);
const [isChatMode, setIsChatMode] = useState(false);
const [input, setInput] = useState("");
const [isTransitioned, setIsTransitioned] = useState(false);
async function setWindowSize() {
if (isTransitioned) {
// await getCurrentWebviewWindow()?.setSize(new LogicalSize(680, 600));
} else {
// await getCurrentWebviewWindow()?.setSize(new LogicalSize(680, 90));
}
}
useEffect(() => {
setWindowSize();
}, [isTransitioned]);
async function changeMode(value: boolean) {
setIsChatMode(value);
setInput("");
if (!value) {
await getCurrentWebviewWindow()?.setSize(new LogicalSize(680, 90));
setIsTransitioned(false);
}
}
async function changeInput(value: string) {
setInput(value);
if (isChatMode) {
await getCurrentWebviewWindow()?.setSize(new LogicalSize(680, 600));
}
}
const handleSendMessage = async (value: string) => {
setInput(value);
if (isChatMode) {
setIsTransitioned(true);
chatAIRef.current?.init();
}
};
const cancelChat = () => {};
const setIsTyping = (value: any) => {
console.log(value);
};
const isTyping = false;
return (
<LayoutGroup>
<AnimatePresence mode="wait">
{isChatMode && input ? (
<ChatAI key="ChatAI" inputValue={input} changeMode={changeMode} />
) : (
<Search
key="Search"
isChatMode={isChatMode}
changeMode={changeMode}
changeInput={changeInput}
/>
)}
</AnimatePresence>
</LayoutGroup>
<div
data-tauri-drag-region
className={`w-full h-full min-h-screen mx-auto overflow-hidden relative`}
>
<div
className={`absolute z-100 w-full flex items-center justify-center duration-500 ${
isTransitioned ? "top-[510px] h-[90px]" : "top-0 h-[90px]"
}`}
>
<InputBox
isChatMode={isChatMode}
inputValue={input}
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={(value) => {
cancelChat();
setIsTyping(value);
}}
changeMode={changeMode}
changeInput={changeInput}
/>
</div>
<div
className={`absolute w-full transition-all duration-500 ${
isTransitioned
? "top-0 opacity-100 pointer-events-auto"
: "-top-[510px] opacity-0 pointer-events-none"
} h-[500px]`}
>
<ChatAI
ref={chatAIRef}
key="ChatAI"
inputValue={input}
isTransitioned={isTransitioned}
changeInput={changeInput}
/>
</div>
<Search
key="Search"
input={input}
isChatMode={isChatMode}
isTransitioned={isTransitioned}
changeInput={changeInput}
/>
</div>
);
}

View File

@@ -5,7 +5,7 @@ interface SettingsPanelProps {
children: React.ReactNode;
}
const SettingsPanel: React.FC<SettingsPanelProps> = ({ title, children }) => {
const SettingsPanel: React.FC<SettingsPanelProps> = ({ children }) => {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm">
{/* <h2 className="text-xl font-semibold mb-6">{title}</h2> */}

View File

@@ -1,15 +1,15 @@
import React, { useContext } from 'react';
import { Menu } from '@headlessui/react';
import { Monitor, Moon, Sun } from 'lucide-react';
import { Theme, ThemeContext } from './index2';
import { useContext } from "react";
import { Menu } from "@headlessui/react";
import { Monitor, Moon, Sun } from "lucide-react";
import { Theme, ThemeContext } from "./index2";
const ThemeSelector = () => {
const { theme, setTheme } = useContext(ThemeContext);
const themes: { value: Theme; label: string; icon: any }[] = [
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
const currentTheme = themes.find((t) => t.value === theme);
@@ -29,8 +29,8 @@ const ThemeSelector = () => {
onClick={() => setTheme(item.value)}
className={`${
active
? 'bg-gray-100 dark:bg-gray-700'
: 'text-gray-900 dark:text-gray-100'
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
>
<item.icon className="w-4 h-4 mr-2" />
@@ -45,4 +45,4 @@ const ThemeSelector = () => {
);
};
export default ThemeSelector;
export default ThemeSelector;

View File

@@ -14,7 +14,9 @@ type ThemeProviderState = {
setTheme: (theme: Theme) => void;
};
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined);
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
undefined
);
export function ThemeProvider({
children,
@@ -29,21 +31,20 @@ export function ThemeProvider({
if (storedTheme) {
setThemeState(storedTheme);
}
let unlistenThemeChanges: (() => void) | undefined;
const setupThemeListener = async () => {
unlistenThemeChanges = await listenForThemeChanges();
};
setupThemeListener();
return () => {
// Cleanup listeners on unmount
unlistenThemeChanges?.();
};
}, [storageKey]);
useEffect(() => {
applyTheme(theme);
@@ -76,12 +77,14 @@ export function ThemeProvider({
}
// Listen for theme changes
const unlisten = await currentWindow.onThemeChanged(({ payload: newTheme }) => {
if (theme === "system") {
applyTheme(newTheme as Theme);
console.log("New theme: " + theme);
const unlisten = await currentWindow.onThemeChanged(
({ payload: newTheme }) => {
if (theme === "system") {
applyTheme(newTheme as Theme);
console.log("New theme: " + theme);
}
}
});
);
return () => {
unlisten();

View File

@@ -1,9 +1,7 @@
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
export default function Header() {
const navigate = useNavigate();
const location = useLocation();
const showBack = location.pathname !== "/ui";
return (
<div>

View File

@@ -5,6 +5,7 @@ import ErrorPage from "../error-page";
// import Settings from "../components/Settings";
import Settings2 from "../components/Settings/index2";
import SearchChat from "../components/SearchChat";
import Transition from "../components/SearchChat/Transition";
import ChatAI from "../components/ChatAI";
import MySearch from "../components/MySearch";
import Layout from "./Layout";
@@ -19,6 +20,7 @@ export const router = createBrowserRouter([
{ path: "/ui/settings", element: <Settings2 /> },
{ path: "/ui/chat", element: <ChatAI /> },
{ path: "/ui/search", element: <MySearch /> },
{ path: "/ui/transition", element: <Transition /> },
],
},
]);

27
src/stores/chatStore.ts Normal file
View File

@@ -0,0 +1,27 @@
import { create } from "zustand";
import {
persist,
// createJSONStorage
} from "zustand/middleware";
export type IChatStore = {
curChatEnd: boolean;
setCurChatEnd: (value: boolean) => void;
};
export const useChatStore = create<IChatStore>()(
persist(
(set) => ({
curChatEnd: true,
setCurChatEnd: (value: boolean) => set(() => ({ curChatEnd: value })),
}),
{
name: "chat-state",
// storage: createJSONStorage(() => sessionStorage),
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) => key === "curChatEnd")
),
}
)
);

View File

@@ -1,5 +1,8 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import {
persist,
// createJSONStorage
} from "zustand/middleware";
export type ITheme = "dark" | "light" | "system";
@@ -18,7 +21,7 @@ export const useThemeStore = create<IThemeStore>()(
}),
{
name: "active-theme",
// storage: createJSONStorage(() => sessionStorage),
// storage: createJSONStorage(() => sessionStorage),
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) => key === "activeTheme")