diff --git a/web/app/components/base/tab-slider/index.tsx b/web/app/components/base/tab-slider/index.tsx index f8e06935da..00abb9e28d 100644 --- a/web/app/components/base/tab-slider/index.tsx +++ b/web/app/components/base/tab-slider/index.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import { useEffect, useState } from 'react' import cn from '@/utils/classnames' import Badge, { BadgeState } from '@/app/components/base/badge/index' +import { usePluginPageContext } from '../../plugins/plugin-page/context' type Option = { value: string text: string @@ -22,6 +23,7 @@ const TabSlider: FC = ({ }) => { const [activeIndex, setActiveIndex] = useState(options.findIndex(option => option.value === value)) const [sliderStyle, setSliderStyle] = useState({}) + const pluginList = usePluginPageContext(v => v.installedPluginList) const updateSliderStyle = (index: number) => { const tabElement = document.getElementById(`tab-${index}`) @@ -71,7 +73,7 @@ const TabSlider: FC = ({ uppercase={true} state={BadgeState.Default} > - 6 + {pluginList.length} } diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx index c93cc81291..5ecf9d27f3 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/index.tsx @@ -9,6 +9,7 @@ import Install from './steps/install' import Installed from '../base/installed' import { useTranslation } from 'react-i18next' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' +import { usePluginPageContext } from '../../plugin-page/context' const i18nPrefix = 'plugin.installModal' @@ -28,6 +29,8 @@ const InstallFromLocalPackage: React.FC = ({ const [uniqueIdentifier, setUniqueIdentifier] = useState(null) const [manifest, setManifest] = useState(null) const [errorMsg, setErrorMsg] = useState(null) + const mutateInstalledPluginList = usePluginPageContext(v => v.mutateInstalledPluginList) + const getTitle = useCallback(() => { if (step === InstallStep.uploadFailed) return t(`${i18nPrefix}.uploadFailed`) @@ -63,9 +66,10 @@ const InstallFromLocalPackage: React.FC = ({ setStep(InstallStep.uploadFailed) }, []) - const handleInstalled = useCallback(async () => { + const handleInstalled = useCallback(() => { + mutateInstalledPluginList() setStep(InstallStep.installed) - }, []) + }, [mutateInstalledPluginList]) const handleFailed = useCallback((errorMsg?: string) => { setStep(InstallStep.installFailed) diff --git a/web/app/components/plugins/plugin-item/action.tsx b/web/app/components/plugins/plugin-item/action.tsx index d995f8c8f8..383e00bf55 100644 --- a/web/app/components/plugins/plugin-item/action.tsx +++ b/web/app/components/plugins/plugin-item/action.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' -import React from 'react' -import { useRouter } from 'next/navigation' +import React, { useCallback } from 'react' +import type { MetaData } from '../types' import { RiDeleteBinLine, RiInformation2Line, RiLoopLeftLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useTranslation } from 'react-i18next' @@ -9,6 +9,8 @@ import PluginInfo from '../plugin-page/plugin-info' import ActionButton from '../../base/action-button' import Tooltip from '../../base/tooltip' import Confirm from '../../base/confirm' +import { uninstallPlugin } from '@/service/plugins' +import { usePluginPageContext } from '../plugin-page/context' const i18nPrefix = 'plugin.action' @@ -20,22 +22,23 @@ type Props = { isShowInfo: boolean isShowDelete: boolean onDelete: () => void + meta: MetaData } - const Action: FC = ({ + pluginId, pluginName, - usedInApps, isShowFetchNewVersion, isShowInfo, isShowDelete, onDelete, + meta, }) => { const { t } = useTranslation() - const router = useRouter() const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo, }] = useBoolean(false) + const mutateInstalledPluginList = usePluginPageContext(v => v.mutateInstalledPluginList) const handleFetchNewVersion = () => { } @@ -44,7 +47,14 @@ const Action: FC = ({ setFalse: hideDeleteConfirm, }] = useBoolean(false) - // const handleDelete = () => { } + const handleDelete = useCallback(async () => { + const res = await uninstallPlugin(pluginId) + if (res.success) { + hideDeleteConfirm() + mutateInstalledPluginList() + onDelete() + } + }, [pluginId, onDelete]) return (
{/* Only plugin installed from GitHub need to check if it's the new version */} @@ -83,9 +93,9 @@ const Action: FC = ({ {isShowPluginInfo && ( )} @@ -97,11 +107,12 @@ const Action: FC = ({ content={
{t(`${i18nPrefix}.deleteContentLeft`)}{pluginName}{t(`${i18nPrefix}.deleteContentRight`)}
- {usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })} + {/* // todo: add usedInApps */} + {/* {usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })} */}
} onCancel={hideDeleteConfirm} - onConfirm={onDelete} + onConfirm={handleDelete} /> ) } diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 739b5a6ebc..3194e3f3de 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -1,67 +1,94 @@ 'use client' import type { FC } from 'react' -import React from 'react' +import React, { useMemo } from 'react' import { useContext } from 'use-context-selector' -import { RiArrowRightUpLine, RiBugLine, RiHardDrive3Line, RiLoginCircleLine, RiVerifiedBadgeLine } from '@remixicon/react' +import { + RiArrowRightUpLine, + RiBugLine, + RiHardDrive3Line, + RiLoginCircleLine, + RiVerifiedBadgeLine, +} from '@remixicon/react' import { useTranslation } from 'react-i18next' import { Github } from '../../base/icons/src/public/common' import Badge from '../../base/badge' -import type { Plugin } from '../types' +import { type InstalledPlugin, PluginSource } from '../types' import CornerMark from '../card/base/corner-mark' import Description from '../card/base/description' -import Icon from '../card/base/card-icon' import OrgInfo from '../card/base/org-info' import Title from '../card/base/title' import Action from './action' import cn from '@/utils/classnames' import I18n from '@/context/i18n' +import { API_PREFIX } from '@/config' type Props = { className?: string - payload: Plugin - source: 'github' | 'marketplace' | 'local' | 'debug' - onDelete: () => void + plugin: InstalledPlugin } const PluginItem: FC = ({ className, - payload, - source, - onDelete, + plugin, }) => { const { locale } = useContext(I18n) const { t } = useTranslation() - const { type, name, org, label } = payload - const hasNewVersion = payload.latest_version !== payload.version + const { + source, + tenant_id, + installation_id, + endpoints_active, + meta, + version, + latest_version, + } = plugin + const { category, author, name, label, description, icon, verified } = plugin.declaration + // Only plugin installed from GitHub need to check if it's the new version + const hasNewVersion = useMemo(() => { + return source === PluginSource.github && latest_version !== version + }, [source, latest_version, version]) + const orgName = useMemo(() => { + return [PluginSource.github, PluginSource.marketplace].includes(source) ? author : '' + }, [source, author]) + + const tLocale = useMemo(() => { + return locale.replace('-', '_') + }, [locale]) return ( -
- + {/* Header */}
- +
+ {`plugin-${installation_id}-logo`} +
- - <RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" /> - <Badge className='ml-1' text={payload.version} hasRedCornerMark={hasNewVersion} /> + <Title title={label[tLocale]} /> + {verified && <RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" />} + <Badge className='ml-1' text={plugin.version} hasRedCornerMark={hasNewVersion} /> </div> <div className='flex items-center justify-between'> - <Description text={payload.brief[locale]} descriptionLineRows={1}></Description> + <Description text={description[tLocale]} descriptionLineRows={1}></Description> <Action - pluginId='xxx' - pluginName={label[locale]} + pluginId={installation_id} + pluginName={label[tLocale]} usedInApps={5} isShowFetchNewVersion={hasNewVersion} - isShowInfo + isShowInfo={source === PluginSource.github} isShowDelete - onDelete={onDelete} + meta={meta} + onDelete={() => {}} /> </div> </div> @@ -71,19 +98,19 @@ const PluginItem: FC<Props> = ({ <div className='flex items-center'> <OrgInfo className="mt-0.5" - orgName={org} + orgName={orgName} packageName={name} packageNameClassName='w-auto max-w-[150px]' /> <div className='mx-2 text-text-quaternary system-xs-regular'>ยท</div> <div className='flex text-text-tertiary system-xs-regular space-x-1'> <RiLoginCircleLine className='w-4 h-4' /> - <span>{t('plugin.endpointsEnabled', { num: 2 })}</span> + <span>{t('plugin.endpointsEnabled', { num: endpoints_active })}</span> </div> </div> <div className='flex items-center'> - {source === 'github' + {source === PluginSource.github && <> <a href='' target='_blank' className='flex items-center gap-1'> <div className='text-text-tertiary system-2xs-medium-uppercase'>{t('plugin.from')}</div> @@ -95,7 +122,7 @@ const PluginItem: FC<Props> = ({ </a> </> } - {source === 'marketplace' + {source === PluginSource.marketplace && <> <a href='' target='_blank' className='flex items-center gap-0.5'> <div className='text-text-tertiary system-2xs-medium-uppercase'>{t('plugin.from')} <span className='text-text-secondary'>marketplace</span></div> @@ -103,7 +130,7 @@ const PluginItem: FC<Props> = ({ </a> </> } - {source === 'local' + {source === PluginSource.local && <> <div className='flex items-center gap-1'> <RiHardDrive3Line className='text-text-tertiary w-3 h-3' /> @@ -111,7 +138,7 @@ const PluginItem: FC<Props> = ({ </div> </> } - {source === 'debug' + {source === PluginSource.debugging && <> <div className='flex items-center gap-1'> <RiBugLine className='w-3 h-3 text-text-warning' /> diff --git a/web/app/components/plugins/plugin-page/context.tsx b/web/app/components/plugins/plugin-page/context.tsx index 10318c1cb4..42736f3edd 100644 --- a/web/app/components/plugins/plugin-page/context.tsx +++ b/web/app/components/plugins/plugin-page/context.tsx @@ -9,14 +9,20 @@ import { createContext, useContextSelector, } from 'use-context-selector' -import type { Permissions } from '../types' +import type { InstalledPlugin, Permissions } from '../types' +import type { FilterState } from './filter-management' import { PermissionType } from '../types' +import { fetchInstalledPluginList } from '@/service/plugins' +import useSWR from 'swr' export type PluginPageContextValue = { containerRef: React.RefObject<HTMLDivElement> permissions: Permissions setPermissions: (permissions: PluginPageContextValue['permissions']) => void - + installedPluginList: InstalledPlugin[] + mutateInstalledPluginList: () => void + filters: FilterState + setFilters: (filter: FilterState) => void } export const PluginPageContext = createContext<PluginPageContextValue>({ @@ -26,6 +32,14 @@ export const PluginPageContext = createContext<PluginPageContextValue>({ debug_permission: PermissionType.noOne, }, setPermissions: () => { }, + installedPluginList: [], + mutateInstalledPluginList: () => {}, + filters: { + categories: [], + tags: [], + searchQuery: '', + }, + setFilters: () => {}, }) type PluginPageContextProviderProps = { @@ -44,6 +58,12 @@ export const PluginPageContextProvider = ({ install_permission: PermissionType.noOne, debug_permission: PermissionType.noOne, }) + const [filters, setFilters] = useState<FilterState>({ + categories: [], + tags: [], + searchQuery: '', + }) + const { data, mutate: mutateInstalledPluginList } = useSWR({ url: '/workspaces/current/plugin/list' }, fetchInstalledPluginList) return ( <PluginPageContext.Provider @@ -51,6 +71,10 @@ export const PluginPageContextProvider = ({ containerRef, permissions, setPermissions, + installedPluginList: data?.plugins || [], + mutateInstalledPluginList, + filters, + setFilters, }} > {children} diff --git a/web/app/components/plugins/plugin-page/list/index.tsx b/web/app/components/plugins/plugin-page/list/index.tsx index 0939c36ca7..23f6e403e5 100644 --- a/web/app/components/plugins/plugin-page/list/index.tsx +++ b/web/app/components/plugins/plugin-page/list/index.tsx @@ -1,22 +1,21 @@ +import type { FC } from 'react' import PluginItem from '../../plugin-item' -import { customTool, extensionDallE, modelGPT4, toolNotion } from '@/app/components/plugins/card/card-mock' +import type { InstalledPlugin } from '../../types' -const PluginList = () => { - const pluginList = [toolNotion, extensionDallE, modelGPT4, customTool] +type IPluginListProps = { + pluginList: InstalledPlugin[] +} +const PluginList: FC<IPluginListProps> = ({ pluginList }) => { return ( <div className='pb-3 bg-white'> - <div> - <div className='grid grid-cols-2 gap-3'> - {pluginList.map((plugin, index) => ( - <PluginItem - key={index} - payload={plugin as any} - onDelete={() => {}} - source={'debug'} - /> - ))} - </div> + <div className='grid grid-cols-2 gap-3'> + {pluginList.map(plugin => ( + <PluginItem + key={plugin.plugin_id} + plugin={plugin} + /> + ))} </div> </div> ) diff --git a/web/app/components/plugins/plugin-page/plugins-panel.tsx b/web/app/components/plugins/plugin-page/plugins-panel.tsx index 7dd8a5e480..8dbbf8eaa5 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel.tsx @@ -1,16 +1,33 @@ 'use client' -import { useState } from 'react' -import type { EndpointListItem, PluginDetail } from '../types' +import { useMemo, useState } from 'react' +import type { EndpointListItem, InstalledPlugin, PluginDetail } from '../types' import type { FilterState } from './filter-management' import FilterManagement from './filter-management' import List from './list' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' import { toolNotion, toolNotionEndpoints } from '@/app/components/plugins/plugin-detail-panel/mock' +import { usePluginPageContext } from './context' +import { useDebounceFn } from 'ahooks' const PluginsPanel = () => { - const handleFilterChange = (filters: FilterState) => { - // - } + const [filters, setFilters] = usePluginPageContext(v => [v.filters, v.setFilters]) + const pluginList = usePluginPageContext(v => v.installedPluginList) as InstalledPlugin[] + + const { run: handleFilterChange } = useDebounceFn((filters: FilterState) => { + setFilters(filters) + }, { wait: 500 }) + + const filteredList = useMemo(() => { + // todo: filter by tags + const { categories, searchQuery } = filters + const filteredList = pluginList.filter((plugin) => { + return ( + (categories.length === 0 || categories.includes(plugin.declaration.category)) + && (searchQuery === '' || plugin.plugin_id.toLowerCase().includes(searchQuery.toLowerCase())) + ) + }) + return filteredList + }, [pluginList, filters]) const [currentPluginDetail, setCurrentPluginDetail] = useState<PluginDetail | undefined>(toolNotion as any) const [currentPluginEndpoints, setCurrentEndpoints] = useState<EndpointListItem[]>(toolNotionEndpoints as any) @@ -24,7 +41,7 @@ const PluginsPanel = () => { </div> <div className='flex px-12 items-start content-start gap-2 flex-grow self-stretch flex-wrap'> <div className='w-full'> - <List /> + <List pluginList={filteredList} /> </div> </div> <PluginDetailPanel diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index 9252264efd..e5af963b8b 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -112,7 +112,7 @@ export type Plugin = { // Repo readme.md content introduction: string repository: string - category: string + category: PluginType install_count: number endpoint: { settings: CredentialFormSchemaBase[] @@ -235,3 +235,29 @@ export type TaskStatusResponse = { plugins: PluginStatus[] } } + +export type MetaData = { + repo: string + version: string + package: string +} + +export type InstalledPlugin = { + plugin_id: string + installation_id: string + declaration: PluginDeclaration + source: PluginSource + tenant_id: string + version: string + latest_version: string + endpoints_active: number + meta: MetaData +} + +export type InstalledPluginListResponse = { + plugins: InstalledPlugin[] +} + +export type UninstallPluginResponse = { + success: boolean +} diff --git a/web/service/plugins.ts b/web/service/plugins.ts index b8bb3af5e1..c40fa12361 100644 --- a/web/service/plugins.ts +++ b/web/service/plugins.ts @@ -6,10 +6,12 @@ import type { EndpointsRequest, EndpointsResponse, InstallPackageResponse, + InstalledPluginListResponse, Permissions, PluginDeclaration, PluginManifestInMarket, TaskStatusResponse, + UninstallPluginResponse, UpdateEndpointRequest, } from '@/app/components/plugins/types' import type { DebugInfo as DebugInfoTypes } from '@/app/components/plugins/types' @@ -110,3 +112,11 @@ export const fetchPermission = async () => { export const updatePermission = async (permissions: Permissions) => { return post('/workspaces/current/plugin/permission/change', { body: permissions }) } + +export const fetchInstalledPluginList: Fetcher<InstalledPluginListResponse, { url: string }> = ({ url }) => { + return get<InstalledPluginListResponse>(url) +} + +export const uninstallPlugin = async (pluginId: string) => { + return post<UninstallPluginResponse>('/workspaces/current/plugin/uninstall', { body: { plugin_installation_id: pluginId } }) +}