coze-studio/frontend/packages/agent-ide/plugin-shared/src/components/plugin-panel/index.tsx

662 lines
22 KiB
TypeScript

/*
* Copyright 2025 coze-dev Authors
*
* 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.
*/
/* eslint-disable max-lines */
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-lines-per-function, complexity -- onApiToggle of PluginItem can be extracted and optimized later */
/* eslint-disable import/order */
import {
type MutableRefObject,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { type WorkflowNodeJSON } from '@flowgram-adapter/free-layout-editor';
import classNames from 'classnames';
import { useInViewport } from 'ahooks';
import groupBy from 'lodash-es/groupBy';
import {
Collapse,
Divider,
Highlight,
Image,
Space,
Toast,
Typography,
UIButton,
} from '@coze-arch/bot-semi';
import { IconViewinchatOutlined } from '@coze-arch/bot-icons';
import { ConnectorList, OfficialLabel } from '@coze-community/components';
import { I18n } from '@coze-arch/i18n';
import {
emitEvent,
OpenBlockEvent,
formatDate,
formatNumber,
} from '@coze-arch/bot-utils';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import { PluginDevelopApi } from '@coze-arch/bot-api';
import { useBotInfoStore } from '@coze-studio/bot-detail-store/bot-info';
import { useSpaceStore } from '@coze-arch/bot-studio-store';
import { OpenModeType } from '@coze-arch/bot-hooks';
import {
From,
type PluginModalModeProps,
} from '../../types/plugin-modal-types';
import { getPluginApiKey } from '../../utils';
import {
type PluginApi,
type PluginInfoForPlayground,
PluginType,
} from '@coze-arch/bot-api/plugin_develop';
import { type Int64 } from '@coze-arch/bot-api/developer_api';
import s from './index.module.less';
import { type SimplifyProductInfo } from '../../service/fetch-plugin';
import { PluginItem } from './item';
import { extractApiParams } from './helper';
import { AvatarName } from '@coze-studio/components';
import { PluginPerfStatics } from './plugin-perf-statics';
import { withSlardarIdButton } from '@coze-studio/bot-utils';
import { Tag, Tooltip } from '@coze-arch/coze-design';
import {
IconCozDesktop,
IconCozInfoCircle,
} from '@coze-arch/coze-design/icons';
import { isBoolean, isUndefined } from 'lodash-es';
import {
type CommercialSetting,
PluginType as ProductPluginType,
} from '@coze-arch/bot-api/product_api';
import { PluginAuthMode } from '../../types/auth-mode';
export interface PluginPanelProps extends PluginModalModeProps {
info: PluginInfoForPlayground & {
listed_at?: Int64;
version_name?: string;
version_ts?: string;
};
highlightWords?: string[];
showButton?: boolean;
showCreator?: boolean;
showCreateTime?: boolean;
showMarketLink?: boolean;
showProjectPluginLink?: boolean;
showPublishTime?: boolean;
className?: string;
pluginApiList: PluginApi[];
onPluginApiListChange: (list: PluginApi[]) => void;
productInfo?: SimplifyProductInfo;
commercialSetting?: CommercialSetting;
isFromMarket?: boolean;
type?: string;
scrollContainerRef?: MutableRefObject<HTMLDivElement | null>;
activeKey?: string | string[] | undefined;
agentId?: string;
workflowNodes?: WorkflowNodeJSON[];
index?: number;
addonBefore?: React.ReactNode;
addonAfter?: React.ReactNode;
}
const CopyPlugin = ({ onApiToggle }: { onApiToggle: () => void }) => (
<UIButton
onClick={e => {
e.stopPropagation();
onApiToggle?.();
}}
>
{I18n.t('add_resource_modal_copy_to_project')}
</UIButton>
);
export const PluginPanel: React.FC<PluginPanelProps> = ({
info,
highlightWords,
showCreator = false,
showCreateTime = true,
showPublishTime = false,
showButton = true,
showCopyPlugin = false,
className = '',
pluginApiList,
productInfo,
showMarketLink,
showProjectPluginLink,
clickProjectPluginCallback,
isFromMarket,
onPluginApiListChange,
openMode,
from,
workflowNodes,
openModeCallback,
onCopyPluginCallback,
scrollContainerRef,
type,
activeKey,
agentId,
index = 0,
addonBefore,
addonAfter,
}) => {
const {
name,
plugin_apis,
id,
plugin_icon,
project_id,
desc_for_human,
create_time = 0,
update_time = 0,
statistic_data,
listed_at,
plugin_product_status,
plugin_type,
is_official,
version_name,
version_ts,
} = info;
const botId = useBotInfoStore(state => state.botId);
const { id: productId, status: marketStatus, auth_mode } = productInfo || {};
const refTarget = useRef(null);
const refHasReport = useRef(false);
// Record the apiId of the currently clicked plugin
const currentApiId = useRef('');
// Record whether the latest plug-in data of the store is currently being pulled
const [isFetching, setIsFetching] = useState(false);
// For example {'pluginID_apiID': [node1, node2, node3,...]}
const pluginApiNodesMap = useMemo(
() =>
groupBy(workflowNodes || [], item => {
const apiParams = item.data?.inputs?.apiParam ?? [];
const pluginID = extractApiParams('pluginID', apiParams);
const apiID = extractApiParams('apiID', apiParams);
return `${pluginID}_${apiID}`;
}),
[workflowNodes],
);
const [isInView] = useInViewport(refTarget, {
root: () => scrollContainerRef?.current,
});
const isCheckNow = useMemo(() => {
if (!id) {
return false;
}
if (activeKey === id || activeKey?.includes(id)) {
return true;
}
return false;
}, [activeKey, id]);
useEffect(() => {
if (isCheckNow) {
sendTeaEvent(EVENT_NAMES.product_click_front, {
plugin_id: info.id,
product_id: `${productInfo?.id}`,
product_name: info?.name || '',
entity_type: 'plugin',
source: 'add_plugin_menu',
from: 'add_plugin_menu',
filter_tag: type || '',
action: 'expand_tools',
c_position: index,
});
}
}, [isCheckNow, productInfo?.id]);
useEffect(() => {
if (!isFromMarket || refHasReport.current) {
return;
}
if (isInView) {
refHasReport.current = true;
sendTeaEvent(EVENT_NAMES.product_show_front, {
plugin_id: info.id,
product_id: `${productInfo?.id}`,
product_name: info?.name || '',
entity_type: 'plugin',
source: 'add_plugin_menu',
from: 'add_plugin_menu',
filter_tag: type || '',
c_position: index,
});
}
}, [isInView, productInfo, info, type]);
const timePrefixText = showPublishTime
? I18n.t('mkl_plugin_publish')
: showCreateTime
? I18n.t('mkl_plugin_created')
: I18n.t('mkl_plugin_updated');
const timeToShow =
(showPublishTime
? Number(listed_at)
: showCreateTime
? Number(create_time)
: Number(update_time)) || 0;
const renderAuthStatus = () => {
if (isUndefined(auth_mode) || auth_mode === PluginAuthMode.NoAuth) {
return null;
}
if (
auth_mode === PluginAuthMode.Required ||
auth_mode === PluginAuthMode.Supported
) {
return (
<Tag color="yellow" className="font-medium !py-2px !px-4px !h-20px">
{I18n.t('plugin_tool_config_status_unauthorized')}
</Tag>
);
}
return (
<Tag color="brand" className="font-medium !py-2px !px-4px !h-20px">
{I18n.t('plugin_tool_config_status_authorized')}
</Tag>
);
};
return (
<Collapse.Panel
data-testid="plugin-collapse-panel"
className={classNames(s['plugin-panel'], className)}
disabled={!plugin_apis?.length}
header={
<div className={s['plugin-panel-header']} ref={refTarget}>
<OfficialLabel
size="small"
visible={productInfo?.is_official ?? false}
>
<Image
className={s['header-icon']}
src={plugin_icon}
preview={false}
/>
</OfficialLabel>
<div className={s['header-main']}>
<div className={s['header-name']}>
<Space spacing={8} className="flex-1">
{/* Plugin name up to 30 characters, no ellipsis required */}
<Typography.Text>
<Highlight
sourceString={name}
searchWords={highlightWords}
component="strong"
/>
</Typography.Text>
{renderAuthStatus()}
{showProjectPluginLink && clickProjectPluginCallback ? (
<IconViewinchatOutlined
className={s['market-link-icon']}
onClick={event => {
event.stopPropagation();
clickProjectPluginCallback?.({
...info,
});
}}
/>
) : null}
{showMarketLink && Number(productId) > 0 ? (
<IconViewinchatOutlined
className={s['market-link-icon']}
onClick={event => {
event.stopPropagation();
sendTeaEvent(EVENT_NAMES.product_click_front, {
plugin_id: info.id,
product_id: `${productInfo?.id}`,
product_name: info?.name || '',
entity_type: 'plugin',
source: 'add_plugin_menu',
from: 'add_plugin_menu',
filter_tag: type || '',
action: 'enter_detailpage',
c_position: index,
});
window.open(
`/store/plugin/${productId}?from=add_plugin_menu`,
);
}}
/>
) : null}
</Space>
</div>
<div className={classNames(s['header-desc'], 'flex items-center')}>
{plugin_type === PluginType.LOCAL ? (
<>
<Tag color="cyan" size="mini">
{I18n.t('local_plugin_label')}
</Tag>
<Divider layout="vertical" margin="4px" className="h-[9px]" />
</>
) : null}
<Typography.Text
ellipsis={{
showTooltip: {
opts: {
content: desc_for_human,
style: { wordWrap: 'break-word', maxWidth: '560px' },
},
},
rows: 1,
}}
>
{desc_for_human}
</Typography.Text>
</div>
<div className="my-[8px] leading-[16px]">
<Space spacing={4}>
{/* condition */}
{addonBefore}
{productInfo?.plugin_type === ProductPluginType.LocalPlugin ? (
<Tag
color="cyan"
prefixIcon={
<IconCozDesktop className="coz-fg-color-cyan text-[10px]" />
}
size="mini"
>
{I18n.t('store_service_plugin')}
</Tag>
) : null}
<Tag color="primary" size="mini">
{I18n.t('bot_edit_page_plugin_list_plugin_has_n_tools', {
n: plugin_apis?.length,
})}
</Tag>
{!!statistic_data?.bot_quote && (
<Tag color="primary" size="mini">
{I18n.t('bot_edit_page_plugin_list_plugin_n_bots_using', {
n: formatNumber(statistic_data?.bot_quote),
})}
</Tag>
)}
{productInfo?.connectors?.length ? (
<>
<Divider
layout="vertical"
margin={0}
className="coz-stroke-primary"
/>
<div className="ml-auto coz-fg-secondary text-base flex items-center gap-6px">
{I18n.t('store_service_plugin_connector')}
<ConnectorList connectors={productInfo?.connectors} />
<Tooltip
content={I18n.t('store_add_connector_tootip')}
theme="dark"
>
<IconCozInfoCircle className="coz-fg-secondary text-lg" />
</Tooltip>
</div>
</>
) : null}
</Space>
</div>
<div className={'flex justify-between'}>
<Space className={s['header-info']}>
{showCreator ? (
<span className={'max-w-[260px]'}>
<AvatarName
avatar={productInfo?.user_info?.avatar_url}
username={productInfo?.user_info?.user_name}
name={productInfo?.user_info?.name}
label={{
name: productInfo?.user_info?.user_label?.label_name,
icon: productInfo?.user_info?.user_label?.icon_url,
href: productInfo?.user_info?.user_label?.jump_link,
}}
nameMaxWidth={150}
/>
</span>
) : null}
{showCreator ? <Divider layout="vertical" /> : null}
<div className={s['creator-time']}>
{`${timePrefixText} `}
{formatDate(timeToShow, 'YYYY-MM-DD HH:mm')}
</div>
{addonAfter}
</Space>
<PluginPerfStatics
className={s['plugin-total']}
successRate={productInfo?.success_rate}
callAmount={productInfo?.call_amount}
avgExecTime={productInfo?.avg_exec_time}
botsUseCount={productInfo?.bots_use_count}
/>
</div>
</div>
{showCopyPlugin ? (
<div>
<CopyPlugin
onApiToggle={() => {
onCopyPluginCallback?.({
pluginID: id,
name,
});
}}
/>
</div>
) : null}
</div>
}
itemKey={`${id}`}
>
{plugin_apis?.map(api => {
const isAdded = pluginApiList.some(
addedApi =>
(addedApi.api_id && addedApi.api_id === api.api_id) ||
(addedApi.plugin_id?.toString() ?? '0') + (addedApi.name ?? '') ===
(api.plugin_id?.toString() ?? '0') + (api.name ?? ''),
);
return (
<PluginItem
data-testid="plugin-panel-item-pluginapi"
isAdded={isAdded}
pluginApi={api}
from={from}
workflowNodes={
pluginApiNodesMap[`${api?.plugin_id}_${api?.api_id}`] ?? []
}
marketPluginInfo={
isFromMarket
? productInfo?.tools?.find(item => item.id === api.api_id)
: undefined
}
isLocalPlugin={
productInfo?.plugin_type === ProductPluginType.LocalPlugin
}
connectors={productInfo?.connectors?.map(item => item.name ?? '')}
marketStatus={isFromMarket ? marketStatus : undefined}
onApiToggle={async () => {
let isSuccess = true;
emitEvent(OpenBlockEvent.PLUGIN_API_BLOCK_OPEN);
if (isAdded) {
onPluginApiListChange(
pluginApiList.filter(
item =>
getPluginApiKey(item) !== getPluginApiKey(api) &&
(!item?.api_id || item?.api_id !== api?.api_id),
),
);
Toast.success({
content: I18n.t('bot_edit_tool_removed_toast', {
api_name: api.name,
}),
showClose: false,
});
sendTeaEvent(EVENT_NAMES.click_tool_select, {
operation: 'remove',
bot_id: botId,
operation_type: 'single',
tool_id: api?.api_id || '',
tool_name: api?.name || '',
product_id: `${productInfo?.id}`,
product_name: info?.name || '',
source: 'add_plugin_list',
from: 'bot_develop',
});
} else {
let apiToSend: PluginApi | undefined;
// The information of the plugin where the api is located is added and stored in the store to avoid GetPlaygroundPluginList multiple requests
const pluginInfo = {
plugin_icon,
plugin_type,
is_official,
project_id,
version_name,
version_ts,
};
// Check whether the name of the Plugins currently to be added has a duplicate name in the added list (the model does not support it, so this is added)
if (
pluginApiList
.map(i => (i.plugin_name ?? '') + i.name)
.includes((api.plugin_name ?? '') + (api.name ?? ''))
) {
Toast.error({
content: I18n.t('plugin_name_conflict_error'),
showClose: false,
});
isSuccess = false;
} else {
if (isFromMarket) {
if (api.plugin_id && api?.api_id && api?.api_id !== '0') {
setIsFetching(true);
currentApiId.current = api.api_id;
// Data from the market needs to be re-pulled to the latest data.
const result =
await PluginDevelopApi.GetPlaygroundPluginList({
page: 1,
size: 1,
plugin_ids: [api.plugin_id],
plugin_types: [
PluginType.PLUGIN,
PluginType.APP,
PluginType.LOCAL,
],
space_id: useSpaceStore.getState().getSpaceId(),
});
setIsFetching(false);
const targetApi =
result?.data?.plugin_list?.[0]?.plugin_apis?.find(
item => item?.api_id === api?.api_id,
);
if (targetApi) {
Object.assign(targetApi, {
plugin_product_status:
result?.data?.plugin_list?.[0]
.plugin_product_status,
...pluginInfo,
});
}
apiToSend = targetApi;
}
} else {
apiToSend = Object.assign(
{ ...api },
{ plugin_product_status, ...pluginInfo },
);
}
if (apiToSend) {
// If it can only be added once, call callback after adding and close the pop-up window
if (
openMode === OpenModeType.OnlyOnceAdd ||
(from &&
[
From.WorkflowAddNode,
From.ProjectIde,
From.ProjectWorkflow,
].includes(from))
) {
const cbResult = await openModeCallback?.({
...apiToSend,
...pluginInfo,
});
/** Allow to add failed scenarios */
if (isBoolean(cbResult)) {
return cbResult;
}
return isSuccess;
}
if (!IS_OPEN_SOURCE) {
// After successfully adding the plugin, quickly bind the preset card information
await PluginDevelopApi.QuickBindPluginPresetCard({
plugin_id: apiToSend.plugin_id,
api_name: apiToSend.name,
bot_id: botId,
agent_id: agentId,
space_id: useSpaceStore.getState().getSpaceId(),
});
}
onPluginApiListChange([...pluginApiList, apiToSend]);
Toast.success({
content: I18n.t('bot_edit_tool_added_toast', {
api_name: api.name,
}),
showClose: false,
});
} else {
Toast.error({
content: withSlardarIdButton(
I18n.t('bot_edit_tool_added_toast_error', {
api_name: api.name,
}),
),
showClose: false,
});
isSuccess = false;
}
}
sendTeaEvent(EVENT_NAMES.click_tool_select, {
operation: 'add',
bot_id: botId,
operation_type: 'single',
tool_id: api?.api_id || '',
tool_name: api?.name || '',
product_id: `${productInfo?.id}`,
product_name: info?.name || '',
source: 'add_plugin_list',
from: 'bot_develop',
});
}
return isSuccess;
}}
key={(api.plugin_id?.toString() ?? '') + (api.name ?? '')}
showButton={showButton}
loading={isFetching && api?.api_id === currentApiId.current}
/>
);
})}
</Collapse.Panel>
);
};