feat: add locales switch (#144)

* feat: add locales

* feat: add locales

* feat: add listen language
This commit is contained in:
BiggerRain
2025-02-18 09:40:00 +08:00
committed by GitHub
parent 4ba842f18b
commit e9ec1be42f
36 changed files with 1263 additions and 883 deletions

View File

@@ -15,6 +15,7 @@
"inputbox",
"katex",
"khtml",
"languagedetector",
"localstorage",
"lucide",
"maximizable",

View File

@@ -19,14 +19,15 @@
"@tauri-apps/plugin-http": "~2.0.1",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-shell": ">=2.0.0",
"@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-websocket": "~2",
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@tauri-apps/plugin-updater": "^2.3.0",
"ahooks": "^3.8.4",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
"i18next": "^23.16.2",
"i18next-browser-languagedetector": "^8.0.3",
"lodash": "^4.17.21",
"lucide-react": "^0.461.0",
"mermaid": "^11.4.0",

23
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
i18next:
specifier: ^23.16.2
version: 23.16.2
i18next-browser-languagedetector:
specifier: ^8.0.3
version: 8.0.3
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -554,46 +557,55 @@ packages:
resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.24.0':
resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.24.0':
resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.24.0':
resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.24.0':
resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.24.0':
resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.24.0':
resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.24.0':
resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.24.0':
resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==}
@@ -652,24 +664,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.2.7':
resolution: {integrity: sha512-+8HZ+txff/Y3YjAh80XcLXcX8kpGXVdr1P8AfjLHxHdS6QD4Md+acSxGTTNbplmHuBaSHJvuTvZf9tU1eDCTDg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-x64-gnu@2.2.7':
resolution: {integrity: sha512-ahlSnuCnUntblp9dG7/w5ZWZOdzRFi3zl0oScgt7GF4KNAOEa7duADsxPA4/FT2hLRa0SvpqtD4IYFvCxoVv3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.2.7':
resolution: {integrity: sha512-+qKAWnJRSX+pjjRbKAQgTdFY8ecdcu8UdJ69i7wn3ZcRn2nMMzOO2LOMOTQV42B7/Q64D1pIpmZj9yblTMvadA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.2.7':
resolution: {integrity: sha512-aa86nRnrwT04u9D9fhf5JVssuAZlUCCc8AjqQjqODQjMd4BMA2+d4K9qBMpEG/1kVh95vZaNsLogjEaqSTTw4A==}
@@ -1426,6 +1442,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
i18next-browser-languagedetector@8.0.3:
resolution: {integrity: sha512-beOOLArattPBc2YZG5IXGJytdYFgUR7cS8Wd6HT4IczIoWKgmTspOQ2yasaGklelVo5seLPmnEKvLHR+E/MdWQ==}
i18next@23.16.2:
resolution: {integrity: sha512-dFyxwLXxEQK32f6tITBMaRht25mZPJhQ0WbC0p3bO2mWBal9lABTMqSka5k+GLSRWLzeJBKDpH7BeIA9TZI7Jg==}
@@ -3607,6 +3626,10 @@ snapshots:
html-url-attributes@3.0.1: {}
i18next-browser-languagedetector@8.0.3:
dependencies:
'@babel/runtime': 7.25.9
i18next@23.16.2:
dependencies:
'@babel/runtime': 7.25.9

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
interface AutoResizeTextareaProps {
input: string;
@@ -11,6 +12,7 @@ const AutoResizeTextarea: React.FC<AutoResizeTextareaProps> = ({
setInput,
handleKeyDown,
}) => {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
@@ -25,7 +27,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={t('search.textarea.placeholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -1,318 +1,342 @@
import {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState,} from "react";
import {MessageSquarePlus, PanelLeft} from "lucide-react";
import {isTauri} from "@tauri-apps/api/core";
import {ChatMessage} from "./ChatMessage";
import type {Chat, Message} from "./types";
import {tauriFetch} from "../../api/tauriFetchClient";
import {useWebSocket} from "../../hooks/useWebSocket";
import {useChatStore} from "../../stores/chatStore";
import {useWindows} from "../../hooks/useWindows";
import {clientEnv} from "@/utils/env";
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { MessageSquarePlus, PanelLeft } from "lucide-react";
import { isTauri } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import { ChatMessage } from "./ChatMessage";
import type { Chat, Message } from "./types";
import { tauriFetch } from "@/api/tauriFetchClient";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useChatStore } from "@/stores/chatStore";
import { useWindows } from "@/hooks/useWindows";
import { clientEnv } from "@/utils/env";
// import { useAppStore } from '@/stores/appStore';
interface ChatAIProps {
isTransitioned: boolean;
changeInput: (val: string) => void;
isSearchActive?: boolean;
isDeepThinkActive?: boolean;
isTransitioned: boolean;
changeInput: (val: string) => void;
isSearchActive?: boolean;
isDeepThinkActive?: boolean;
}
export interface ChatAIRef {
init: (value: string) => void;
cancelChat: () => void;
connected: boolean;
reconnect: () => void;
init: (value: string) => void;
cancelChat: () => void;
connected: boolean;
reconnect: () => void;
}
const ChatAI = memo(forwardRef<ChatAIRef, ChatAIProps>(
({isTransitioned, changeInput, isSearchActive, isDeepThinkActive}, ref) => {
useImperativeHandle(ref, () => ({
init: init,
cancelChat: cancelChat,
connected: connected,
reconnect: reconnect
}));
const ChatAI = memo(
forwardRef<ChatAIRef, ChatAIProps>(
(
{ isTransitioned, changeInput, isSearchActive, isDeepThinkActive },
ref
) => {
const { t } = useTranslation();
useImperativeHandle(ref, () => ({
init: init,
cancelChat: cancelChat,
connected: connected,
reconnect: reconnect,
}));
// const appStore = useAppStore();
// const appStore = useAppStore();
const {createWin} = useWindows();
const { createWin } = useWindows();
const {curChatEnd, setCurChatEnd, setConnected} = useChatStore();
const { curChatEnd, setCurChatEnd, setConnected } = useChatStore();
const [activeChat, setActiveChat] = useState<Chat>();
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [activeChat, setActiveChat] = useState<Chat>();
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [websocketId, setWebsocketId] = useState("");
const [curMessage, setCurMessage] = useState("");
const [curId, setCurId] = useState("");
const [websocketId, setWebsocketId] = useState("");
const [curMessage, setCurMessage] = useState("");
const [curId, setCurId] = useState("");
const curChatEndRef = useRef(curChatEnd);
curChatEndRef.current = curChatEnd;
const curChatEndRef = useRef(curChatEnd);
curChatEndRef.current = curChatEnd;
const curIdRef = useRef(curId);
curIdRef.current = curId;
const curIdRef = useRef(curId);
curIdRef.current = curId;
const handleMessageChunk = useCallback((chunk: string) => {
setCurMessage(prev => prev + chunk);
}, []);
const handleMessageChunk = useCallback((chunk: string) => {
setCurMessage((prev) => prev + chunk);
}, []);
// console.log("chat useWebSocket", clientEnv.COCO_WEBSOCKET_URL)
const {messages, setMessages, connected, reconnect} = useWebSocket(
clientEnv.COCO_WEBSOCKET_URL,
(msg) => {
// console.log("msg", msg);
// console.log("chat useWebSocket", clientEnv.COCO_WEBSOCKET_URL)
const { messages, setMessages, connected, reconnect } = useWebSocket(
clientEnv.COCO_WEBSOCKET_URL,
(msg) => {
// console.log("msg", msg);
if (msg.includes("websocket-session-id")) {
const array = msg.split(" ");
setWebsocketId(array[2]);
}
if (msg.includes("websocket-session-id")) {
const array = msg.split(" ");
setWebsocketId(array[2]);
}
if (msg.includes("PRIVATE")) {
if (
msg.includes("assistant finished output") ||
curChatEndRef.current
) {
setCurChatEnd(true);
} else {
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
const chunkData = JSON.parse(cleanedData);
if (chunkData.reply_to_message === curIdRef.current) {
handleMessageChunk(chunkData.message_chunk)
return chunkData.message_chunk;
}
} catch (error) {
console.error("JSON Parse error:", error);
}
}
}
}
);
useEffect(() => {
setConnected(connected)
}, [connected])
const simulateAssistantResponse = useCallback(() => {
if (messages.length === 0 || !activeChat?._id) return;
console.log("messages", messages);
const assistantMessage: Message = {
_id: activeChat._id,
_source: {
type: "assistant",
message: messages,
},
};
const updatedChat = {
...activeChat,
messages: [...(activeChat.messages || []), assistantMessage],
};
setMessages("");
setCurMessage("");
console.log("updatedChat", updatedChat);
setActiveChat(updatedChat);
const timer = setTimeout(() => setIsTyping(false), 1000);
return () => clearTimeout(timer);
}, [activeChat?._id]);
// websocket
useEffect(() => {
if (curChatEnd) {
simulateAssistantResponse();
}
}, [messages, curChatEnd]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
};
useEffect(() => {
scrollToBottom();
}, [activeChat?.messages, isTyping, curMessage]);
useEffect(() => {
return () => {
chatClose();
setMessages("");
setCurMessage("");
setActiveChat(undefined);
setIsTyping(false);
setCurChatEnd(true);
};
}, []);
const createNewChat = useCallback(async (value: string = "") => {
chatClose();
try {
const response = await tauriFetch({
url: "/chat/_new",
method: "POST",
});
console.log("_new", response);
const newChat: Chat = response.data;
setActiveChat(newChat);
handleSendMessage(value, newChat);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
}, []);
const init = (value: string) => {
if (!curChatEnd) return;
if (!activeChat?._id) {
createNewChat(value);
if (msg.includes("PRIVATE")) {
if (
msg.includes("assistant finished output") ||
curChatEndRef.current
) {
setCurChatEnd(true);
} else {
handleSendMessage(value);
}
};
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?search=${isSearchActive}&deep_thinking=${isDeepThinkActive}`,
method: "POST",
headers: {
"WEBSOCKET-SESSION-ID": websocketId,
},
body: JSON.stringify({message: content}),
});
console.log("_send", response, websocketId);
setCurId(response.data[0]?._id);
const updatedChat: Chat = {
...newChat,
messages: [...(newChat?.messages || []), ...(response.data || [])],
};
changeInput("");
console.log("updatedChat2", updatedChat);
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 () => {
setCurChatEnd(true);
setIsTyping(false);
if (!activeChat?._id) return;
try {
const response = await tauriFetch({
url: `/chat/${activeChat._id}/_cancel`,
method: "POST",
});
console.log("_cancel", response);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
async function openChatAI() {
if (isTauri()) {
createWin && createWin({
label: "chat",
title: "Coco Chat",
dragDropEnabled: true,
center: true,
width: 1000,
height: 800,
alwaysOnTop: false,
skipTaskbar: false,
decorations: true,
closable: true,
url: "/ui/chat",
});
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
const chunkData = JSON.parse(cleanedData);
if (chunkData.reply_to_message === curIdRef.current) {
handleMessageChunk(chunkData.message_chunk);
return chunkData.message_chunk;
}
} catch (error) {
console.error("JSON Parse error:", error);
}
}
}
}
);
if (!isTransitioned) return null;
useEffect(() => {
setConnected(connected);
}, [connected]);
return (
<div
data-tauri-drag-region
className={`h-[500px] flex flex-col rounded-xl overflow-hidden`}
const simulateAssistantResponse = useCallback(() => {
if (messages.length === 0 || !activeChat?._id) return;
console.log("messages", messages);
const assistantMessage: Message = {
_id: activeChat._id,
_source: {
type: "assistant",
message: messages,
},
};
const updatedChat = {
...activeChat,
messages: [...(activeChat.messages || []), assistantMessage],
};
setMessages("");
setCurMessage("");
console.log("updatedChat", updatedChat);
setActiveChat(updatedChat);
const timer = setTimeout(() => setIsTyping(false), 1000);
return () => clearTimeout(timer);
}, [activeChat?._id]);
// websocket
useEffect(() => {
if (curChatEnd) {
simulateAssistantResponse();
}
}, [messages, curChatEnd]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
};
useEffect(() => {
scrollToBottom();
}, [activeChat?.messages, isTyping, curMessage]);
useEffect(() => {
return () => {
chatClose();
setMessages("");
setCurMessage("");
setActiveChat(undefined);
setIsTyping(false);
setCurChatEnd(true);
};
}, []);
const createNewChat = useCallback(async (value: string = "") => {
chatClose();
try {
const response = await tauriFetch({
url: "/chat/_new",
method: "POST",
});
console.log("_new", response);
const newChat: Chat = response.data;
setActiveChat(newChat);
handleSendMessage(value, newChat);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
}, []);
const init = (value: string) => {
if (!curChatEnd) return;
if (!activeChat?._id) {
createNewChat(value);
} else {
handleSendMessage(value);
}
};
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?search=${isSearchActive}&deep_thinking=${isDeepThinkActive}`,
method: "POST",
headers: {
"WEBSOCKET-SESSION-ID": websocketId,
},
body: JSON.stringify({ message: content }),
});
console.log("_send", response, websocketId);
setCurId(response.data[0]?._id);
const updatedChat: Chat = {
...newChat,
messages: [...(newChat?.messages || []), ...(response.data || [])],
};
changeInput("");
console.log("updatedChat2", updatedChat);
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 () => {
setCurChatEnd(true);
setIsTyping(false);
if (!activeChat?._id) return;
try {
const response = await tauriFetch({
url: `/chat/${activeChat._id}/_cancel`,
method: "POST",
});
console.log("_cancel", response);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
async function openChatAI() {
if (isTauri()) {
createWin &&
createWin({
label: "chat",
title: "Coco Chat",
dragDropEnabled: true,
center: true,
width: 1000,
height: 800,
alwaysOnTop: false,
skipTaskbar: false,
decorations: true,
closable: true,
url: "/ui/chat",
});
}
}
if (!isTransitioned) return null;
return (
<div
data-tauri-drag-region
className={`h-[500px] flex flex-col rounded-xl overflow-hidden`}
>
<header
data-tauri-drag-region
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]`}
>
<header
data-tauri-drag-region
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>
<button
onClick={() => {
createNewChat();
}}
className={`p-2 rounded-lg transition-colors text-[#333] dark:text-[#d8d8d8]`}
>
<MessageSquarePlus className="h-4 w-4" />
</button>
</header>
<button
onClick={() => {
createNewChat();
}}
className={`p-2 rounded-lg transition-colors text-[#333] dark:text-[#d8d8d8]`}
>
<MessageSquarePlus className="h-4 w-4"/>
</button>
</header>
{/* Chat messages */}
{/* Chat messages */}
<div className="w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar">
{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}
{!connected && (
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-red-500/10 rounded-md font-normal text-xs text-gray-400 flex items-center gap-4">
{t("assistant.chat.connectionError")}
<div
className="w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar">
{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}/>
className="w-[96px] h-[24px] bg-[#0061FF] rounded-[12px] font-normal text-xs text-white flex items-center justify-center cursor-pointer"
onClick={reconnect}
>
{t("assistant.chat.reconnect")}
</div>
</div>
);
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
);
}
));
)
);
export default ChatAI;

View File

@@ -6,6 +6,7 @@ import {
useRef,
useEffect,
} from "react";
import { useTranslation } from "react-i18next";
import AutoResizeTextarea from "./AutoResizeTextarea";
import StopIcon from "@/icons/Stop";
@@ -31,6 +32,7 @@ export function ChatInput({
isDeepThinkActive,
setIsDeepThinkActive,
}: ChatInputProps) {
const { t } = useTranslation();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -120,7 +122,9 @@ export function ChatInput({
<button
type="button"
className={`h-5 px-2 inline-flex items-center border rounded-[10px] transition-colors relative ${
isDeepThinkActive ? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]" : "border-[#262727]"
isDeepThinkActive
? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]"
: "border-[#262727]"
}`}
onClick={DeepThinkClick}
>
@@ -131,14 +135,20 @@ export function ChatInput({
: "text-[#333] dark:text-white"
}`}
/>
<span className={isDeepThinkActive ? "text-[#0072FF]" : "dark:text-white"}>
Deep Think
<span
className={
isDeepThinkActive ? "text-[#0072FF]" : "dark:text-white"
}
>
{t("assistant.input.deepThink")}
</span>
</button>
<button
type="button"
className={`h-5 px-2 inline-flex items-center border rounded-[10px] transition-colors relative ${
isSearchActive ? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]" : "border-[#262727]"
isSearchActive
? "bg-[rgba(0,114,255,0.3)] border-[rgba(0,114,255,0.3)]"
: "border-[#262727]"
}`}
onClick={SearchClick}
>
@@ -149,8 +159,12 @@ export function ChatInput({
: "text-[#333] dark:text-white"
}`}
/>
<span className={isSearchActive ? "text-[#0072FF]" : "dark:text-white"}>
Search
<span
className={
isSearchActive ? "text-[#0072FF]" : "dark:text-white"
}
>
{t("assistant.input.search")}
</span>
</button>
{/* <button

View File

@@ -6,6 +6,7 @@ import Markdown from "./Markdown";
import { formatThinkingMessage } from "@/utils/index";
import logoImg from "@/assets/icon.svg";
import { SourceResult } from "./SourceResult";
import { useTranslation } from "react-i18next";
interface ChatMessageProps {
message: Message;
@@ -13,6 +14,7 @@ interface ChatMessageProps {
}
export function ChatMessage({ message, isTyping }: ChatMessageProps) {
const { t } = useTranslation();
const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
const [responseTime, setResponseTime] = useState(0);
const startTimeRef = useRef<number | null>(null);
@@ -46,8 +48,14 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
}`}
>
<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" : ""}
{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">
@@ -70,14 +78,16 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
<>
<Brain className="w-4 h-4 animate-pulse text-[#999999]" />
<span className="text-xs text-[#999999] italic">
AI is thinking...
{t("assistant.message.thinking")}
</span>
</>
) : (
<>
<Brain className="w-4 h-4 text-[#999999]" />
<span className="text-xs text-[#999999]">
Thought for {responseTime.toFixed(1)} seconds
{t("assistant.message.thoughtTime", {
time: responseTime.toFixed(1),
})}
</span>
</>
)}

View File

@@ -1,4 +1,6 @@
import { useTranslation } from "react-i18next";
import { MessageSquare, Plus } from "lucide-react";
import type { Chat } from "./types";
interface SidebarProps {
chats: Chat[];
@@ -16,6 +18,8 @@ export function Sidebar({
onSelectChat,
className = "",
}: SidebarProps) {
const { t } = useTranslation();
return (
<div className={`h-full flex flex-col ${className}`}>
<div className="p-4">
@@ -24,7 +28,7 @@ export function Sidebar({
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all border border-[#E6E6E6] dark:border-[#272626] text-gray-700 hover:bg-gray-50/80 active:bg-gray-100/80 dark:text-white dark:hover:bg-gray-600/50 dark:active:bg-gray-500/50`}
>
<Plus className={`h-4 w-4 text-[#0072FF] dark:text-[#0072FF]`} />
New Chat
{t("assistant.sidebar.newChat")}
</button>
</div>
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2 custom-scrollbar">
@@ -33,8 +37,8 @@ export function Sidebar({
key={chat._id}
className={`group relative rounded-xl transition-all ${
activeChat._id === chat._id
? 'bg-gray-100/80 dark:bg-gray-700/50'
: 'hover:bg-gray-50/80 dark:hover:bg-gray-600/30'
? "bg-gray-100/80 dark:bg-gray-700/50"
: "hover:bg-gray-50/80 dark:hover:bg-gray-600/30"
}`}
>
<button
@@ -44,15 +48,17 @@ export function Sidebar({
<MessageSquare
className={`h-4 w-4 flex-shrink-0 ${
activeChat._id === chat._id
? 'text-[#0072FF] dark:text-[#0072FF]'
: 'text-gray-400 dark:text-gray-500'
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-gray-400 dark:text-gray-500"
}`}
/>
<span className={`truncate ${
activeChat._id === chat._id
? 'text-gray-900 dark:text-white font-medium'
: 'text-gray-600 dark:text-gray-300'
}`}>
<span
className={`truncate ${
activeChat._id === chat._id
? "text-gray-900 dark:text-white font-medium"
: "text-gray-600 dark:text-gray-300"
}`}
>
{chat.title || chat._id}
</span>
</button>

View File

@@ -5,6 +5,8 @@ import {
SquareArrowOutUpRight,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { OpenURLWithBrowser } from "@/utils/index";
interface SourceResultProps {
@@ -21,6 +23,7 @@ interface SourceItem {
}
export function SourceResult({ text }: SourceResultProps) {
const { t } = useTranslation();
const [isSourceExpanded, setIsSourceExpanded] = useState(false);
if (!text?.includes("<Source")) {
@@ -42,13 +45,11 @@ export function SourceResult({ text }: SourceResultProps) {
const sourceData = getSourceData();
return (
<div
className={`mt-2 ${
isSourceExpanded
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
: ""
}`}
>
<div className={`mt-2 ${
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 ${
@@ -58,7 +59,7 @@ export function SourceResult({ text }: SourceResultProps) {
<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]">
Found {totalResults} results
{t('assistant.source.foundResults', { count: Number(totalResults) })}
</span>
</div>
{isSourceExpanded ? (

View File

@@ -15,6 +15,7 @@ import {
getCurrent as getCurrentDeepLinkUrls,
} from "@tauri-apps/plugin-deep-link";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import { UserProfile } from "./UserProfile";
import { DataSourcesList } from "./DataSourcesList";
@@ -26,6 +27,8 @@ import { useConnectStore } from "@/stores/connectStore";
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
export default function Cloud() {
const { t } = useTranslation();
const SidebarRef = useRef<{ refreshData: () => void }>(null);
const error = useAppStore((state) => state.error);
@@ -371,7 +374,7 @@ export default function Cloud() {
{currentService?.auth_provider?.sso?.url ? (
<div className="mb-8">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Account Information
{t('cloud.accountInfo')}
</h2>
{currentService?.profile ? (
<UserProfile
@@ -387,7 +390,7 @@ export default function Cloud() {
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
>
Login
{t('cloud.login')}
</button>
)}
@@ -398,7 +401,7 @@ export default function Cloud() {
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
onClick={() => setLoading(false)} // Reset loading state
>
Cancel
{t('cloud.cancel')}
</button>
<button
onClick={() => {
@@ -423,7 +426,7 @@ export default function Cloud() {
)
}
>
EULA | Privacy Policy
{t('cloud.privacyPolicy')}
</button>
</div>
)}

View File

@@ -1,17 +1,19 @@
import React, { useState } from "react";
import { ChevronLeft } from "lucide-react";
import {useAppStore} from "@/stores/appStore";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/stores/appStore";
interface ConnectServiceProps {
setIsConnect: (isConnect: boolean) => void;
onAddServer: (endpoint: string) => void;
}
export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
const { t } = useTranslation();
const [endpointLink, setEndpointLink] = useState("");
const [refreshLoading, ] = useState(false);
const [errorMessage, setErrorMessage] = useState(''); // State to store the error message
const [refreshLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); // State to store the error message
const setError = useAppStore((state) => state.setError);
@@ -30,16 +32,19 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
setIsConnect(true); // Only set as connected if the server is added successfully
} catch (err: any) {
// Handle the error if something goes wrong
const errorMessage = typeof err === 'string' ? err : err?.message || 'An unknown error occurred.';
setErrorMessage("ERR:"+errorMessage);
const errorMessage =
typeof err === "string"
? err
: err?.message || "An unknown error occurred.";
setErrorMessage("ERR:" + errorMessage);
setError(errorMessage);
console.error('Error:', errorMessage);
console.error("Error:", errorMessage);
}
};
// Function to close the error message
const closeError = () => {
setErrorMessage('');
setErrorMessage("");
};
return (
@@ -52,16 +57,13 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
<ChevronLeft className="w-4 h-4" />
</button>
<div className="text-xl text-[#101010] dark:text-white">
Connecting to Your Coco-Server
{t("cloud.connect.title")}
</div>
</div>
<div className="mb-8">
<p className="text-gray-600 dark:text-gray-400">
Running your own private instance of coco-server ensures complete control over
your data, keeping it secure and accessible only within your environment.
Enjoy enhanced privacy, better performance, and seamless integration with your
internal systems.
{t("cloud.connect.description")}
</p>
</div>
@@ -71,23 +73,25 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
htmlFor="endpoint"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2.5"
>
Server address
{t("cloud.connect.serverAddress")}
</label>
<div className="flex gap-2">
<input
type="text"
id="endpoint"
value={endpointLink}
placeholder="For example: https://coco.infini.cloud/"
placeholder={t("cloud.connect.serverPlaceholder")}
onChange={(e) => setEndpointLink(e.target.value)}
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={()=>onAddServerClick(endpointLink)}
onClick={() => onAddServerClick(endpointLink)}
>
{refreshLoading ? "Connecting..." : "Connect"}
{refreshLoading
? t("cloud.connect.connecting")
: t("cloud.connect.connect")}
</button>
</div>
</div>
@@ -95,30 +99,28 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
{/*//TODO move to outer container, move error state to global*/}
{errorMessage && (
<div className="mb-8">
<div
className="mb-8"
style={{
color: "red",
marginTop: "10px",
display: "block", // Makes sure the error message starts on a new line
marginBottom: "10px",
}}
>
<div style={{
color: 'red',
marginTop: '10px',
display: 'block', // Makes sure the error message starts on a new line
marginBottom: '10px',
}}>
<span>{errorMessage}</span>
<button
onClick={closeError}
style={{
background: 'none',
border: 'none',
color: 'red',
cursor: 'pointer'
}}
>
</button>
</div>
<span>{errorMessage}</span>
<button
onClick={closeError}
style={{
background: "none",
border: "none",
color: "red",
cursor: "pointer",
}}
></button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,80 +1,79 @@
import {useEffect, useState} from "react";
import {RefreshCcw} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { RefreshCcw } from "lucide-react";
import {DataSourceItem} from "./DataSourceItem";
import {useConnectStore} from "@/stores/connectStore";
import {useAppStore} from "@/stores/appStore";
import {invoke} from "@tauri-apps/api/core";
import { DataSourceItem } from "./DataSourceItem";
import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
import { invoke } from "@tauri-apps/api/core";
export function DataSourcesList({server}: { server: string }) {
const datasourceData = useConnectStore((state) => state.datasourceData);
const setError = useAppStore((state) => state.setError);
const [refreshLoading, setRefreshLoading] = useState(false);
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
const setConnectorData = useConnectStore((state) => state.setConnectorData);
export function DataSourcesList({ server }: { server: string }) {
const { t } = useTranslation();
const datasourceData = useConnectStore((state) => state.datasourceData);
const setError = useAppStore((state) => state.setError);
const [refreshLoading, setRefreshLoading] = useState(false);
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
const setConnectorData = useConnectStore((state) => state.setConnectorData);
function initServerAppData({server}: { server: string }) {
//fetch datasource data
invoke("get_connectors_by_server", {id: server})
.then((res: any) => {
console.log("get_connectors_by_server", res);
setConnectorData(res, server);
})
.catch((err: any) => {
setError(err);
throw err; // Propagate error back up
})
.finally(() => {
function initServerAppData({ server }: { server: string }) {
//fetch datasource data
invoke("get_connectors_by_server", { id: server })
.then((res: any) => {
console.log("get_connectors_by_server", res);
setConnectorData(res, server);
})
.catch((err: any) => {
setError(err);
throw err; // Propagate error back up
})
.finally(() => {});
});
//fetch datasource data
invoke("get_datasources_by_server", { id: server })
.then((res: any) => {
console.log("get_datasources_by_server", res);
setDatasourceData(res, server);
})
.catch((err: any) => {
setError(err);
throw err; // Propagate error back up
})
.finally(() => {});
}
//fetch datasource data
invoke("get_datasources_by_server", {id: server})
.then((res: any) => {
console.log("get_datasources_by_server", res);
setDatasourceData(res, server);
})
.catch((err: any) => {
setError(err);
throw err; // Propagate error back up
})
.finally(() => {
});
async function getDatasourceData() {
setRefreshLoading(true);
try {
initServerAppData({ server });
} catch (e) {
setError(e);
} finally {
setRefreshLoading(false);
}
}
async function getDatasourceData() {
setRefreshLoading(true);
try {
initServerAppData({server});
} catch (e) {
setError(e);
} finally {
setRefreshLoading(false);
}
}
useEffect(() => {
getDatasourceData();
}, []);
useEffect(() => {
getDatasourceData()
}, [])
return (
<div className="space-y-4">
<h2 className="flex justify-between text-xl font-medium text-gray-900 dark:text-white">
Data Source
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => getDatasourceData()}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
/>
</button>
</h2>
<div className="space-y-4">
{datasourceData[server]?.map((source) => (
<DataSourceItem key={source.id} {...source} />
))}
</div>
</div>
);
return (
<div className="space-y-4">
<h2 className="flex justify-between text-xl font-medium text-gray-900 dark:text-white">
{t("cloud.dataSource.title")}
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => getDatasourceData()}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
/>
</button>
</h2>
<div className="space-y-4">
{datasourceData[server]?.map((source) => (
<DataSourceItem key={source.id} {...source} />
))}
</div>
</div>
);
}

View File

@@ -1,13 +0,0 @@
export function Divider() {
return (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 text-gray-400 bg-gray-800">or continue with</span>
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { forwardRef } from "react";
import { Plus } from "lucide-react";
@@ -9,75 +10,79 @@ interface SidebarProps {
serverList: any[];
}
export const Sidebar = forwardRef<{ refreshData: () => void; }, SidebarProps>(
({ onAddServer, serverList }, _ref) => {
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
({ onAddServer, serverList }, _ref) => {
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore(
(state) => state.setCurrentService
);
const onAddServerClick = () => {
onAddServer();
};
// Extracted server item rendering
const renderServerItem = (item: any) => {
return (
<div
key={item?.id}
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 ${
currentService?.id === item?.id ? "dark:bg-blue-900/20 dark:bg-blue-900" // Apply background color when selected
: "bg-gray-50 dark:bg-gray-900" // Default background color when not selected
}`}
onClick={() => setCurrentService(item)}
>
<img
src={item?.provider?.icon || cocoLogoImg}
alt="LogoImg"
className="w-5 h-5"
/>
<span className="font-medium">{item?.name}</span>
<div className="flex-1" />
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
{item?.available ? (
<div className="w-3 h-3 rounded-full bg-[#00DB5E]" />
) : (
<div className="w-3 h-3 rounded-full bg-[#FF4747]" />
)}
</button>
</div>
);
};
const onAddServerClick = () => {
onAddServer();
};
// Extracted server item rendering
const renderServerItem = (item: any) => {
return (
<div className="w-64 min-h-[550px] border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="p-4 py-8">
{/* Render Built-in Servers */}
<div>
{serverList
.filter((item) => item?.builtin)
.map((item) => renderServerItem(item))}
</div>
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Your Coco-Servers
</div>
{/* Render Non-Built-in Servers */}
<div>
{serverList
.filter((item) => !item?.builtin)
.map((item) => renderServerItem(item))}
</div>
<div className="space-y-2">
<button
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
onClick={onAddServerClick}
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
</div>
<div
key={item?.id}
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 ${
currentService?.id === item?.id
? "dark:bg-blue-900/20 dark:bg-blue-900"
: "bg-gray-50 dark:bg-gray-900"
}`}
onClick={() => setCurrentService(item)}
>
<img
src={item?.provider?.icon || cocoLogoImg}
alt="LogoImg"
className="w-5 h-5"
/>
<span className="font-medium">{item?.name}</span>
<div className="flex-1" />
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
{item?.available ? (
<div className="w-3 h-3 rounded-full bg-[#00DB5E]" />
) : (
<div className="w-3 h-3 rounded-full bg-[#FF4747]" />
)}
</button>
</div>
);
}
);
};
return (
<div className="w-64 min-h-[550px] border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="p-4 py-8">
{/* Render Built-in Servers */}
<div>
{serverList
.filter((item) => item?.builtin)
.map((item) => renderServerItem(item))}
</div>
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
{t("cloud.sidebar.yourServers")}
</div>
{/* Render Non-Built-in Servers */}
<div>
{serverList
.filter((item) => !item?.builtin)
.map((item) => renderServerItem(item))}
</div>
<div className="space-y-2">
<button
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
onClick={onAddServerClick}
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}
);

View File

@@ -1,19 +0,0 @@
import React from 'react';
interface SocialButtonProps {
icon: React.ReactNode;
provider: string;
onClick: () => void;
}
export function SocialButton({ icon, provider, onClick }: SocialButtonProps) {
return (
<button
onClick={onClick}
className="w-full flex items-center justify-center gap-3 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
{icon}
<span>Continue with {provider}</span>
</button>
);
}

View File

@@ -18,10 +18,10 @@ interface UserProfileProps {
onLogout: (server: string) => void;
}
export function UserProfile({ server,userInfo,onLogout }: UserProfileProps) {
export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
const handleLogout = () => {
onLogout(server);
console.log("Logout",server);
console.log("Logout", server);
};
return (

View File

@@ -1,55 +0,0 @@
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { ChevronDown, Globe2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface Language {
code: string;
name: string;
flag: string;
keyboard: string;
}
const languages: Language[] = [
{ code: "en", name: "English", flag: "🇺🇸", keyboard: "E" },
{ code: "zh", name: "中文", flag: "🇨🇳", keyboard: "Z" },
];
export default function LangToggle() {
const { i18n } = useTranslation();
const [currentLng, setCurrentLng] = useState(languages[0]);
const changeLanguage = (lng: Language) => {
setCurrentLng(lng);
i18n.changeLanguage(lng.code);
};
return (
<Menu>
<MenuButton className="inline-flex items-center gap-2 rounded-md py-1.5 px-3 text-sm/6 font-semibold dark:text-white shadow-inner dark:shadow-white/10 focus:outline-none dark:data-[hover]:bg-gray-700 dark:data-[open]:bg-gray-700 data-[focus]:outline-1 dark:data-[focus]:outline-white">
<Globe2 className="h-4 w-4 text-gray-600" />
<span className="text-base">{currentLng.flag}</span>
<span>{currentLng.name}</span>
<ChevronDown className="size-4 dark:fill-white/60" />
</MenuButton>
<MenuItems
anchor="bottom end"
className="w-[160px] origin-top-right rounded-xl border dark:border-white/5 dark:bg-white/5 p-1 text-sm/6 dark:text-white transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
>
{languages.map((language) => (
<MenuItem key={language.code}>
<button
className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 dark:data-[focus]:bg-white/10"
onClick={() => changeLanguage(language)}
>
<span className="mr-1 text-base">{language.flag}</span>
<span>{language.name}</span>
<kbd className="ml-auto hidden font-sans text-xs dark:text-white/50 group-data-[focus]:inline">
{language.keyboard}
</kbd>
</button>
</MenuItem>
))}
</MenuItems>
</Menu>
);
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
import { useTranslation } from "react-i18next";
interface AutoResizeTextareaProps {
input: string;
@@ -12,6 +13,7 @@ const AutoResizeTextarea = forwardRef<
{ reset: () => void; focus: () => void },
AutoResizeTextareaProps
>(({ input, setInput, handleKeyDown, connected }, ref) => {
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
@@ -47,8 +49,8 @@ const AutoResizeTextarea = forwardRef<
autoCapitalize="none"
spellCheck="false"
className="text-base flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder={connected ? "Ask whatever you want ..." : ""}
aria-label="Ask whatever you want ..."
placeholder={connected ? t('search.textarea.placeholder') : ""}
aria-label={t('search.textarea.ariaLabel')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => handleKeyDown?.(e)}

View File

@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { formatter } from "@/utils/index";
import TypeIcon from "@/components/Common/Icons/TypeIcon";
@@ -8,10 +9,12 @@ interface DocumentDetailProps {
}
export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
const { t } = useTranslation();
return (
<div className="p-4">
<div className="font-normal text-xs text-[#666] dark:text-[#999] mb-2">
Details
{t('search.document.details')}
</div>
{/* <div className="mb-4">
@@ -30,65 +33,64 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
<div className="py-4 mt-4">
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Name</div>
<div className="text-[#666]">{t('search.document.name')}</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-60 break-words">
{document?.title || "-"}
</div>
</div>
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Source</div>
<div className="text-[#666]">{t('search.document.source')}</div>
<div className="text-[#333] dark:text-[#D8D8D8] flex justify-end text-right w-56 break-words">
<TypeIcon item={document} className="w-4 h-4 mr-1" />
{document?.source?.name || "-"}
</div>
</div>
{/* <div className="flex justify-between font-normal text-xs mb-2.5">
<div className="text-[#666]">Where</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
-
</div>
</div> */}
{document?.updated ? (
{document?.updated && (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Updated at</div>
<div className="text-[#666]">{t('search.document.updatedAt')}</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{document?.updated || "-"}
</div>
</div>
) : null}
{document?.last_updated_by?.user?.username ? (
)}
{document?.last_updated_by?.user?.username && (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Update by</div>
<div className="text-[#666]">{t('search.document.updatedBy')}</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{document?.last_updated_by?.user?.username || "-"}
</div>
</div>
) : null}
{document?.owner?.username ? (
)}
{document?.owner?.username && (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Created by</div>
<div className="text-[#666]">{t('search.document.createdBy')}</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{document?.owner?.username || "-"}
</div>
</div>
) : null}
{document?.type ? (
)}
{document?.type && (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Type</div>
<div className="text-[#666]">{t('search.document.type')}</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{document?.type || "-"}
</div>
</div>
) : null}
{document?.size ? (
)}
{document?.size && (
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
<div className="text-[#666]">Size</div>
<div className="text-[#666]">{t('search.document.size')}</div>
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
{formatter.bytes(document?.size || 0)}
</div>
</div>
) : null}
)}
</div>
</div>
);

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
import { useInfiniteScroll } from "ahooks";
import { isTauri, invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
import { useTranslation } from "react-i18next";
import { useSearchStore } from "@/stores/searchStore";
import { SearchHeader } from "./SearchHeader";
@@ -28,6 +29,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
viewMode,
setViewMode,
}) => {
const { t } = useTranslation();
const sourceData = useSearchStore((state) => state.sourceData);
const [selectedItem, setSelectedItem] = useState<number | null>(null);
@@ -200,11 +202,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
}, [selectedItem]);
return (
<div
className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full ${
viewMode === "list" ? "w-[100%]" : "w-[50%]"
}`}
>
<div className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full ${
viewMode === "list" ? "w-[100%]" : "w-[50%]"
}`}>
<div className="px-2 flex-shrink-0">
<SearchHeader
total={total}
@@ -213,10 +213,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
/>
</div>
<div
ref={containerRef}
className="flex-1 overflow-y-auto custom-scrollbar"
>
<div ref={containerRef} className="flex-1 overflow-y-auto custom-scrollbar">
{data?.list.map((hit: any, index: number) => {
const isSelected = selectedItem === index;
const item = hit.document;
@@ -246,7 +243,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
{loading && (
<div className="flex justify-center py-4">
<span>Loading...</span>
<span>{t('search.list.loading')}</span>
</div>
)}
@@ -255,9 +252,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
data-tauri-drag-region
className="h-full w-full flex flex-col items-center"
>
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
<img
src={noDataImg}
alt={t('search.list.noDataAlt')}
className="w-16 h-16 mt-24"
/>
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
No Results
{t('search.list.noResults')}
</div>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { ArrowDown01, Command, CornerDownLeft } from "lucide-react";
import { emit } from "@tauri-apps/api/event";
import { useTranslation } from "react-i18next";
import logoImg from "@/assets/icon.svg";
import { useSearchStore } from "@/stores/searchStore";
@@ -12,6 +13,7 @@ interface FooterProps {
}
export default function Footer({}: FooterProps) {
const { t } = useTranslation();
const sourceData = useSearchStore((state) => state.sourceData);
function openSetting() {
@@ -32,23 +34,18 @@ export default function Footer({}: FooterProps) {
src={logoImg}
className="w-4 h-4 cursor-pointer"
onClick={openSetting}
alt={t('search.footer.logoAlt')}
/>
)}
<span className="text-xs text-gray-500 dark:text-gray-400">
{sourceData?.source?.name || "v1.0.0"}
{sourceData?.source?.name || t('search.footer.version', { version: 'v1.0.0' })}
</span>
</div>
{/* {name ? (
<div className="flex gap-2 items-center text-[#666] text-xs">
<AppWindowMac className="w-5 h-5" /> {name}
</div>
) : null} */}
</div>
<div className="flex items-center gap-3">
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-xs">
<span className="mr-1.5 ">Select:</span>
<span className="mr-1.5">{t('search.footer.select')}:</span>
<kbd className="coco-modal-footer-commands-key pr-1">
{isMac ? (
<Command className="w-3 h-3" />
@@ -64,7 +61,7 @@ export default function Footer({}: FooterProps) {
</kbd>
</div>
<div className="flex items-center text-[#666] dark:text-[#666] text-xs">
<span className="mr-1.5 ">Open: </span>
<span className="mr-1.5">{t('search.footer.open')}: </span>
<kbd className="coco-modal-footer-commands-key pr-1">
<CornerDownLeft className="w-3 h-3" />
</kbd>

View File

@@ -2,6 +2,7 @@ import { ArrowBigLeft, Search, Send, Globe, Brain } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import ChatSwitch from "@/components/Common/ChatSwitch";
import AutoResizeTextarea from "./AutoResizeTextarea";
@@ -40,6 +41,9 @@ export default function ChatInput({
isDeepThinkActive,
setIsDeepThinkActive,
}: ChatInputProps) {
const { t } = useTranslation();
const showTooltip = useAppStore(
(state: { showTooltip: boolean }) => state.showTooltip
);
@@ -259,7 +263,7 @@ export default function ChatInput({
autoCapitalize="none"
spellCheck="false"
className="text-base 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 ..."
placeholder={t('search.input.searchPlaceholder')}
value={inputValue}
onChange={(e) => {
onSend(e.target.value);
@@ -347,12 +351,12 @@ export default function ChatInput({
{!connected && isChatMode ? (
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-red-500/10 rounded-md font-normal text-xs text-gray-400 flex items-center gap-4">
Unable to connect to the server
{t('search.input.connectionError')}
<div
className="w-[96px] h-[24px] bg-[#0061FF] rounded-[12px] font-normal text-xs text-white flex items-center justify-center cursor-pointer"
onClick={ReconnectClick}
>
Reconnect ({countdown})
{t('search.input.reconnect')} ({countdown})
</div>
</div>
) : null}
@@ -376,7 +380,7 @@ export default function ChatInput({
}`}
/>
<span className={isDeepThinkActive ? "text-[#0072FF]" : ""}>
Deep Think
{t('search.input.deepThink')}
</span>
</button>
<button
@@ -391,7 +395,7 @@ export default function ChatInput({
}`}
/>
<span className={isSearchActive ? "text-[#0072FF]" : ""}>
Search
{t('search.input.search')}
</span>
</button>
{/*<button*/}

View File

@@ -2,110 +2,124 @@ import TypeIcon from "@/components/Common/Icons/TypeIcon";
import RichIcon from "@/components/Common/Icons/RichIcon";
interface ListRightProps {
item: any;
isSelected: boolean;
showIndex: boolean;
currentIndex: number;
goToTwoPage: (item: any) => void;
item: any;
isSelected: boolean;
showIndex: boolean;
currentIndex: number;
goToTwoPage: (item: any) => void;
}
export default function ListRight({
item,
isSelected,
showIndex,
currentIndex,
goToTwoPage
item,
isSelected,
showIndex,
currentIndex,
goToTwoPage,
}: ListRightProps) {
return (
<div className="flex-1 text-right min-w-[160px] h-full pl-5 text-[12px] flex gap-2 items-center justify-end relative">
{item?.rich_categories ? null : (
<div
className="w-4 h-4 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
goToTwoPage(item);
}}
>
<TypeIcon
item={item}
className="w-4 h-4 cursor-pointer"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
goToTwoPage(item);
}}
/>
</div>
)}
{item?.rich_categories ? (
<div className="flex items-center justify-end max-w-[calc(100%-20px)] whitespace-nowrap">
<RichIcon
item={item}
className="w-4 h-4 mr-2 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
goToTwoPage(item);
}}
/>
<span
className={`${isSelected ? "text-[#C8C8C8]" : "text-[#666]"} text-right truncate`}
>
{item?.rich_categories?.map(
(rich_item: any, index: number) => {
if (
item?.rich_categories.length > 2 &&
index === item?.rich_categories.length - 1
)
return "";
return (index !== 0 ? "/" : "") + rich_item?.label;
}
)}
</span>
{item?.rich_categories.length > 2 ? (
<span className={`${isSelected ? "text-[#C8C8C8]" : "text-[#666]"} text-right truncate`}>
{"/" + item?.rich_categories?.at(-1)?.label}
</span>
) : null}
</div>
) : item?.category || item?.subcategory ? (
<span
className={`text-[12px] truncate ${isSelected ? "text-[#DCDCDC]" : "text-[#999] dark:text-[#666]"
}`}
>
{(item?.category || "") + (item?.subcategory ? `/${item?.subcategory}` : "")}
</span>
) : (
<span
className={`text-[12px] truncate ${isSelected ? "text-[#DCDCDC]" : "text-[#999] dark:text-[#666]"
}`}
>
{item?.last_updated_by?.user?.username || item?.owner?.username || item?.updated || item?.created || item?.type || ""}
</span>
)}
{isSelected ? (
<div
className={`absolute ${showIndex && currentIndex < 10 ? "right-7" : "right-0"
} w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${isSelected
? "shadow-[-6px_0px_6px_2px_#950599]"
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
}`}
>
</div>
) : null}
{showIndex && currentIndex < 10 ? (
<div
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] rounded-md ${isSelected
? "shadow-[-6px_0px_6px_2px_#950599]"
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
}`}
>
{currentIndex}
</div>
) : null}
return (
<div className="flex-1 text-right min-w-[160px] h-full pl-5 text-[12px] flex gap-2 items-center justify-end relative">
{item?.rich_categories ? null : (
<div
className="w-4 h-4 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
goToTwoPage(item);
}}
>
<TypeIcon
item={item}
className="w-4 h-4 cursor-pointer"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
goToTwoPage(item);
}}
/>
</div>
);
}
)}
{item?.rich_categories ? (
<div className="flex items-center justify-end max-w-[calc(100%-20px)] whitespace-nowrap">
<RichIcon
item={item}
className="w-4 h-4 mr-2 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
goToTwoPage(item);
}}
/>
<span
className={`${
isSelected ? "text-[#C8C8C8]" : "text-[#666]"
} text-right truncate`}
>
{item?.rich_categories?.map((rich_item: any, index: number) => {
if (
item?.rich_categories.length > 2 &&
index === item?.rich_categories.length - 1
)
return "";
return (index !== 0 ? "/" : "") + rich_item?.label;
})}
</span>
{item?.rich_categories.length > 2 ? (
<span
className={`${
isSelected ? "text-[#C8C8C8]" : "text-[#666]"
} text-right truncate`}
>
{"/" + item?.rich_categories?.at(-1)?.label}
</span>
) : null}
</div>
) : item?.category || item?.subcategory ? (
<span
className={`text-[12px] truncate ${
isSelected ? "text-[#DCDCDC]" : "text-[#999] dark:text-[#666]"
}`}
>
{(item?.category || "") +
(item?.subcategory ? `/${item?.subcategory}` : "")}
</span>
) : (
<span
className={`text-[12px] truncate ${
isSelected ? "text-[#DCDCDC]" : "text-[#999] dark:text-[#666]"
}`}
>
{item?.last_updated_by?.user?.username ||
item?.owner?.username ||
item?.updated ||
item?.created ||
item?.type ||
""}
</span>
)}
{isSelected ? (
<div
className={`absolute ${
showIndex && currentIndex < 10 ? "right-7" : "right-0"
} w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${
isSelected
? "shadow-[-6px_0px_6px_2px_#950599]"
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
}`}
>
</div>
) : null}
{showIndex && currentIndex < 10 ? (
<div
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] rounded-md ${
isSelected
? "shadow-[-6px_0px_6px_2px_#950599]"
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
}`}
>
{currentIndex}
</div>
) : null}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { Command } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
// import { isTauri } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import DropdownList from "./DropdownList";
import Footer from "./Footer";
@@ -18,6 +19,7 @@ interface SearchProps {
}
function Search({ isChatMode, input }: SearchProps) {
const { t } = useTranslation();
const sourceData = useSearchStore((state) => state.sourceData);
const [IsError, setIsError] = useState<boolean>(false);
@@ -149,10 +151,10 @@ function Search({ isChatMode, input }: SearchProps) {
>
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
No Results
{t('search.main.noResults')}
</div>
<div className="mt-10 text-sm text-[#333] dark:text-[#D8D8D8] flex">
Ask Coco AI
{t('search.main.askCoco')}
{isMac ? (
<span className="ml-3 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
<Command className="w-3 h-3" />

View File

@@ -1,5 +1,6 @@
import React from "react";
import { AlignLeft, Columns2 } from "lucide-react";
import { useTranslation } from "react-i18next";
interface SearchHeaderProps {
total: number;
@@ -12,14 +13,16 @@ export const SearchHeader: React.FC<SearchHeaderProps> = ({
viewMode,
setViewMode,
}) => {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between py-1">
<div className="text-xs text-gray-600 dark:text-gray-400">
Found
{t('search.header.found')}
<span className="px-1 font-medium text-gray-900 dark:text-gray-100">
{total}
</span>
results
{t('search.header.results')}
</div>
<div className="flex gap-2">
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">

View File

@@ -1,35 +1,47 @@
import { Globe, Github } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@/contexts/ThemeContext";
import { OpenURLWithBrowser } from "@/utils";
import logoLight from "@/assets/images/logo-text-light.svg";
import logoDark from "@/assets/images/logo-text-dark.svg";
export default function AboutView(){
export default function AboutView() {
const { t } = useTranslation();
const { theme } = useTheme();
const logo = theme === 'dark' ? logoDark : logoLight
const logo = theme === "dark" ? logoDark : logoLight;
return (
<div className="flex justify-center items-center flex-col h-[calc(100vh-170px)]">
<div>
<img src={logo} className="w-48 dark:text-white"/>
<img src={logo} className="w-48 dark:text-white" alt={t('settings.about.logo')} />
</div>
<div className="mt-8 font-medium text-gray-900 dark:text-gray-100">
Search, Connect, CollaborateAll in one place
{t('settings.about.slogan')}
</div>
<div className="flex justify-center items-center mt-10">
<button onClick={() => OpenURLWithBrowser('https://coco.rs')} className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"><Globe className="w-3 text-blue-500"/></button>
<button onClick={() => OpenURLWithBrowser('https://github.com/infinilabs/coco-app')} className="w-6 h-6 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700" ><Github className="w-3 text-blue-500"/></button>
<button
onClick={() => OpenURLWithBrowser("https://coco.rs")}
className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
aria-label={t('settings.about.website')}
>
<Globe className="w-3 text-blue-500" />
</button>
<button
onClick={() => OpenURLWithBrowser("https://github.com/infinilabs/coco-app")}
className="w-6 h-6 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"
aria-label={t('settings.about.github')}
>
<Github className="w-3 text-blue-500" />
</button>
</div>
<div className="mt-8 text-sm text-gray-500 dark:text-gray-400">
Version 1.0.0
{t('settings.about.version', { version: '1.0.0' })}
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
©{new Date().getFullYear()} INFINI Labs, All Rights Reserved.
{t('settings.about.copyright', { year: new Date().getFullYear() })}
</div>
</div>
)
}
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import SettingsItem from "./SettingsItem";
import { useAppStore } from "@/stores/appStore";
@@ -12,6 +13,7 @@ const ENDPOINTS = [
];
export default function AdvancedSettings() {
const { t } = useTranslation();
const endpoint = useAppStore(state => state.endpoint);
const setEndpoint = useAppStore(state => state.setEndpoint);
@@ -25,20 +27,18 @@ export default function AdvancedSettings() {
<div className="space-y-8">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Advanced Settings
{t('settings.advanced.title')}
</h2>
<div className="space-y-6">
<SettingsItem
icon={Globe}
title="API Endpoint"
description="Domain name for interface and websocket"
title={t('settings.advanced.endpoint.title')}
description={t('settings.advanced.endpoint.description')}
>
<div className={`p-4 rounded-lg`}>
<select
value={endpoint}
onChange={(e) =>
onChangeEndpoint(e.target.value as AppEndpoint)
}
onChange={(e) => onChangeEndpoint(e.target.value as AppEndpoint)}
className={`w-full px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white border-gray-300 text-gray-900 dark:bg-gray-800 dark:border-gray-600 dark:text-white`}
>
{ENDPOINTS.map(({ value, label }) => (

View File

@@ -8,12 +8,15 @@ import {
Power,
Tags,
// Trash2,
Globe,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { isTauri, invoke } from "@tauri-apps/api/core";
import {
isEnabled,
// enable, disable
} from "@tauri-apps/plugin-autostart";
import { emit } from '@tauri-apps/api/event';
import SettingsItem from "./SettingsItem";
import SettingsToggle from "./SettingsToggle";
@@ -34,6 +37,7 @@ export function ThemeOption({
theme: AppTheme;
}) {
const { theme: currentTheme, changeTheme } = useTheme();
const { t } = useTranslation();
const isSelected = currentTheme === theme;
@@ -45,41 +49,36 @@ export function ThemeOption({
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
} flex flex-col items-center justify-center space-y-2 transition-all`}
title={title}
>
<Icon className={`w-6 h-6 ${isSelected ? "text-blue-500" : ""}`} />
<span
className={`text-sm font-medium ${isSelected ? "text-blue-500" : ""}`}
>
{title}
<span className={`text-sm font-medium ${isSelected ? "text-blue-500" : ""}`}>
{t(`settings.appearance.${theme}`)}
</span>
</button>
);
}
export default function GeneralSettings() {
const { t, i18n } = useTranslation();
const [launchAtLogin, setLaunchAtLogin] = useState(true);
const showTooltip = useAppStore((state) => state.showTooltip);
const setShowTooltip = useAppStore((state) => state.setShowTooltip);
const language = useAppStore((state) => state.language);
const setLanguage = useAppStore((state) => state.setLanguage);
// const setAuth = useAuthStore((state) => state.setAuth);
// const setUserInfo = useAuthStore((state) => state.setUserInfo);
// const endpoint = useAppStore((state) => state.endpoint);
useEffect(() => {
const fetchAutoStartStatus = async () => {
if (isTauri()) {
try {
const status = await isEnabled();
setLaunchAtLogin(status);
} catch (error) {
console.error("Failed to fetch autostart status:", error);
}
const fetchAutoStartStatus = async () => {
if (isTauri()) {
try {
const status = await isEnabled();
setLaunchAtLogin(status);
} catch (error) {
console.error("Failed to fetch autostart status:", error);
}
};
fetchAutoStartStatus();
}, []);
}
};
const enableAutoStart = async () => {
if (isTauri()) {
@@ -118,7 +117,11 @@ export default function GeneralSettings() {
}
useEffect(() => {
fetchAutoStartStatus();
getCurrentShortcut();
if (language) {
i18n.changeLanguage(language);
}
}, []);
const changeShortcut = (key: Shortcut) => {
@@ -162,31 +165,42 @@ export default function GeneralSettings() {
// useAppStore.persist.clearStorage();
// }, [endpoint]);
const currentLanguage = language || i18n.language;
const changeLanguage = async (lang: string) => {
i18n.changeLanguage(lang);
setLanguage(lang);
//
try {
await emit('language-changed', { language: lang });
} catch (error) {
console.error('Failed to emit language change event:', error);
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
General Settings
{t('settings.general')}
</h2>
<div className="space-y-6">
<SettingsItem
icon={Power}
title="Startup"
description="Automatically start Coco when you login"
title={t('settings.startup.title')}
description={t('settings.startup.description')}
>
<SettingsToggle
checked={launchAtLogin}
onChange={(value) =>
value ? enableAutoStart() : disableAutoStart()
}
label="Launch at login"
onChange={(value) => value ? enableAutoStart() : disableAutoStart()}
label={t('settings.startup.toggle')}
/>
</SettingsItem>
<SettingsItem
icon={Command}
title="Coco Hotkey"
description="Global shortcut to open Coco"
title={t('settings.hotkey.title')}
description={t('settings.hotkey.description')}
>
<div className="flex items-center gap-2">
<ShortcutItem
@@ -200,75 +214,47 @@ export default function GeneralSettings() {
</div>
</SettingsItem>
{/* <SettingsItem
icon={Monitor}
title="Window Mode"
description="Choose how Coco appears on your screen"
>
<SettingsSelect
options={["Standard Window", "Compact Mode", "Full Screen"]}
/>
</SettingsItem> */}
<SettingsItem
icon={Palette}
title="Appearance"
description="Choose your preferred theme"
title={t('settings.appearance.title')}
description={t('settings.appearance.description')}
>
<div></div>
</SettingsItem>
<div className="grid grid-cols-3 gap-4">
<ThemeOption icon={Sun} title="Light" theme="light" />
<ThemeOption icon={Moon} title="Dark" theme="dark" />
<ThemeOption icon={Monitor} title="Auto" theme="auto" />
<ThemeOption icon={Sun} title={t('settings.appearance.light')} theme="light" />
<ThemeOption icon={Moon} title={t('settings.appearance.dark')} theme="dark" />
<ThemeOption icon={Monitor} title={t('settings.appearance.auto')} theme="auto" />
</div>
<SettingsItem
icon={Globe}
title={t('settings.language.title')}
description={t('settings.language.description')}
>
<div className="flex items-center gap-2">
<select
value={currentLanguage}
onChange={(e) => changeLanguage(e.target.value)}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="en">{t('settings.language.english')}</option>
<option value="zh">{t('settings.language.chinese')}</option>
</select>
</div>
</SettingsItem>
<SettingsItem
icon={Tags}
title="Tooltip"
description="Tooltip display for shortcut keys"
title={t('settings.tooltip.title')}
description={t('settings.tooltip.description')}
>
<SettingsToggle
checked={showTooltip}
onChange={(value) => setShowTooltip(value)}
label="Tooltip display"
label={t('settings.tooltip.toggle')}
/>
</SettingsItem>
{/* <SettingsItem
icon={Layout}
title="Text Size"
description="Adjust the application text size"
>
<SettingsSelect options={["Small", "Medium", "Large"]} />
</SettingsItem> */}
{/* <SettingsItem
icon={Star}
title="Favorites"
description="Manage your favorite commands"
>
<button className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors duration-200">
Manage Favorites
</button>
</SettingsItem> */}
{/* <SettingsItem
icon={Trash2}
title="Clear Cache"
description="Clear cached data and settings"
>
<div className="space-y-2">
<div className="flex gap-2">
<button
onClick={clearAllCache}
className=" px-4 py-2 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 border border-red-200 dark:border-red-800 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
Clear All Cache
</button>
</div>
</div>
</SettingsItem> */}
</div>
</div>
</div>

View File

@@ -1,5 +1,8 @@
import { formatKey, sortKeys } from "@/utils/keyboardUtils";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatKey, sortKeys } from "@/utils/keyboardUtils";
interface ShortcutItemProps {
shortcut: string[];
isEditing: boolean;
@@ -17,6 +20,8 @@ export function ShortcutItem({
onSave,
onCancel,
}: ShortcutItemProps) {
const { t } = useTranslation();
const renderKeys = (keys: string[]) => {
const sortedKeys = sortKeys(keys);
return sortedKeys.map((key, index) => (
@@ -30,9 +35,7 @@ export function ShortcutItem({
};
return (
<div
className={`flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-gray-700`}
>
<div className={`flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-gray-700`}>
<div className="flex items-center gap-4">
{isEditing ? (
<>
@@ -41,7 +44,7 @@ export function ShortcutItem({
renderKeys(currentKeys)
) : (
<span className={`italic text-gray-500 dark:text-gray-400`}>
Press keys...
{t('settings.shortcut.pressKeys')}
</span>
)}
</div>
@@ -52,7 +55,7 @@ export function ShortcutItem({
className={`px-3 py-1 text-sm rounded bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700
disabled:opacity-50 disabled:cursor-not-allowed`}
>
Save
{t('settings.shortcut.save')}
</button>
<button
onClick={onCancel}
@@ -69,7 +72,7 @@ export function ShortcutItem({
onClick={onEdit}
className={`px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500`}
>
Edit
{t('settings.shortcut.edit')}
</button>
</>
)}

View File

@@ -1,23 +1,27 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from "./locales/en/translation.json";
import zhTranslation from "./locales/zh/translation.json";
i18n.use(initReactI18next).init({
resources: {
en: {
translation: enTranslation,
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: {
translation: enTranslation,
},
zh: {
translation: zhTranslation,
},
},
zh: {
translation: zhTranslation,
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
},
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
});
export default i18n;

View File

@@ -1,7 +1,165 @@
{
"welcome": "Welcome to Coco App",
"home": "Home",
"settings": "Settings",
"activeTheme": "Current theme:",
"InputMessage": "Input your message here..."
}
"settings": {
"general": "General Settings",
"startup": {
"title": "Startup",
"description": "Automatically start Coco when you login",
"toggle": "Launch at login"
},
"hotkey": {
"title": "Coco Hotkey",
"description": "Global shortcut to open Coco"
},
"appearance": {
"title": "Appearance",
"description": "Choose your preferred theme",
"light": "Light",
"dark": "Dark",
"auto": "Auto"
},
"language": {
"title": "Language",
"description": "Choose your preferred language",
"english": "English",
"chinese": "中文"
},
"tooltip": {
"title": "Tooltip",
"description": "Tooltip display for shortcut keys",
"toggle": "Tooltip display"
},
"shortcut": {
"pressKeys": "Press keys...",
"save": "Save",
"edit": "Edit"
},
"about": {
"logo": "Coco Logo",
"slogan": "Search, Connect, Collaborate—All in one place",
"website": "Visit Website",
"github": "Visit GitHub",
"version": "Version {{version}}",
"copyright": "©{{year}} INFINI Labs, All Rights Reserved."
},
"advanced": {
"title": "Advanced Settings",
"endpoint": {
"title": "API Endpoint",
"description": "Domain name for interface and websocket"
}
},
"tabs": {
"general": "General",
"extensions": "Extensions",
"connect": "Connect",
"advanced": "Advanced",
"about": "About",
"extensionsContent": "Extensions settings content",
"advancedContent": "Advanced Settings content"
}
},
"search": {
"textarea": {
"placeholder": "Ask whatever you want ...",
"ariaLabel": "Ask whatever you want"
},
"document": {
"details": "Details",
"name": "Name",
"source": "Source",
"updatedAt": "Updated at",
"updatedBy": "Updated by",
"createdBy": "Created by",
"type": "Type",
"size": "Size"
},
"list": {
"loading": "Loading...",
"noResults": "No Results",
"noDataAlt": "No data image"
},
"footer": {
"logoAlt": "Coco Logo",
"version": "{{version}}",
"select": "Select",
"open": "Open"
},
"input": {
"searchPlaceholder": "Search whatever you want ...",
"connectionError": "Unable to connect to the server",
"reconnect": "Reconnect",
"deepThink": "Deep Think",
"search": "Search"
},
"main": {
"noDataAlt": "No data image",
"noResults": "No Results",
"askCoco": "Ask Coco AI"
},
"header": {
"found": "Found",
"results": "results"
}
},
"assistant": {
"chat": {
"openChat": "Open Chat Window",
"newChat": "New Chat",
"connectionError": "Unable to connect to the server",
"reconnect": "Reconnect"
},
"input": {
"stopMessage": "Stop message",
"deepThink": "Deep Think",
"deepThinkTooltip": "Enable deep thinking mode",
"search": "Search",
"searchTooltip": "Enable search mode"
},
"message": {
"logo": "Coco AI Logo",
"aiName": "Coco AI",
"thinking": "AI is thinking...",
"thoughtTime": "Thought for {{time}} seconds",
"thinkingButton": "View thinking process"
},
"sidebar": {
"newChat": "New Chat",
"newChatTooltip": "Create a new chat",
"selectChat": "Select this chat",
"untitledChat": "Untitled Chat"
},
"source": {
"foundResults": "Found {{count}} results"
}
},
"cloud": {
"banner": "Banner Image",
"accountInfo": "Account Information",
"login": "Login",
"cancel": "Cancel",
"copyUrl": "Copy URL",
"privacyPolicy": "EULA | Privacy Policy",
"connect": {
"back": "Back",
"title": "Connecting to Your Coco-Server",
"description": "Running your own private instance of coco-server ensures complete control over your data, keeping it secure and accessible only within your environment. Enjoy enhanced privacy, better performance, and seamless integration with your internal systems.",
"serverAddress": "Server address",
"serverPlaceholder": "For example: https://coco.infini.cloud/",
"connecting": "Connecting...",
"connect": "Connect",
"closeError": "Close error message"
},
"dataSource": {
"title": "Data Source",
"refresh": "Refresh data source list"
},
"sidebar": {
"selectServer": "Select Server",
"serverLogo": "Server Logo",
"serverOnline": "Server Online",
"serverOffline": "Server Offline",
"yourServers": "Your Coco-Servers",
"addServer": "Add New Server"
}
}
}

View File

@@ -1,7 +1,165 @@
{
"welcome": "欢迎使用 Coco App",
"home": "主页",
"settings": "设置",
"activeTheme": "当前主题:",
"InputMessage": "在此输入您的消息..."
}
"settings": {
"general": "通用设置",
"startup": {
"title": "启动项",
"description": "登录时自动启动 Coco",
"toggle": "开机自启"
},
"hotkey": {
"title": "快捷键",
"description": "打开 Coco 的全局快捷键"
},
"appearance": {
"title": "外观",
"description": "选择您喜欢的主题",
"light": "浅色",
"dark": "深色",
"auto": "自动"
},
"language": {
"title": "语言",
"description": "选择您的首选语言",
"english": "English",
"chinese": "中文"
},
"tooltip": {
"title": "提示",
"description": "快捷键提示显示",
"toggle": "显示提示"
},
"shortcut": {
"pressKeys": "请按键...",
"save": "保存",
"edit": "编辑"
},
"about": {
"logo": "Coco 标志",
"slogan": "搜索、连接、协作 — 一站式解决方案",
"website": "访问官网",
"github": "访问 GitHub",
"version": "版本 {{version}}",
"copyright": "©{{year}} INFINI Labs保留所有权利。"
},
"advanced": {
"title": "高级设置",
"endpoint": {
"title": "API 接口",
"description": "接口和 WebSocket 的域名"
}
},
"tabs": {
"general": "通用",
"extensions": "扩展",
"connect": "连接",
"advanced": "高级",
"about": "关于",
"extensionsContent": "扩展设置内容",
"advancedContent": "高级设置内容"
}
},
"search": {
"textarea": {
"placeholder": "问我任何问题...",
"ariaLabel": "输入你想问的问题"
},
"document": {
"details": "详细信息",
"name": "名称",
"source": "来源",
"updatedAt": "更新时间",
"updatedBy": "更新者",
"createdBy": "创建者",
"type": "类型",
"size": "大小"
},
"list": {
"loading": "加载中...",
"noResults": "暂无结果",
"noDataAlt": "无数据图片"
},
"footer": {
"logoAlt": "Coco 图标",
"version": "{{version}}",
"select": "选择",
"open": "打开"
},
"input": {
"searchPlaceholder": "搜索任何内容...",
"connectionError": "无法连接到服务器",
"reconnect": "重新连接",
"deepThink": "深度思考",
"search": "搜索"
},
"main": {
"noDataAlt": "无数据图片",
"noResults": "暂无结果",
"askCoco": "询问 Coco AI"
},
"header": {
"found": "找到",
"results": "个结果"
}
},
"assistant": {
"chat": {
"openChat": "打开聊天窗口",
"newChat": "新建对话",
"connectionError": "无法连接到服务器",
"reconnect": "重新连接"
},
"input": {
"stopMessage": "停止生成",
"deepThink": "深度思考",
"deepThinkTooltip": "启用深度思考模式",
"search": "搜索",
"searchTooltip": "启用搜索模式"
},
"message": {
"logo": "Coco AI 图标",
"aiName": "Coco AI",
"thinking": "AI 正在思考...",
"thoughtTime": "思考了 {{time}} 秒",
"thinkingButton": "查看思考过程"
},
"sidebar": {
"newChat": "新建对话",
"newChatTooltip": "创建新的对话",
"selectChat": "选择此对话",
"untitledChat": "未命名对话"
},
"source": {
"foundResults": "找到 {{count}} 个结果"
}
},
"cloud": {
"banner": "横幅图片",
"accountInfo": "账户信息",
"login": "登录",
"cancel": "取消",
"copyUrl": "复制链接",
"privacyPolicy": "用户协议 | 隐私政策",
"connect": {
"back": "返回",
"title": "连接到您的 Coco-Server",
"description": "运行您自己的私有 coco-server 实例可以确保对数据的完全控制,使其在您的环境中保持安全且仅可访问。享受增强的隐私保护、更好的性能和与内部系统的无缝集成。",
"serverAddress": "服务器地址",
"serverPlaceholder": "例如https://coco.infini.cloud/",
"connecting": "连接中...",
"connect": "连接",
"closeError": "关闭错误提示"
},
"dataSource": {
"title": "数据源",
"refresh": "刷新数据源列表"
},
"sidebar": {
"selectServer": "选择服务器",
"serverLogo": "服务器图标",
"serverOnline": "服务器在线",
"serverOffline": "服务器离线",
"yourServers": "您的 Coco-Servers",
"addServer": "添加新服务器"
}
}
}

View File

@@ -4,6 +4,7 @@ import { RouterProvider } from "react-router-dom";
import { ThemeProvider } from "./contexts/ThemeContext";
import { router } from "./routes/index";
import './i18n';
import "./main.css";

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
import { Settings, Puzzle, Settings2, Info, Server } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsPanel from "@/components/Settings/SettingsPanel";
import GeneralSettings from "@/components/Settings/GeneralSettings";
@@ -11,6 +12,7 @@ import Footer from "@/components/Footer";
import ApiDetails from "@/components/Common/ApiDetails";
function SettingsPage() {
const { t } = useTranslation();
const [defaultIndex, setDefaultIndex] = useState<number>(0);
const [searchParams] = useSearchParams();
@@ -21,11 +23,11 @@ function SettingsPage() {
}, [name]);
const tabs = [
{ name: "General", icon: Settings },
{ name: "Extensions", icon: Puzzle },
{ name: "Connect", icon: Server },
{ name: "Advanced", icon: Settings2 },
{ name: "About", icon: Info },
{ name: t('settings.tabs.general'), icon: Settings },
{ name: t('settings.tabs.extensions'), icon: Puzzle },
{ name: t('settings.tabs.connect'), icon: Server },
{ name: t('settings.tabs.advanced'), icon: Settings2 },
{ name: t('settings.tabs.about'), icon: Info },
];
return (
@@ -71,7 +73,7 @@ function SettingsPage() {
<TabPanel>
<SettingsPanel title="">
<div className="text-gray-600 dark:text-gray-400">
Extensions settings content
{t('settings.tabs.extensionsContent')}
</div>
</SettingsPanel>
</TabPanel>
@@ -81,7 +83,7 @@ function SettingsPage() {
<TabPanel>
<SettingsPanel title="">
<div className="text-gray-600 dark:text-gray-400">
Advanced Settings content
{t('settings.tabs.advancedContent')}
</div>
</SettingsPanel>
</TabPanel>

View File

@@ -1,11 +1,15 @@
import { useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { useTranslation } from 'react-i18next';
import { listen } from '@tauri-apps/api/event';
import { useAppStore } from '@/stores/appStore';
import useEscape from "@/hooks/useEscape";
import useSettingsWindow from "@/hooks/useSettingsWindow";
export default function Layout() {
const location = useLocation();
function updateBodyClass(path: string) {
const body = document.body;
body.className = "";
@@ -14,6 +18,7 @@ export default function Layout() {
body.classList.add("input-body");
}
}
useEffect(() => {
updateBodyClass(location.pathname);
}, [location.pathname]);
@@ -22,5 +27,23 @@ export default function Layout() {
useSettingsWindow();
const { i18n } = useTranslation();
const language = useAppStore((state) => state.language);
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
const unlistenLanguageChange = listen('language-changed', (event: any) => {
const { language } = event.payload;
i18n.changeLanguage(language);
});
return () => {
unlistenLanguageChange.then(unlisten => unlisten());
};
}, []);
return <Outlet />;
}

View File

@@ -19,11 +19,12 @@ export type IAppStore = {
// ssoServerID: string;
// setSSOServerID: (ssoServerID: string) => void,
endpoint: AppEndpoint,
endpoint_http: string,
endpoint_websocket: string,
setEndpoint: (endpoint: AppEndpoint) => void,
language: string;
setLanguage: (language: string) => void;
initializeListeners: () => void;
};
@@ -62,6 +63,8 @@ export const useAppStore = create<IAppStore>()(
endpoint_websocket
});
},
language: "en",
setLanguage: (language: string) => set({ language }),
initializeListeners: () => {
listen(ENDPOINT_CHANGE_EVENT, (event: any) => {
const { endpoint, endpoint_http, endpoint_websocket } = event.payload;
@@ -79,6 +82,7 @@ export const useAppStore = create<IAppStore>()(
endpoint: state.endpoint,
endpoint_http: state.endpoint_http,
endpoint_websocket: state.endpoint_websocket,
language: state.language,
}),
}
)