feat: add web login (#967)

* feat: add web login

* refactor: update

* refactor: update
This commit is contained in:
ayangweb
2025-11-07 17:12:00 +08:00
committed by GitHub
parent 6067fa7029
commit bab98d4576
16 changed files with 485 additions and 119 deletions

View File

@@ -112,15 +112,11 @@ export const Post = <T>(
}
axios
.post(
baseURL + url,
data,
{
params,
headers,
withCredentials: true,
} as any
)
.post(baseURL + url, data, {
params,
headers,
withCredentials: true,
} as any)
.then((result) => {
resolve([null, result.data as FcResponse<T>]);
})

View File

@@ -10,6 +10,9 @@ import { useConnectStore } from "@/stores/connectStore";
// import SessionFile from "./SessionFile";
import ScrollToBottom from "@/components/Common/ScrollToBottom";
import { useChatStore } from "@/stores/chatStore";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { useAppStore } from "@/stores/appStore";
import { NoResults } from "../Common/UI/NoResults";
interface ChatContentProps {
activeChat?: Chat;
@@ -97,87 +100,100 @@ export const ChatContent = ({
setIsAtBottom(isAtBottom);
};
const { isTauri } = useAppStore();
const { disabled } = useWebConfigStore();
return (
<div className="flex-1 overflow-hidden flex flex-col justify-between relative user-select-text">
<div
ref={scrollRef}
className="flex-1 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 relative"
onScroll={handleScroll}
>
{(!activeChat || activeChat?.messages?.length === 0) &&
!visibleStartPage && <Greetings />}
{!isTauri && disabled ? (
<NoResults />
) : (
<>
<div
ref={scrollRef}
className="flex-1 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 relative"
onScroll={handleScroll}
>
{(!activeChat || activeChat?.messages?.length === 0) &&
!visibleStartPage && <Greetings />}
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
message={message}
isTyping={false}
onResend={handleSendMessage}
/>
))}
{activeChat?.messages?.map((message, index) => (
<ChatMessage
key={message._id + index}
message={message}
isTyping={false}
onResend={handleSendMessage}
/>
))}
{(!curChatEnd ||
query_intent ||
tools ||
fetch_source ||
pick_source ||
deep_read ||
think ||
response) &&
activeChat?._source?.id ? (
<ChatMessage
key={"current"}
message={{
_id: "current",
_source: {
type: "assistant",
assistant_id:
allMessages[allMessages.length - 1]?._source?.assistant_id,
message: "",
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={!curChatEnd}
query_intent={query_intent}
tools={tools}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
formatUrl={formatUrl}
/>
) : null}
{(!curChatEnd ||
query_intent ||
tools ||
fetch_source ||
pick_source ||
deep_read ||
think ||
response) &&
activeChat?._source?.id ? (
<ChatMessage
key={"current"}
message={{
_id: "current",
_source: {
type: "assistant",
assistant_id:
allMessages[allMessages.length - 1]?._source
?.assistant_id,
message: "",
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={!curChatEnd}
query_intent={query_intent}
tools={tools}
fetch_source={fetch_source}
pick_source={pick_source}
deep_read={deep_read}
think={think}
response={response}
loadingStep={loadingStep}
formatUrl={formatUrl}
/>
) : null}
{timedoutShow ? (
<ChatMessage
key={"timedout"}
message={{
_id: "timedout",
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
{timedoutShow ? (
<ChatMessage
key={"timedout"}
message={{
_id: "timedout",
_source: {
type: "assistant",
message: t("assistant.chat.timedout"),
question: Question,
},
}}
onResend={handleSendMessage}
isTyping={false}
/>
) : null}
<div ref={messagesEndRef} />
</div>
{uploadAttachments.length > 0 && (
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
<AttachmentList />
</div>
{uploadAttachments.length > 0 && (
<div
key={currentSessionId}
className="max-h-[120px] overflow-auto p-2"
>
<AttachmentList />
</div>
)}
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
</>
)}
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
</div>
);
};

View File

@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import clsx from "clsx";
import CommonIcon from "@/components/Common/Icons/CommonIcon";
import Copyright from "@/components/Common/Copyright";
import logoImg from "@/assets/icon.svg";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
@@ -17,6 +16,7 @@ import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import FontIcon from "../Icons/FontIcon";
import TogglePin from "../TogglePin";
import WebFooter from "./WebFooter";
interface FooterProps {
setIsPinnedWeb?: (value: boolean) => void;
@@ -49,7 +49,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
return updateInfo && !skipVersions.includes(updateInfo.version);
}, [updateInfo, skipVersions]);
const renderLeft = () => {
const renderTauriLeft = () => {
if (sourceData?.source?.name) {
return (
<div className="flex items-center gap-2">
@@ -116,12 +116,17 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
return (
<div
data-tauri-drag-region={isTauri}
className="px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-md rounded-t-none overflow-hidden"
className={clsx(
"px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-md rounded-t-none",
{
"overflow-hidden": isTauri,
}
)}
>
{isTauri ? (
<div className="flex items-center">
<div className="flex items-center space-x-2">
{renderLeft()}
{renderTauriLeft()}
<TogglePin
className={clsx({
@@ -132,7 +137,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
</div>
</div>
) : (
<Copyright />
<WebFooter />
)}
<div className={`flex mobile:hidden items-center gap-3`}>

View File

@@ -5,6 +5,11 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import clsx from "clsx";
import { formatKey } from "@/utils/keyboardUtils";
import SearchEmpty from "../SearchEmpty";
import FontIcon from "../Icons/FontIcon";
import WebLoginButton from "./WebLoginButton";
import WebRefreshButton from "./WebRefreshButton";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { useAppStore } from "@/stores/appStore";
export const NoResults = () => {
const { t } = useTranslation();
@@ -12,33 +17,66 @@ export const NoResults = () => {
const modifierKey = useShortcutsStore((state) => state.modifierKey);
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
const { isTauri } = useAppStore();
const { disabled } = useWebConfigStore();
const renderContent = () => {
if (!isTauri && disabled) {
return (
<div className="flex flex-col items-center gap-4 text-sm">
<FontIcon
name="font_coco-logo-line"
className="size-20 text-[#999]"
/>
<div className="text-center">
<p>{t("webLogin.hints.welcome")}</p>
<p>{t("webLogin.hints.pleaseLogin")}</p>
</div>
<div className="flex gap-2">
<WebLoginButton />
<WebRefreshButton className="size-8" />
</div>
</div>
);
}
return (
<>
<SearchEmpty />
<div
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
>
{t("search.main.askCoco")}
<span
className={clsx(
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
{
"px-1": !isMac,
}
)}
>
{formatKey(modifierKey)}
</span>
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
{modeSwitch}
</span>
</div>
</>
);
};
return (
<div
data-tauri-drag-region
className="h-full w-full flex flex-col justify-center items-center"
>
<SearchEmpty />
<div
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
>
{t("search.main.askCoco")}
<span
className={clsx(
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
{
"px-1": !isMac,
}
)}
>
{formatKey(modifierKey)}
</span>
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
{modeSwitch}
</span>
</div>
{renderContent()}
</div>
);
};

View File

@@ -0,0 +1,98 @@
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import FontIcon from "../Icons/FontIcon";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { LogOut } from "lucide-react";
import clsx from "clsx";
import Copyright from "../Copyright";
import WebLoginButton from "./WebLoginButton";
import WebRefreshButton from "./WebRefreshButton";
import WebUserAvatar from "./WebUserAvatar";
import { Post } from "@/api/axiosRequest";
import { useTranslation } from "react-i18next";
const WebFooter = () => {
const { integration, loginInfo, setIntegration, setLoginInfo } =
useWebConfigStore();
const { t } = useTranslation();
return (
<div className="relative">
<Popover>
<PopoverButton
onMouseDown={() => {
console.log("WebFooter PopoverButton click");
}}
>
{loginInfo ? (
<WebUserAvatar />
) : (
<FontIcon
name="font_coco-logo-line"
className="size-5 text-[#999]"
/>
)}
</PopoverButton>
<PopoverPanel className="absolute z-50 bottom-5 left-0 w-[300px] rounded-xl bg-white dark:bg-[#202126] text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 -translate-y-2">
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<span>{t("webLogin.title")}</span>
<WebRefreshButton />
</div>
<div className="py-2">
{loginInfo ? (
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<WebUserAvatar
className="!size-12"
icon={{ className: "!size-6" }}
/>
<div className="flex flex-col">
<span>{loginInfo.name}</span>
<span className="text-[#999]">{loginInfo.email}</span>
</div>
</div>
<button
className="flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-[8px] border dark:border-white/10"
onClick={async () => {
await Post("/account/logout", void 0);
setIntegration(void 0);
setLoginInfo(void 0);
}}
>
<LogOut
className={clsx(
"size-3 text-[#0287FF] transition-transform duration-1000"
)}
/>
</button>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<span className="text-[#999]">
{integration?.guest?.enabled
? t("webLogin.hints.tourist")
: t("webLogin.hints.login")}
</span>
<WebLoginButton />
</div>
)}
</div>
</div>
<div className="p-3 border-t dark:border-t-white/10">
<Copyright />
</div>
</PopoverPanel>
</Popover>
</div>
);
};
export default WebFooter;

View File

@@ -0,0 +1,26 @@
import { useAppStore } from "@/stores/appStore";
import { Button } from "@headlessui/react";
import { SquareArrowOutUpRight } from "lucide-react";
import { useTranslation } from "react-i18next";
const WebLoginButton = () => {
const { endpoint } = useAppStore();
const { t } = useTranslation();
const handleClick = () => {
window.open(endpoint);
};
return (
<Button
className="px-6 h-8 text-white bg-[#0287FF] flex rounded-[8px] items-center justify-center gap-1"
onClick={handleClick}
>
<span>{t("webLogin.buttons.login")}</span>
<SquareArrowOutUpRight className="size-4" />
</Button>
);
};
export default WebLoginButton;

View File

@@ -0,0 +1,48 @@
import { RefreshCw } from "lucide-react";
import { FC, useState } from "react";
import { Button, ButtonProps } from "@headlessui/react";
import clsx from "clsx";
import VisibleKey from "../VisibleKey";
import { useWebConfigStore } from "@/stores/webConfigStore";
const WebRefreshButton: FC<ButtonProps> = (props) => {
const { className, ...rest } = props;
const [isRefreshing, setIsRefreshing] = useState(false);
const { onRefresh } = useWebConfigStore();
const handleRefresh = async () => {
try {
setIsRefreshing(true);
await onRefresh();
} finally {
setIsRefreshing(false);
}
};
return (
<Button
{...rest}
onClick={handleRefresh}
className={clsx(
"flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-[8px] border dark:border-white/10",
className
)}
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
className={clsx(
"size-3 text-[#0287FF] transition-transform duration-1000",
{
"animate-spin": isRefreshing,
}
)}
/>
</VisibleKey>
</Button>
);
};
export default WebRefreshButton;

View File

@@ -0,0 +1,24 @@
import clsx from "clsx";
import { LucideProps, User } from "lucide-react";
import { FC, HTMLAttributes } from "react";
interface WebUserAvatarProps extends HTMLAttributes<HTMLDivElement> {
icon?: LucideProps;
}
const WebUserAvatar: FC<WebUserAvatarProps> = (props) => {
const { className, icon } = props;
return (
<div
className={clsx(
"flex items-center justify-center size-5 rounded-full border dark:border-white/10 overflow-hidden",
className
)}
>
<User {...icon} className={clsx("size-4", icon?.className)}></User>
</div>
);
};
export default WebUserAvatar;

View File

@@ -1,3 +1,5 @@
import { useAppStore } from "@/stores/appStore";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { useBoolean } from "ahooks";
import {
useImperativeHandle,
@@ -101,6 +103,9 @@ const AutoResizeTextarea = forwardRef<
[setInput]
);
const { isTauri } = useAppStore();
const { disabled } = useWebConfigStore();
return (
<>
<textarea
@@ -121,6 +126,7 @@ const AutoResizeTextarea = forwardRef<
setTimeout(setFalse, 0);
}}
rows={1}
disabled={!isTauri && disabled}
/>
<div ref={calcRef} className="absolute whitespace-nowrap -z-10">

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import SearchPopover from "./SearchPopover";
import MCPPopover from "./MCPPopover";
import ChatSwitch from "@/components/Common/ChatSwitch";
import Copyright from "@/components/Common/Copyright";
import type { DataSource } from "@/types/commands";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
@@ -17,6 +16,7 @@ import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
import InputUpload from "./InputUpload";
import WebFooter from "../Common/UI/WebFooter";
interface InputControlsProps {
isChatMode: boolean;
@@ -237,7 +237,7 @@ const InputControls = ({
(source?.type !== "deep_think" || !source?.config?.visible) &&
!(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) && (
<div className="px-[9px]">
<Copyright />
<WebFooter />
</div>
)}
</div>

View File

@@ -596,5 +596,17 @@
},
"deepLink": {
"extensionInstallSuccessfully": "Extension installed successfully."
},
"webLogin": {
"title": "Account Information",
"hints": {
"tourist": "Tourist mode, login to unlock the full experience.",
"login": "Please log in to your account to start.",
"welcome": "Welcome to Coco AI.",
"pleaseLogin": "Please log in to your account to start."
},
"buttons": {
"login": "Login"
}
}
}

View File

@@ -595,5 +595,17 @@
},
"deepLink": {
"extensionInstallSuccessfully": "扩展安装成功。"
},
"webLogin": {
"title": "账户信息",
"hints": {
"tourist": "游客模式,登录解锁完整体验。",
"login": "请登录您的账户以开始。",
"welcome": "欢迎访问 Coco AI。",
"pleaseLogin": "请登录您的帐户开始使用。"
},
"buttons": {
"login": "登录"
}
}
}

View File

@@ -13,6 +13,9 @@ import ErrorNotification from "@/components/Common/ErrorNotification";
import "@/i18n";
import "@/web.css";
import { Get } from "@/api/axiosRequest";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { isPlainObject } from "lodash-es";
interface WebAppProps {
headers?: Record<string, unknown>;
@@ -32,6 +35,8 @@ interface WebAppProps {
formatUrl?: (item: any) => string;
isOpen?: boolean;
language?: string;
settings?: any;
refreshSettings?: () => Promise<void>;
}
function WebApp({
@@ -45,7 +50,7 @@ function WebApp({
hasModules = ["search", "chat"],
defaultModule = "search",
assistantIDs = [],
theme = "dark",
theme = "auto",
searchPlaceholder = "",
chatPlaceholder = "",
showChatHistory = false,
@@ -53,9 +58,11 @@ function WebApp({
setIsPinned,
onCancel,
formatUrl,
language = 'en',
language = "en",
settings,
refreshSettings,
}: WebAppProps) {
const {setIsTauri, setEndpoint} = useAppStore();
const { setIsTauri, setEndpoint } = useAppStore();
const setModeSwitch = useShortcutsStore((state) => state.setModeSwitch);
const setInternetSearch = useShortcutsStore((state) => {
return state.setInternetSearch;
@@ -66,11 +73,35 @@ function WebApp({
i18n.changeLanguage(language);
}, [language]);
const {
integration,
loginInfo,
setIntegration,
setLoginInfo,
setOnRefresh,
setDisabled,
} = useWebConfigStore();
const getUserProfile = async () => {
const [_, result] = await Get("/account/profile");
if (isPlainObject(result)) {
setLoginInfo(result as any);
}
};
useEffect(() => {
getUserProfile();
setIsTauri(false);
setEndpoint(serverUrl);
setModeSwitch("S");
setInternetSearch("E");
setIntegration(settings);
setOnRefresh(async () => {
await getUserProfile();
return refreshSettings?.();
});
localStorage.setItem("headers", JSON.stringify(headers || {}));
}, []);
@@ -83,6 +114,10 @@ function WebApp({
useModifierKeyPress();
useViewportHeight();
useEffect(() => {
setDisabled(!loginInfo && !integration?.guest?.enabled);
}, [integration, loginInfo]);
return (
<div
id="searchChat-container"
@@ -126,7 +161,7 @@ function WebApp({
startPage={startPage}
formatUrl={formatUrl}
/>
<ErrorNotification isTauri={false}/>
<ErrorNotification isTauri={false} />
</div>
);
}

View File

@@ -1,16 +1,16 @@
import { useMount } from "ahooks";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import LayoutOutlet from "./outlet";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
const Layout = () => {
const { language } = useAppStore();
const [ready, setReady] = useState(false);
useMount(async () => {
await invoke("backend_setup", {
await platformAdapter.invokeBackend("backend_setup", {
appLang: language,
});

View File

@@ -0,0 +1,51 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface Integration {
guest?: {
enabled?: boolean;
run_as?: string;
};
}
interface LoginInfo {
id: string;
name: string;
email: string;
}
export type IWebAccessControlStore = {
integration?: Integration;
setIntegration: (integration?: Integration) => void;
loginInfo?: LoginInfo;
setLoginInfo: (loginInfo?: LoginInfo) => void;
onRefresh: () => Promise<void>;
setOnRefresh: (onRefresh: () => Promise<void>) => void;
disabled: boolean;
setDisabled: (disabled: boolean) => void;
};
export const useWebConfigStore = create<IWebAccessControlStore>()(
persist(
(set) => ({
setIntegration: (integration) => {
return set({ integration });
},
setLoginInfo: (loginInfo) => {
return set({ loginInfo });
},
onRefresh: async () => {},
setOnRefresh: (onRefresh) => {
return set({ onRefresh });
},
disabled: true,
setDisabled: (disabled) => {
return set({ disabled });
},
}),
{
name: "web-config-store",
partialize: () => ({}),
}
)
);

View File

@@ -10,7 +10,6 @@ import { getCurrentWindowService } from "@/commands/windowService";
import { useSearchStore } from "@/stores/searchStore";
import i18next from "i18next";
// 1
export async function copyToClipboard(text: string) {
const addError = useAppStore.getState().addError;
const language = useAppStore.getState().language;