refactor: service info related components (#537)

* refactor: service info related components

* docs: update notes

* refactor: chat header service status
This commit is contained in:
BiggerRain
2025-05-20 17:02:10 +08:00
committed by GitHub
parent acc3b1a0d2
commit ee750620f2
22 changed files with 714 additions and 453 deletions

View File

@@ -57,6 +57,7 @@ Information about release notes of Coco Server is provided here.
- style: search error styles #533
- fix: server image loading failure #534
- chore: skip register server that not logged in #536
- refactor: service info related components #537
## 0.4.0 (2025-04-27)

View File

@@ -125,8 +125,6 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
response = res;
}
console.log("assistant_search", response);
let assistantList = response?.hits?.hits ?? [];
console.log("assistantList", assistantList);
@@ -143,13 +141,13 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
}
}
console.log("state.allAssistants", state.allAssistants);
//console.log("state.allAssistants", state.allAssistants);
const matched = state.allAssistants.find((item: any) => {
return item._id === currentAssistant?._id;
});
console.log("matched", matched);
//console.log("matched", matched);
if (matched) {
setCurrentAssistant(matched);
@@ -318,7 +316,6 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
placeholder={t("assistant.popover.search")}
className="w-full h-8 px-2 bg-transparent border rounded-md dark:border-white/10"
onChange={(event) => {
console.log("onChange", event.target.value);
setKeyword(event.target.value.trim());
}}
/>

View File

@@ -5,7 +5,7 @@ import HistoryIcon from "@/icons/History";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
import WindowsFullIcon from "@/icons/WindowsFull";
import { useAppStore, IServer } from "@/stores/appStore";
import { useAppStore } from "@/stores/appStore";
import type { Chat } from "@/types/chat";
import platformAdapter from "@/utils/platformAdapter";
import VisibleKey from "../Common/VisibleKey";
@@ -13,6 +13,8 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import { HISTORY_PANEL_ID } from "@/constants";
import { AssistantList } from "./AssistantList";
import { ServerList } from "./ServerList";
import { Server } from "@/types/server"
interface ChatHeaderProps {
onCreateNewChat: () => void;
@@ -20,7 +22,7 @@ interface ChatHeaderProps {
setIsSidebarOpen: () => void;
isSidebarOpen: boolean;
activeChat: Chat | undefined;
reconnect: (server?: IServer) => void;
reconnect: (server?: Server) => void;
isLogin: boolean;
setIsLogin: (isLogin: boolean) => void;
isChatPage?: boolean;

View File

@@ -3,16 +3,18 @@ import { Settings, RefreshCw, Check, Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { useKeyPress } from "ahooks";
import { isNil } from "lodash-es";
import logoImg from "@/assets/icon.svg";
import ServerIcon from "@/icons/Server";
import VisibleKey from "../Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore, IServer } from "@/stores/appStore";
import { useAppStore } from "@/stores/appStore";
import { useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import { isNil } from "lodash-es";
import { Server as IServer } from "@/types/server";
import StatusIndicator from "@/components/Cloud/StatusIndicator";
interface ServerListProps {
isLogin: boolean;
@@ -215,17 +217,16 @@ export function ServerList({
{server.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
AI Assistant: {server.assistantCount || 1}
AI Assistant: {1}
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<span
className={`w-3 h-3 rounded-full ${
server.health?.status
? `bg-[${server.health?.status}]`
: "bg-gray-400 dark:bg-gray-600"
}`}
<StatusIndicator
enabled={server.enabled}
public={server.public}
hasProfile={!!server?.profile}
status={server.health?.status}
/>
<div className="size-4 flex justify-end">
{currentService?.id === server.id && (

View File

@@ -77,7 +77,7 @@ const Splash = () => {
});
const settingsAssistantList = useMemo(() => {
console.log("assistantList", assistantList);
//console.log("assistantList", assistantList);
return assistantList.filter((item) => {
return settings?.display_assistants?.includes(item?._source?.id);

View File

@@ -1,64 +1,32 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
CalendarSync,
Copy,
GitFork,
Globe,
PackageOpen,
RefreshCcw,
Trash2,
} from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useEffect, useRef, useState, useCallback } from "react";
import { emit } from "@tauri-apps/api/event";
import { UserProfile } from "./UserProfile";
import { DataSourcesList } from "./DataSourcesList";
import { Sidebar } from "./Sidebar";
import { Connect } from "./Connect";
import { OpenURLWithBrowser } from "@/utils";
import { useAppStore } from "@/stores/appStore";
import { useConnectStore } from "@/stores/connectStore";
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import Tooltip from "@/components/Common/Tooltip";
import {
list_coco_servers,
add_coco_server,
enable_server,
disable_server,
logout_coco_server,
remove_coco_server,
refresh_coco_server_info,
handle_sso_callback,
} from "@/commands";
import ServiceInfo from "./ServiceInfo";
import ServiceAuth from "./ServiceAuth";
export default function Cloud() {
const { t } = useTranslation();
const SidebarRef = useRef<{ refreshData: () => void }>(null);
const errors = useAppStore((state) => state.errors);
const addError = useAppStore((state) => state.addError);
const [isConnect, setIsConnect] = useState(true);
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore((state) => state.setCurrentService);
const serverList = useConnectStore((state) => state.serverList);
const setServerList = useConnectStore((state) => state.setServerList);
const [loading, setLoading] = useState(false);
const [refreshLoading, setRefreshLoading] = useState(false);
// fetch the servers
@@ -68,7 +36,6 @@ export default function Cloud() {
useEffect(() => {
console.log("currentService", currentService);
setLoading(false);
setRefreshLoading(false);
setIsConnect(true);
}, [JSON.stringify(currentService)]);
@@ -131,178 +98,30 @@ export default function Cloud() {
});
};
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code || !serverId) {
addError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await handle_sso_callback({
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code,
const refreshClick = useCallback(
(id: string) => {
setRefreshLoading(true);
refresh_coco_server_info(id)
.then((res: any) => {
console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => {
console.log("fetchServers", r);
});
// update currentService
setCurrentService(res);
emit("login_or_logout", true);
})
.finally(() => {
setRefreshLoading(false);
});
if (serverId != null) {
refreshClick(serverId);
}
getCurrentWindow().setFocus();
} catch (e) {
console.error("Sign in failed:", e);
} finally {
setLoading(false);
}
},
[ssoRequestID]
[fetchServers]
);
const handleUrl = (url: string) => {
try {
const urlObject = new URL(url.trim());
console.log("handle urlObject:", urlObject);
// pass request_id and check with local, if the request_id are same, then continue
const reqId = urlObject.searchParams.get("request_id");
const code = urlObject.searchParams.get("code");
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = currentService?.id;
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
};
// Fetch the initial deep link intent
useEffect(() => {
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData("text").trim();
console.log("handle paste text:", pastedText);
if (isValidCallbackUrl(pastedText)) {
// Handle the URL as if it's a deep link
console.log("handle callback on paste:", pastedText);
handleUrl(pastedText);
}
};
// Function to check if the pasted URL is valid for our deep link scheme
const isValidCallbackUrl = (url: string) => {
return url && url.startsWith("coco://oauth_callback");
};
// Adding event listener for paste events
document.addEventListener("paste", handlePaste);
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
if (isValidCallbackUrl(urls[0].trim())) {
handleUrl(urls[0]);
}
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
addError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
return () => {
unlisten.then((fn) => fn());
document.removeEventListener("paste", handlePaste);
};
}, [ssoRequestID]);
const LoginClick = useCallback(() => {
if (loading) return; // Prevent multiple clicks if already loading
let requestID = uuidv4();
setSSORequestID(requestID);
// Generate the login URL with the current appUid
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
console.log("Open SSO link, requestID:", ssoRequestID, url);
// Open the URL in a browser
OpenURLWithBrowser(url);
// Start loading state
setLoading(true);
}, [ssoRequestID, loading, currentService]);
const refreshClick = (id: string) => {
setRefreshLoading(true);
refresh_coco_server_info(id)
.then((res: any) => {
console.log("refresh_coco_server_info", id, res);
fetchServers(false).then((r) => {
console.log("fetchServers", r);
});
// update currentService
setCurrentService(res);
emit("login_or_logout", true);
})
.finally(() => {
setRefreshLoading(false);
});
};
function onAddServer() {
setIsConnect(false);
}
function onLogout(id: string) {
console.log("onLogout", id);
setRefreshLoading(true);
logout_coco_server(id)
.then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res));
refreshClick(id);
emit("login_or_logout", false);
})
.finally(() => {
setRefreshLoading(false);
});
}
const removeServer = (id: string) => {
remove_coco_server(id).then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => {
console.log("fetchServers", r);
});
});
};
const enable_coco_server = useCallback(
async (enabled: boolean) => {
if (enabled) {
await enable_server(currentService?.id);
} else {
await disable_server(currentService?.id);
}
setCurrentService({ ...currentService, enabled });
await fetchServers(false);
},
[currentService?.id]
);
return (
<div className="flex bg-gray-50 dark:bg-gray-900">
<Sidebar
@@ -314,152 +133,16 @@ export default function Cloud() {
<main className="flex-1 p-4 py-8">
{isConnect ? (
<div className="max-w-4xl mx-auto">
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
<img
width="100%"
src={currentService?.provider?.banner || bannerImg}
alt="banner"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = bannerImg;
}}
/>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<Tooltip content={currentService?.endpoint}>
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
{currentService?.name}
</div>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<SettingsToggle
checked={currentService?.enabled}
className={clsx({
"bg-red-600 focus:ring-red-500": !currentService?.enabled,
})}
label={
currentService?.enabled
? t("cloud.enable_server")
: t("cloud.disable_server")
}
onChange={enable_coco_server}
/>
<ServiceInfo
refreshLoading={refreshLoading}
refreshClick={refreshClick}
fetchServers={fetchServers}
/>
<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={() =>
OpenURLWithBrowser(currentService?.provider?.website)
}
>
<Globe className="w-3.5 h-3.5" />
</button>
<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={() => refreshClick(currentService?.id)}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${
refreshLoading ? "animate-spin" : ""
}`}
/>
</button>
{!currentService?.builtin && (
<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={() => removeServer(currentService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button>
)}
</div>
</div>
<div className="mb-8">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
<span className="flex items-center gap-1">
<PackageOpen className="w-4 h-4" />{" "}
{currentService?.provider?.name}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<GitFork className="w-4 h-4" />{" "}
{currentService?.version?.number}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
</span>
</div>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{currentService?.provider?.description}
</p>
</div>
{currentService?.auth_provider?.sso?.url ? (
<div className="mb-8">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{t("cloud.accountInfo")}
</h2>
{currentService?.profile ? (
<UserProfile
server={currentService?.id}
userInfo={currentService?.profile}
onLogout={onLogout}
/>
) : (
<div>
{/* Login Button (conditionally rendered when not loading) */}
{!loading && (
<button
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
>
{t("cloud.login")}
</button>
)}
{/* Cancel Button and Copy URL button while loading */}
{loading && (
<div className="flex items-center space-x-2">
<button
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
>
{t("cloud.cancel")}
</button>
<button
onClick={() => {
navigator.clipboard.writeText(
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
);
}}
className="text-xl text-blue-500 hover:text-blue-600"
>
<Copy className="inline mr-2" />{" "}
</button>
<div className="text-justify italic text-xs">
{t("cloud.manualCopyLink")}
</div>
</div>
)}
{/* Privacy Policy Link */}
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(
currentService?.provider?.privacy_policy
)
}
>
{t("cloud.privacyPolicy")}
</button>
</div>
)}
</div>
) : null}
<ServiceAuth
setRefreshLoading={setRefreshLoading}
refreshClick={refreshClick}
/>
{currentService?.profile ? (
<DataSourcesList server={currentService?.id} />

View File

@@ -0,0 +1,253 @@
import { memo, useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid";
import { emit } from "@tauri-apps/api/event";
import {
getCurrent as getCurrentDeepLinkUrls,
onOpenUrl,
} from "@tauri-apps/plugin-deep-link";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { UserProfile } from "./UserProfile";
import { OpenURLWithBrowser } from "@/utils";
import { useConnectStore } from "@/stores/connectStore";
import { useAppStore } from "@/stores/appStore";
import { logout_coco_server, handle_sso_callback } from "@/commands";
interface ServiceAuthProps {
setRefreshLoading: (loading: boolean) => void;
refreshClick: (id: string) => void;
}
const ServiceAuth = memo(
({ setRefreshLoading, refreshClick }: ServiceAuthProps) => {
const { t } = useTranslation();
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
const addError = useAppStore((state) => state.addError);
const currentService = useConnectStore((state) => state.currentService);
const [loading, setLoading] = useState(false);
const LoginClick = useCallback(() => {
if (loading) return; // Prevent multiple clicks if already loading
let requestID = uuidv4();
setSSORequestID(requestID);
// Generate the login URL with the current appUid
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
console.log("Open SSO link, requestID:", ssoRequestID, url);
// Open the URL in a browser
OpenURLWithBrowser(url);
// Start loading state
setLoading(true);
}, [ssoRequestID, loading, currentService]);
const onLogout = useCallback(
(id: string) => {
console.log("onLogout", id);
setRefreshLoading(true);
logout_coco_server(id)
.then((res: any) => {
console.log("logout_coco_server", id, JSON.stringify(res));
refreshClick(id);
emit("login_or_logout", false);
})
.finally(() => {
setRefreshLoading(false);
});
},
[refreshClick]
);
const handleOAuthCallback = useCallback(
async (code: string | null, serverId: string | null) => {
if (!code || !serverId) {
addError("No authorization code received");
return;
}
try {
console.log("Handling OAuth callback:", { code, serverId });
await handle_sso_callback({
serverId: serverId, // Make sure 'server_id' is the correct argument
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
code: code,
});
if (serverId != null) {
refreshClick(serverId);
}
getCurrentWindow().setFocus();
} catch (e) {
console.error("Sign in failed:", e);
} finally {
setLoading(false);
}
},
[ssoRequestID]
);
const handleUrl = (url: string) => {
try {
const urlObject = new URL(url.trim());
console.log("handle urlObject:", urlObject);
// pass request_id and check with local, if the request_id are same, then continue
const reqId = urlObject.searchParams.get("request_id");
const code = urlObject.searchParams.get("code");
if (reqId != ssoRequestID) {
console.log("Request ID not matched, skip");
addError("Request ID not matched, skip");
return;
}
const serverId = currentService?.id;
handleOAuthCallback(code, serverId);
} catch (err) {
console.error("Failed to parse URL:", err);
addError("Invalid URL format: " + err);
}
};
// Fetch the initial deep link intent
useEffect(() => {
setLoading(false);
// Function to handle pasted URL
const handlePaste = (event: any) => {
const pastedText = event.clipboardData.getData("text").trim();
console.log("handle paste text:", pastedText);
if (isValidCallbackUrl(pastedText)) {
// Handle the URL as if it's a deep link
console.log("handle callback on paste:", pastedText);
handleUrl(pastedText);
}
};
// Function to check if the pasted URL is valid for our deep link scheme
const isValidCallbackUrl = (url: string) => {
return url && url.startsWith("coco://oauth_callback");
};
// Adding event listener for paste events
document.addEventListener("paste", handlePaste);
getCurrentDeepLinkUrls()
.then((urls) => {
console.log("URLs:", urls);
if (urls && urls.length > 0) {
if (isValidCallbackUrl(urls[0].trim())) {
handleUrl(urls[0]);
}
}
})
.catch((err) => {
console.error("Failed to get initial URLs:", err);
addError("Failed to get initial URLs: " + err);
});
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
return () => {
unlisten.then((fn) => fn());
document.removeEventListener("paste", handlePaste);
};
}, [ssoRequestID]);
if (!currentService?.auth_provider?.sso?.url) {
return null;
}
return (
<div className="mb-8">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
{t("cloud.accountInfo")}
</h2>
{currentService?.profile ? (
<UserProfile
server={currentService?.id}
userInfo={currentService?.profile}
onLogout={onLogout}
/>
) : (
<div>
{/* Login Button (conditionally rendered when not loading) */}
{!loading && <LoginButton LoginClick={LoginClick} />}
{/* Cancel Button and Copy URL button while loading */}
{loading && (
<LoadingState
onCancel={() => setLoading(false)}
onCopy={() => {
navigator.clipboard.writeText(
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
);
}}
/>
)}
{/* Privacy Policy Link */}
<button
className="text-xs text-[#0096FB] dark:text-blue-400 block"
onClick={() =>
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
}
>
{t("cloud.privacyPolicy")}
</button>
</div>
)}
</div>
);
}
);
export default ServiceAuth;
const LoginButton = memo(({ LoginClick }: { LoginClick: () => void }) => {
const { t } = useTranslation();
return (
<button
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
aria-label={t("cloud.login")}
>
{t("cloud.login")}
</button>
);
});
const LoadingState = memo(
({ onCancel, onCopy }: { onCancel: () => void; onCopy: () => void }) => {
const { t } = useTranslation();
return (
<div className="flex items-center space-x-2">
<button
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
onClick={onCancel}
>
{t("cloud.cancel")}
</button>
<button
onClick={onCopy}
className="text-xl text-blue-500 hover:text-blue-600"
>
<Copy className="inline mr-2" />{" "}
</button>
<div className="text-justify italic text-xs">
{t("cloud.manualCopyLink")}
</div>
</div>
);
}
);

View File

@@ -0,0 +1,26 @@
import { memo } from "react";
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
import { useConnectStore } from "@/stores/connectStore";
interface ServiceBannerProps {}
const ServiceBanner = memo(({}: ServiceBannerProps) => {
const currentService = useConnectStore((state) => state.currentService);
return (
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
<img
width="100%"
src={currentService?.provider?.banner || bannerImg}
alt="banner"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = bannerImg;
}}
/>
</div>
);
});
export default ServiceBanner;

View File

@@ -0,0 +1,104 @@
import { memo, useCallback } from "react";
import { Globe, RefreshCcw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import Tooltip from "@/components/Common/Tooltip";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { OpenURLWithBrowser } from "@/utils";
import { useConnectStore } from "@/stores/connectStore";
import { enable_server, disable_server, remove_coco_server } from "@/commands";
interface ServiceHeaderProps {
refreshLoading?: boolean;
refreshClick: (id: string) => void;
fetchServers: (force: boolean) => Promise<void>;
}
const ServiceHeader = memo(
({ refreshLoading, refreshClick, fetchServers }: ServiceHeaderProps) => {
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const setCurrentService = useConnectStore(
(state) => state.setCurrentService
);
const enable_coco_server = useCallback(
async (enabled: boolean) => {
if (enabled) {
await enable_server(currentService?.id);
} else {
await disable_server(currentService?.id);
}
setCurrentService({ ...currentService, enabled });
await fetchServers(false);
},
[currentService?.id]
);
const removeServer = (id: string) => {
remove_coco_server(id).then((res: any) => {
console.log("remove_coco_server", id, JSON.stringify(res));
fetchServers(true).then((r) => {
console.log("fetchServers", r);
});
});
};
return (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<Tooltip content={currentService?.endpoint}>
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
{currentService?.name}
</div>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<SettingsToggle
checked={currentService?.enabled}
className={clsx({
"bg-red-600 focus:ring-red-500": !currentService?.enabled,
})}
label={
currentService?.enabled
? t("cloud.enable_server")
: t("cloud.disable_server")
}
onChange={enable_coco_server}
/>
<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={() =>
OpenURLWithBrowser(currentService?.provider?.website)
}
>
<Globe className="w-3.5 h-3.5" />
</button>
<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={() => refreshClick(currentService?.id)}
>
<RefreshCcw
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
/>
</button>
{!currentService?.builtin && (
<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={() => removeServer(currentService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
</button>
)}
</div>
</div>
);
}
);
export default ServiceHeader;

View File

@@ -0,0 +1,31 @@
import { memo } from "react";
import ServiceBanner from "./ServiceBanner";
import ServiceHeader from "./ServiceHeader";
import ServiceMetadata from "./ServiceMetadata";
interface ServiceInfoProps {
refreshLoading?: boolean;
refreshClick: (id: string) => void;
fetchServers: (force: boolean) => Promise<void>;
}
const ServiceInfo = memo(
({ refreshLoading, refreshClick, fetchServers }: ServiceInfoProps) => {
return (
<>
<ServiceBanner />
<ServiceHeader
refreshLoading={refreshLoading}
refreshClick={refreshClick}
fetchServers={fetchServers}
/>
<ServiceMetadata />
</>
);
}
);
export default ServiceInfo;

View File

@@ -0,0 +1,33 @@
import { memo } from "react";
import { PackageOpen, GitFork, CalendarSync } from "lucide-react";
import { useConnectStore } from "@/stores/connectStore";
interface ServiceMetadataProps {}
const ServiceMetadata = memo(({}: ServiceMetadataProps) => {
const currentService = useConnectStore((state) => state.currentService);
return (
<div className="mb-8">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
<span className="flex items-center gap-1">
<PackageOpen className="w-4 h-4" /> {currentService?.provider?.name}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<GitFork className="w-4 h-4" /> {currentService?.version?.number}
</span>
<span className="mx-4">|</span>
<span className="flex items-center gap-1">
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
</span>
</div>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{currentService?.provider?.description}
</p>
</div>
);
});
export default ServiceMetadata;

View File

@@ -1,13 +1,20 @@
import { useTranslation } from "react-i18next";
import { forwardRef } from "react";
import { forwardRef, useMemo, useCallback } from "react";
import { Plus } from "lucide-react";
import cocoLogoImg from "@/assets/app-icon.png";
import { useConnectStore } from "@/stores/connectStore";
import { Server } from "@/types/server";
import StatusIndicator from "./StatusIndicator";
interface SidebarProps {
onAddServer: () => void;
serverList: any[];
serverList: Server[];
}
interface ServerGroups {
builtinServers: JSX.Element[];
customServers: JSX.Element[];
}
export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
@@ -18,69 +25,84 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
(state) => state.setCurrentService
);
const onAddServerClick = () => {
onAddServer();
};
const getServerItemClassName = useCallback((isSelected: boolean) => {
return `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 whitespace-nowrap ${
isSelected
? "dark:bg-blue-900/20 dark:bg-blue-900 border border-[#0087ff]"
: "bg-gray-50 dark:bg-gray-900 border border-[#e6e6e6] dark:border-gray-700"
}`;
}, []);
// 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 whitespace-nowrap ${
currentService?.id === item?.id
? "dark:bg-blue-900/20 dark:bg-blue-900 border border-[#0087ff]"
: "bg-gray-50 dark:bg-gray-900 border border-[#e6e6e6] dark:border-gray-700"
}`}
onClick={() => setCurrentService(item)}
>
<img
src={item?.provider?.icon || cocoLogoImg}
alt="LogoImg"
className="w-5 h-5 flex-shrink-0"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = cocoLogoImg;
}}
/>
<span className="font-medium truncate max-w-[140px]">{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.health?.status ? (
<div className={`w-3 h-3 rounded-full bg-[${item.health?.status}]`} />
) : (
<div className="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-600" />
)}
</button>
</div>
const renderServerItem = useCallback(
(item: Server) => {
const isSelected = currentService?.id === item.id;
return (
<div
key={item.id}
className={getServerItemClassName(isSelected)}
onClick={() => setCurrentService(item)}
>
<img
src={item?.provider?.icon || cocoLogoImg}
alt={`${item.name} logo`}
className="w-5 h-5 flex-shrink-0"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = cocoLogoImg;
}}
/>
<span className="font-medium truncate max-w-[140px]">
{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">
<StatusIndicator
enabled={item.enabled}
public={item.public}
hasProfile={!!item?.profile}
status={item.health?.status}
/>
</button>
</div>
);
},
[currentService]
);
const { builtinServers, customServers } = useMemo(() => {
const groups = serverList.reduce<ServerGroups>(
(acc, item) => {
const renderedItem = renderServerItem(item);
if (item?.builtin) {
acc.builtinServers.push(renderedItem);
} else {
acc.customServers.push(renderedItem);
}
return acc;
},
{ builtinServers: [], customServers: [] }
);
};
return groups;
}, [serverList, renderServerItem]);
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>
{builtinServers}
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 mt-6">
{t("cloud.sidebar.yourServers")}
</div>
{/* Render Non-Built-in Servers */}
<div>
{serverList
.filter((item) => !item?.builtin)
.map((item) => renderServerItem(item))}
</div>
{customServers}
<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}
onClick={onAddServer}
>
<Plus className="w-5 h-5" />
</button>

View File

@@ -0,0 +1,43 @@
import { useMemo, memo } from "react";
import { Status } from "@/types/server";
type StatusIndicatorProps = {
enabled: boolean;
public: boolean;
hasProfile: boolean;
status?: Status;
};
const StatusIndicator = memo(
({ enabled, public: isPublic, hasProfile, status }: StatusIndicatorProps) => {
// If service is enabled AND (public OR (private AND logged in)) AND service is available
// Otherwise show gray status
const isActive =
enabled && (isPublic || (!isPublic && hasProfile)) && status;
const statusColorClass = useMemo(() => {
if (!isActive) return "bg-gray-400 dark:bg-gray-600";
switch (status) {
case "green":
return "bg-green-500";
case "yellow":
return "bg-yellow-500";
case "red":
return "bg-red-500";
default:
return "bg-gray-400 dark:bg-gray-600";
}
}, [isActive, status]);
return (
<div
className={`w-3 h-3 rounded-full ${statusColorClass}`}
role="status"
aria-label={isActive ? "active" : "inactive"}
/>
);
}
);
export default StatusIndicator;

View File

@@ -1,16 +1,6 @@
import { User, LogOut } from "lucide-react";
interface UserPreferences {
theme: "dark" | "light";
language: string;
}
interface UserInfo {
name: string;
email: string;
avatar?: string;
roles: string[]; // ["admin", "editor"]
preferences: UserPreferences;
}
import { UserProfile as UserInfo } from "@/types/server";
interface UserProfileProps {
server: string; //server's id

View File

@@ -21,7 +21,7 @@ import { useCreation } from "ahooks";
import SettingsItem from "./SettingsItem";
import SettingsToggle from "./SettingsToggle";
import { ShortcutItem } from "./ShortcutItem";
import { Shortcut } from "./shortcut";
import { Shortcut } from "../../types/shortcut";
import { useShortcutEditor } from "@/hooks/useShortcutEditor";
import { useAppStore } from "@/stores/appStore";
import { AppTheme } from "@/types/index";

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { Shortcut } from "@/components/Settings/shortcut";
import { Shortcut } from "@/types/shortcut";
import { normalizeKey, isModifierKey, sortKeys } from "@/utils/keyboardUtils";
const RESERVED_SHORTCUTS = [

View File

@@ -1,8 +1,9 @@
import { useEffect, useCallback, useRef } from "react";
import { useWebSocket as useWebSocketAHook } from "ahooks";
import { useAppStore, IServer } from "@/stores/appStore";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { Server } from "@/types/server";
enum ReadyState {
Connecting = 0,
@@ -15,7 +16,7 @@ interface WebSocketProps {
clientId: string;
connected: boolean;
setConnected: (connected: boolean) => void;
currentService: IServer | null;
currentService: Server | null;
dealMsgRef: React.MutableRefObject<((msg: string) => void) | null>;
onWebsocketSessionId?: (sessionId: string) => void;
}
@@ -101,7 +102,7 @@ export default function useWebSocket({
// 2. If not connected or disconnected, input box has a connect button, clicking it will connect to WebSocket
// src/components/Search/InputBox.tsx
const reconnect = useCallback(
async (server?: IServer) => {
async (server?: Server) => {
if (isTauri) {
const targetServer = server || currentService;
if (!targetServer?.id) return;

View File

@@ -6,26 +6,6 @@ import platformAdapter from "@/utils/platformAdapter";
const ENDPOINT_CHANGE_EVENT = "endpoint-changed";
export interface IServer {
id: string;
name: string;
endpoint: string;
provider: {
icon: string;
};
enabled: boolean;
public: boolean;
profile?: any;
available?: boolean;
health?: {
status: string;
};
assistantCount?: number;
minimal_client_version?: {
number: number;
};
}
interface ErrorMessage {
id: string;
type: "error" | "warning" | "info";

View File

@@ -3,6 +3,7 @@ import { persist, subscribeWithSelector } from "zustand/middleware";
import { produce } from "immer";
import platformAdapter from "@/utils/platformAdapter";
import { Server } from "@/types/server"
const CONNECTOR_CHANGE_EVENT = "connector_data_change";
const DATASOURCE_CHANGE_EVENT = "datasourceData_change";
@@ -12,10 +13,10 @@ type keyArrayObject = {
};
export type IConnectStore = {
serverList: any[];
setServerList: (servers: []) => void;
currentService: any;
setCurrentService: (service: any) => void;
serverList: Server[];
setServerList: (servers: Server[]) => void;
currentService: Server;
setCurrentService: (service: Server) => void;
connector_data: keyArrayObject;
setConnectorData: (connector_data: any[], key: string) => void;
datasourceData: keyArrayObject;
@@ -41,7 +42,7 @@ export const useConnectStore = create<IConnectStore>()(
persist(
(set) => ({
serverList: [],
setServerList: (serverList: []) => {
setServerList: (serverList: Server[]) => {
console.log("set serverList:", serverList);
set(
produce((draft) => {
@@ -49,7 +50,35 @@ export const useConnectStore = create<IConnectStore>()(
})
);
},
currentService: "default_coco_server",
// ... existing code ...
currentService: {
id: "default_coco_server",
builtin: true,
enabled: true,
name: "Coco Cloud",
endpoint: "https://coco.infini.cloud",
provider: {
name: "INFINI Labs",
icon: "https://coco.infini.cloud/icon.png",
website: "http://infinilabs.com",
eula: "http://infinilabs.com/eula.txt",
privacy_policy: "http://infinilabs.com/privacy_policy.txt",
banner: "https://coco.infini.cloud/banner.jpg",
description: "Coco AI Server - Search, Connect, Collaborate, AI-powered enterprise search, all in one space."
},
version: {
number: "1.0.0_SNAPSHOT"
},
public: false,
available: true,
auth_provider: {
sso: {
url: "https://coco.infini.cloud/sso/login/"
}
},
priority: 0
},
// ... existing code ...
setCurrentService: (server: any) => {
console.log("set default server:", server);
set(

65
src/types/server.ts Normal file
View File

@@ -0,0 +1,65 @@
interface Provider {
name: string;
icon?: string;
banner?: string;
description?: string;
eula?: string;
privacy_policy?: string;
website?: string;
auth_provider?: {
sso: {
url: string;
};
};
}
interface Version {
number: string;
}
interface MinimalClientVersion {
number: string;
}
export type Status = 'green' | 'yellow' | 'red';
interface Health {
services?: Record<string, Status>;
status: Status;
}
interface Preferences {
theme?: string;
language?: string;
}
export interface UserProfile {
id: string;
name: string;
email: string;
avatar?: string;
preferences?: Preferences;
}
export interface Server {
id: string;
builtin: boolean;
name: string;
endpoint: string;
provider: Provider;
version: Version;
minimal_client_version?: MinimalClientVersion;
updated?: string;
enabled: boolean;
public: boolean;
available: boolean;
health?: Health;
profile?: UserProfile;
auth_provider: {
sso: {
url: string;
};
};
priority: number;
}

View File

@@ -27,7 +27,7 @@ export async function copyToClipboard(text: string) {
document.execCommand("copy");
console.info("Copy Success");
} catch (error) {
console.info("Copy Failed");
console.error("Copy Failed");
}
document.body.removeChild(textArea);
}
@@ -68,7 +68,7 @@ export const IsTauri = () => {
);
};
export const OpenURLWithBrowser = async (url: string) => {
export const OpenURLWithBrowser = async (url?: string) => {
if (!url) return;
if (IsTauri()) {
try {