Update Dahsboard UI (#1349)

Co-authored-by: Wendong-Fan <w3ndong.fan@gmail.com>
Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com>
This commit is contained in:
Douglas Lai 2026-02-23 18:12:27 +00:00 committed by GitHub
parent ddada3e642
commit 38c7717f3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2405 additions and 2105 deletions

View file

@ -36,7 +36,7 @@ import {
} from '@/hooks/useIntegrationManagement';
import { getProxyBaseURL } from '@/lib';
import { OAuth } from '@/lib/oauth';
import { MCPEnvDialog } from '@/pages/Setting/components/MCPEnvDialog';
import { MCPEnvDialog } from '@/pages/Connectors/components/MCPEnvDialog';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -53,6 +53,7 @@ interface IntegrationListProps {
// Manage mode props (Setting)
showSelect?: boolean;
selectPlaceholder?: string;
selectValue?: string;
selectContent?: React.ReactNode;
onSelectChange?: (value: string, item: IntegrationItem) => void;
showConfigButton?: boolean;
@ -73,6 +74,7 @@ export default function IntegrationList({
onShowEnvConfig,
showSelect = false,
selectPlaceholder = 'Select...',
selectValue,
selectContent,
onSelectChange,
showConfigButton = true,
@ -293,7 +295,7 @@ export default function IntegrationList({
const itemClassName = isSelectMode
? 'cursor-pointer hover:bg-surface-hover-subtle px-3 py-2 flex justify-between'
: 'w-full px-6 py-4 bg-surface-secondary rounded-2xl';
: 'w-full px-6 py-4 bg-surface-tertiary rounded-2xl';
const titleClassName = isSelectMode
? 'text-base leading-snug font-bold text-text-action'
@ -338,7 +340,7 @@ export default function IntegrationList({
}
>
{isSelectMode ? (
<div className="flex items-center gap-xs">
<div className="gap-xs flex items-center">
{(isSelectMode || showStatusDot) && (
<img
src={ellipseIcon}
@ -359,8 +361,8 @@ export default function IntegrationList({
</div>
</div>
) : (
<div className="flex w-full flex-row items-center justify-between gap-xs">
<div className="flex flex-row items-center gap-xs">
<div className="gap-xs flex w-full flex-row items-center justify-between">
<div className="gap-xs flex flex-row items-center">
{showStatusDot && (
<img
src={ellipseIcon}
@ -385,7 +387,7 @@ export default function IntegrationList({
</Tooltip>
</div>
</div>
<div className="flex flex-row items-center gap-md">
<div className="gap-md flex flex-row items-center">
{showConfigButton && (
<Button
type="button"
@ -453,13 +455,16 @@ export default function IntegrationList({
</div>
{!isSelectMode && showSelect && (
<div className="mt-6 flex w-full flex-row items-center gap-md border-x-0 border-b-0 border-solid border-border-secondary pt-6">
<div className="flex w-full flex-row items-center justify-between gap-md">
<div className="mt-6 gap-md border-border-secondary pt-6 flex w-full flex-row items-center border-x-0 border-b-0 border-solid">
<div className="gap-md flex w-full flex-row items-center justify-between">
<div className="text-body-md text-text-body">
{' '}
Default {item.name}
</div>
<Select onValueChange={(v) => onSelectChange?.(v, item)}>
<Select
{...(selectValue !== undefined && { value: selectValue })}
onValueChange={(v) => onSelectChange?.(v, item)}
>
<SelectTrigger size="default" className="w-[240px]">
<SelectValue placeholder={selectPlaceholder} />
</SelectTrigger>

View file

@ -63,11 +63,12 @@ export default function SearchInput({
}, [onChange]);
useEffect(() => {
if (userExpanded && inputRef.current) {
const id = requestAnimationFrame(() => {
if (userExpanded) {
// Delay focus until input is mounted (AnimatePresence mode="wait" ~150ms)
const id = setTimeout(() => {
inputRef.current?.focus();
});
return () => cancelAnimationFrame(id);
}, 150);
return () => clearTimeout(id);
}
}, [userExpanded]);
@ -79,9 +80,9 @@ export default function SearchInput({
return (
<motion.div
className={cn(
'flex items-center justify-center overflow-hidden rounded-lg border border-solid border-transparent bg-transparent py-0.5',
'rounded-lg py-0.5 flex items-center justify-center overflow-hidden border border-solid border-transparent bg-transparent',
'focus-within:border-input-border-focus focus-within:bg-input-bg-input',
'hover:border-transparent hover:bg-surface-tertiary'
'hover:bg-surface-tertiary hover:border-transparent'
)}
initial={false}
animate={{ width: isExpanded ? EXPANDED_WIDTH : COLLAPSED_WIDTH }}
@ -116,13 +117,13 @@ export default function SearchInput({
) : (
<motion.div
key="input"
className="flex min-w-0 flex-1 items-center gap-0 pr-1"
className="min-w-0 gap-0 pr-1 flex flex-1 items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<span className="pointer-events-none ml-2 inline-flex h-4 w-4 shrink-0 items-center justify-center text-icon-secondary">
<span className="ml-2 h-4 w-4 text-icon-secondary pointer-events-none inline-flex shrink-0 items-center justify-center">
<Search className="h-4 w-4" />
</span>
<input
@ -139,14 +140,14 @@ export default function SearchInput({
onSearch?.();
}
}}
className="h-6 min-w-0 flex-1 bg-transparent pl-2 text-label-sm text-text-heading outline-none placeholder:text-text-label"
className="h-6 min-w-0 pl-2 text-label-sm text-text-heading placeholder:text-text-label flex-1 bg-transparent outline-none"
/>
<TooltipSimple content={clearLabel}>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 rounded-full text-icon-secondary"
className="text-icon-secondary shrink-0 rounded-full"
onClick={collapse}
aria-label={clearLabel}
>

View file

@ -0,0 +1,127 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
'use client';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
IconWrapper,
useAnimateIconContext,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type RadioProps = IconProps<keyof typeof animations>;
const animations = {
default: (() => {
const animation: Record<string, Variants> = {};
for (let i = 1; i <= 2; i++) {
animation[`path${i}`] = {
initial: { opacity: 1, scale: 1 },
animate: {
opacity: 0,
scale: 0,
transition: {
opacity: {
duration: 0.2,
ease: 'easeInOut',
repeat: 1,
repeatType: 'reverse',
repeatDelay: 0.2,
delay: 0.2 * (i - 1),
},
scale: {
duration: 0.2,
ease: 'easeInOut',
repeat: 1,
repeatType: 'reverse',
repeatDelay: 0.2,
delay: 0.2 * (i - 1),
},
},
},
};
}
return animation;
})() satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: RadioProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="M16.247 7.761a6 6 0 0 1 0 8.478"
variants={variants.path1}
initial="initial"
animate={controls}
/>
<motion.path
d="M19.075 4.933a10 10 0 0 1 0 14.134"
variants={variants.path2}
initial="initial"
animate={controls}
/>
<motion.path
d="M7.753 16.239a6 6 0 0 1 0-8.478"
variants={variants.path1}
initial="initial"
animate={controls}
/>
<motion.path
d="M4.925 19.067a10 10 0 0 1 0-14.134"
variants={variants.path2}
initial="initial"
animate={controls}
/>
<motion.circle
cx="12"
cy="12"
r="2"
variants={variants.circle}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function Radio(props: RadioProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
Radio,
Radio as RadioIcon,
type RadioProps as RadioIconProps,
type RadioProps,
};

View file

@ -30,8 +30,19 @@
"continue-with-github-login": "الدخول باستخدام جيت هاب",
"installation-failed": "فشل التثبيت",
"projects": "المشاريع",
"agents": "الوكلاء",
"mcp-tools": "MCP والأدوات",
"connectors": "الموصلات",
"connectors-description": "إدارة محركات البحث وأدوات MCP وتكاملات المتصفح.",
"channels": "القنوات",
"channels-overview": "نظرة عامة",
"channels-overview-coming-soon-description": "نظرة عامة على القنوات قيد التطوير.",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "تكامل هذه القناة قيد التطوير.",
"browser": "المتصفح",
"settings": "الإعدادات",
"general": "عام",
@ -137,7 +148,13 @@
"projects-hub": "مركز المشاريع",
"browser-management": "إدارة المتصفح",
"browser-management-description": "يتم تخزين ملفات تعريف الارتباط للمتصفح محلياً على جهازك، وتُستخدم فقط للمهام على نفس المواقع، ويمكن مسحها أو تعطيلها في أي وقت.",
"browser-connections": "الاتصالات",
"browser-connections-description": "تكوين بروتوكول أدوات مطوري Chrome لأتمتة المتصفح.",
"browser-plugins": "المكوّنات الإضافية",
"browser-plugins-description": "إدارة إضافات المتصفح لتحسين الوظائف.",
"restart-to-apply": "إعادة التشغيل للتطبيق",
"browser-cookie": "Cookies",
"browser-cookie-management": "إدارة ملفات تعريف الارتباط",
"browser-cookies": "ملفات تعريف الارتباط للمتصفح",
"browser-cookies-description": "استخدم \"فتح المتصفح\" لتسجيل الدخول إلى صفحة ويب وحفظ ملفات تعريف الارتباط الخاصة بها. بعد إعادة تشغيل التطبيق، سيتذكرها Eigent ويساعدك في إكمال المهام المستقبلية على تلك المواقع.",
"cookie-domains": "نطاقات ملفات تعريف الارتباط",

View file

@ -30,8 +30,19 @@
"continue-with-github-login": "Mit Github anmelden",
"installation-failed": "Installation fehlgeschlagen",
"projects": "Projekte",
"agents": "Agenten",
"mcp-tools": "MCP & Tools",
"connectors": "Konnektoren",
"connectors-description": "Suchmaschinen, MCP-Tools und Browser-Integrationen verwalten.",
"channels": "Kanäle",
"channels-overview": "Übersicht",
"channels-overview-coming-soon-description": "Die Kanäle-Übersicht wird entwickelt.",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "Die Integration für diesen Kanal wird entwickelt.",
"browser": "Browser",
"settings": "Einstellungen",
"general": "Allgemein",
@ -137,7 +148,13 @@
"projects-hub": "Projekte-Hub",
"browser-management": "Browser-Verwaltung",
"browser-management-description": "Die Browser-Cookies werden lokal auf Ihrem Gerät gespeichert, werden nur für Aufgaben auf denselben Websites verwendet und können jederzeit gelöscht oder deaktiviert werden.",
"browser-connections": "Verbindungen",
"browser-connections-description": "Chrome DevTools Protocol-Konfiguration für Browser-Automatisierung.",
"browser-plugins": "Plugins",
"browser-plugins-description": "Browser-Erweiterungen für erweiterte Funktionalität verwalten.",
"restart-to-apply": "Neu starten, um anzuwenden",
"browser-cookie": "Cookies",
"browser-cookie-management": "Cookie-Verwaltung",
"browser-cookies": "Browser-Cookies",
"browser-cookies-description": "Verwenden Sie \"Browser öffnen\" um sich bei einer Webseite anzumelden und deren Cookies zu speichern. Nach dem Neustart der Desktop-App wird sich Eigent daran erinnern und Ihnen helfen, zukünftige Aufgaben auf diesen Websites zu erledigen.",
"cookie-domains": "Cookie-Domains",

View file

@ -31,8 +31,19 @@
"installation-failed": "Installation Failed",
"backend-startup-failed": "Backend Startup Failed",
"projects": "Projects",
"agents": "Agents",
"mcp-tools": "MCP & Tools",
"connectors": "Connectors",
"connectors-description": "Manage search engines, MCP tools, and browser integrations.",
"channels": "Channels",
"channels-overview": "Overview",
"channels-overview-coming-soon-description": "Channels overview is under development.",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "Integration for this channel is under development.",
"browser": "Browser",
"settings": "Settings",
"general": "General",
@ -140,7 +151,13 @@
"projects-hub": "Projects Hub",
"browser-management": "Browser Management",
"browser-management-description": "The browser cookies are stored locally on your device, used only for tasks on those same sites, and can be cleared or disabled at any time.",
"browser-connections": "Connections",
"browser-connections-description": "Chrome DevTools Protocol configuration for browser automation.",
"browser-plugins": "Plugins",
"browser-plugins-description": "Manage browser extensions for enhanced functionality.",
"restart-to-apply": "Restart to Apply",
"browser-cookie": "Cookies",
"browser-cookie-management": "Cookie Management",
"browser-cookies": "Browser Cookies",
"browser-cookies-description": "Use 「Open Browser」to sign in to a webpage and save its cookies. After you restart the desktop app, Eigent will remember them and help you complete future tasks on those sites.",
"cookie-domains": "Cookie Domains",

View file

@ -30,8 +30,19 @@
"continue-with-github-login": "Continuar con Github",
"installation-failed": "Error al instalar",
"projects": "Proyectos",
"agents": "Agentes",
"mcp-tools": "MCP & Herramientas",
"connectors": "Conectores",
"connectors-description": "Gestionar motores de búsqueda, herramientas MCP e integraciones del navegador.",
"channels": "Canales",
"channels-overview": "Resumen",
"channels-overview-coming-soon-description": "El resumen de canales está en desarrollo.",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "La integración de este canal está en desarrollo.",
"browser": "Navegador",
"settings": "Ajustes",
"general": "General",
@ -137,7 +148,13 @@
"projects-hub": "Centro de Proyectos",
"browser-management": "Gestión del Navegador",
"browser-management-description": "Las cookies del navegador se almacenan localmente en su dispositivo, se usan solo para tareas en esos mismos sitios y se pueden borrar o desactivar en cualquier momento.",
"browser-connections": "Conexiones",
"browser-connections-description": "Configuración del protocolo Chrome DevTools para automatización del navegador.",
"browser-plugins": "Complementos",
"browser-plugins-description": "Gestionar extensiones del navegador para mejorar la funcionalidad.",
"restart-to-apply": "Reiniciar para Aplicar",
"browser-cookie": "Cookies",
"browser-cookie-management": "Gestión de Cookies",
"browser-cookies": "Cookies del Navegador",
"browser-cookies-description": "Use \"Abrir Navegador\" para iniciar sesión en una página web y guardar sus cookies. Después de reiniciar la aplicación, Eigent las recordará y le ayudará a completar tareas futuras en esos sitios.",
"cookie-domains": "Dominios de Cookies",

View file

@ -30,8 +30,19 @@
"continue-with-github-login": "Continuer avec Github",
"installation-failed": "Échec de l'installation",
"projects": "Projets",
"agents": "Agents",
"mcp-tools": "MCP & Outils",
"connectors": "Connecteurs",
"connectors-description": "Gérer les moteurs de recherche, les outils MCP et les intégrations du navigateur.",
"channels": "Canaux",
"channels-overview": "Aperçu",
"channels-overview-coming-soon-description": "L'aperçu des canaux est en cours de développement.",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "L'intégration de ce canal est en cours de développement.",
"browser": "Navigateur",
"settings": "Paramètres",
"general": "Général",
@ -137,7 +148,13 @@
"projects-hub": "Hub des Projets",
"browser-management": "Gestion du Navigateur",
"browser-management-description": "Les cookies du navigateur sont stockés localement sur votre appareil, utilisés uniquement pour les tâches sur ces mêmes sites, et peuvent être effacés ou désactivés à tout moment.",
"browser-connections": "Connexions",
"browser-connections-description": "Configuration du protocole Chrome DevTools pour l'automatisation du navigateur.",
"browser-plugins": "Plugins",
"browser-plugins-description": "Gérer les extensions du navigateur pour des fonctionnalités améliorées.",
"restart-to-apply": "Redémarrer pour Appliquer",
"browser-cookie": "Cookies",
"browser-cookie-management": "Gestion des Cookies",
"browser-cookies": "Cookies du Navigateur",
"browser-cookies-description": "Utilisez \"Ouvrir le Navigateur\" pour vous connecter à une page web et enregistrer ses cookies. Après avoir redémarré l'application, Eigent s'en souviendra et vous aidera à compléter les tâches futures sur ces sites.",
"cookie-domains": "Domaines de Cookies",

View file

@ -30,8 +30,19 @@
"continue-with-github-login": "Continua con Github",
"installation-failed": "Installazione fallita",
"projects": "Progetti",
"agents": "Agenti",
"mcp-tools": "MCP e Strumenti",
"connectors": "Connettori",
"connectors-description": "Gestisci motori di ricerca, strumenti MCP e integrazioni del browser.",
"channels": "Canali",
"channels-overview": "Panoramica",
"channels-overview-coming-soon-description": "La panoramica dei canali è in fase di sviluppo.",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "L'integrazione per questo canale è in fase di sviluppo.",
"browser": "Navigatore",
"settings": "Impostazioni",
"general": "Generale",
@ -137,7 +148,13 @@
"projects-hub": "Hub Progetti",
"browser-management": "Gestione Browser",
"browser-management-description": "I cookie del browser sono memorizzati localmente sul tuo dispositivo, utilizzati solo per le attività su quegli stessi siti e possono essere cancellati o disabilitati in qualsiasi momento.",
"browser-connections": "Connessioni",
"browser-connections-description": "Configurazione del protocollo Chrome DevTools per l'automazione del browser.",
"browser-plugins": "Plugin",
"browser-plugins-description": "Gestisci le estensioni del browser per funzionalità avanzate.",
"restart-to-apply": "Riavvia per Applicare",
"browser-cookie": "Cookies",
"browser-cookie-management": "Gestione Cookie",
"browser-cookies": "Cookie del Browser",
"browser-cookies-description": "Usa \"Apri Browser\" per accedere a una pagina web e salvare i suoi cookie. Dopo aver riavviato l'applicazione, Eigent li ricorderà e ti aiuterà a completare le attività future su quei siti.",
"cookie-domains": "Domini dei Cookie",

View file

@ -30,8 +30,19 @@
"continue-with-github-login": "Githubでログイン",
"installation-failed": "インストールに失敗しました",
"projects": "プロジェクト",
"agents": "エージェント",
"mcp-tools": "MCP & ツール",
"connectors": "コネクタ",
"connectors-description": "検索エンジン、MCPツール、ブラウザ連携を管理します。",
"channels": "チャンネル",
"channels-overview": "概要",
"channels-overview-coming-soon-description": "チャンネル概要は開発中です。",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "このチャンネルの統合は開発中です。",
"browser": "ブラウザ",
"settings": "設定",
"general": "一般",
@ -137,7 +148,13 @@
"projects-hub": "プロジェクトハブ",
"browser-management": "ブラウザ管理",
"browser-management-description": "ブラウザのCookieはデバイスにローカルに保存され、同じサイトのタスクでのみ使用され、いつでもクリアまたは無効にできます。",
"browser-connections": "接続",
"browser-connections-description": "ブラウザ自動化のためのChrome DevTools Protocol設定。",
"browser-plugins": "プラグイン",
"browser-plugins-description": "機能強化のためのブラウザ拡張機能を管理。",
"restart-to-apply": "適用するために再起動",
"browser-cookie": "Cookies",
"browser-cookie-management": "Cookie管理",
"browser-cookies": "ブラウザCookie",
"browser-cookies-description": "「ブラウザを開く」を使用して、ウェブページにログインし、そのCookieを保存します。再起動すると、Eigentはそれらを記憶し、それらのサイトで将来のタスクを完了するのに役立ちます。",
"cookie-domains": "Cookieドメイン",

View file

@ -30,8 +30,19 @@
"continue-with-github-login": "Github로 로그인",
"installation-failed": "설치 실패",
"projects": "프로젝트",
"agents": "에이전트",
"mcp-tools": "MCP 및 도구",
"connectors": "커넥터",
"connectors-description": "검색 엔진, MCP 도구 및 브라우저 통합을 관리합니다.",
"channels": "채널",
"channels-overview": "개요",
"channels-overview-coming-soon-description": "채널 개요는 개발 중입니다.",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "이 채널 통합은 개발 중입니다.",
"browser": "브라우저",
"settings": "설정",
"general": "일반",
@ -137,7 +148,13 @@
"projects-hub": "프로젝트 허브",
"browser-management": "브라우저 관리",
"browser-management-description": "브라우저 쿠키는 기기에 로컬로 저장되며, 동일한 사이트의 작업에만 사용되며 언제든지 지우거나 비활성화할 수 있습니다.",
"browser-connections": "연결",
"browser-connections-description": "브라우저 자동화를 위한 Chrome DevTools Protocol 설정.",
"browser-plugins": "플러그인",
"browser-plugins-description": "향상된 기능을 위한 브라우저 확장 프로그램 관리.",
"restart-to-apply": "적용하려면 다시 시작",
"browser-cookie": "쿠키",
"browser-cookie-management": "Cookie 관리",
"browser-cookies": "브라우저 쿠키",
"browser-cookies-description": "「브라우저 열기」를 사용하여 웹페이지에 로그인하고 쿠키를 저장하세요. 다시 시작한 후 Eigent는 이를 기억하고 해당 사이트에서 향후 작업을 완료하는 데 도움을 줍니다.",
"cookie-domains": "쿠키 도메인",

View file

@ -30,8 +30,19 @@
"continue-with-github-login": "Продолжить с Github",
"installation-failed": "Установка не удалась",
"projects": "Проекты",
"agents": "Агенты",
"mcp-tools": "MCP и Инструменты",
"connectors": "Коннекторы",
"connectors-description": "Управление поисковыми системами, инструментами MCP и интеграциями браузера.",
"channels": "Каналы",
"channels-overview": "Обзор",
"channels-overview-coming-soon-description": "Обзор каналов находится в разработке.",
"channels-whatsapp": "WhatsApp",
"channels-lark": "Lark",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "Интеграция для этого канала находится в разработке.",
"browser": "Браузер",
"settings": "Настройки",
"general": "Общие",
@ -137,7 +148,13 @@
"projects-hub": "Центр проектов",
"browser-management": "Управление браузером",
"browser-management-description": "Файлы cookie браузера хранятся локально на вашем устройстве, используются только для задач на тех же сайтах и могут быть очищены или отключены в любое время.",
"browser-connections": "Подключения",
"browser-connections-description": "Настройка протокола Chrome DevTools для автоматизации браузера.",
"browser-plugins": "Плагины",
"browser-plugins-description": "Управление расширениями браузера для расширенной функциональности.",
"restart-to-apply": "Перезапустить для применения",
"browser-cookie": "Cookies",
"browser-cookie-management": "Управление Cookie",
"browser-cookies": "Файлы cookie браузера",
"browser-cookies-description": "Используйте \"Открыть браузер\" чтобы войти на веб-страницу и сохранить её файлы cookie. После перезапуска клиента Eigent запомнит их и поможет вам выполнить будущие задачи на этих сайтах.",
"cookie-domains": "Домены cookie",

View file

@ -33,8 +33,19 @@
"installation-failed": "安装失败",
"backend-startup-failed": "后端启动失败",
"projects": "项目",
"agents": "智能体",
"mcp-tools": "MCP & 工具",
"connectors": "连接器",
"connectors-description": "管理搜索引擎、MCP 工具和浏览器集成。",
"channels": "频道",
"channels-overview": "概览",
"channels-overview-coming-soon-description": "频道概览正在开发中。",
"channels-whatsapp": "WhatsApp",
"channels-lark": "飞书",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "此频道的集成正在开发中。",
"browser": "浏览器",
"settings": "设置",
"general": "通用",
@ -140,7 +151,13 @@
"projects-hub": "项目中心",
"browser-management": "浏览器管理",
"browser-management-description": "浏览器 Cookie 存储在您设备本地,仅用于相同网站的任务,可随时清除或禁用。",
"browser-connections": "连接",
"browser-connections-description": "Chrome 开发者工具协议配置,用于浏览器自动化。",
"browser-plugins": "插件",
"browser-plugins-description": "管理浏览器扩展以增强功能。",
"restart-to-apply": "重启以应用",
"browser-cookie": "Cookies",
"browser-cookie-management": "Cookie 管理",
"browser-cookies": "浏览器 Cookie",
"browser-cookies-description": "使用「打开浏览器」登录网页并保存其 Cookie。重启客户端后Eigent 会记住它们,并帮助您在这些网站上完成未来的任务。",
"cookie-domains": "Cookie 域名",

View file

@ -32,8 +32,19 @@
"continue-with-github-login": "使用 Github 登录",
"installation-failed": "安装失败",
"projects": "專案",
"agents": "智能體",
"mcp-tools": "MCP & 工具",
"connectors": "連接器",
"connectors-description": "管理搜尋引擎、MCP 工具與瀏覽器整合。",
"channels": "頻道",
"channels-overview": "概覽",
"channels-overview-coming-soon-description": "頻道概覽正在開發中。",
"channels-whatsapp": "WhatsApp",
"channels-lark": "飛書",
"channels-telegram": "Telegram",
"channels-slack": "Slack",
"channels-discord": "Discord",
"channels-coming-soon-description": "此頻道的整合正在開發中。",
"browser": "瀏覽器",
"settings": "設定",
"general": "一般",
@ -140,7 +151,13 @@
"projects-hub": "專案中心",
"browser-management": "瀏覽器管理",
"browser-management-description": "瀏覽器 Cookie 儲存在您裝置本地,僅用於相同網站的工作,可隨時清除或停用。",
"browser-connections": "連線",
"browser-connections-description": "Chrome 開發者工具協定設定,用於瀏覽器自動化。",
"browser-plugins": "外掛程式",
"browser-plugins-description": "管理瀏覽器擴充功能以增強功能。",
"restart-to-apply": "重新啟動以套用",
"browser-cookie": "Cookies",
"browser-cookie-management": "Cookie 管理",
"browser-cookies": "瀏覽器 Cookie",
"browser-cookies-description": "使用「開啟瀏覽器」登入網頁並儲存其 Cookie。重新啟動用戶端後Eigent 會記住它們,並幫助您在這些網站上完成未來的任務。",
"cookie-domains": "Cookie 網域",

View file

@ -1115,13 +1115,13 @@ export default function SettingModels() {
<button
key={tabId}
onClick={() => setSelectedTab(tabId)}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
className={`rounded-xl px-3 py-2 flex w-full items-center justify-between transition-all duration-200 ${isSubItem ? 'pl-3' : ''} ${
isActive
? 'bg-fill-fill-transparent-active'
: 'bg-fill-fill-transparent hover:bg-fill-fill-transparent-hover'
} `}
>
<div className="flex items-center justify-center gap-3">
<div className="gap-3 flex items-center justify-center">
{modelImage ? (
<img
src={modelImage}
@ -1141,7 +1141,7 @@ export default function SettingModels() {
</span>
</div>
{isConfigured && (
<div className="m-1 h-2 w-2 rounded-full bg-text-success" />
<div className="m-1 h-2 w-2 bg-text-success rounded-full" />
)}
</button>
);
@ -1153,16 +1153,16 @@ export default function SettingModels() {
if (selectedTab === 'cloud') {
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
return (
<div className="flex h-64 items-center justify-center text-text-label">
<div className="h-64 text-text-label flex items-center justify-center">
{t('setting.cloud-not-available-in-local-proxy')}
</div>
);
}
return (
<div className="flex w-full flex-col rounded-2xl bg-surface-tertiary">
<div className="mx-6 mb-4 flex flex-col justify-start self-stretch border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary pb-4 pt-2">
<div className="inline-flex items-center justify-start gap-2 self-stretch">
<div className="text-body-base my-2 flex-1 justify-center font-bold text-text-heading">
<div className="rounded-2xl bg-surface-tertiary flex w-full flex-col">
<div className="mx-6 mb-4 border-border-secondary pb-4 pt-2 flex flex-col justify-start self-stretch border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-start self-stretch">
<div className="text-body-base my-2 font-bold text-text-heading flex-1 justify-center">
{t('setting.eigent-cloud')}
</div>
{cloudPrefer ? (
@ -1181,7 +1181,7 @@ export default function SettingModels() {
<Button
variant="ghost"
size="xs"
className="rounded-full !text-text-label"
className="!text-text-label rounded-full"
onClick={() => {
setLocalPrefer(false);
setActiveModelIdx(null);
@ -1205,7 +1205,7 @@ export default function SettingModels() {
onClick={() => {
window.location.href = `https://www.eigent.ai/pricing`;
}}
className="cursor-pointer text-body-sm text-text-label underline"
className="text-body-sm text-text-label cursor-pointer underline"
>
{t('setting.pricing-options')}
</span>
@ -1215,7 +1215,7 @@ export default function SettingModels() {
</div>
</div>
{/*Content Area*/}
<div className="flex w-full flex-row items-center justify-between gap-4 px-6 pb-4">
<div className="gap-4 px-6 pb-4 flex w-full flex-row items-center justify-between">
<div className="text-body-sm text-text-body">
{t('setting.credits')}:{' '}
{loadingCredits ? (
@ -1240,9 +1240,9 @@ export default function SettingModels() {
<Settings />
</Button>
</div>
<div className="flex w-full flex-1 items-center justify-between px-6 pb-4">
<div className="flex min-w-0 flex-1 items-center">
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-body-sm">
<div className="px-6 pb-4 flex w-full flex-1 items-center justify-between">
<div className="min-w-0 flex flex-1 items-center">
<span className="text-body-sm overflow-hidden text-ellipsis whitespace-nowrap">
{t('setting.select-model-type')}
</span>
</div>
@ -1306,15 +1306,15 @@ export default function SettingModels() {
const canSwitch = !!form[idx].provider_id;
return (
<div className="flex w-full flex-col rounded-2xl bg-surface-tertiary">
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary pb-4 pt-2">
<div className="inline-flex items-center justify-between gap-2 self-stretch">
<div className="rounded-2xl bg-surface-tertiary flex w-full flex-col">
<div className="mx-6 mb-4 border-border-secondary pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-between self-stretch">
<div className="text-body-base my-2 font-bold text-text-heading">
{item.name}
</div>
<div className="flex items-center gap-2">
<div className="gap-2 flex items-center">
{form[idx].prefer ? (
<span className="inline-flex items-center rounded-full px-2 py-1 text-label-xs font-bold text-text-success">
<span className="px-2 py-1 text-label-xs font-bold text-text-success inline-flex items-center rounded-full">
{t('setting.default')}
</span>
) : (
@ -1325,8 +1325,8 @@ export default function SettingModels() {
onClick={() => handleSwitch(idx, true)}
className={
canSwitch
? 'inline-flex items-center rounded-full bg-button-transparent-fill-hover !text-text-label shadow-none hover:bg-button-transparent-fill-active'
: 'inline-flex items-center gap-1.5'
? 'bg-button-transparent-fill-hover !text-text-label hover:bg-button-transparent-fill-active inline-flex items-center rounded-full shadow-none'
: 'gap-1.5 inline-flex items-center'
}
>
{!canSwitch
@ -1335,9 +1335,9 @@ export default function SettingModels() {
</Button>
)}
{form[idx].provider_id ? (
<div className="h-2 w-2 shrink-0 rounded-full bg-text-success" />
<div className="h-2 w-2 bg-text-success shrink-0 rounded-full" />
) : (
<div className="h-2 w-2 shrink-0 rounded-full bg-text-label opacity-10" />
<div className="h-2 w-2 bg-text-label shrink-0 rounded-full opacity-10" />
)}
</div>
</div>
@ -1345,7 +1345,7 @@ export default function SettingModels() {
{item.description}
</div>
</div>
<div className="flex w-full flex-col items-center gap-4 px-6">
<div className="gap-4 px-6 flex w-full flex-col items-center">
{/* API Key Setting */}
<Input
id={`apiKey-${item.id}`}
@ -1426,7 +1426,7 @@ export default function SettingModels() {
{item.externalConfig &&
form[idx].externalConfig &&
form[idx].externalConfig.map((ec, ecIdx) => (
<div key={ec.key} className="flex h-full w-full flex-col gap-4">
<div key={ec.key} className="gap-4 flex h-full w-full flex-col">
{ec.options && ec.options.length > 0 ? (
<Select
value={ec.value}
@ -1493,7 +1493,7 @@ export default function SettingModels() {
))}
</div>
{/* Action Button */}
<div className="flex justify-end gap-2 px-6 py-4">
<div className="gap-2 px-6 py-4 flex justify-end">
<Button
variant="ghost"
size="sm"
@ -1526,10 +1526,10 @@ export default function SettingModels() {
const isPreferred = localPrefer && localPlatform === platform;
return (
<div className="flex w-full flex-col rounded-2xl bg-surface-tertiary">
<div className="mx-6 mb-4 flex flex-col items-start justify-between border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary pb-4 pt-2">
<div className="inline-flex items-center justify-between gap-2 self-stretch">
<div className="flex items-center gap-2">
<div className="rounded-2xl bg-surface-tertiary flex w-full flex-col">
<div className="mx-6 mb-4 border-border-secondary pb-4 pt-2 flex flex-col items-start justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<div className="gap-2 inline-flex items-center justify-between self-stretch">
<div className="gap-2 flex items-center">
<div className="text-body-base my-2 font-bold text-text-heading">
{platform === 'ollama'
? 'Ollama'
@ -1557,7 +1557,7 @@ export default function SettingModels() {
onClick={() => handleLocalSwitch(true)}
className={
isConfigured
? 'rounded-full bg-button-transparent-fill-hover !text-text-label shadow-none'
? 'bg-button-transparent-fill-hover !text-text-label rounded-full shadow-none'
: ''
}
>
@ -1568,14 +1568,14 @@ export default function SettingModels() {
)}
</div>
{isConfigured ? (
<div className="h-2 w-2 rounded-full bg-text-success" />
<div className="h-2 w-2 bg-text-success rounded-full" />
) : (
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
)}
</div>
</div>
{/* Model Endpoint URL Setting */}
<div className="flex w-full flex-col items-center gap-4 px-6">
<div className="gap-4 px-6 flex w-full flex-col items-center">
<Input
size="default"
title={t('setting.model-endpoint-url')}
@ -1623,8 +1623,8 @@ export default function SettingModels() {
note={localError ?? undefined}
/>
{platform === 'ollama' ? (
<div className="flex w-full flex-col gap-1">
<div className="flex w-full items-end gap-2">
<div className="gap-1 flex w-full flex-col">
<div className="gap-2 flex w-full items-end">
<div className="flex-1">
<Select
value={currentType}
@ -1718,7 +1718,7 @@ export default function SettingModels() {
)}
</div>
{/* Action Button */}
<div className="flex justify-end gap-2 px-6 py-4">
<div className="gap-2 px-6 py-4 flex justify-end">
<Button
variant="ghost"
size="sm"
@ -1748,8 +1748,8 @@ export default function SettingModels() {
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="sticky top-0 z-10 flex w-full items-center justify-between bg-surface-primary px-6 pb-6 pt-8">
<div className="flex w-full flex-col items-start justify-between gap-4">
<div className="top-0 bg-surface-primary px-6 pb-6 pt-8 sticky z-10 flex w-full items-center justify-between">
<div className="gap-4 flex w-full flex-col items-start justify-between">
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-text-heading">
{t('setting.models')}
@ -1758,10 +1758,10 @@ export default function SettingModels() {
</div>
</div>
{/* Content Section */}
<div className="mb-8 flex flex-col gap-6">
<div className="mb-8 gap-6 flex flex-col">
{/* Default Model Cascading Dropdown */}
<div className="flex w-full flex-row items-center justify-between gap-4 rounded-2xl bg-surface-secondary px-6 py-4">
<div className="flex w-full flex-col items-start justify-center gap-1">
<div className="gap-4 rounded-2xl bg-surface-secondary px-6 py-4 flex w-full flex-col items-end justify-between">
<div className="gap-1 flex w-full flex-col items-start justify-center">
<div className="text-body-base font-bold text-text-heading">
{t('setting.models-default-setting-title')}
</div>
@ -1771,11 +1771,11 @@ export default function SettingModels() {
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex w-fit items-center justify-between gap-2 rounded-lg border-[0.5px] border-solid border-border-success bg-surface-success px-3 py-1 font-semibold text-text-success transition-colors hover:opacity-70 active:opacity-90">
<span className="whitespace-nowrap text-body-sm">
<button className="gap-2 rounded-lg border-border-success bg-surface-success px-3 py-1 font-semibold text-text-success flex w-fit items-center justify-between border-[0.5px] border-solid transition-colors hover:opacity-70 active:opacity-90">
<span className="text-body-sm whitespace-nowrap">
{getDefaultModelDisplayText()}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0 text-text-success" />
<ChevronDown className="h-4 w-4 text-text-success flex-shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[180px]">
@ -1829,7 +1829,7 @@ export default function SettingModels() {
}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div className="gap-2 flex items-center">
{modelImage ? (
<img
src={modelImage}
@ -1850,15 +1850,15 @@ export default function SettingModels() {
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
<div className="gap-1 flex items-center">
{!isConfigured && (
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-text-success" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 rounded-full bg-text-success" />
<div className="h-2 w-2 bg-text-success rounded-full" />
)}
</div>
</DropdownMenuItem>
@ -1890,7 +1890,7 @@ export default function SettingModels() {
}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div className="gap-2 flex items-center">
{modelImage ? (
<img
src={modelImage}
@ -1911,15 +1911,15 @@ export default function SettingModels() {
{model.name}
</span>
</div>
<div className="flex items-center gap-1">
<div className="gap-1 flex items-center">
{!isConfigured && (
<div className="h-2 w-2 rounded-full bg-text-label opacity-10" />
<div className="h-2 w-2 bg-text-label rounded-full opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-text-success" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 rounded-full bg-text-success" />
<div className="h-2 w-2 bg-text-success rounded-full" />
)}
</div>
</DropdownMenuItem>
@ -1932,17 +1932,17 @@ export default function SettingModels() {
</div>
{/* Content Section with Sidebar */}
<div className="flex w-full flex-col items-start justify-between gap-2 rounded-2xl bg-surface-secondary px-6 py-4">
<div className="text-body-base sticky top-[86px] z-10 mb-2 w-full border-x-0 border-b-[0.5px] border-t-0 border-solid border-border-secondary bg-surface-secondary pb-4 font-bold text-text-heading">
<div className="gap-2 rounded-2xl bg-surface-secondary px-6 py-4 flex w-full flex-col items-start justify-between">
<div className="text-body-base mb-2 border-border-secondary bg-surface-secondary pb-4 font-bold text-text-heading sticky top-[86px] z-10 w-full border-x-0 border-t-0 border-b-[0.5px] border-solid">
{t('setting.models-configuration')}
</div>
<div className="flex w-full flex-row items-start justify-between">
{/* Sidebar */}
<div className="-ml-2 mr-4 h-full w-[240px] rounded-2xl bg-surface-secondary">
<div className="flex flex-col gap-4">
<div className="-ml-2 mr-4 rounded-2xl bg-surface-secondary h-full w-[240px]">
<div className="gap-4 flex flex-col">
{/* Eigent Cloud Section */}
<div className="flex flex-col gap-1">
<div className="gap-1 flex flex-col">
<div className="px-3 py-2 text-body-sm font-bold text-text-heading">
{t('setting.eigent-cloud')}
</div>
@ -1957,10 +1957,10 @@ export default function SettingModels() {
)}
</div>
{/* Bring Your Own Key Section */}
<div className="flex flex-col gap-1">
<div className="gap-1 flex flex-col">
<button
onClick={() => setByokCollapsed(!byokCollapsed)}
className="flex items-center justify-between rounded-lg bg-transparent px-3 py-2 transition-colors hover:bg-surface-secondary"
className="rounded-lg px-3 py-2 hover:bg-surface-secondary flex items-center justify-between bg-transparent transition-colors"
>
<div className="text-body-sm font-bold text-text-heading">
{t('setting.custom-model')}
@ -1972,7 +1972,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
className={`ease-in-out overflow-hidden transition-all duration-300 ${
byokCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -1992,10 +1992,10 @@ export default function SettingModels() {
</div>
{/* Local Model Section */}
<div className="flex flex-col gap-1">
<div className="gap-1 flex flex-col">
<button
onClick={() => setLocalCollapsed(!localCollapsed)}
className="flex items-center justify-between rounded-lg bg-transparent px-3 py-2 transition-colors hover:bg-surface-secondary"
className="rounded-lg px-3 py-2 hover:bg-surface-secondary flex items-center justify-between bg-transparent transition-colors"
>
<div className="text-body-sm font-bold text-text-heading">
{t('setting.local-model')}
@ -2007,7 +2007,7 @@ export default function SettingModels() {
)}
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
className={`ease-in-out overflow-hidden transition-all duration-300 ${
localCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[2000px] opacity-100'
@ -2050,7 +2050,7 @@ export default function SettingModels() {
</div>
</div>
{/* Main Content */}
<div className="sticky top-[136px] z-10 min-w-0 flex-1">
<div className="min-w-0 sticky top-[136px] z-10 flex-1">
{renderContent()}
</div>
</div>

View file

@ -23,7 +23,7 @@ import Skills from './Skills';
export default function Capabilities() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('skills');
const [activeTab, setActiveTab] = useState('models');
const menuItems = [
{
@ -46,8 +46,8 @@ export default function Capabilities() {
return (
<div className="m-auto flex h-auto max-w-[940px] flex-col">
<div className="flex h-auto w-full px-6">
<div className="sticky top-20 flex h-full w-40 flex-shrink-0 flex-grow-0 flex-col justify-between self-start pr-6 pt-8">
<div className="px-6 flex h-auto w-full">
<div className="top-20 w-40 pr-6 pt-8 sticky flex h-full flex-shrink-0 flex-grow-0 flex-col justify-between self-start">
<VerticalNavigation
items={
menuItems.map((menu) => ({
@ -59,14 +59,14 @@ export default function Capabilities() {
}
value={activeTab}
onValueChange={handleTabChange}
className="h-full min-h-0 w-full flex-1 gap-0"
className="min-h-0 gap-0 h-full w-full flex-1"
listClassName="w-full h-full overflow-y-auto"
contentClassName="hidden"
/>
</div>
<div className="flex h-auto w-full flex-1 flex-col">
<div className="flex flex-col gap-4">
<div className="gap-4 flex flex-col">
{activeTab === 'models' && <Models />}
{activeTab === 'skills' && <Skills />}
{activeTab === 'memory' && <Memory />}

316
src/pages/Browser/CDP.tsx Normal file
View file

@ -0,0 +1,316 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import AlertDialog from '@/components/ui/alertDialog';
import { Button } from '@/components/ui/button';
import { Globe, Link2, Loader2, Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
interface CdpBrowser {
id: string;
port: number;
isExternal: boolean;
name?: string;
addedAt: number;
}
export default function CDP() {
const { t } = useTranslation();
const [cdpBrowsers, setCdpBrowsers] = useState<CdpBrowser[]>([]);
const [deletingBrowser, setDeletingBrowser] = useState<string | null>(null);
const [browserToRemove, setBrowserToRemove] = useState<CdpBrowser | null>(
null
);
const [showConnectDialog, setShowConnectDialog] = useState(false);
const [connectPort, setConnectPort] = useState('');
const [connectChecking, setConnectChecking] = useState(false);
const [connectError, setConnectError] = useState('');
const loadCdpBrowsers = async () => {
if (window.electronAPI?.getCdpBrowsers) {
try {
const browsers = await window.electronAPI.getCdpBrowsers();
setCdpBrowsers(browsers);
} catch (error) {
console.error('Failed to load CDP browsers:', error);
}
}
};
useEffect(() => {
loadCdpBrowsers();
}, []);
useEffect(() => {
if (!window.electronAPI?.onCdpPoolChanged) return;
const cleanup = window.electronAPI.onCdpPoolChanged(
(browsers: CdpBrowser[]) => {
setCdpBrowsers(browsers);
}
);
return cleanup;
}, []);
const handleRemoveBrowser = async (browserId: string) => {
setDeletingBrowser(browserId);
try {
if (window.electronAPI?.removeCdpBrowser) {
const result = await window.electronAPI.removeCdpBrowser(browserId);
if (result.success) {
toast.success(t('layout.browser-removed'));
} else {
toast.error(result.error || t('layout.failed-to-remove-browser'));
}
}
} catch (error: any) {
toast.error(error?.message || t('layout.failed-to-remove-browser'));
} finally {
setDeletingBrowser(null);
setBrowserToRemove(null);
}
};
const handleOpenNewBrowser = async () => {
try {
toast.loading(t('layout.launching-browser', { port: '...' }), {
id: 'launch-browser',
});
const result = await window.electronAPI?.launchCdpBrowser();
if (result?.success) {
toast.success(t('layout.browser-launched', { port: result.port }), {
id: 'launch-browser',
});
} else {
toast.error(result?.error || t('layout.failed-to-launch-browser'), {
id: 'launch-browser',
});
}
} catch (error: any) {
toast.error(error?.message || t('layout.failed-to-launch-browser'), {
id: 'launch-browser',
});
}
};
const handleConnectExistingBrowser = () => {
setConnectPort('');
setConnectError('');
setShowConnectDialog(true);
};
const handleCheckAndConnect = async () => {
const portNum = parseInt(connectPort, 10);
if (Number.isNaN(portNum) || portNum < 1 || portNum > 65535) {
setConnectError(t('layout.invalid-port'));
return;
}
if (cdpBrowsers.some((browser) => browser.port === portNum)) {
setConnectError(t('layout.port-already-in-use'));
return;
}
setConnectChecking(true);
setConnectError('');
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
const controller = new AbortController();
timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`http://localhost:${portNum}/json/version`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
timeoutId = null;
if (!response.ok) {
setConnectError(t('layout.no-browser-on-port', { port: portNum }));
return;
}
if (window.electronAPI?.addCdpBrowser) {
const addResult = await window.electronAPI.addCdpBrowser(
portNum,
true,
`External Browser (${portNum})`
);
if (!addResult?.success) {
setConnectError(
addResult?.error || t('layout.failed-to-add-browser')
);
return;
}
} else {
setConnectError(t('layout.failed-to-add-browser'));
return;
}
toast.success(t('layout.connected-browser', { port: portNum }));
setShowConnectDialog(false);
} catch {
setConnectError(t('layout.no-browser-on-port', { port: portNum }));
} finally {
if (timeoutId) clearTimeout(timeoutId);
setConnectChecking(false);
}
};
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
<AlertDialog
isOpen={!!browserToRemove}
onClose={() => setBrowserToRemove(null)}
onConfirm={() => {
if (browserToRemove) {
handleRemoveBrowser(browserToRemove.id);
}
}}
title={t('layout.remove-browser')}
message={t('layout.remove-browser-confirm', {
name: browserToRemove?.name || `Browser ${browserToRemove?.port}`,
port: browserToRemove?.port,
})}
confirmText={t('layout.remove')}
cancelText={t('layout.cancel')}
confirmVariant="cuation"
/>
<div className="flex w-full items-center justify-between px-6 pb-6 pt-8">
<div className="text-heading-sm font-bold text-text-heading">
{t('layout.cdp-browser-connection')}
</div>
</div>
<div className="flex flex-col gap-4 px-6">
<div className="flex items-center gap-3">
<Button variant="primary" size="sm" onClick={handleOpenNewBrowser}>
<Plus className="h-4 w-4" />
{t('layout.open-new-browser')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleConnectExistingBrowser}
>
<Link2 className="h-4 w-4 text-button-tertiery-text-default" />
{t('layout.connect-existing-browser')}
</Button>
</div>
<div className="flex flex-col gap-2">
<div className="text-body-base font-bold text-text-body">
{t('layout.cdp-browser-pool')}
</div>
{cdpBrowsers.length > 0 ? (
<div className="flex flex-col gap-2">
{cdpBrowsers.map((browser) => (
<div
key={browser.id}
className="flex items-center justify-between rounded-xl border-solid border-border-disabled bg-surface-tertiary px-4 py-2"
>
<div className="flex w-full flex-row items-center gap-2">
<div className="h-2 w-2 shrink-0 rounded-full bg-text-success" />
<div className="flex flex-col items-start justify-start">
<span className="text-body-sm font-bold text-text-body">
{browser.name || `Browser ${browser.port}`}
</span>
<span className="text-label-xs text-text-label">
{t('layout.port')} {browser.port}
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setBrowserToRemove(browser)}
disabled={deletingBrowser === browser.id}
className="ml-3 flex-shrink-0"
>
<Trash2 className="h-4 w-4 text-text-cuation" />
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center px-4 py-8">
<Globe className="mb-4 h-12 w-12 text-icon-secondary opacity-50" />
<div className="text-body-base text-center font-bold text-text-label">
{t('layout.no-browsers-in-pool')}
</div>
<p className="text-center text-label-xs font-medium text-text-label">
{t('layout.add-browsers-hint')}
</p>
</div>
)}
</div>
</div>
{showConnectDialog && (
<div className="bg-black/50 fixed inset-0 z-50 flex items-center justify-center">
<div className="w-full max-w-md rounded-xl bg-surface-primary p-6 shadow-lg">
<div className="text-body-base mb-2 font-bold text-text-heading">
{t('layout.connect-existing-browser')}
</div>
<p className="mb-4 text-label-xs text-text-label">
{t('layout.connect-existing-browser-description')}
</p>
<input
type="text"
value={connectPort}
onChange={(event) => {
setConnectPort(event.target.value);
setConnectError('');
}}
placeholder={t('layout.enter-port-number')}
className="w-full rounded-lg border border-border-disabled bg-surface-secondary px-4 py-2 text-body-sm text-text-body outline-none focus:border-border-focus"
onKeyDown={(event) => {
if (event.key === 'Enter') handleCheckAndConnect();
}}
/>
{connectError && (
<p className="mt-2 text-label-xs text-text-cuation">
{connectError}
</p>
)}
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConnectDialog(false)}
>
{t('layout.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleCheckAndConnect}
disabled={connectChecking}
>
{connectChecking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Link2 className="h-4 w-4" />
)}
{t('layout.check-and-connect')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,365 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { fetchDelete, fetchGet, fetchPost } from '@/api/http';
import AlertDialog from '@/components/ui/alertDialog';
import { Button } from '@/components/ui/button';
import { Cookie, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
interface CookieDomain {
domain: string;
cookie_count: number;
last_access: string;
}
interface GroupedDomain {
mainDomain: string;
subdomains: CookieDomain[];
totalCookies: number;
}
export default function Cookies() {
const { t } = useTranslation();
const [loginLoading, setLoginLoading] = useState(false);
const [cookiesLoading, setCookiesLoading] = useState(false);
const [cookieDomains, setCookieDomains] = useState<CookieDomain[]>([]);
const [deletingDomain, setDeletingDomain] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [showRestartDialog, setShowRestartDialog] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const getMainDomain = (domain: string): string => {
const cleanDomain = domain.startsWith('.') ? domain.substring(1) : domain;
const parts = cleanDomain.split('.');
if (parts.length <= 2) {
return cleanDomain;
}
return parts.slice(-2).join('.');
};
const groupDomainsByMain = (domains: CookieDomain[]): GroupedDomain[] => {
const grouped = new Map<string, CookieDomain[]>();
domains.forEach((item) => {
const mainDomain = getMainDomain(item.domain);
if (!grouped.has(mainDomain)) {
grouped.set(mainDomain, []);
}
grouped.get(mainDomain)!.push(item);
});
return Array.from(grouped.entries())
.map(([mainDomain, subdomains]) => ({
mainDomain,
subdomains,
totalCookies: subdomains.reduce(
(sum, item) => sum + item.cookie_count,
0
),
}))
.sort((a, b) => a.mainDomain.localeCompare(b.mainDomain));
};
useEffect(() => {
handleLoadCookies();
}, []);
const handleBrowserLogin = async () => {
setLoginLoading(true);
try {
const currentCookieCount = cookieDomains.reduce(
(sum, item) => sum + item.cookie_count,
0
);
const response = await fetchPost('/browser/login');
if (response) {
toast.success('Browser opened successfully for login');
const checkInterval = setInterval(async () => {
try {
const statusResponse = await fetchGet('/browser/status');
if (!statusResponse || !statusResponse.is_open) {
clearInterval(checkInterval);
await handleLoadCookies();
const newResponse = await fetchGet('/browser/cookies');
if (newResponse && newResponse.success) {
const newDomains = newResponse.domains || [];
const newCookieCount = newDomains.reduce(
(sum: number, item: CookieDomain) => sum + item.cookie_count,
0
);
if (newCookieCount > currentCookieCount) {
const addedCount = newCookieCount - currentCookieCount;
toast.success(
`Added ${addedCount} cookie${addedCount !== 1 ? 's' : ''}`
);
setHasUnsavedChanges(true);
setShowRestartDialog(true);
} else if (newCookieCount < currentCookieCount) {
setHasUnsavedChanges(true);
setShowRestartDialog(true);
}
}
}
} catch (error) {
console.error(error);
clearInterval(checkInterval);
await handleLoadCookies();
}
}, 500);
}
} catch (error: any) {
toast.error(error?.message || 'Failed to open browser');
} finally {
setLoginLoading(false);
}
};
const handleLoadCookies = async () => {
setCookiesLoading(true);
try {
const response = await fetchGet('/browser/cookies');
if (response && response.success) {
const domains = response.domains || [];
setCookieDomains(domains);
} else {
setCookieDomains([]);
}
} catch (error: any) {
toast.error(error?.message || 'Failed to load cookies');
setCookieDomains([]);
} finally {
setCookiesLoading(false);
}
};
const handleDeleteMainDomain = async (
mainDomain: string,
subdomains: CookieDomain[]
) => {
setDeletingDomain(mainDomain);
try {
const deletePromises = subdomains.map((item) =>
fetchDelete(`/browser/cookies/${encodeURIComponent(item.domain)}`)
);
await Promise.all(deletePromises);
toast.success(`Deleted cookies for ${mainDomain} and all subdomains`);
const domainsToRemove = new Set(subdomains.map((item) => item.domain));
setCookieDomains((prev) =>
prev.filter((item) => !domainsToRemove.has(item.domain))
);
setHasUnsavedChanges(true);
setShowRestartDialog(true);
} catch (error: any) {
toast.error(
error?.message || `Failed to delete cookies for ${mainDomain}`
);
} finally {
setDeletingDomain(null);
}
};
const handleDeleteAll = async () => {
setDeletingAll(true);
try {
await fetchDelete('/browser/cookies');
toast.success('Deleted all cookies');
setCookieDomains([]);
setHasUnsavedChanges(true);
setShowRestartDialog(true);
} catch (error: any) {
toast.error(error?.message || 'Failed to delete all cookies');
} finally {
setDeletingAll(false);
}
};
const handleRestartApp = () => {
if (window.electronAPI && window.electronAPI.restartApp) {
window.electronAPI.restartApp();
} else {
toast.error('Restart function not available');
}
};
const handleConfirmRestart = () => {
setShowRestartDialog(false);
handleRestartApp();
};
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
<AlertDialog
isOpen={showRestartDialog}
onClose={() => setShowRestartDialog(false)}
onConfirm={handleConfirmRestart}
title="Cookies Updated"
message="Cookies have been updated. Would you like to restart the application to use the new cookies?"
confirmText="Yes, Restart"
cancelText="No, Add More"
confirmVariant="information"
/>
<div className="px-6 pb-6 pt-8 flex w-full items-center justify-between">
<div className="text-heading-sm font-bold text-text-heading">
{t('layout.browser-cookie-management')}
</div>
</div>
<div className="gap-4 flex flex-col">
<div className="rounded-xl border-border-disabled bg-surface-secondary p-6 relative flex w-full flex-col border">
<div className="right-6 top-6 absolute">
<Button
variant="information"
size="xs"
onClick={handleRestartApp}
className="gap-0 ease-in-out justify-center overflow-hidden rounded-full transition-all duration-300"
>
<RefreshCw className="flex-shrink-0" />
<span
className={`ease-in-out overflow-hidden transition-all duration-300 ${
hasUnsavedChanges
? 'pl-2 max-w-[150px] opacity-100'
: 'ml-0 max-w-0 opacity-0'
}`}
>
{t('layout.restart-to-apply')}
</span>
</Button>
</div>
<div className="text-body-sm text-text-label max-w-[600px]">
{t('layout.browser-cookies-description')}
</div>
<div className="mt-4 gap-3 border-border-secondary pt-3 flex w-full flex-col border-[0.5px] border-x-0 border-b-0 border-solid">
<div className="py-2 flex flex-row items-center justify-between">
<div className="gap-2 flex flex-row items-center justify-start">
<div className="text-body-base font-bold text-text-body">
{t('layout.cookie-domains')}
</div>
{cookieDomains.length > 0 && (
<div className="rounded-lg bg-tag-fill-info px-2 text-label-sm font-bold text-text-information">
{groupDomainsByMain(cookieDomains).length}
</div>
)}
</div>
<div className="gap-2 flex items-center">
{cookieDomains.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleDeleteAll}
disabled={deletingAll}
className="!text-text-cuation uppercase"
>
{deletingAll
? t('layout.deleting')
: t('layout.delete-all')}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleLoadCookies}
disabled={cookiesLoading}
>
<RefreshCw
className={`h-4 w-4 ${cookiesLoading ? 'animate-spin' : ''}`}
/>
</Button>
<Button
variant="primary"
size="sm"
onClick={handleBrowserLogin}
disabled={loginLoading}
>
<Plus className="h-4 w-4" />
{loginLoading
? t('layout.opening')
: t('layout.open-browser')}
</Button>
</div>
</div>
{cookieDomains.length > 0 ? (
<div className="gap-2 flex flex-col">
{groupDomainsByMain(cookieDomains).map((group, index) => (
<div
key={index}
className="rounded-xl border-border-disabled bg-surface-tertiary px-4 py-2 flex items-center justify-between border-solid"
>
<div className="flex w-full flex-col items-start justify-start">
<span className="text-body-sm font-bold text-text-body truncate">
{group.mainDomain}
</span>
<span className="mt-1 text-label-xs text-text-label">
{group.totalCookies} Cookie
{group.totalCookies !== 1 ? 's' : ''}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteMainDomain(
group.mainDomain,
group.subdomains
)
}
disabled={deletingDomain === group.mainDomain}
className="ml-3 flex-shrink-0"
>
<Trash2 className="h-4 w-4 text-text-cuation" />
</Button>
</div>
))}
</div>
) : (
<div className="px-4 py-8 flex flex-col items-center justify-center">
<Cookie className="mb-4 h-12 w-12 text-icon-secondary opacity-50" />
<div className="text-body-base font-bold text-text-label text-center">
{t('layout.no-cookies-saved-yet')}
</div>
<p className="text-label-xs font-medium text-text-label text-center">
{t('layout.no-cookies-saved-yet-description')}
</p>
</div>
)}
</div>
</div>
<div className="text-label-xs text-text-label w-full text-center">
For more information, check out our
<a
href="https://www.eigent.ai/privacy-policy"
target="_blank"
className="ml-1 text-text-information underline"
rel="noreferrer"
>
{t('layout.privacy-policy')}
</a>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { Puzzle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function Extension() {
const { t } = useTranslation();
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
<div className="px-6 pb-6 pt-8 flex w-full items-center justify-between">
<div className="text-heading-sm font-bold text-text-heading">
{t('layout.browser-plugins')}
</div>
</div>
<div className="gap-4 px-6 flex flex-col">
<div className="rounded-xl border-border-disabled bg-surface-secondary px-6 py-16 flex flex-col items-center justify-center border">
<Puzzle className="mb-4 h-12 w-12 text-icon-secondary opacity-50" />
<div className="text-body-base font-bold text-text-label text-center">
{t('layout.coming-soon')}
</div>
<p className="mt-2 text-label-sm text-text-label text-center">
{t('layout.browser-plugins-description')}
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import VerticalNavigation, {
type VerticalNavItem,
} from '@/components/Navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import CDP from './CDP';
import Cookies from './Cookies';
import Extension from './Extension';
export default function Browser() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('cdp');
const menuItems = [
{
id: 'cdp',
name: t('layout.browser-connections'),
},
{
id: 'extension',
name: t('layout.browser-plugins'),
},
{
id: 'cookies',
name: t('layout.browser-cookie'),
},
];
const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
};
return (
<div className="m-auto flex h-auto max-w-[940px] flex-col">
<div className="px-6 flex h-auto w-full">
<div className="top-20 w-40 pr-6 pt-8 sticky flex h-full flex-shrink-0 flex-grow-0 flex-col justify-between self-start">
<VerticalNavigation
items={
menuItems.map((menu) => ({
value: menu.id,
label: (
<span className="text-body-sm font-bold">{menu.name}</span>
),
})) as VerticalNavItem[]
}
value={activeTab}
onValueChange={handleTabChange}
className="min-h-0 gap-0 h-full w-full flex-1"
listClassName="w-full h-full overflow-y-auto"
contentClassName="hidden"
/>
</div>
<div className="flex h-auto w-full flex-1 flex-col">
<div className="gap-4 flex flex-col">
{activeTab === 'cdp' && <CDP />}
{activeTab === 'extension' && <Extension />}
{activeTab === 'cookies' && <Cookies />}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,88 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import VerticalNavigation, {
type VerticalNavItem,
} from '@/components/Navigation';
import { MessageSquare } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
const VISIBLE_CHANNELS = ['overview', 'whatsapp', 'lark'] as const;
export default function Channels() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<string>('overview');
const menuItems = VISIBLE_CHANNELS.map((id) => ({
id,
name: t(`layout.channels-${id}`),
}));
const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
};
return (
<div className="m-auto flex h-auto max-w-[940px] flex-col">
<div className="px-6 flex h-auto w-full">
<div className="top-20 w-40 pr-6 pt-8 sticky flex h-full flex-shrink-0 flex-grow-0 flex-col justify-between self-start">
<VerticalNavigation
items={
menuItems.map((menu) => ({
value: menu.id,
label: (
<span className="text-body-sm font-bold">{menu.name}</span>
),
})) as VerticalNavItem[]
}
value={activeTab}
onValueChange={handleTabChange}
className="min-h-0 gap-0 h-full w-full flex-1"
listClassName="w-full h-full overflow-y-auto"
contentClassName="hidden"
/>
</div>
<div className="flex h-auto w-full flex-1 flex-col">
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="px-6 pb-6 pt-8 flex w-full items-center justify-between">
<div className="text-heading-sm font-bold text-text-heading">
{menuItems.find((m) => m.id === activeTab)?.name ?? ''}
</div>
</div>
{/* Content Section */}
<div className="mb-12 gap-6 flex flex-col">
<div className="rounded-2xl bg-surface-secondary px-6 py-4 flex w-full flex-col items-center justify-between">
<div className="h-16 w-16 flex items-center justify-center">
<MessageSquare className="h-8 w-8 text-icon-secondary" />
</div>
<h2 className="mb-2 text-body-md font-bold text-text-heading">
{t('layout.coming-soon')}
</h2>
<p className="max-w-md text-body-sm text-text-label text-center">
{activeTab === 'overview'
? t('layout.channels-overview-coming-soon-description')
: t('layout.channels-coming-soon-description')}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,707 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
fetchGet,
fetchPost,
proxyFetchDelete,
proxyFetchGet,
proxyFetchPost,
proxyFetchPut,
} from '@/api/http';
import IntegrationList from '@/components/IntegrationList';
import SearchInput from '@/components/SearchInput';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { getProxyBaseURL } from '@/lib';
import { useAuthStore } from '@/store/authStore';
import { motion } from 'framer-motion';
import { Plus } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import MCPAddDialog from './components/MCPAddDialog';
import MCPConfigDialog from './components/MCPConfigDialog';
import MCPDeleteDialog from './components/MCPDeleteDialog';
import MCPList from './components/MCPList';
import type { MCPConfigForm, MCPUserItem } from './components/types';
import { arrayToArgsJson, parseArgsToArray } from './components/utils';
import { ConfigFile } from 'electron/main/utils/mcpConfig';
import { toast } from 'sonner';
// Filter out Search from integrations (Search is now in its own Connectors tab)
const EXCLUDED_FROM_MCP = ['Search'];
export default function SettingMCP() {
const { checkAgentTool } = useAuthStore();
const { t } = useTranslation();
const [items, setItems] = useState<MCPUserItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [showConfig, setShowConfig] = useState<MCPUserItem | null>(null);
const [configForm, setConfigForm] = useState<MCPConfigForm | null>(null);
const [saving, setSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [addType, setAddType] = useState<'local' | 'remote'>('local');
const [localJson, setLocalJson] = useState(
`{
"mcpServers": {
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
}
}
}`
);
const [remoteName, setRemoteName] = useState('');
const [remoteUrl, setRemoteUrl] = useState('');
const [installing, setInstalling] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<MCPUserItem | null>(null);
const [deleting, setDeleting] = useState(false);
const [switchLoading, setSwitchLoading] = useState<Record<number, boolean>>(
{}
);
const [activeTab, setActiveTab] = useState<'mcp-tools' | 'your-mcp'>(
'mcp-tools'
);
const [searchQuery, setSearchQuery] = useState('');
// add: integrations list
const [integrations, setIntegrations] = useState<any[]>([]);
const [isLoadingIntegrations, setIsLoadingIntegrations] = useState(true);
const [refreshKey, setRefreshKey] = useState<number>(0);
// Filter integrations (MCP & Tools) by search
const filteredIntegrations = useMemo(() => {
if (!searchQuery.trim()) return integrations;
const q = searchQuery.toLowerCase().trim();
return integrations.filter(
(item) =>
(item.key || '').toLowerCase().includes(q) ||
(item.name || '').toLowerCase().includes(q) ||
(item.desc || '').toLowerCase().includes(q)
);
}, [integrations, searchQuery]);
// Filter your MCPs by search
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return items;
const q = searchQuery.toLowerCase().trim();
return items.filter(
(item) =>
(item.mcp_name || '').toLowerCase().includes(q) ||
(item.mcp_desc || '').toLowerCase().includes(q) ||
(item.mcp_key || '').toLowerCase().includes(q)
);
}, [items, searchQuery]);
// get list
const fetchList = useCallback(() => {
setIsLoading(true);
setError('');
proxyFetchGet('/api/mcp/users')
.then((res) => {
if (Array.isArray(res)) {
setItems(res);
} else if (Array.isArray(res.items)) {
setItems(res.items);
} else {
setItems([]);
}
})
.catch((err) => {
setError(err?.message || t('setting.load-failed'));
})
.finally(() => {
setIsLoading(false);
});
}, [t]);
// get integrations
useEffect(() => {
setIsLoadingIntegrations(true);
proxyFetchGet('/api/config/info')
.then((res) => {
if (res && typeof res === 'object') {
const baseURL = getProxyBaseURL();
const list = Object.entries(res).map(
([key, value]: [string, any]) => {
let onInstall = null;
// Special handling for Notion MCP
if (key.toLowerCase() === 'notion') {
onInstall = async () => {
try {
const response = await fetchPost('/install/tool/notion');
if (response.success) {
// Check if there's a warning (connection failed but installation marked as complete)
if (response.warning) {
toast.warning(response.warning, { duration: 5000 });
} else {
toast.success(
t('setting.notion-mcp-installed-successfully')
);
}
// Save to config to mark as installed
await proxyFetchPost('/api/configs', {
config_group: 'Notion',
config_name: 'MCP_REMOTE_CONFIG_DIR',
config_value:
response.toolkit_name || 'NotionMCPToolkit',
});
// Refresh the integrations list to show the installed state
fetchList();
// Force refresh IntegrationList component
setRefreshKey((prev) => prev + 1);
} else {
toast.error(
response.error ||
t('setting.failed-to-install-notion-mcp')
);
}
} catch (error: any) {
toast.error(
error.message || t('setting.failed-to-install-notion-mcp')
);
}
};
} else if (key.toLowerCase() === 'google calendar') {
onInstall = async () => {
try {
const response = await fetchPost(
'/install/tool/google_calendar'
);
if (response.success) {
// Check if there's a warning (connection failed but installation marked as complete)
if (response.warning) {
toast.warning(response.warning, { duration: 5000 });
} else {
toast.success(
t('setting.google-calendar-installed-successfully')
);
}
try {
// Ensure we persist a marker config to indicate installation
const existingConfigs =
await proxyFetchGet('/api/configs');
const existing = Array.isArray(existingConfigs)
? existingConfigs.find(
(c: any) =>
c.config_group?.toLowerCase() ===
'google calendar' &&
c.config_name === 'GOOGLE_REFRESH_TOKEN'
)
: null;
const configPayload = {
config_group: 'Google Calendar',
config_name: 'GOOGLE_REFRESH_TOKEN',
config_value: 'exists',
};
if (existing) {
await proxyFetchPut(
`/api/configs/${existing.id}`,
configPayload
);
} else {
await proxyFetchPost('/api/configs', configPayload);
}
} catch (configError) {
console.warn(
'Failed to persist Google Calendar config',
configError
);
}
// Refresh the integrations list to show the installed state
fetchList();
// Force refresh IntegrationList component
setRefreshKey((prev) => prev + 1);
} else if (response.status === 'authorizing') {
// Authorization in progress - start polling for completion
toast.info(
t('setting.please-complete-authorization-in-browser')
);
// Poll for authorization completion via oauth status endpoint
const pollInterval = setInterval(async () => {
try {
const statusResp = await fetchGet(
'/oauth/status/google_calendar'
);
if (statusResp?.status === 'success') {
clearInterval(pollInterval);
// Now that auth succeeded, run install again to initialize toolkit
const finalize = await fetchPost(
'/install/tool/google_calendar'
);
if (finalize?.success) {
const configs =
await proxyFetchGet('/api/configs');
const existing = Array.isArray(configs)
? configs.find(
(c: any) =>
c.config_group?.toLowerCase() ===
'google calendar' &&
c.config_name === 'GOOGLE_REFRESH_TOKEN'
)
: null;
const payload = {
config_group: 'Google Calendar',
config_name: 'GOOGLE_REFRESH_TOKEN',
config_value: 'exists',
};
if (existing) {
await proxyFetchPut(
`/api/configs/${existing.id}`,
payload
);
} else {
await proxyFetchPost('/api/configs', payload);
}
toast.success(
t(
'setting.google-calendar-installed-successfully'
)
);
fetchList();
setRefreshKey((prev) => prev + 1);
}
} else if (
statusResp?.status === 'failed' ||
statusResp?.status === 'cancelled'
) {
clearInterval(pollInterval);
const msg =
statusResp?.error ||
(statusResp?.status === 'cancelled'
? t('setting.authorization-cancelled')
: t('setting.authorization-failed'));
toast.error(msg);
}
// if still authorizing, continue polling
} catch (err) {
console.error('Polling oauth status failed', err);
}
}, 2000);
// Safety timeout
setTimeout(
() => clearInterval(pollInterval),
5 * 60 * 1000
);
} else {
toast.error(
response.error ||
response.message ||
t('setting.failed-to-install-google-calendar')
);
}
} catch (error: any) {
toast.error(
error.message ||
t('setting.failed-to-install-google-calendar')
);
}
};
} else {
onInstall = () => {
const url = `${baseURL}/api/oauth/${key.toLowerCase()}/login`;
// Open in a new window to avoid navigating the app/webview
window.open(url, '_blank');
};
}
return {
key,
name: key,
env_vars: value.env_vars,
desc:
value.env_vars && value.env_vars.length > 0
? `${t(
'setting.environmental-variables-required'
)}: ${value.env_vars.join(', ')}`
: key.toLowerCase() === 'notion'
? t('setting.notion-workspace-integration')
: key.toLowerCase() === 'google calendar'
? t('setting.google-calendar-integration')
: '',
onInstall,
};
}
);
setIntegrations(
list.filter((item) => !EXCLUDED_FROM_MCP.includes(item.key))
);
}
})
.finally(() => {
setIsLoadingIntegrations(false);
});
}, [fetchList, t]);
useEffect(() => {
fetchList();
}, [fetchList]);
// MCP list switch
const handleSwitch = async (id: number, checked: boolean) => {
setSwitchLoading((l) => ({ ...l, [id]: true }));
try {
await proxyFetchPut(`/api/mcp/users/${id}`, { status: checked ? 1 : 2 });
fetchList();
} finally {
setSwitchLoading((l) => ({ ...l, [id]: false }));
}
};
// config dialog
useEffect(() => {
if (showConfig) {
setConfigForm({
mcp_name: showConfig.mcp_name || '',
mcp_desc: showConfig.mcp_desc || '',
command: showConfig.command || '',
argsArr: showConfig.args ? parseArgsToArray(showConfig.args) : [],
env: showConfig.env ? { ...showConfig.env } : {},
});
setErrorMsg(null);
} else {
setConfigForm(null);
setErrorMsg(null);
}
}, [showConfig]);
const handleConfigSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!configForm || !showConfig) return;
setSaving(true);
setErrorMsg(null);
try {
const mcpData = {
mcp_name: configForm.mcp_name,
mcp_desc: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
env: configForm.env,
};
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, mcpData);
if (window.ipcRenderer) {
//Partial payload to empty env {}
const payload: any = {
description: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
};
if (configForm.env && Object.keys(configForm.env).length > 0) {
payload.env = configForm.env;
}
window.ipcRenderer.invoke('mcp-update', mcpData.mcp_name, payload);
}
setShowConfig(null);
fetchList();
} catch (err: any) {
setErrorMsg(err?.message || t('setting.save-failed'));
} finally {
setSaving(false);
}
};
const handleConfigClose = () => {
setShowConfig(null);
setConfigForm(null);
setErrorMsg(null);
};
const handleConfigSwitch = async (checked: boolean) => {
if (!showConfig) return;
setSaving(true);
try {
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, {
status: checked ? 1 : 0,
});
setShowConfig((prev) =>
prev ? { ...prev, status: checked ? 1 : 0 } : prev
);
fetchList();
} finally {
setSaving(false);
}
};
// add MCP dialog
const handleInstall = async () => {
setInstalling(true);
try {
if (addType === 'local') {
let data: ConfigFile;
try {
data = JSON.parse(localJson);
// validate mcpServers structure
if (!data.mcpServers || typeof data.mcpServers !== 'object') {
throw new Error('Invalid mcpServers');
}
// check for name conflicts with existing items
const serverNames = Object.keys(data.mcpServers);
const conflict = serverNames.find((name) =>
items.some((d) => d.mcp_name === name)
);
if (conflict) {
toast.error(
t('setting.mcp-server-already-exists', { name: conflict }),
{
closeButton: true,
}
);
setInstalling(false);
return;
}
} catch (e) {
console.error('Invalid JSON:', e);
toast.error(t('setting.invalid-json'), { closeButton: true });
setInstalling(false);
return;
}
let res = await proxyFetchPost('/api/mcp/import/local', data);
if (res.detail) {
toast.error(t('setting.invalid-json'), { closeButton: true });
setInstalling(false);
return;
}
if (window.ipcRenderer) {
const mcpServers = data['mcpServers'];
for (const [key, value] of Object.entries(mcpServers)) {
await window.ipcRenderer.invoke('mcp-install', key, value);
}
}
}
setShowAdd(false);
setLocalJson(`{
"mcpServers": {}
}`);
setRemoteName('');
setRemoteUrl('');
fetchList();
} finally {
setInstalling(false);
}
};
// delete dialog
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
checkAgentTool(deleteTarget.mcp_name);
await proxyFetchDelete(`/api/mcp/users/${deleteTarget.id}`);
// notify main process
if (window.ipcRenderer) {
console.log('deleteTarget', deleteTarget.mcp_key);
await window.ipcRenderer.invoke('mcp-remove', deleteTarget.mcp_key);
}
setDeleteTarget(null);
fetchList();
} finally {
setDeleting(false);
}
};
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="px-6 pb-6 pt-8 flex w-full items-center justify-between">
<div className="text-heading-sm font-bold text-text-heading">
{t('setting.mcp-and-tools')}
</div>
</div>
{/* Content Section */}
<div className="mb-12 gap-6 flex flex-col">
<div className="gap-4 rounded-2xl bg-surface-secondary px-6 py-4 flex w-full flex-col items-center justify-between">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'mcp-tools' | 'your-mcp')}
className="w-full"
>
<div className="gap-4 border-border-secondary bg-surface-secondary sticky top-[84px] z-10 flex w-full items-center justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<TabsList
variant="outline"
className="h-auto flex-1 justify-start border-0 bg-transparent"
>
<TabsTrigger
value="mcp-tools"
className="data-[state=active]:bg-transparent"
>
{t('setting.mcp-and-tools')}
</TabsTrigger>
<TabsTrigger
value="your-mcp"
className="data-[state=active]:bg-transparent"
>
{t('setting.your-own-mcps')}
</TabsTrigger>
</TabsList>
<div className="gap-2 flex items-center">
<SearchInput
variant="icon"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('setting.search-mcp')}
/>
<Button
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
>
<Plus className="h-4 w-4" />
{t('setting.add-mcp-server')}
</Button>
</div>
</div>
<TabsContent value="mcp-tools" className="mt-4">
{isLoadingIntegrations ? (
<div className="gap-4 flex w-full flex-col items-start justify-start">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="rounded-2xl bg-surface-tertiary px-6 py-4 relative w-full overflow-hidden"
>
<div className="gap-xs flex w-full flex-row items-center justify-between">
<div className="gap-xs flex flex-row items-center">
<div className="mr-2 h-3 w-3 bg-surface-hover-subtle rounded-full" />
<div className="h-5 w-32 rounded-md bg-surface-hover-subtle" />
<div className="h-4 w-4 rounded bg-surface-hover-subtle" />
</div>
<div className="h-9 w-20 rounded-lg bg-surface-hover-subtle" />
</div>
<motion.div
className="inset-0 via-white/20 absolute w-1/2 bg-gradient-to-r from-transparent to-transparent"
initial={{ x: '-100%' }}
animate={{ x: '200%' }}
transition={{
repeat: Infinity,
duration: 1.5,
ease: 'linear',
}}
/>
</div>
))}
</div>
) : filteredIntegrations.length === 0 ? (
<div className="py-8 text-text-label text-center">
{searchQuery.trim()
? t('dashboard.no-results')
: t('setting.no-mcp-servers')}
</div>
) : (
<IntegrationList
key={refreshKey}
variant="manage"
items={filteredIntegrations}
showConfigButton={false}
showInstallButton={true}
/>
)}
</TabsContent>
<TabsContent value="your-mcp" className="mt-4">
{isLoading && (
<div className="py-8 text-text-label text-center">
{t('setting.loading')}
</div>
)}
{error && (
<div className="py-8 text-text-error text-center">{error}</div>
)}
{!isLoading && !error && items.length === 0 && (
<div className="gap-4 py-12 flex flex-col items-center justify-center">
<p className="text-body-md text-text-label">
{t('setting.no-mcp-servers')}
</p>
<Button
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
>
<Plus className="h-4 w-4" />
{t('setting.add-mcp-server')}
</Button>
</div>
)}
{!isLoading &&
!error &&
items.length > 0 &&
filteredItems.length === 0 && (
<div className="py-8 text-text-label text-center">
{t('dashboard.no-results')}
</div>
)}
{!isLoading && !error && filteredItems.length > 0 && (
<MCPList
items={filteredItems}
onSetting={setShowConfig}
onDelete={setDeleteTarget}
onSwitch={handleSwitch}
switchLoading={switchLoading}
/>
)}
</TabsContent>
</Tabs>
</div>
</div>
{/* Dialogs */}
<MCPConfigDialog
open={!!showConfig}
form={configForm}
mcp={showConfig}
onChange={setConfigForm as any}
onSave={handleConfigSave}
onClose={handleConfigClose}
loading={saving}
errorMsg={errorMsg}
onSwitchStatus={handleConfigSwitch}
/>
<MCPAddDialog
open={showAdd}
addType={addType}
setAddType={setAddType}
localJson={localJson}
setLocalJson={setLocalJson}
remoteName={remoteName}
setRemoteName={setRemoteName}
remoteUrl={remoteUrl}
setRemoteUrl={setRemoteUrl}
installing={installing}
onClose={() => setShowAdd(false)}
onInstall={handleInstall}
/>
<MCPDeleteDialog
open={!!deleteTarget}
target={deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
loading={deleting}
/>
</div>
);
}

View file

@ -0,0 +1,241 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { proxyFetchGet, proxyFetchPost } from '@/api/http';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAuthStore } from '@/store/authStore';
import { Eye } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
interface SearchEngineProvider {
id: string;
name: string;
description: string;
requiresApiKey: boolean;
enabledByDefault?: boolean;
recommended?: boolean;
fields: Array<{
key: string;
label: string;
placeholder?: string;
note?: string;
}>;
}
function buildSearchEngines(
modelType: 'cloud' | 'local' | 'custom'
): SearchEngineProvider[] {
if (modelType === 'custom') {
return [
{
id: 'google',
name: 'Google',
description:
'Connect to Google Custom Search (requires API key and CSE ID).',
requiresApiKey: true,
fields: [
{
key: 'GOOGLE_API_KEY',
label: 'Google API Key',
placeholder: 'Enter your Google API key from Google Cloud Console',
note: 'Learn how to get your Google API key → https://developers.google.com/custom-search/v1/overview',
},
{
key: 'SEARCH_ENGINE_ID',
label: 'Search Engine ID',
placeholder:
'Enter the Custom Search Engine ID associated with your API key',
},
],
},
];
}
return [
{
id: 'google',
name: 'Google',
description:
'Google Search integration available. No setup required — enabled by default.',
requiresApiKey: false,
enabledByDefault: true,
recommended: true,
fields: [],
},
];
}
export default function Search() {
const { t } = useTranslation();
const { modelType } = useAuthStore();
const [formData, setFormData] = useState<Record<string, string>>({});
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState(false);
const engines = useMemo(() => buildSearchEngines(modelType), [modelType]);
useEffect(() => {
proxyFetchGet('/api/configs').then((configsRes) => {
const configsList = Array.isArray(configsRes) ? configsRes : [];
const existingData: Record<string, string> = {};
engines.forEach((engine) => {
engine.fields.forEach((field) => {
const config = configsList.find(
(c: any) => c.config_name === field.key
);
if (config) {
existingData[field.key] = config.config_value || '';
}
});
});
setFormData(existingData);
});
}, [engines]);
const selectedProvider = engines[0];
const handleFieldChange = (fieldKey: string, value: string) => {
setFormData((prev) => ({ ...prev, [fieldKey]: value }));
};
const saveConfiguration = async () => {
if (selectedProvider.enabledByDefault) {
toast.info(t('setting.this-service-is-already-enabled-by-default'));
return;
}
setSaving(true);
try {
if (selectedProvider.requiresApiKey) {
for (const field of selectedProvider.fields) {
const value = formData[field.key];
if (value && value.trim() !== '') {
await proxyFetchPost('/api/configs', {
config_group: 'Search',
config_name: field.key,
config_value: value.trim(),
});
}
}
} else {
await proxyFetchPost('/api/configs', {
config_group: 'Search',
config_name: `ENABLE_${selectedProvider.id.toUpperCase()}_SEARCH`,
config_value: 'true',
});
}
toast.success(t('setting.configuration-saved-successfully'));
const res = await proxyFetchGet('/api/configs');
const configsList = Array.isArray(res) ? res : [];
const existingData: Record<string, string> = {};
engines.forEach((engine) => {
engine.fields.forEach((field) => {
const config = configsList.find(
(c: any) => c.config_name === field.key
);
if (config) {
existingData[field.key] = config.config_value || '';
}
});
});
setFormData(existingData);
} catch (error) {
console.error(error);
toast.error(t('setting.failed-to-save-configuration'));
} finally {
setSaving(false);
}
};
return (
<div className="m-auto flex h-auto w-full flex-1 flex-col">
{/* Header Section */}
<div className="px-6 pb-6 pt-8 flex w-full items-center justify-between">
<div className="text-heading-sm font-bold text-text-heading">
{t('setting.search-engine')}
</div>
</div>
{/* Content Section - Google configuration */}
<div className="mb-12">
<div className="rounded-2xl bg-surface-secondary px-6 py-4">
<div className="flex flex-col">
<div className="gap-2 pb-2 flex flex-col">
<div className="text-label-lg font-bold">
{selectedProvider.name}
</div>
<div className="text-label-sm font-normal text-text-label">
{selectedProvider.description}
</div>
</div>
<div className="pt-4 flex-1">
{selectedProvider.requiresApiKey ? (
<div className="space-y-4">
{selectedProvider.fields.map((field) => (
<div key={field.key}>
<Input
id={field.key}
size="default"
title={field.label}
type={showKeys[field.key] ? 'text' : 'password'}
placeholder={field.placeholder}
value={formData[field.key] || ''}
onChange={(e) =>
handleFieldChange(field.key, e.target.value)
}
note={field.note}
className="mt-1"
backIcon={<Eye className="h-5 w-5" />}
onBackIconClick={() =>
setShowKeys((prev) => ({
...prev,
[field.key]: !prev[field.key],
}))
}
/>
</div>
))}
</div>
) : (
<div className="rounded-lg bg-surface-primary p-4">
<p className="text-label-sm text-text-label">
{selectedProvider.id === 'wiki'
? t(
'setting.this-service-is-public-and-does-not-require-credentials'
)
: t('setting.this-service-does-not-require-an-api-key')}
</p>
</div>
)}
</div>
{!selectedProvider.enabledByDefault && (
<div className="gap-3 pt-4 flex items-center justify-end">
<Button size="sm" onClick={saveConfiguration} disabled={saving}>
{saving
? t('setting.saving')
: selectedProvider.requiresApiKey
? t('setting.save-changes')
: `${t('setting.enable')} ${selectedProvider.name} ${t('setting.search')}`}
</Button>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -116,7 +116,7 @@ export default function CookieManager() {
<div className="rounded-2xl bg-surface-secondary px-6 py-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2 text-base font-bold leading-12 text-text-primary">
<div className="gap-2 text-base font-bold leading-12 text-text-primary flex items-center">
<Cookie className="h-5 w-5" />
{t('setting.cookie-manager')}
</div>
@ -124,7 +124,7 @@ export default function CookieManager() {
{t('setting.cookie-manager-description')}
</div>
</div>
<div className="flex gap-2">
<div className="gap-2 flex">
<Button
onClick={loadCookies}
variant="outline"
@ -152,8 +152,8 @@ export default function CookieManager() {
</div>
{/* Search Bar */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-text-tertiary" />
<div className="mb-4 relative">
<Search className="left-3 h-4 w-4 text-text-tertiary absolute top-1/2 -translate-y-1/2 transform" />
<Input
type="text"
placeholder={t('setting.search-domains')}
@ -166,13 +166,13 @@ export default function CookieManager() {
{/* Cookie List */}
<div className="space-y-2">
{isLoading ? (
<div className="py-8 text-center text-text-secondary">
<RefreshCw className="mx-auto mb-2 h-6 w-6 animate-spin" />
<div className="py-8 text-text-secondary text-center">
<RefreshCw className="mb-2 h-6 w-6 animate-spin mx-auto" />
{t('setting.loading-cookies')}
</div>
) : filteredDomains.length === 0 ? (
<div className="py-8 text-center text-text-secondary">
<Cookie className="mx-auto mb-3 h-12 w-12 opacity-30" />
<div className="py-8 text-text-secondary text-center">
<Cookie className="mb-3 h-12 w-12 mx-auto opacity-30" />
<div className="mb-1 text-base font-medium">
{domains.length === 0
? t('setting.no-cookies-found')
@ -188,13 +188,13 @@ export default function CookieManager() {
filteredDomains.map((item) => (
<div
key={item.domain}
className="flex items-center justify-between rounded-lg border border-border-primary bg-surface-primary p-3 transition-colors hover:border-border-secondary"
className="rounded-lg border-border-primary bg-surface-primary p-3 hover:border-border-secondary flex items-center justify-between border transition-colors"
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text-primary">
<div className="font-medium text-text-primary truncate">
{item.domain}
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-text-tertiary">
<div className="mt-1 gap-3 text-xs text-text-tertiary flex items-center">
<span>
{t('setting.cookies-count', { count: item.cookie_count })}
</span>
@ -222,9 +222,9 @@ export default function CookieManager() {
{/* Warning */}
{domains.length > 0 && (
<div className="dark:bg-yellow-900/20 mt-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-600 dark:text-yellow-500" />
<div className="dark:bg-yellow-900/20 mt-4 rounded-lg border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 border">
<div className="gap-2 flex items-start">
<AlertTriangle className="mt-0.5 h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0" />
<div className="text-xs text-yellow-800 dark:text-yellow-200">
{t('setting.cookie-delete-warning')}
</div>

View file

@ -128,7 +128,7 @@ export default function MCPAddDialog({
{jsonError}
</div>
)}
<div className="overflow-hidden rounded-xl border border-border-primary">
<div className="rounded-xl border-border-primary overflow-hidden border">
<MonacoEditor
height="300px"
width="100%"

View file

@ -64,7 +64,7 @@ export default function MCPConfigDialog({
<form
ref={formRef}
onSubmit={onSave}
className="flex flex-col gap-4 p-md"
className="gap-4 p-md flex flex-col"
>
<Input
title={t('setting.name')}
@ -104,7 +104,7 @@ export default function MCPConfigDialog({
)}
/>
<div className="mb-1 block text-label-sm font-normal">
<div className="mb-1 text-label-sm font-normal block">
Env (key-value)
</div>
{Object.entries(form.env).map(([k, v], idx) => (

View file

@ -33,8 +33,8 @@ export default function MCPDeleteDialog({
const { t } = useTranslation();
if (!open || !target) return null;
return (
<div className="bg-black/30 fixed inset-0 z-30 flex items-center justify-center">
<div className="min-w-[320px] max-w-[90vw] rounded-lg bg-white-100% p-6 shadow-lg">
<div className="bg-black/30 inset-0 fixed z-30 flex items-center justify-center">
<div className="rounded-lg bg-white-100% p-6 shadow-lg max-w-[90vw] min-w-[320px]">
<div className="mb-2 font-bold text-red-600">
{t('setting.confirm-delete')}
</div>
@ -42,7 +42,7 @@ export default function MCPDeleteDialog({
{t('setting.are-you-sure-you-want-to-delete')}{' '}
<b>{target.mcp_name}</b>?
</div>
<div className="flex justify-end gap-2">
<div className="gap-2 flex justify-end">
<Button variant="outline" onClick={onCancel} disabled={loading}>
{t('setting.cancel')}
</Button>

View file

@ -307,8 +307,8 @@ export const MCPEnvDialog: FC<MCPEnvDialogProps> = ({
})}
/>
<div className="flex flex-col gap-3 p-md">
<div className="flex items-center gap-md">
<div className="gap-3 p-md flex flex-col">
<div className="gap-md flex items-center">
{getCategoryIcon(activeMcp?.category?.name)}
<div>
<div className="text-base font-bold leading-9 text-text-action">
@ -328,7 +328,7 @@ export const MCPEnvDialog: FC<MCPEnvDialogProps> = ({
verticalAlign: 'middle',
}}
/>
<span className="line-clamp-1 items-center justify-center self-stretch overflow-hidden text-ellipsis break-words text-xs font-medium leading-normal">
<span className="text-xs font-medium leading-normal line-clamp-1 items-center justify-center self-stretch overflow-hidden break-words text-ellipsis">
{getGithubRepoName(activeMcp?.home_page)}
</span>
</div>
@ -336,7 +336,7 @@ export const MCPEnvDialog: FC<MCPEnvDialogProps> = ({
</div>
</div>
</div>
<div className="flex flex-col gap-md">
<div className="gap-md flex flex-col">
{Object.keys(activeMcp?.install_command?.env || {}).map((key) => {
const getNoteContent = () => {
let noteContent = envValues[key]?.tip || '';

View file

@ -37,9 +37,9 @@ export default function MCPListItem({
const [_showMenu, setShowMenu] = useState(false);
const { t } = useTranslation();
return (
<div className="mb-4 flex items-center justify-between gap-4 rounded-2xl bg-surface-secondary p-4">
<div className="flex items-center gap-xs">
<div className="mx-xs h-3 w-3 rounded-full bg-green-500"></div>
<div className="mb-4 gap-4 rounded-2xl bg-surface-tertiary p-4 flex items-center justify-between">
<div className="gap-xs flex items-center">
<div className="mx-xs h-3 w-3 bg-green-500 rounded-full"></div>
<div className="text-base font-bold leading-9 text-text-primary">
{item.mcp_name}
</div>
@ -49,7 +49,7 @@ export default function MCPListItem({
</TooltipSimple>
</div>
</div>
<div className="flex items-center gap-2">
<div className="gap-2 flex items-center">
{/* <Switch
checked={item.status === 1}
disabled={loading}

View file

@ -26,7 +26,7 @@ import { useAuthStore } from '@/store/authStore';
import { ChevronLeft, CircleAlert, Store } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MCPEnvDialog } from './components/MCPEnvDialog';
import { MCPEnvDialog } from './MCPEnvDialog';
interface MCPItem {
id: number;
name: string;
@ -273,7 +273,7 @@ export default function MCPMarket({
<div className="flex h-full flex-col items-center">
{externalKeyword === undefined && (
<>
<div className="text-body sticky top-0 z-[20] mb-0 flex w-full max-w-4xl items-center justify-between py-2">
<div className="text-body top-0 mb-0 max-w-4xl py-2 sticky z-[20] flex w-full items-center justify-between">
<Button
variant="ghost"
size="sm"
@ -296,7 +296,7 @@ export default function MCPMarket({
)}
{/* Category toggle row */}
<div className="flex w-full py-2">
<div className="py-2 flex w-full">
<ToggleGroup
type="single"
value={categoryId ? String(categoryId) : 'all'}
@ -321,24 +321,24 @@ export default function MCPMarket({
onConnect={onConnect}
activeMcp={activeMcp}
></MCPEnvDialog>
<div className="flex w-full flex-col gap-4 pt-4">
<div className="gap-4 pt-4 flex w-full flex-col">
{isLoading && items.length === 0 && (
<div className="py-8 text-center text-text-muted">
<div className="py-8 text-text-muted text-center">
{t('setting.loading')}
</div>
)}
{error && (
<div className="py-8 text-center text-text-error">{error}</div>
<div className="py-8 text-text-error text-center">{error}</div>
)}
{!isLoading && !error && items.length === 0 && (
<div className="py-8 text-center text-text-muted">
<div className="py-8 text-text-muted text-center">
{t('setting.no-mcp-services')}
</div>
)}
{items.map((item) => (
<div
key={item.id}
className="flex items-center rounded-2xl bg-surface-secondary p-4"
className="rounded-2xl bg-surface-secondary p-4 flex items-center"
>
{/* Left: Icon */}
<div className="mr-4 flex items-center">
@ -356,10 +356,10 @@ export default function MCPMarket({
);
})()}
</div>
<div className="flex min-w-0 flex-1 flex-col justify-center">
<div className="flex w-full items-center gap-xs pb-1">
<div className="flex flex-1 items-center gap-xs">
<span className="truncate text-base font-bold leading-9 text-text-primary">
<div className="min-w-0 flex flex-1 flex-col justify-center">
<div className="gap-xs pb-1 flex w-full items-center">
<div className="gap-xs flex flex-1 items-center">
<span className="text-base font-bold leading-9 text-text-primary truncate">
{item.name}
</span>
<TooltipSimple content={item.description}>
@ -400,7 +400,7 @@ export default function MCPMarket({
verticalAlign: 'middle',
}}
/>
<span className="items-center justify-center self-stretch text-xs font-medium leading-3">
<span className="text-xs font-medium leading-3 items-center justify-center self-stretch">
{(() => {
const parts = item.home_page.split('/');
return parts.length > 4 ? parts[4] : item.home_page;
@ -408,7 +408,7 @@ export default function MCPMarket({
</span>
</div>
)}
<div className="mt-1 whitespace-pre-line break-words text-sm text-text-muted">
<div className="mt-1 text-sm text-text-muted break-words whitespace-pre-line">
{item.description}
</div>
</div>
@ -416,12 +416,12 @@ export default function MCPMarket({
))}
<div ref={loader} />
{isLoading && items.length > 0 && (
<div className="py-4 text-center text-text-muted">
<div className="py-4 text-text-muted text-center">
{t('setting.loading-more')}
</div>
)}
{!hasMore && items.length > 0 && (
<div className="py-4 text-center text-text-muted">
<div className="py-4 text-text-muted text-center">
{t('setting.no-more-mcp-servers')}
</div>
)}

View file

@ -0,0 +1,72 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import VerticalNavigation, {
type VerticalNavItem,
} from '@/components/Navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import MCP from './MCP';
import Search from './Search';
export default function Connectors() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('mcp');
const menuItems: Array<{ id: string; name: string }> = [
{
id: 'search',
name: t('layout.search'),
},
{
id: 'mcp',
name: t('setting.mcp'),
},
];
const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
};
return (
<div className="m-auto flex h-auto max-w-[940px] flex-col">
<div className="px-6 flex h-auto w-full">
<div className="top-20 w-40 pr-6 pt-8 sticky flex h-full flex-shrink-0 flex-grow-0 flex-col justify-between self-start">
<VerticalNavigation
items={
menuItems.map((menu) => ({
value: menu.id,
label: (
<span className="text-body-sm font-bold">{menu.name}</span>
),
})) as VerticalNavItem[]
}
value={activeTab}
onValueChange={handleTabChange}
className="min-h-0 gap-0 h-full w-full flex-1"
listClassName="w-full h-full overflow-y-auto"
contentClassName="hidden"
/>
</div>
<div className="flex h-auto w-full flex-1 flex-col">
<div className="gap-4 flex flex-col">
{activeTab === 'search' && <Search />}
{activeTab === 'mcp' && <MCP />}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,722 +0,0 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { fetchDelete, fetchGet, fetchPost } from '@/api/http';
import VerticalNavigation from '@/components/Navigation';
import AlertDialog from '@/components/ui/alertDialog';
import { Button } from '@/components/ui/button';
import {
Cookie,
Globe,
Link2,
Loader2,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
interface CookieDomain {
domain: string;
cookie_count: number;
last_access: string;
}
interface GroupedDomain {
mainDomain: string;
subdomains: CookieDomain[];
totalCookies: number;
}
interface CdpBrowser {
id: string;
port: number;
isExternal: boolean;
name?: string;
addedAt: number;
}
export default function Browser() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('connection');
const [loginLoading, setLoginLoading] = useState(false);
const [cookiesLoading, setCookiesLoading] = useState(false);
const [cookieDomains, setCookieDomains] = useState<CookieDomain[]>([]);
const [deletingDomain, setDeletingDomain] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [showRestartDialog, setShowRestartDialog] = useState(false);
const [showCookieRestartDialog, setShowCookieRestartDialog] = useState(false);
// CDP port configuration
const [cdpPort, setCdpPort] = useState<number>(9223);
// CDP Browser Pool
const [cdpBrowsers, setCdpBrowsers] = useState<CdpBrowser[]>([]);
const [deletingBrowser, setDeletingBrowser] = useState<string | null>(null);
const [browserToRemove, setBrowserToRemove] = useState<CdpBrowser | null>(
null
);
// Connect Existing Browser dialog
const [showConnectDialog, setShowConnectDialog] = useState(false);
const [connectPort, setConnectPort] = useState('');
const [connectChecking, setConnectChecking] = useState(false);
const [connectError, setConnectError] = useState('');
// Extract main domain (e.g., "aa.bb.cc" -> "bb.cc", "www.google.com" -> "google.com")
const getMainDomain = (domain: string): string => {
// Remove leading dot if present
const cleanDomain = domain.startsWith('.') ? domain.substring(1) : domain;
const parts = cleanDomain.split('.');
// For domains with 2 or fewer parts, return as is
if (parts.length <= 2) {
return cleanDomain;
}
// For domains with more parts, return last 2 parts (main domain)
return parts.slice(-2).join('.');
};
// Group domains by main domain
const groupDomainsByMain = (domains: CookieDomain[]): GroupedDomain[] => {
const grouped = new Map<string, CookieDomain[]>();
domains.forEach((item) => {
const mainDomain = getMainDomain(item.domain);
if (!grouped.has(mainDomain)) {
grouped.set(mainDomain, []);
}
grouped.get(mainDomain)!.push(item);
});
return Array.from(grouped.entries())
.map(([mainDomain, subdomains]) => ({
mainDomain,
subdomains,
totalCookies: subdomains.reduce(
(sum, item) => sum + item.cookie_count,
0
),
}))
.sort((a, b) => a.mainDomain.localeCompare(b.mainDomain));
};
// Auto-load cookies on component mount
useEffect(() => {
handleLoadCookies();
// Load current browser port on mount
loadCurrentBrowserPort();
// Load CDP browser pool
loadCdpBrowsers();
}, []);
// Listen for CDP pool push updates from main process (health-check removes dead browsers)
useEffect(() => {
if (!window.electronAPI?.onCdpPoolChanged) return;
const cleanup = window.electronAPI.onCdpPoolChanged(
(browsers: CdpBrowser[]) => {
setCdpBrowsers(browsers);
}
);
return cleanup;
}, []);
const loadCurrentBrowserPort = async () => {
if (window.electronAPI?.getBrowserPort) {
const port = await window.electronAPI.getBrowserPort();
setCdpPort(port);
}
};
const loadCdpBrowsers = async () => {
if (window.electronAPI?.getCdpBrowsers) {
try {
const browsers = await window.electronAPI.getCdpBrowsers();
setCdpBrowsers(browsers);
} catch (error) {
console.error('Failed to load CDP browsers:', error);
}
}
};
const handleRemoveBrowser = async (browserId: string) => {
setDeletingBrowser(browserId);
try {
if (window.electronAPI?.removeCdpBrowser) {
const result = await window.electronAPI.removeCdpBrowser(browserId);
if (result.success) {
toast.success(t('layout.browser-removed'));
} else {
toast.error(result.error || t('layout.failed-to-remove-browser'));
}
}
} catch (error: any) {
toast.error(error.message || t('layout.failed-to-remove-browser'));
} finally {
setDeletingBrowser(null);
setBrowserToRemove(null);
}
};
const handleOpenNewBrowser = async () => {
try {
toast.loading(t('layout.launching-browser', { port: '...' }), {
id: 'launch-browser',
});
const result = await window.electronAPI?.launchCdpBrowser();
if (result?.success) {
toast.success(t('layout.browser-launched', { port: result.port }), {
id: 'launch-browser',
});
} else {
toast.error(result?.error || t('layout.failed-to-launch-browser'), {
id: 'launch-browser',
});
}
} catch (error: any) {
toast.error(error.message || t('layout.failed-to-launch-browser'), {
id: 'launch-browser',
});
}
};
const handleConnectExistingBrowser = () => {
setConnectPort('');
setConnectError('');
setShowConnectDialog(true);
};
const handleCheckAndConnect = async () => {
const portNum = parseInt(connectPort, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
setConnectError(t('layout.invalid-port'));
return;
}
// Check if port is already in the pool
if (cdpBrowsers.some((b) => b.port === portNum)) {
setConnectError(t('layout.port-already-in-use'));
return;
}
setConnectChecking(true);
setConnectError('');
try {
// Probe the port to check if a CDP browser is listening
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`http://localhost:${portNum}/json/version`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
setConnectError(t('layout.no-browser-on-port', { port: portNum }));
return;
}
// Port is alive — add to CDP pool
if (window.electronAPI?.addCdpBrowser) {
const addResult = await window.electronAPI.addCdpBrowser(
portNum,
true,
`External Browser (${portNum})`
);
if (!addResult?.success) {
setConnectError(
addResult?.error || t('layout.failed-to-add-browser')
);
return;
}
} else {
setConnectError(t('layout.failed-to-add-browser'));
return;
}
toast.success(t('layout.connected-browser', { port: portNum }));
setShowConnectDialog(false);
} catch {
setConnectError(t('layout.no-browser-on-port', { port: portNum }));
} finally {
setConnectChecking(false);
}
};
const handleBrowserLogin = async () => {
setLoginLoading(true);
const currentCookieCount = cookieDomains.reduce(
(sum, item) => sum + item.cookie_count,
0
);
try {
const response = await fetchPost('/browser/login');
if (response) {
toast.success(t('layout.browser-opened'));
// Listen for browser close event to reload cookies
const checkInterval = setInterval(async () => {
try {
// Check if browser is still open by making a request
// When browser closes, reload cookies
const statusResponse = await fetchGet('/browser/status');
if (!statusResponse || !statusResponse.is_open) {
clearInterval(checkInterval);
await handleLoadCookies();
// Check if cookies changed
const newResponse = await fetchGet('/browser/cookies');
if (newResponse && newResponse.success) {
const newDomains = newResponse.domains || [];
const newCookieCount = newDomains.reduce(
(sum: number, item: CookieDomain) => sum + item.cookie_count,
0
);
if (newCookieCount > currentCookieCount) {
// Cookies were added, show success toast and restart dialog
const addedCount = newCookieCount - currentCookieCount;
toast.success(
t('layout.cookies-added', { count: addedCount })
);
setShowRestartDialog(true);
} else if (newCookieCount < currentCookieCount) {
setShowRestartDialog(true);
}
}
}
} catch (error) {
// Browser might be closed
clearInterval(checkInterval);
await handleLoadCookies();
}
}, 500);
}
} catch (error: any) {
toast.error(error?.message || t('layout.failed-to-open-browser'));
} finally {
setLoginLoading(false);
}
};
const handleLoadCookies = async () => {
setCookiesLoading(true);
try {
const response = await fetchGet('/browser/cookies');
if (response && response.success) {
const domains = response.domains || [];
setCookieDomains(domains);
} else {
setCookieDomains([]);
}
} catch (error: any) {
toast.error(error?.message || t('layout.failed-to-load-cookies'));
setCookieDomains([]);
} finally {
setCookiesLoading(false);
}
};
const handleDeleteMainDomain = async (
mainDomain: string,
subdomains: CookieDomain[]
) => {
setDeletingDomain(mainDomain);
try {
// Delete all subdomains under this main domain
const deletePromises = subdomains.map((item) =>
fetchDelete(`/browser/cookies/${encodeURIComponent(item.domain)}`)
);
await Promise.all(deletePromises);
toast.success(
t('layout.deleted-cookies-for-domain', { domain: mainDomain })
);
// Remove from local state
const domainsToRemove = new Set(subdomains.map((item) => item.domain));
setCookieDomains((prev) =>
prev.filter((item) => !domainsToRemove.has(item.domain))
);
// Show restart dialog after successful deletion
setShowRestartDialog(true);
} catch (error: any) {
toast.error(
error?.message ||
t('layout.failed-to-delete-cookies-for-domain', {
domain: mainDomain,
})
);
} finally {
setDeletingDomain(null);
}
};
const handleDeleteAll = async () => {
setDeletingAll(true);
try {
await fetchDelete('/browser/cookies');
toast.success(t('layout.deleted-all-cookies'));
setCookieDomains([]);
// Show restart dialog after successful deletion
setShowRestartDialog(true);
} catch (error: any) {
toast.error(error?.message || t('layout.failed-to-delete-all-cookies'));
} finally {
setDeletingAll(false);
}
};
const handleRestartApp = () => {
if (window.electronAPI && window.electronAPI.restartApp) {
window.electronAPI.restartApp();
} else {
toast.error(t('layout.restart-not-available'));
}
};
const handleConfirmRestart = () => {
setShowRestartDialog(false);
handleRestartApp();
};
return (
<div className="m-auto flex h-auto max-w-[940px] flex-col">
{/* Restart Dialog */}
<AlertDialog
isOpen={showRestartDialog}
onClose={() => setShowRestartDialog(false)}
onConfirm={handleConfirmRestart}
title={t('layout.cookies-updated')}
message={t('layout.cookies-updated-message')}
confirmText={t('layout.yes-restart')}
cancelText={t('layout.no-add-more')}
confirmVariant="information"
/>
{/* Cookie Restart Confirm Dialog */}
<AlertDialog
isOpen={showCookieRestartDialog}
onClose={() => setShowCookieRestartDialog(false)}
onConfirm={() => {
setShowCookieRestartDialog(false);
handleRestartApp();
}}
title={t('layout.restart-required')}
message={t('layout.restart-required-message')}
confirmText={t('layout.restart')}
cancelText={t('layout.cancel')}
confirmVariant="information"
/>
{/* Remove Browser Confirmation Dialog */}
<AlertDialog
isOpen={!!browserToRemove}
onClose={() => setBrowserToRemove(null)}
onConfirm={() => {
if (browserToRemove) {
handleRemoveBrowser(browserToRemove.id);
}
}}
title={t('layout.remove-browser')}
message={t('layout.remove-browser-confirm', {
name: browserToRemove?.name || `Browser ${browserToRemove?.port}`,
port: browserToRemove?.port,
})}
confirmText={t('layout.remove')}
cancelText={t('layout.cancel')}
confirmVariant="cuation"
/>
{/* Connect Existing Browser Dialog */}
{showConnectDialog && (
<div className="bg-black/50 fixed inset-0 z-50 flex items-center justify-center">
<div className="w-full max-w-md rounded-xl bg-surface-primary p-6 shadow-lg">
<div className="text-body-base mb-2 font-bold text-text-heading">
{t('layout.connect-existing-browser')}
</div>
<p className="mb-4 text-label-xs text-text-label">
{t('layout.connect-existing-browser-description')}
</p>
<input
type="text"
value={connectPort}
onChange={(e) => {
setConnectPort(e.target.value);
setConnectError('');
}}
placeholder={t('layout.enter-port-number')}
className="w-full rounded-lg border border-border-disabled bg-surface-secondary px-4 py-2 text-body-sm text-text-body outline-none focus:border-border-focus"
onKeyDown={(e) => {
if (e.key === 'Enter') handleCheckAndConnect();
}}
/>
{connectError && (
<p className="mt-2 text-label-xs text-text-cuation">
{connectError}
</p>
)}
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConnectDialog(false)}
>
{t('layout.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleCheckAndConnect}
disabled={connectChecking}
>
{connectChecking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Link2 className="h-4 w-4" />
)}
{t('layout.check-and-connect')}
</Button>
</div>
</div>
</div>
)}
<div className="flex h-auto w-full px-6">
{/* Left Sidebar */}
<div className="sticky top-20 flex h-full w-48 flex-shrink-0 flex-grow-0 flex-col self-start pr-6 pt-8">
<VerticalNavigation
items={[
{
value: 'connection',
label: (
<span className="text-body-sm font-bold">
{t('layout.browser-connection')}
</span>
),
},
{
value: 'cookies',
label: (
<span className="text-body-sm font-bold">
{t('layout.cookies-management')}
</span>
),
},
]}
value={activeTab}
onValueChange={setActiveTab}
className="h-full min-h-0 w-full flex-1 gap-0"
listClassName="w-full h-full overflow-y-auto"
contentClassName="hidden"
/>
</div>
{/* Right Content */}
<div className="flex h-auto w-full flex-1 flex-col pb-8 pt-8">
{activeTab === 'connection' && (
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-text-heading">
{t('layout.cdp-browser-connection')}
</div>
{/* Action Buttons */}
<div className="mt-4 flex items-center gap-3">
<Button
variant="primary"
size="sm"
onClick={handleOpenNewBrowser}
>
<Plus className="h-4 w-4" />
{t('layout.open-new-browser')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleConnectExistingBrowser}
>
<Link2 className="h-4 w-4 text-button-tertiery-text-default" />
{t('layout.connect-existing-browser')}
</Button>
</div>
{/* CDP Browser Pool */}
<div className="mt-6 flex flex-col gap-3">
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center justify-start gap-2">
<div className="text-body-base font-bold text-text-body">
{t('layout.cdp-browser-pool')}
</div>
</div>
</div>
{cdpBrowsers.length > 0 ? (
<div className="flex flex-col gap-2">
{cdpBrowsers.map((browser) => (
<div
key={browser.id}
className="flex items-center justify-between rounded-xl border-solid border-border-disabled bg-surface-secondary px-4 py-2"
>
<div className="flex w-full flex-row items-center gap-2">
<div className="h-2 w-2 shrink-0 rounded-full bg-text-success" />
<div className="flex flex-col items-start justify-start">
<span className="text-body-sm font-bold text-text-body">
{browser.name || `Browser ${browser.port}`}
</span>
<span className="text-label-xs text-text-label">
{t('layout.port')} {browser.port}
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setBrowserToRemove(browser)}
disabled={deletingBrowser === browser.id}
className="ml-3 flex-shrink-0"
>
<Trash2 className="h-4 w-4 text-text-cuation" />
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center px-4 py-8">
<Globe className="mb-4 h-12 w-12 text-icon-secondary opacity-50" />
<div className="text-body-base text-center font-bold text-text-label">
{t('layout.no-browsers-in-pool')}
</div>
<p className="text-center text-label-xs font-medium text-text-label">
{t('layout.add-browsers-hint')}
</p>
</div>
)}
</div>
</div>
)}
{activeTab === 'cookies' && (
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-text-heading">
{t('layout.browser-cookies-management')}
</div>
{/* Action Buttons */}
<div className="mt-4 flex items-center gap-3">
<Button
variant="primary"
size="sm"
onClick={handleBrowserLogin}
disabled={loginLoading}
>
<Cookie className="h-4 w-4" />
{loginLoading
? t('layout.opening')
: t('layout.open-browser')}
</Button>
</div>
{/* Cookie Domains */}
<div className="mt-6 flex flex-col gap-3">
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center justify-start gap-2">
<div className="text-body-base font-bold text-text-body">
{t('layout.cookie-domains')}
</div>
{cookieDomains.length > 0 && (
<div className="rounded-lg bg-tag-fill-info px-2 text-label-sm font-bold text-text-information">
{groupDomainsByMain(cookieDomains).length}
</div>
)}
</div>
<div className="flex items-center gap-2">
{cookieDomains.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleDeleteAll}
disabled={deletingAll}
className="uppercase !text-text-cuation"
>
{deletingAll
? t('layout.deleting')
: t('layout.delete-all')}
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => setShowCookieRestartDialog(true)}
title={t('layout.restart-to-enable-cookies-tooltip')}
>
<RefreshCw className="h-4 w-4 text-text-information" />
</Button>
</div>
</div>
{cookieDomains.length > 0 ? (
<div className="flex flex-col gap-2">
{groupDomainsByMain(cookieDomains).map((group, index) => (
<div
key={index}
className="flex items-center justify-between rounded-xl border-solid border-border-disabled bg-surface-secondary px-4 py-2"
>
<div className="flex w-full flex-col items-start justify-start">
<span className="truncate text-body-sm font-bold text-text-body">
{group.mainDomain}
</span>
<span className="mt-1 text-label-xs text-text-label">
{t('layout.cookie-count', {
count: group.totalCookies,
})}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteMainDomain(
group.mainDomain,
group.subdomains
)
}
disabled={deletingDomain === group.mainDomain}
className="ml-3 flex-shrink-0"
>
<Trash2 className="h-4 w-4 text-text-cuation" />
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center px-4 py-8">
<Cookie className="mb-4 h-12 w-12 text-icon-secondary opacity-50" />
<div className="text-body-base text-center font-bold text-text-label">
{t('layout.no-cookies-saved-yet')}
</div>
<p className="text-center text-label-xs font-medium text-text-label">
{t('layout.no-cookies-saved-yet-description')}
</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -15,6 +15,7 @@
import { Bot } from '@/components/animate-ui/icons/bot';
import { Compass } from '@/components/animate-ui/icons/compass';
import { Hammer } from '@/components/animate-ui/icons/hammer';
import { Radio } from '@/components/animate-ui/icons/radio';
import { Settings } from '@/components/animate-ui/icons/settings';
import { Sparkle } from '@/components/animate-ui/icons/sparkle';
import {
@ -25,7 +26,7 @@ import AlertDialog from '@/components/ui/alertDialog';
import { Button } from '@/components/ui/button';
import WordCarousel from '@/components/ui/WordCarousel';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import Project from '@/pages/Dashboard/Project';
import Project from '@/pages/Projects/Project';
import Setting from '@/pages/Setting';
import { useAuthStore } from '@/store/authStore';
import { Plus } from 'lucide-react';
@ -33,21 +34,25 @@ import { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Agents from './Agents';
import Browser from './Dashboard/Browser';
import MCP from './Setting/MCP';
import Browser from './Browser';
import Channels from './Channels';
import Connectors from './Connectors';
const VALID_TABS = [
'projects',
'workers',
'trigger',
'settings',
'mcp_tools',
'browser',
'agents',
'channels',
'connectors',
'browser',
'settings',
] as const;
type TabType = (typeof VALID_TABS)[number];
const TAB_ALIASES: Record<string, TabType> = {
mcp_tools: 'connectors',
};
export default function Home() {
const { t } = useTranslation();
const navigate = useNavigate();
@ -61,8 +66,11 @@ export default function Home() {
// Compute activeTab from URL, fallback to 'projects' if not in URL or invalid
const activeTab = useMemo(() => {
const tabFromUrl = searchParams.get('tab');
if (tabFromUrl && VALID_TABS.includes(tabFromUrl as TabType)) {
return tabFromUrl as TabType;
if (tabFromUrl) {
const normalizedTab = TAB_ALIASES[tabFromUrl] ?? tabFromUrl;
if (VALID_TABS.includes(normalizedTab as TabType)) {
return normalizedTab as TabType;
}
}
return 'projects' as TabType;
}, [searchParams]);
@ -161,11 +169,19 @@ export default function Home() {
iconAnimateOnHover="default"
icon={<Bot className="h-4 w-4" />}
>
{t('setting.agents')}
{t('layout.agents')}
</MenuToggleItem>
<MenuToggleItem
size="xs"
value="mcp_tools"
value="channels"
iconAnimateOnHover="default"
icon={<Radio className="h-4 w-4" />}
>
{t('layout.channels')}
</MenuToggleItem>
<MenuToggleItem
size="xs"
value="connectors"
iconAnimateOnHover="default"
icon={<Hammer />}
>
@ -175,7 +191,7 @@ export default function Home() {
size="xs"
value="browser"
iconAnimateOnHover="default"
icon={<Compass />}
icon={<Compass className="h-4 w-4" />}
>
{t('layout.browser')}
</MenuToggleItem>
@ -185,7 +201,7 @@ export default function Home() {
iconAnimateOnHover="default"
icon={<Settings />}
>
{t('layout.general')}
{t('layout.settings')}
</MenuToggleItem>
</MenuToggleGroup>
</div>
@ -196,10 +212,11 @@ export default function Home() {
</div>
</div>
{activeTab === 'projects' && <Project />}
{activeTab === 'mcp_tools' && <MCP />}
{activeTab === 'agents' && <Agents />}
{activeTab === 'channels' && <Channels />}
{activeTab === 'connectors' && <Connectors />}
{activeTab === 'browser' && <Browser />}
{activeTab === 'settings' && <Setting />}
{activeTab === 'agents' && <Agents />}
</div>
);
}

View file

@ -268,9 +268,9 @@ export default function Project() {
/>
{/* Header Section */}
<div className="flex w-full border-x-0 border-t-0 border-solid border-border-disabled">
<div className="mx-auto flex w-full max-w-[900px] items-center justify-between px-6 pb-4 pt-8">
<div className="flex w-full flex-row items-center justify-between gap-4">
<div className="border-border-disabled flex w-full border-x-0 border-t-0 border-solid">
<div className="px-6 pb-4 pt-8 mx-auto flex w-full max-w-[900px] items-center justify-between">
<div className="gap-4 flex w-full flex-row items-center justify-between">
<div className="flex flex-col">
<div className="text-heading-sm font-bold text-text-heading">
{t('layout.projects-hub')}
@ -281,7 +281,7 @@ export default function Project() {
</div>
<div className="flex w-full">
<div className="mx-auto flex min-h-[calc(100vh-86px)] w-full max-w-[940px] flex-col items-start justify-start px-6 py-8">
<div className="px-6 py-8 mx-auto flex min-h-[calc(100vh-86px)] w-full max-w-[940px] flex-col items-start justify-start">
<GroupedHistoryView
onTaskSelect={handleSetActive}
onTaskDelete={handleDelete}

View file

@ -1,828 +0,0 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
fetchGet,
fetchPost,
proxyFetchDelete,
proxyFetchGet,
proxyFetchPost,
proxyFetchPut,
} from '@/api/http';
import IntegrationList from '@/components/IntegrationList';
import SearchInput from '@/components/SearchInput';
import { Button } from '@/components/ui/button';
import { getProxyBaseURL } from '@/lib';
import { useAuthStore } from '@/store/authStore';
import { ChevronDown, ChevronLeft, ChevronUp, Plus } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import MCPAddDialog from './components/MCPAddDialog';
import MCPConfigDialog from './components/MCPConfigDialog';
import MCPDeleteDialog from './components/MCPDeleteDialog';
import MCPList from './components/MCPList';
import SearchEngineConfigDialog from './components/SearchEngineConfigDialog';
import type { MCPConfigForm, MCPUserItem } from './components/types';
import { arrayToArgsJson, parseArgsToArray } from './components/utils';
import MCPMarket from './MCPMarket';
import { SelectItem, SelectItemWithButton } from '@/components/ui/select';
import { Tag as TagComponent } from '@/components/ui/tag';
import { ConfigFile } from 'electron/main/utils/mcpConfig';
import { toast } from 'sonner';
export default function SettingMCP() {
const _navigate = useNavigate();
const { checkAgentTool } = useAuthStore();
const { modelType } = useAuthStore();
const { t } = useTranslation();
const [items, setItems] = useState<MCPUserItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [showConfig, setShowConfig] = useState<MCPUserItem | null>(null);
const [configForm, setConfigForm] = useState<MCPConfigForm | null>(null);
const [saving, setSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [addType, setAddType] = useState<'local' | 'remote'>('local');
const [localJson, setLocalJson] = useState(
`{
"mcpServers": {
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
}
}
}`
);
const [remoteName, setRemoteName] = useState('');
const [remoteUrl, setRemoteUrl] = useState('');
const [installing, setInstalling] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<MCPUserItem | null>(null);
const [deleting, setDeleting] = useState(false);
const [switchLoading, setSwitchLoading] = useState<Record<number, boolean>>(
{}
);
const [collapsedMCP, setCollapsedMCP] = useState(false);
const [collapsedExternal, setCollapsedExternal] = useState(false);
const [showMarket, setShowMarket] = useState(false);
const [marketKeyword, setMarketKeyword] = useState('');
const [showSearchEngineConfig, setShowSearchEngineConfig] = useState(false);
// add: integrations list
const [integrations, setIntegrations] = useState<any[]>([]);
const [refreshKey, setRefreshKey] = useState<number>(0);
const [essentialIntegrations, _setEssentialIntegrations] = useState<any[]>([
{
key: 'Search',
name: 'Search Engine',
env_vars: ['GOOGLE_API_KEY', 'SEARCH_ENGINE_ID'],
desc: (
<>
{t('setting.environmental-variables-required')}: GOOGLE_API_KEY,
SEARCH_ENGINE_ID
<br />
<span
style={{
fontSize: '0.875rem',
marginTop: '0.25rem',
display: 'block',
}}
>
{t('setting.get-google-search-api')}:{' '}
<a
onClick={() => {
window.location.href =
'https://developers.google.com/custom-search/v1/overview';
}}
className="text-text-link underline"
>
{t('setting.google-custom-search-api')}
</a>
</span>
</>
),
},
]);
// default search engine and availability
const [_defaultSearchEngine, setDefaultSearchEngine] =
useState<string>('google');
const [_hasGoogleSearch, setHasGoogleSearch] = useState<boolean>(false);
const [configs, setConfigs] = useState<any[]>([]);
useEffect(() => {
proxyFetchGet('/api/configs').then((configsRes) => {
const configs = Array.isArray(configsRes) ? configsRes : [];
setConfigs(configs);
const hasGoogleApiKey = !!configs.find(
(item: any) => item.config_name === 'GOOGLE_API_KEY'
);
const hasGoogleCseId = !!configs.find(
(item: any) => item.config_name === 'SEARCH_ENGINE_ID'
);
setHasGoogleSearch(hasGoogleApiKey && hasGoogleCseId);
const defaultEngine = configs.find(
(item: any) =>
item.config_group?.toLowerCase() === 'search' &&
item.config_name === 'DEFAULT_SEARCH_ENGINE'
)?.config_value;
if (defaultEngine) setDefaultSearchEngine(defaultEngine);
else setDefaultSearchEngine('google'); // Default to Google
});
}, []);
// get list
const fetchList = useCallback(() => {
setIsLoading(true);
setError('');
proxyFetchGet('/api/mcp/users')
.then((res) => {
if (Array.isArray(res)) {
setItems(res);
} else if (Array.isArray(res.items)) {
setItems(res.items);
} else {
setItems([]);
}
})
.catch((err) => {
setError(err?.message || t('setting.load-failed'));
})
.finally(() => {
setIsLoading(false);
});
}, [t]);
// get integrations
useEffect(() => {
proxyFetchGet('/api/config/info').then((res) => {
if (res && typeof res === 'object') {
const baseURL = getProxyBaseURL();
const list = Object.entries(res).map(([key, value]: [string, any]) => {
let onInstall = null;
// Special handling for Notion MCP
if (key.toLowerCase() === 'notion') {
onInstall = async () => {
try {
const response = await fetchPost('/install/tool/notion');
if (response.success) {
// Check if there's a warning (connection failed but installation marked as complete)
if (response.warning) {
toast.warning(response.warning, { duration: 5000 });
} else {
toast.success(
t('setting.notion-mcp-installed-successfully')
);
}
// Save to config to mark as installed
await proxyFetchPost('/api/configs', {
config_group: 'Notion',
config_name: 'MCP_REMOTE_CONFIG_DIR',
config_value: response.toolkit_name || 'NotionMCPToolkit',
});
// Refresh the integrations list to show the installed state
fetchList();
// Force refresh IntegrationList component
setRefreshKey((prev) => prev + 1);
} else {
toast.error(
response.error || t('setting.failed-to-install-notion-mcp')
);
}
} catch (error: any) {
toast.error(
error.message || t('setting.failed-to-install-notion-mcp')
);
}
};
} else if (key.toLowerCase() === 'google calendar') {
onInstall = async () => {
try {
const response = await fetchPost(
'/install/tool/google_calendar'
);
if (response.success) {
// Check if there's a warning (connection failed but installation marked as complete)
if (response.warning) {
toast.warning(response.warning, { duration: 5000 });
} else {
toast.success(
t('setting.google-calendar-installed-successfully')
);
}
try {
// Ensure we persist a marker config to indicate installation
const existingConfigs = await proxyFetchGet('/api/configs');
const existing = Array.isArray(existingConfigs)
? existingConfigs.find(
(c: any) =>
c.config_group?.toLowerCase() ===
'google calendar' &&
c.config_name === 'GOOGLE_REFRESH_TOKEN'
)
: null;
const configPayload = {
config_group: 'Google Calendar',
config_name: 'GOOGLE_REFRESH_TOKEN',
config_value: 'exists',
};
if (existing) {
await proxyFetchPut(
`/api/configs/${existing.id}`,
configPayload
);
} else {
await proxyFetchPost('/api/configs', configPayload);
}
} catch (configError) {
console.warn(
'Failed to persist Google Calendar config',
configError
);
}
// Refresh the integrations list to show the installed state
fetchList();
// Force refresh IntegrationList component
setRefreshKey((prev) => prev + 1);
} else if (response.status === 'authorizing') {
// Authorization in progress - start polling for completion
toast.info(
t('setting.please-complete-authorization-in-browser')
);
// Poll for authorization completion via oauth status endpoint
const pollInterval = setInterval(async () => {
try {
const statusResp = await fetchGet(
'/oauth/status/google_calendar'
);
if (statusResp?.status === 'success') {
clearInterval(pollInterval);
// Now that auth succeeded, run install again to initialize toolkit
const finalize = await fetchPost(
'/install/tool/google_calendar'
);
if (finalize?.success) {
const configs = await proxyFetchGet('/api/configs');
const existing = Array.isArray(configs)
? configs.find(
(c: any) =>
c.config_group?.toLowerCase() ===
'google calendar' &&
c.config_name === 'GOOGLE_REFRESH_TOKEN'
)
: null;
const payload = {
config_group: 'Google Calendar',
config_name: 'GOOGLE_REFRESH_TOKEN',
config_value: 'exists',
};
if (existing) {
await proxyFetchPut(
`/api/configs/${existing.id}`,
payload
);
} else {
await proxyFetchPost('/api/configs', payload);
}
toast.success(
t('setting.google-calendar-installed-successfully')
);
fetchList();
setRefreshKey((prev) => prev + 1);
}
} else if (
statusResp?.status === 'failed' ||
statusResp?.status === 'cancelled'
) {
clearInterval(pollInterval);
const msg =
statusResp?.error ||
(statusResp?.status === 'cancelled'
? t('setting.authorization-cancelled')
: t('setting.authorization-failed'));
toast.error(msg);
}
// if still authorizing, continue polling
} catch (err) {
console.error('Polling oauth status failed', err);
}
}, 2000);
// Safety timeout
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
} else {
toast.error(
response.error ||
response.message ||
t('setting.failed-to-install-google-calendar')
);
}
} catch (error: any) {
toast.error(
error.message ||
t('setting.failed-to-install-google-calendar')
);
}
};
} else {
onInstall = () => {
const url = `${baseURL}/api/oauth/${key.toLowerCase()}/login`;
// Open in a new window to avoid navigating the app/webview
window.open(url, '_blank');
};
}
return {
key,
name: key,
env_vars: value.env_vars,
desc:
value.env_vars && value.env_vars.length > 0
? `${t(
'setting.environmental-variables-required'
)}: ${value.env_vars.join(', ')}`
: key.toLowerCase() === 'notion'
? t('setting.notion-workspace-integration')
: key.toLowerCase() === 'google calendar'
? t('setting.google-calendar-integration')
: '',
onInstall,
};
});
console.log('API response:', res);
console.log('Generated list:', list);
console.log('Essential integrations:', essentialIntegrations);
setIntegrations(
list.filter(
(item) => !essentialIntegrations.find((i) => i.key === item.key)
)
);
}
});
}, [essentialIntegrations, fetchList, t]);
useEffect(() => {
fetchList();
}, [fetchList]);
// MCP list switch
const handleSwitch = async (id: number, checked: boolean) => {
setSwitchLoading((l) => ({ ...l, [id]: true }));
try {
await proxyFetchPut(`/api/mcp/users/${id}`, { status: checked ? 1 : 2 });
fetchList();
} finally {
setSwitchLoading((l) => ({ ...l, [id]: false }));
}
};
// config dialog
useEffect(() => {
if (showConfig) {
setConfigForm({
mcp_name: showConfig.mcp_name || '',
mcp_desc: showConfig.mcp_desc || '',
command: showConfig.command || '',
argsArr: showConfig.args ? parseArgsToArray(showConfig.args) : [],
env: showConfig.env ? { ...showConfig.env } : {},
});
setErrorMsg(null);
} else {
setConfigForm(null);
setErrorMsg(null);
}
}, [showConfig]);
const handleConfigSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!configForm || !showConfig) return;
setSaving(true);
setErrorMsg(null);
try {
const mcpData = {
mcp_name: configForm.mcp_name,
mcp_desc: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
env: configForm.env,
};
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, mcpData);
if (window.ipcRenderer) {
//Partial payload to empty env {}
const payload: any = {
description: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
};
if (configForm.env && Object.keys(configForm.env).length > 0) {
payload.env = configForm.env;
}
window.ipcRenderer.invoke('mcp-update', mcpData.mcp_name, payload);
}
setShowConfig(null);
fetchList();
} catch (err: any) {
setErrorMsg(err?.message || t('setting.save-failed'));
} finally {
setSaving(false);
}
};
const handleConfigClose = () => {
setShowConfig(null);
setConfigForm(null);
setErrorMsg(null);
};
const handleConfigSwitch = async (checked: boolean) => {
if (!showConfig) return;
setSaving(true);
try {
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, {
status: checked ? 1 : 0,
});
setShowConfig((prev) =>
prev ? { ...prev, status: checked ? 1 : 0 } : prev
);
fetchList();
} finally {
setSaving(false);
}
};
// add MCP dialog
const handleInstall = async () => {
setInstalling(true);
try {
if (addType === 'local') {
let data: ConfigFile;
try {
data = JSON.parse(localJson);
// validate mcpServers structure
if (!data.mcpServers || typeof data.mcpServers !== 'object') {
throw new Error('Invalid mcpServers');
}
// check for name conflicts with existing items
const serverNames = Object.keys(data.mcpServers);
const conflict = serverNames.find((name) =>
items.some((d) => d.mcp_name === name)
);
if (conflict) {
toast.error(
t('setting.mcp-server-already-exists', { name: conflict }),
{
closeButton: true,
}
);
setInstalling(false);
return;
}
} catch (e) {
console.error('Invalid JSON:', e);
toast.error(t('setting.invalid-json'), { closeButton: true });
setInstalling(false);
return;
}
let res = await proxyFetchPost('/api/mcp/import/local', data);
if (res.detail) {
toast.error(t('setting.invalid-json'), { closeButton: true });
setInstalling(false);
return;
}
if (window.ipcRenderer) {
const mcpServers = data['mcpServers'];
for (const [key, value] of Object.entries(mcpServers)) {
await window.ipcRenderer.invoke('mcp-install', key, value);
}
}
}
setShowAdd(false);
setLocalJson(`{
"mcpServers": {}
}`);
setRemoteName('');
setRemoteUrl('');
fetchList();
} finally {
setInstalling(false);
}
};
// delete dialog
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
checkAgentTool(deleteTarget.mcp_name);
await proxyFetchDelete(`/api/mcp/users/${deleteTarget.id}`);
// notify main process
if (window.ipcRenderer) {
console.log('deleteTarget', deleteTarget.mcp_key);
await window.ipcRenderer.invoke('mcp-remove', deleteTarget.mcp_key);
}
setDeleteTarget(null);
fetchList();
} finally {
setDeleting(false);
}
};
// Generate search engine selection content
const generateSearchEngineSelectContent = () => {
console.log('Generating search engine select content, configs:', configs);
const isCustom = modelType === 'custom';
// Google Search - requires API key and Search Engine ID in custom mode
const hasGoogleApiKey = configs.some(
(c: any) => c.config_name === 'GOOGLE_API_KEY'
);
const hasGoogleCseId = configs.some(
(c: any) => c.config_name === 'SEARCH_ENGINE_ID'
);
const hasGoogle = hasGoogleApiKey && hasGoogleCseId;
console.log('Search engine status:', { hasGoogle, isCustom });
return (
<>
{/* Custom mode: require API key configuration */}
{isCustom ? (
<SelectItemWithButton
value="google"
label={
<span>
<span>Google Search </span>
<TagComponent asChild>
<span>{t('setting.recommended')}</span>
</TagComponent>
</span>
}
enabled={hasGoogle}
buttonText={t('setting.setting')}
onButtonClick={() => setShowSearchEngineConfig(true)}
/>
) : (
<>
{/* Cloud or Local mode: Google enabled by default */}
<SelectItem value="google">
<span>Google Search </span>
<TagComponent asChild>
<span>{t('setting.recommended')}</span>
</TagComponent>
</SelectItem>
</>
)}
</>
);
};
return (
<div className="m-auto h-auto flex-1">
{/* Header Section */}
<div className="flex w-full">
<div className="mx-auto flex w-full max-w-[940px] items-center justify-between px-6 pb-4 pt-8">
<div className="flex w-full items-center justify-between">
{showMarket ? (
<div className="flex w-full items-center justify-between gap-sm">
<Button
variant="ghost"
size="icon"
onClick={() => setShowMarket(false)}
>
<ChevronLeft />
</Button>
<div className="text-heading-sm font-bold text-text-heading">
{t('setting.mcp-market')}
</div>
<div className="ml-auto flex items-center gap-2">
<div className="w-full">
<SearchInput
value={marketKeyword}
onChange={(e) => setMarketKeyword(e.target.value)}
/>
</div>
</div>
</div>
) : (
<div className="flex w-full items-center justify-between">
<div className="text-heading-sm font-bold text-text-heading">
{t('setting.mcp-and-tools')}
</div>
<div className="flex items-center gap-sm">
<Button
variant="outline"
size="sm"
onClick={() => setShowAdd(true)}
>
<Plus />
<span>{t('setting.add-mcp-server')}</span>
</Button>
{/* <Button variant="outline" size="sm" onClick={() => setShowMarket(true)}>
<Store />
<span>{t("setting.market")}</span>
</Button> */}
</div>
</div>
)}
</div>
</div>
</div>
{/* Content Section */}
<div className="flex w-full">
<div className="mx-auto flex min-h-[calc(100vh-86px)] w-full max-w-[940px] items-start justify-center px-6 py-8">
<div className="flex w-full flex-col gap-8">
{showMarket ? (
<div className="pt-2">
<MCPMarket
onBack={() => setShowMarket(false)}
keyword={marketKeyword}
/>
</div>
) : (
<>
<div className="w-full flex-1">
<IntegrationList
variant="manage"
items={essentialIntegrations}
showConfigButton={true}
showInstallButton={false}
showSelect
showStatusDot={false}
selectPlaceholder="Google Search"
selectContent={generateSearchEngineSelectContent()}
onSelectChange={async (value) => {
try {
setDefaultSearchEngine(value);
await proxyFetchPost('/api/configs', {
config_group: 'Search',
config_name: 'DEFAULT_SEARCH_ENGINE',
config_value: value,
});
} catch (e) {
console.error(
'Error setting default search engine:',
e
);
}
}}
onConfigClick={(item) => {
if (item.key === 'Search') {
setShowSearchEngineConfig(true);
}
}}
/>
</div>
<div className="flex flex-col">
<div className="inline-flex items-center justify-start gap-2 self-stretch py-2">
<span className="text-body-md font-bold text-text-body">
{t('setting.mcp')}
</span>
<div className="flex-1" />
<Button
variant="ghost"
size="md"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCollapsedMCP((c) => !c);
}}
>
{collapsedMCP ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
</div>
{!collapsedMCP && (
<IntegrationList
key={refreshKey}
variant="manage"
items={integrations}
showConfigButton={false}
showInstallButton={true}
/>
)}
</div>
<div className="flex flex-col">
<div className="inline-flex items-center justify-start gap-2 self-stretch py-2">
<div className="justify-center text-body-md font-bold text-text-body">
{t('setting.your-own-mcps')}
</div>
<div className="flex-1" />
<Button
variant="ghost"
size="md"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCollapsedExternal((c) => !c);
}}
>
{collapsedExternal ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
</div>
{!collapsedExternal && (
<>
{isLoading && (
<div className="py-8 text-center text-text-label">
{t('setting.loading')}
</div>
)}
{error && (
<div className="py-8 text-center text-text-error">
{error}
</div>
)}
{!isLoading && !error && items.length === 0 && (
<div className="py-8 text-center text-text-label">
{t('setting.no-mcp-servers')}
</div>
)}
{!isLoading && (
<MCPList
items={items}
onSetting={setShowConfig}
onDelete={setDeleteTarget}
onSwitch={handleSwitch}
switchLoading={switchLoading}
/>
)}
</>
)}
</div>
<MCPConfigDialog
open={!!showConfig}
form={configForm}
mcp={showConfig}
onChange={setConfigForm as any}
onSave={handleConfigSave}
onClose={handleConfigClose}
loading={saving}
errorMsg={errorMsg}
onSwitchStatus={handleConfigSwitch}
/>
<MCPAddDialog
open={showAdd}
addType={addType}
setAddType={setAddType}
localJson={localJson}
setLocalJson={setLocalJson}
remoteName={remoteName}
setRemoteName={setRemoteName}
remoteUrl={remoteUrl}
setRemoteUrl={setRemoteUrl}
installing={installing}
onClose={() => setShowAdd(false)}
onInstall={handleInstall}
/>
<MCPDeleteDialog
open={!!deleteTarget}
target={deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
loading={deleting}
/>
<SearchEngineConfigDialog
open={showSearchEngineConfig}
onClose={() => setShowSearchEngineConfig(false)}
/>
</>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,395 +0,0 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { proxyFetchGet, proxyFetchPost } from '@/api/http';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogContentSection,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tag as TagComponent } from '@/components/ui/tag';
import { useAuthStore } from '@/store/authStore';
import { AlertTriangle, Check, Circle, Eye } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
interface SearchEngineProvider {
id: string;
name: string;
description: string;
requiresApiKey: boolean;
enabledByDefault?: boolean;
recommended?: boolean;
fields: Array<{
key: string;
label: string;
placeholder?: string;
note?: string;
}>;
}
interface SearchEngineConfigDialogProps {
open: boolean;
onClose: () => void;
}
function buildSearchEngines(
modelType: 'cloud' | 'local' | 'custom'
): SearchEngineProvider[] {
// Only Google search engine, with custom mode requiring API key configuration
if (modelType === 'custom') {
return [
{
id: 'google',
name: 'Google',
description:
'Connect to Google Custom Search (requires API key and CSE ID).',
requiresApiKey: true,
fields: [
{
key: 'GOOGLE_API_KEY',
label: 'Google API Key',
placeholder: 'Enter your Google API key from Google Cloud Console',
note: 'Learn how to get your Google API key → https://developers.google.com/custom-search/v1/overview',
},
{
key: 'SEARCH_ENGINE_ID',
label: 'Search Engine ID',
placeholder:
'Enter the Custom Search Engine ID associated with your API key',
},
],
},
];
}
// cloud or local → Google enabled by default, no config required
return [
{
id: 'google',
name: 'Google',
description:
'Google Search integration available. No setup required — enabled by default.',
requiresApiKey: false,
enabledByDefault: true,
recommended: true,
fields: [],
},
];
}
export default function SearchEngineConfigDialog({
open,
onClose,
}: SearchEngineConfigDialogProps) {
const { t } = useTranslation();
const { modelType } = useAuthStore();
const [selectedProvider, setSelectedProvider] =
useState<SearchEngineProvider>(buildSearchEngines(modelType)[0]);
const [configs, setConfigs] = useState<any[]>([]);
const [formData, setFormData] = useState<Record<string, string>>({});
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
const [_testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const engines = useMemo(() => buildSearchEngines(modelType), [modelType]);
// Load existing configurations
useEffect(() => {
if (open) {
proxyFetchGet('/api/configs')
.then((res) => {
setConfigs(Array.isArray(res) ? res : []);
// Initialize form data with existing values
const existingData: Record<string, string> = {};
engines.forEach((engine) => {
engine.fields.forEach((field) => {
const config = res.find((c: any) => c.config_name === field.key);
if (config) {
existingData[field.key] = config.config_value || '';
}
});
});
setFormData(existingData);
})
.catch((err) => {
console.error('Failed to load configs:', err);
setConfigs([]);
});
}
}, [open, engines]);
const getProviderStatus = (provider: SearchEngineProvider) => {
// For providers that are enabled by default, always show as configured
if (provider.enabledByDefault) {
return 'configured';
}
if (!provider.requiresApiKey) {
// For providers that don't require API keys, check if they're enabled
const isEnabled = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === 'search' &&
c.config_name === `ENABLE_${provider.id.toUpperCase()}_SEARCH` &&
c.config_value === 'true'
);
return isEnabled ? 'configured' : 'not-configured';
}
// For providers that require API keys, check if all required fields are filled
const requiredFields = provider.fields.map((f) => f.key);
const filledFields = requiredFields.filter((fieldKey) => {
const config = configs.find((c: any) => c.config_name === fieldKey);
return config && config.config_value && config.config_value.trim() !== '';
});
if (filledFields.length === 0) {
return 'not-configured';
} else if (filledFields.length === requiredFields.length) {
return 'configured';
} else {
return 'incomplete';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'configured':
return <Check className="h-4 w-4 text-text-success" />;
case 'incomplete':
return <AlertTriangle className="h-4 w-4 text-text-cuation" />;
default:
return <Circle className="h-4 w-4 text-text-label" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'configured':
return t('setting.configured');
case 'incomplete':
return t('setting.incomplete');
default:
return t('setting.not-configured');
}
};
const handleFieldChange = (fieldKey: string, value: string) => {
setFormData((prev) => ({
...prev,
[fieldKey]: value,
}));
};
const _testConnection = async () => {
if (!selectedProvider.requiresApiKey) {
toast.success(t('setting.this-service-does-not-require-an-api-key'));
return;
}
setTesting(true);
try {
// Here you would implement actual connection testing
// For now, we'll just simulate a test
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success(t('setting.connection-test-successful'));
} catch (error) {
console.error(error);
toast.error(t('setting.connection-test-failed'));
} finally {
setTesting(false);
}
};
const saveConfiguration = async () => {
// Skip saving for engines that are enabled by default
if (selectedProvider.enabledByDefault) {
toast.info(t('setting.this-service-is-already-enabled-by-default'));
return;
}
setSaving(true);
try {
if (selectedProvider.requiresApiKey) {
// Save API key fields
for (const field of selectedProvider.fields) {
const value = formData[field.key];
if (value && value.trim() !== '') {
await proxyFetchPost('/api/configs', {
config_group: 'Search',
config_name: field.key,
config_value: value.trim(),
});
}
}
} else {
// Enable the service
await proxyFetchPost('/api/configs', {
config_group: 'Search',
config_name: `ENABLE_${selectedProvider.id.toUpperCase()}_SEARCH`,
config_value: 'true',
});
}
toast.success(t('setting.configuration-saved-successfully'));
// Refresh configs
const res = await proxyFetchGet('/api/configs');
setConfigs(Array.isArray(res) ? res : []);
} catch (error) {
console.error(error);
toast.error(t('setting.failed-to-save-configuration'));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent size="lg">
<DialogHeader title={t('setting.search-engine-integrations')} />
<DialogContentSection className="flex h-full">
{/* Left Panel - Provider List */}
<div className="w-1/3 border border-y-0 border-l-0 border-solid border-border-secondary pr-4">
<div className="flex flex-col gap-2">
{engines.map((provider) => {
const status = getProviderStatus(provider);
const isSelected = selectedProvider.id === provider.id;
return (
<Button
variant="ghost"
size="md"
key={provider.id}
onClick={() => setSelectedProvider(provider)}
className={`w-full justify-between border border-solid border-transparent bg-transparent transition-colors duration-200 ease-in-out ${isSelected ? 'border border-solid border-border-primary bg-surface-secondary' : 'hover:bg-surface-secondary'}`}
>
<div className="flex items-center gap-3">
{getStatusIcon(status)}
<div className="flex items-center gap-2 text-label-sm font-bold">
<span>{provider.name}</span>
{provider.recommended ? (
<TagComponent asChild>
<span>{t('setting.recommended')}</span>
</TagComponent>
) : null}
</div>
</div>
<div className="text-xs font-extralight text-text-label">
{getStatusText(status)}
</div>
</Button>
);
})}
</div>
</div>
{/* Right Panel - Configuration Detail */}
<div className="h-[400px] flex-1 pl-4">
<div className="flex h-full flex-col">
{/* Provider Header */}
<div className="flex flex-col gap-2 pb-2">
<div className="text-label-lg font-bold">
{selectedProvider.name}
</div>
<div className="text-label-sm font-normal text-text-label">
{selectedProvider.description}
</div>
</div>
{/* Configuration Form */}
<div className="flex-1 pt-4">
{selectedProvider.requiresApiKey ? (
<div className="space-y-4">
{selectedProvider.fields.map((field) => (
<div key={field.key}>
<Input
id={field.key}
size="default"
title={field.label}
type={showKeys[field.key] ? 'text' : 'password'}
placeholder={field.placeholder}
value={formData[field.key] || ''}
onChange={(e) =>
handleFieldChange(field.key, e.target.value)
}
note={field.note}
className="mt-1"
backIcon={<Eye className="h-5 w-5" />}
onBackIconClick={() =>
setShowKeys((prev) => ({
...prev,
[field.key]: !prev[field.key],
}))
}
/>
</div>
))}
</div>
) : (
<div className="rounded-lg bg-surface-primary p-4">
<p className="text-label-sm text-text-label">
{selectedProvider.id === 'wiki'
? t(
'setting.this-service-is-public-and-does-not-require-credentials'
)
: t('setting.this-service-does-not-require-an-api-key')}
</p>
</div>
)}
</div>
{/* Action Buttons */}
{!selectedProvider.enabledByDefault && (
<div className="flex items-center justify-end gap-3">
{/* {selectedProvider.requiresApiKey && (
<Button
variant="outline"
size="sm"
onClick={testConnection}
disabled={testing}
>
{testing ? t("setting.testing") : t("setting.test-connection")}
</Button>
)} */}
<Button
size="sm"
onClick={saveConfiguration}
disabled={saving}
>
{saving
? t('setting.saving')
: selectedProvider.requiresApiKey
? t('setting.save-changes')
: `${t('setting.enable')} ${selectedProvider.name} ${t('setting.search')}`}
</Button>
</div>
)}
</div>
</div>
</DialogContentSection>
<DialogFooter className="justify-between !rounded-b-xl bg-white-100% p-md">
<p className="flex items-center gap-1 text-label-xs text-text-label">
{t(
'setting.your-api-keys-are-stored-securely-and-never-shared-externally'
)}
</p>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -69,8 +69,8 @@ export default function Setting() {
return (
<div className="m-auto flex h-auto max-w-[940px] flex-col">
<div className="flex h-auto w-full px-6">
<div className="sticky top-20 flex h-full w-40 flex-shrink-0 flex-grow-0 flex-col justify-between self-start pr-6 pt-8">
<div className="px-6 flex h-auto w-full">
<div className="top-20 w-40 pr-6 pt-8 sticky flex h-full flex-shrink-0 flex-grow-0 flex-col justify-between self-start">
<VerticalNavigation
items={
settingMenus.map((menu) => {
@ -84,11 +84,11 @@ export default function Setting() {
}
value={activeTab}
onValueChange={handleTabChange}
className="h-full min-h-0 w-full flex-1 gap-0"
className="min-h-0 gap-0 h-full w-full flex-1"
listClassName="w-full h-full overflow-y-auto"
contentClassName="hidden"
/>
<div className="mt-4 flex w-full flex-shrink-0 flex-grow-0 flex-col items-center justify-center gap-4 border-x-0 border-b-0 border-t-[0.5px] border-solid border-border-secondary py-4">
<div className="mt-4 gap-4 border-border-secondary py-4 flex w-full flex-shrink-0 flex-grow-0 flex-col items-center justify-center border-x-0 border-t-[0.5px] border-b-0 border-solid">
<button
onClick={() =>
window.open(
@ -97,7 +97,7 @@ export default function Setting() {
'noopener,noreferrer'
)
}
className="flex w-full cursor-pointer flex-row items-center justify-center gap-2 rounded-lg bg-surface-tertiary px-6 py-1.5 transition-opacity duration-200 hover:opacity-60"
className="gap-2 rounded-lg bg-surface-tertiary px-6 py-1.5 flex w-full cursor-pointer flex-row items-center justify-center transition-opacity duration-200 hover:opacity-60"
>
<TagIcon className="h-4 w-4 text-text-success" />
<div className="text-label-sm font-semibold text-text-body">
@ -120,7 +120,7 @@ export default function Setting() {
</div>
<div className="flex h-auto w-full flex-1 flex-col">
<div className="flex flex-col gap-4">
<div className="gap-4 flex flex-col">
{activeTab === 'general' && <General />}
{activeTab === 'privacy' && <Privacy />}
</div>

View file

@ -16,7 +16,7 @@ import { describe, expect, it } from 'vitest';
import {
arrayToArgsJson,
parseArgsToArray,
} from '../../../../src/pages/Setting/components/utils';
} from '../../../../src/pages/Connectors/components/utils';
describe('parseArgsToArray', () => {
it('should parse JSON array string to array', () => {