feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
/*
* 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.
*/
import { createContext, type RefObject, useContext } from 'react';
export interface PublishContainerContextProps {
getContainerRef: () => RefObject<HTMLDivElement> | null;
/** 发布渠道的布局受到顶部 header 高度影响 用这个变量将他们关联起来 */
publishHeaderHeight: number;
setPublishHeaderHeight: (height: number) => void;
}
export const PublishContainerContext =
createContext<PublishContainerContextProps>({
getContainerRef: () => null,
publishHeaderHeight: 0,
setPublishHeaderHeight: () => 0,
});
export const usePublishContainer = () => useContext(PublishContainerContext);

View File

@@ -0,0 +1,53 @@
/*
* 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.
*/
import { useUserInfo } from '@coze-arch/foundation-sdk';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { useParams } from 'react-router-dom';
import { publishAnchorService } from '@/service/connector-anchor';
export const useBizConnectorAnchor = () => {
const userId = useUserInfo()?.user_id_str;
const projectId = useParams<DynamicParams>().project_id;
const setAnchor = (connectorId: string) => {
if (!userId || !projectId) {
return;
}
return publishAnchorService.setAnchor({ projectId, userId, connectorId });
};
const getAnchor = () => {
if (!userId || !projectId) {
return;
}
return publishAnchorService.getAnchor({ userId, projectId });
};
const removeAnchor = () => {
if (!userId || !projectId) {
return;
}
return publishAnchorService.removeAnchor({ userId, projectId });
};
return {
setAnchor,
getAnchor,
removeAnchor,
};
};

View File

@@ -0,0 +1,299 @@
/*
* 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 complexity */
/* eslint-disable @coze-arch/max-line-per-function */
import { useParams } from 'react-router-dom';
import { useState, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { debounce, union, find } from 'lodash-es';
import { useInfiniteScroll } from 'ahooks';
import {
CheckType,
WorkflowMode,
type Workflow,
} from '@coze-arch/idl/workflow_api';
import {
type PublishConnectorInfo,
ConnectorConfigStatus,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle, IconCozEmpty } from '@coze-arch/coze-design/icons';
import {
Modal,
Search,
Checkbox,
Divider,
Spin,
Tooltip,
Space,
EmptyState,
} from '@coze-arch/coze-design';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { workflowApi } from '@coze-arch/bot-api';
import { useProjectPublishStore } from '@/store';
export interface DataList {
list: Workflow[];
hasMore?: boolean;
nextCursorId?: string;
total: number;
nextPageIndex: number;
}
const debounceTimer = 500;
export const UseMcpConfigModal = ({
record,
}: {
record: PublishConnectorInfo;
}) => {
const [visible, setVisible] = useState(false);
const [searchVal, setSearchVal] = useState<string>('');
const [checkedList, setCheckedList] = useState<string[]>([]);
const { space_id = '', project_id = '' } = useParams<DynamicParams>();
const {
connectorPublishConfig,
setProjectPublishInfo,
connectorList,
selectedConnectorIds,
} = useProjectPublishStore(
useShallow(state => ({
connectorPublishConfig: state.connectorPublishConfig,
setProjectPublishInfo: state.setProjectPublishInfo,
connectorList: state.connectorList,
selectedConnectorIds: state.selectedConnectorIds,
})),
);
const containerRef = useRef<HTMLDivElement>(null);
const { loading, data, loadingMore } = useInfiniteScroll<DataList>(
async d => {
const res = await workflowApi.GetWorkFlowList({
space_id,
project_id,
flow_mode: WorkflowMode.All,
checker: [CheckType.MCPPublish],
size: 15,
page: d?.nextPageIndex ?? 1,
name: searchVal,
});
return {
list: res.data?.workflow_list ?? [],
total: Number(res.data?.total ?? 0),
nextPageIndex: (d?.nextPageIndex || 1) + 1,
};
},
{
target: containerRef,
reloadDeps: [searchVal],
isNoMore: dataSource =>
Boolean(
!dataSource?.total ||
(dataSource.nextPageIndex - 1) * 15 >= dataSource.total,
),
},
);
// 只能选中未禁用的workflow
const filterPassList = data?.list?.filter(
item =>
find(item?.check_result, {
type: CheckType.MCPPublish,
})?.is_pass,
);
//半选状态
const indeterminate =
checkedList.length > 0 &&
checkedList.length < (filterPassList?.length || 0);
//全选状态
const checkAll = checkedList.length === (filterPassList?.length || 0);
const close = () => {
setVisible(false);
};
const handelConfirm = () => {
setProjectPublishInfo({
connectorPublishConfig: {
...connectorPublishConfig,
[record.id]: {
selected_workflows: checkedList.map(item => {
const res = find(data?.list, {
workflow_id: item,
});
return {
workflow_id: res?.workflow_id,
workflow_name: res?.name,
};
}),
},
},
connectorList: connectorList.map(item => {
if (item.id === record.id) {
return {
...item,
config_status: ConnectorConfigStatus.Configured,
};
}
return item;
}),
selectedConnectorIds: union(selectedConnectorIds, [record.id]), //ID合并去重
});
close();
};
return {
open: () => {
setVisible(true);
const ids = connectorPublishConfig?.[record.id]?.selected_workflows;
setCheckedList(ids?.map(item => item.workflow_id ?? '') ?? []);
},
close,
node: (
<Modal
title={I18n.t('app_publish_connector_space_mcp_config_dialog_title')}
size="large"
visible={visible}
onCancel={close}
okButtonProps={{ loading, disabled: !checkedList.length }}
okText={I18n.t('app_publish_connector_space_mcp_config_dialog_confirm')}
cancelText={I18n.t(
'app_publish_connector_space_mcp_config_dialog_cancel',
)}
onOk={handelConfirm}
>
<div className="text-[12px]">
{I18n.t('app_publish_connector_space_mcp_config_dialog_desc')}
</div>
<Space className="mb-[16px]" spacing={4}>
<div className="text-[12px]">
{I18n.t('app_publish_connector_space_mcp_config_dialog_desc2')}
</div>
<Tooltip
position="top"
content={
<div className="whitespace-pre-line">
{I18n.t(
'app_publish_connector_space_mcp_config_dialog_hover_wf_constraints',
)}
</div>
}
>
<IconCozInfoCircle className="text-[14px]" />
</Tooltip>
</Space>
<div className="font-[500] mb-[12px]">
{I18n.t('app_publish_connector_space_mcp_config_dialog_choose_wf')}
<span className="coz-fg-hglt-red">*</span>
</div>
<div className="border border-solid coz-stroke-primary rounded py-[12px]">
<div className="mx-[12px]">
<Search
className="!w-full"
placeholder={I18n.t(
'app_publish_connector_space_mcp_config_dialog_search_placeholder',
)}
value={searchVal}
onSearch={debounce(v => {
setSearchVal(v);
}, debounceTimer)}
/>
</div>
<Divider className="my-[8px]" />
<div className="mx-[12px]">
{data?.list.length ? (
<Checkbox
className="my-[8px] px-[4px]"
indeterminate={indeterminate}
checked={checkAll}
onChange={e => {
setCheckedList(
e.target.checked
? filterPassList?.map(item => item.workflow_id || '') ||
[]
: [],
);
}}
>
{I18n.t(
'app_publish_connector_space_mcp_config_dialog_filter_all',
)}
</Checkbox>
) : null}
<div
ref={containerRef}
className="max-h-[300px] overflow-x-hidden overflow-y-auto"
>
<Checkbox.Group
className="gap-[4px]"
value={checkedList}
onChange={setCheckedList}
>
{data?.list?.map(option => {
const mcpOpt = find(option?.check_result, {
type: CheckType.MCPPublish,
});
return (
<Checkbox
className="p-[4px]"
key={option.workflow_id}
value={option.workflow_id}
disabled={!mcpOpt?.is_pass}
>
{mcpOpt?.is_pass ? (
option.name
) : (
<Tooltip position="top" content={mcpOpt?.reason}>
{option.name}
</Tooltip>
)}
</Checkbox>
);
})}
</Checkbox.Group>
{/* 加载中 */}
{loadingMore && data?.list.length ? (
<div className="text-center">
<Spin size="small" />
</div>
) : null}
{/* 空状态 */}
{!data?.list.length ? (
<EmptyState
className="my-[80px] mx-auto"
icon={<IconCozEmpty />}
title={I18n.t(
'app_publish_connector_space_mcp_config_dialog_no_results_found',
)}
/>
) : null}
</div>
</div>
</div>
</Modal>
),
};
};

View File

@@ -0,0 +1,261 @@
/*
* 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.
*/
import React, { useEffect, useState } from 'react';
import { useRequest } from 'ahooks';
import {
ConnectorPublishStatus,
PublishRecordStatus,
type PublishRecordDetail,
} from '@coze-arch/idl/intelligence_api';
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
import {
IconCozCheckMarkCircle,
IconCozClock,
IconCozCrossCircle,
} from '@coze-arch/coze-design/icons';
import { Modal, Select, Tag, type TagProps } from '@coze-arch/coze-design';
import { type OptionProps } from '@coze-arch/bot-semi/Select';
import { intelligenceApi } from '@coze-arch/bot-api';
import { EProjectPermission, useProjectAuth } from '@coze-common/auth';
import { isPublishFinish } from '../utils/is-publish-finish';
import { ProjectPublishProgress } from '../publish-progress';
enum PublishStatus {
Publishing,
Failed,
Success,
}
const PublishStatusMap: Record<
PublishStatus,
Pick<TagProps, 'prefixIcon' | 'color'> & { text: I18nKeysNoOptionsType }
> = {
[PublishStatus.Publishing]: {
prefixIcon: <IconCozClock />,
color: 'brand',
text: 'project_releasing',
},
[PublishStatus.Failed]: {
prefixIcon: <IconCozCrossCircle />,
color: 'red',
text: 'project_release_failed',
},
[PublishStatus.Success]: {
prefixIcon: <IconCozCheckMarkCircle />,
color: 'green',
text: 'project_release_success',
},
};
function toPublishStatus(record: PublishRecordDetail) {
const projectFailed =
record.publish_status === PublishRecordStatus.PackFailed ||
record.publish_status === PublishRecordStatus.AuditNotPass;
const connectorsFailed =
record.connector_publish_result?.some(
item => item.connector_publish_status === ConnectorPublishStatus.Failed,
) ?? false;
// project 本身失败 或 部分渠道发布失败 -> 整体失败
if (projectFailed || connectorsFailed) {
return PublishStatus.Failed;
}
const projectPublishing =
record.publish_status === PublishRecordStatus.Packing ||
record.publish_status === PublishRecordStatus.Auditing ||
record.publish_status === PublishRecordStatus.ConnectorPublishing;
const connectorsPublishing =
record.connector_publish_result?.some(
item =>
item.connector_publish_status === ConnectorPublishStatus.Default ||
item.connector_publish_status === ConnectorPublishStatus.Auditing,
) ?? false;
// project 本身发布中 或 部分渠道发布中 -> 整体发布中
if (projectPublishing || connectorsPublishing) {
return PublishStatus.Publishing;
}
return PublishStatus.Success;
}
export interface ProjectPublishStatusProps {
spaceId: string;
projectId: string;
defaultRecordID?: string;
}
/* eslint @coze-arch/max-line-per-function: ["error", {"max": 300}] */
export function usePublishStatus({
spaceId,
projectId,
defaultRecordID,
}: ProjectPublishStatusProps) {
const [status, setStatus] = useState<PublishStatus | undefined>();
const [latestRecord, setLatestRecord] = useState<PublishRecordDetail>();
const [recordList, setRecordList] = useState<OptionProps[]>([]);
const [selectedVersion, setSelectedVersion] = useState(defaultRecordID);
const [selectedRecord, setSelectedRecord] = useState<PublishRecordDetail>();
const [modalVisible, setModalVisible] = useState(false);
// 轮询最新发布记录,直到不属于“发布中”状态后停止
const latestRecordRequest = useRequest(
() => intelligenceApi.GetPublishRecordDetail({ project_id: projectId }),
{
manual: true,
pollingInterval: 5000,
pollingWhenHidden: false,
pollingErrorRetryCount: 3,
onSuccess: res => {
const record = res.data;
// 没有发布记录时停止轮询
if (!record || typeof record.publish_status !== 'number') {
latestRecordRequest.cancel();
return;
}
setStatus(toPublishStatus(record));
setLatestRecord(record);
// 首次请求最新发布记录后,默认选中其版本号
if (!selectedVersion) {
setRecordList([
{ value: record.publish_record_id, label: record.version_number },
]);
setSelectedVersion(record.publish_record_id ?? '');
} else if (selectedVersion === record.publish_record_id) {
setSelectedRecord(record);
}
if (isPublishFinish(record)) {
latestRecordRequest.cancel();
}
},
},
);
// 获取发布记录列表
const recordListRequest = useRequest(
() => intelligenceApi.GetPublishRecordList({ project_id: projectId }),
{
manual: true,
onSuccess: res => {
setRecordList(
res.data?.map(item => ({
value: item.publish_record_id,
label: item.version_number,
})) ?? [],
);
},
},
);
const hasPermission = useProjectAuth(
EProjectPermission.PUBLISH,
projectId,
spaceId,
);
// 用户有“发布”权限时,启动轮询
useEffect(() => {
if (!hasPermission || defaultRecordID) {
return;
}
latestRecordRequest.run();
}, [hasPermission, defaultRecordID]);
// 手动请求选择的发布记录
const recordDetailRequest = useRequest(
(recordId: string) =>
intelligenceApi.GetPublishRecordDetail({
project_id: projectId,
publish_record_id: recordId,
}),
{
manual: true,
onSuccess: res => {
const record = res.data;
setSelectedRecord(record);
if (record?.publish_record_id === latestRecord?.publish_record_id) {
setLatestRecord(record);
}
},
},
);
const tagConfig = PublishStatusMap[status ?? PublishStatus.Failed];
const showingRecord = selectedRecord ?? latestRecord;
const open = async () => {
await recordListRequest.runAsync();
if (defaultRecordID) {
await changeVersion(defaultRecordID);
}
setModalVisible(true);
};
const close = () => {
setModalVisible(false);
};
const changeVersion = async (version: string) => {
setSelectedVersion(version);
await recordDetailRequest.run(version);
};
return {
latestVersion: latestRecord,
currentVersion: recordList.find(item => item.value === selectedVersion),
open,
close,
modal: (
<Modal
title={I18n.t('project_release_stage')}
visible={modalVisible}
footer={null}
onCancel={() => setModalVisible(false)}
>
<div className="flex flex-col gap-[16px]">
<Select
className="w-full"
optionList={recordList}
value={selectedVersion}
onSelect={version => {
if (typeof version === 'string') {
changeVersion(version);
}
}}
/>
{showingRecord ? (
<ProjectPublishProgress record={showingRecord} />
) : null}
</div>
</Modal>
),
tag: (
<Tag
size="mini"
prefixIcon={tagConfig.prefixIcon}
color={tagConfig.color}
// Tag 组件默认 display: inline-flex, 而外层 span line-height > 1, 会导致其高度大于 Tag 本身
className="flex !px-[3px] font-medium"
>
{I18n.t(tagConfig.text)}
</Tag>
),
};
}

View File

@@ -0,0 +1,25 @@
/*
* 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.
*/
export { ProjectPublish } from './publish-main';
export { useBizConnectorAnchor } from './hooks/use-biz-connector-anchor';
export {
usePublishStatus,
type ProjectPublishStatusProps,
} from './hooks/use-publish-status';
export { PublishButton } from './publish-button';

View File

@@ -0,0 +1,276 @@
/*
* 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 complexity */
/* eslint-disable react-hooks/exhaustive-deps */
import { useNavigate } from 'react-router-dom';
import React, { type ReactNode, useCallback } from 'react';
import { useIsPublishRecordReady } from '@coze-studio/publish-manage-hooks';
import {
useProjectAuth,
EProjectPermission,
useProjectRole,
} from '@coze-common/auth';
import { IntelligenceType } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import {
IconCozAnalytics,
IconCozArrowDown,
IconCozArrowRight,
IconCozDocument,
IconCozLongArrowTopRight,
IconCozTrigger,
} from '@coze-arch/coze-design/icons';
import {
Button,
Divider,
IconButton,
Menu,
Popover,
Tooltip,
} from '@coze-arch/coze-design';
import { useFlags } from '@coze-arch/bot-flags';
import { usePublishStatus } from '../hooks/use-publish-status';
import { useBizConnectorAnchor } from '../hooks/use-biz-connector-anchor';
const isLocalDevMode = () => {
const searchParams = new URLSearchParams(location.search);
return searchParams.has('devBlock');
};
/* eslint @coze-arch/max-line-per-function: ["error", {"max": 300}] */
export const PublishButton = ({
spaceId,
projectId,
hasPublished,
}: {
spaceId: string;
projectId: string;
hasPublished: boolean;
}) => {
const navigate = useNavigate();
const { modal, tag, latestVersion, open } = usePublishStatus({
spaceId,
projectId,
});
const hasPublishPermission = useProjectAuth(
EProjectPermission.PUBLISH,
projectId,
spaceId,
);
const { removeAnchor } = useBizConnectorAnchor();
const handlePublish = useCallback(() => {
removeAnchor();
navigate(`/space/${spaceId}/project-ide/${projectId}/publish`);
}, [spaceId, projectId, removeAnchor]);
const projectRoles = useProjectRole(projectId);
const [FLAGS] = useFlags();
const { ready, inited } = useIsPublishRecordReady({
type: IntelligenceType.Project,
intelligenceId: projectId,
spaceId,
enable: !!(
// 社区版暂不支持该功能
(
FLAGS['bot.studio.publish_management'] &&
hasPublished &&
projectRoles.length &&
!IS_OPEN_SOURCE
)
),
});
const menuAnalysis = (
<MenuItem
disabled={!ready}
onClick={() => {
navigate(`/space/${spaceId}/publish/app/${projectId}?tab=analysis`);
}}
text={I18n.t('analytics_page_title')}
prefix={
<IconCozAnalytics className="w-[24px] h-[24px] px-[4px] py-[4px]" />
}
/>
);
const menuLogs = (
<MenuItem
disabled={!ready}
onClick={() => {
navigate(`/space/${spaceId}/publish/app/${projectId}?tab=logs`);
}}
text={I18n.t('release_management_trace')}
prefix={
<IconCozDocument className="w-[24px] h-[24px] px-[4px] py-[4px]" />
}
/>
);
const menuTriggers = (
<MenuItem
disabled={!ready}
onClick={() => {
navigate(`/space/${spaceId}/publish/app/${projectId}?tab=triggers`);
}}
text={I18n.t('release_management_trigger')}
prefix={
<IconCozTrigger className="w-[24px] h-[24px] px-[4px] py-[4px]" />
}
/>
);
if (!hasPublishPermission) {
return null;
}
if (!hasPublished) {
return (
<Button
onClick={handlePublish}
disabled={isLocalDevMode()}
data-testid="project.goto.publish-button"
>
{I18n.t('project_ide_frame_publish')}
</Button>
);
}
return (
<>
{modal}
<Popover
trigger="click"
zIndex={999}
content={
<div className="px-[4px] py-[8px] rounded-[12px] w-[244px] flex flex-col gap-[12px]">
{latestVersion?.version_number ? (
<div
className="flex justify-between items-center h-[32px] px-[4px] cursor-pointer"
onClick={() => open()}
>
<span className="flex-grow mr-[8px]">
{`${I18n.t('app_ide_publish_modal_recent_publication')} ${latestVersion?.version_number}`}
</span>
{tag}
<IconButton
size="small"
className="ml-[4px]"
icon={<IconCozArrowRight />}
color="secondary"
/>
</div>
) : null}
<div className="px-[8px] w-full">
<Divider />
</div>
{/* 社区版暂不支持该功能 */}
{FLAGS['bot.studio.publish_management'] && !IS_OPEN_SOURCE ? (
<div>
<div className="coz-fg-secondary font-[500] px-[8px] pt-[4px] pb-0 mb-[2px]">
{I18n.t('app_ide_publish_modal_publish_management')}
</div>
<Menu>
{ready || !inited ? (
menuAnalysis
) : (
<Tooltip
theme="dark"
content={I18n.t('release_management_generating')}
>
<div>{menuAnalysis}</div>
</Tooltip>
)}
{ready || !inited ? (
menuLogs
) : (
<Tooltip
theme="dark"
content={I18n.t('release_management_generating')}
>
<div>{menuLogs}</div>
</Tooltip>
)}
{ready || !inited ? (
menuTriggers
) : (
<Tooltip
theme="dark"
content={I18n.t('release_management_generating')}
>
<div>{menuTriggers}</div>
</Tooltip>
)}
</Menu>
</div>
) : null}
<div className="px-[4px] w-full">
<Button
className="w-full"
onClick={handlePublish}
disabled={isLocalDevMode()}
data-testid="project.goto.publish-button"
>
{I18n.t('app_ide_publish_modal_publish_button')}
</Button>
</div>
</div>
}
>
<Button>
<span className="mr-[4px]">
{I18n.t('project_ide_frame_publish')}
</span>
<IconCozArrowDown />
</Button>
</Popover>
</>
);
};
const MenuItem = ({
prefix,
text,
onClick,
disabled,
}: {
prefix?: ReactNode;
text?: string;
onClick?: () => void;
disabled?: boolean;
}) => (
<Menu.Item
className="px-[4px] py-[4px]"
disabled={disabled}
onClick={onClick}
>
<div className="w-[228px] flex items-center justify-between gap-[4px]">
{prefix}
<span className="flex-grow">{text}</span>
<IconCozLongArrowTopRight className="w-[24px] h-[24px] px-[4px] py-[4px] coz-fg-secondary" />
</div>
</Menu.Item>
);

View File

@@ -0,0 +1,140 @@
/*
* 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.
*/
import { useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { every, some } from 'lodash-es';
import { useRequest } from 'ahooks';
import { type PublishConnectorInfo } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { FormSelect, useFormApi } from '@coze-arch/coze-design';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { type OptionProps } from '@coze-arch/bot-semi/Select';
import { IconCozArrowDown } from '@coze-arch/bot-icons';
import { workflowApi } from '@coze-arch/bot-api';
import { useParams } from 'react-router-dom';
import { useProjectPublishStore } from '@/store';
interface ApiBindProps {
checked: boolean;
record: PublishConnectorInfo;
}
export const ApiBind = (props: ApiBindProps) => {
const { checked, record } = props;
const { id } = record;
const { space_id = '', project_id = '' } = useParams<DynamicParams>();
const formApi = useFormApi();
const { connectorPublishConfig, setProjectPublishInfo } =
useProjectPublishStore(
useShallow(state => ({
connectorPublishConfig: state.connectorPublishConfig,
setProjectPublishInfo: state.setProjectPublishInfo,
})),
);
const { data: workflowOptions = [], loading: workflowOptionsLoading } =
useRequest(async () => {
const { data } = await workflowApi.GetWorkFlowList({
project_id,
space_id,
page: 1,
size: 50,
});
return (
data.workflow_list?.map(item => ({
label: item.name ?? '',
value: item.workflow_id ?? '',
})) ?? []
);
});
useEffect(() => {
// 初始化时校验一次是否有workflow
if (checked && !workflowOptionsLoading) {
formApi.validate(['api_workflow']);
}
}, [workflowOptionsLoading]);
const checkWorkflowExist = (val: OptionProps[]) =>
every(val, (item: OptionProps) => {
if ('value' in item) {
return some(
workflowOptions,
(optionItem: OptionProps) => optionItem.value === item.value,
);
}
return true;
});
const validate = (val: OptionProps[]) => {
if (checked) {
const workflowExist = checkWorkflowExist(val);
if (!workflowExist) {
return I18n.t('project_release_chatflow3');
}
if (!val?.length) {
return I18n.t('project_release_select_chatflow');
}
return '';
}
return '';
};
return (
<div>
<FormSelect
field="api_workflow"
optionList={workflowOptions}
onChangeWithObject
arrowIcon={<IconCozArrowDown />}
initValue={
connectorPublishConfig[id]?.selected_workflows?.map(i => ({
label: i.workflow_name,
value: i.workflow_id,
})) ?? []
}
className={`w-[172px] mr-2 ${!checked ? 'hidden' : ''}`}
noLabel
placeholder={I18n.t('project_release_select_chatflow')}
multiple
validate={validate}
showClear
trigger={['custom', 'change']}
noErrorMessage={!checked}
onChange={(value: unknown) => {
setProjectPublishInfo({
connectorPublishConfig: {
...connectorPublishConfig,
[id]: {
selected_workflows: (value as OptionProps[])?.map(i => ({
workflow_id: i.value?.toString(),
workflow_name: i.label?.toString(),
})),
},
},
});
}}
maxTagCount={2}
ellipsisTrigger
showRestTagsPopover={true}
restTagsPopoverProps={{ position: 'top' }}
/>
</div>
);
};

View File

@@ -0,0 +1,111 @@
/*
* 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.
*/
// @file 社区版暂不支持商店渠道绑定,用于未来拓展
import { type MouseEventHandler } from 'react';
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { type PublishConnectorInfo } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { ProductEntityType } from '@coze-arch/bot-api/product_api';
import { FormSelect, type optionRenderProps } from '@coze-arch/coze-design';
import { type StoreBindKey, useProjectPublishStore } from '@/store';
import { toDisplayScreenOption } from '@/publish-main/utils/display-screen-option';
import { useProductCategoryOptions } from '@/publish-main/hooks/use-product-category-options';
import { OptionWithTooltip } from '../option-with-tooltip';
export interface StoreBindProps {
checked: boolean;
record: PublishConnectorInfo;
onClick: MouseEventHandler;
}
export const StoreBind = ({
checked,
record,
onClick: inputOnClick,
}: StoreBindProps) => {
const { bind_info, id = '', UIOptions } = record;
const displayScreenOptions = UIOptions?.map(toDisplayScreenOption) ?? [];
const defaultDisplayScreen = bind_info?.display_screen;
const { connectors, setProjectPublishInfo } = useProjectPublishStore(
useShallow(state => ({
connectors: state.connectors,
setProjectPublishInfo: state.setProjectPublishInfo,
})),
);
const { categoryOptions } = useProductCategoryOptions(ProductEntityType.Bot);
const handleSelect = (key: StoreBindKey, value: string) => {
setProjectPublishInfo({
connectors: {
...connectors,
[id]: {
...bind_info,
...connectors[id],
[key]: value,
},
},
});
};
return (
<div
className={classNames('flex w-full gap-[6px] mt-auto')}
onClick={inputOnClick}
>
<FormSelect
noLabel
field="store_display_screen"
insetLabel={I18n.t('project_release_display_label')}
fieldClassName="w-[50%]"
className="w-full"
initValue={defaultDisplayScreen}
optionList={displayScreenOptions}
renderOptionItem={(option: optionRenderProps) => (
<OptionWithTooltip option={option} tooltip={option.tooltip} />
)}
rules={[{ required: checked }]}
onSelect={(value: unknown) =>
handleSelect('display_screen', value as string)
}
/>
<FormSelect
noLabel
field="store_category_id"
insetLabel={I18n.t('mkpl_bots_category')}
fieldClassName="w-[50%]"
className="w-full"
placeholder={I18n.t('select_category')}
initValue={bind_info?.category_id}
optionList={categoryOptions}
rules={[
{
required: checked,
message: I18n.t('select_category'),
},
]}
onSelect={(value: unknown) =>
handleSelect('category_id', value as string)
}
/>
</div>
);
};

View File

@@ -0,0 +1,55 @@
.template-form {
.editor-container {
border: 1px solid rgba(var(--coze-stroke-6), var(--coze-stroke-6-alpha));
&:focus-within {
border-color: var(--semi-color-focus-border);
}
}
:global {
.semi-form-field {
padding-top: 0;
}
.semi-upload-picture {
.semi-upload-picture-add {
background-color: transparent;
border: 1px solid rgba(var(--coze-stroke-6), var(--coze-stroke-6-alpha));
}
// 上传失败的图片,红色 outline 会超出边距
.semi-upload-picture-file-card-error {
outline-offset: -1px;
}
}
.coz-single-select {
.semi-radio {
margin-top: 3px;
margin-bottom: 3px;
// stylelint-disable-next-line selector-class-pattern -- 覆盖 semi 样式
.semi-radio-addon-buttonRadio {
border-radius: 6px;
}
}
}
.coz-input-number.semi-input-number {
.semi-input-wrapper {
border-color: rgba(var(--coze-stroke-6), var(--coze-stroke-6-alpha));
}
.semi-input-wrapper-focus {
border-color: var(--semi-color-focus-border);
}
.semi-input-suffix {
margin-right: 30px;
font-size: 12px;
font-weight: 400;
}
}
}
}

View File

@@ -0,0 +1,148 @@
/*
* 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.
*/
// @file 社区版暂不支持模版渠道绑定,用于未来拓展
import { useParams } from 'react-router-dom';
import { type MouseEventHandler, useEffect, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { ProductEntityType, type UserInfo } from '@coze-arch/idl/product_api';
import { type PublishConnectorInfo } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { ProductApi } from '@coze-arch/bot-api';
import { Button, Modal } from '@coze-arch/coze-design';
import { useProjectPublishStore } from '@/store';
import {
entityInfoToTemplateForm,
type TemplateForm,
templateFormToBindInfo,
} from './types';
import {
TemplateConfigForm,
type TemplateConfigFormRef,
} from './template-config-form';
interface TemplateBindProps {
record: PublishConnectorInfo;
onClick: MouseEventHandler;
}
export function TemplateBind({
record,
onClick: inputOnClick,
}: TemplateBindProps) {
const [modalVisible, setModalVisible] = useState(false);
const [userInfo, setUserInfo] = useState<UserInfo>();
const templateConfigForm = useRef<TemplateConfigFormRef>(null);
const [savedValues, setSavedValues] = useState<Partial<TemplateForm>>();
const { connectors, setProjectPublishInfo } = useProjectPublishStore(
useShallow(state => ({
connectors: state.connectors,
setProjectPublishInfo: state.setProjectPublishInfo,
})),
);
const { project_id = '' } = useParams<DynamicParams>();
// 回填模板配置
const fillTemplateFrom = async () => {
const productInfo = await ProductApi.PublicGetProductEntityInfo({
entity_id: project_id,
entity_type: ProductEntityType.ProjectTemplate,
});
if (productInfo.data.meta_info?.name) {
const formValues = entityInfoToTemplateForm(
productInfo.data,
record.UIOptions?.find(item => item.available),
);
setSavedValues(formValues);
setProjectPublishInfo({
templateConfigured: formValues.agreement === true,
connectors: {
...connectors,
// @ts-expect-error 可以接受 Partial
[record.id]: templateFormToBindInfo(formValues),
},
});
}
if (productInfo.data.meta_info?.user_info) {
setUserInfo(productInfo.data.meta_info.user_info);
}
};
useEffect(() => {
fillTemplateFrom();
}, []);
const showModal = () => {
templateConfigForm.current?.fillInitialValues(savedValues ?? {});
setModalVisible(true);
};
const closeModal = () => {
setModalVisible(false);
};
const handleSubmit = async () => {
const formValues = await templateConfigForm.current?.validate();
if (!formValues) {
return;
}
setSavedValues(formValues);
setProjectPublishInfo({
templateConfigured: true,
connectors: {
...connectors,
[record.id]: templateFormToBindInfo(formValues),
},
});
closeModal();
};
return (
<div
className={classNames('h-full flex items-end', {
hidden: !record.allow_publish,
})}
onClick={inputOnClick}
>
<Button size="small" color="primary" onClick={showModal}>
{I18n.t('project_release_template_info')}
</Button>
<Modal
title={I18n.t('project_release_template_info')}
width={800}
visible={modalVisible}
closable
onCancel={closeModal}
onOk={handleSubmit}
okText={I18n.t('prompt_submit')}
lazyRender={false}
keepDOM={true}
>
<TemplateConfigForm
ref={templateConfigForm}
record={record}
userInfo={userInfo}
/>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,242 @@
/*
* 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.
*/
import {
forwardRef,
useImperativeHandle,
useRef,
useState,
Suspense,
} from 'react';
import classNames from 'classnames';
import { ProductEntityType, type UserInfo } from '@coze-arch/idl/product_api';
import { type PublishConnectorInfo } from '@coze-arch/idl/intelligence_api';
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
import { typeSafeJSONParse } from '@coze-arch/bot-utils';
import { useFlags } from '@coze-arch/bot-flags';
import {
LazyEditorFullInput,
DeltaSet,
type DeltaSetOptions,
type Editor,
EditorEventType,
normalizeSchema,
} from '@coze-common/md-editor-adapter';
import { IconCozPlus } from '@coze-arch/coze-design/icons';
import {
type FileItem,
Form,
FormInput,
FormSelect,
Typography,
type optionRenderProps,
} from '@coze-arch/coze-design';
import {
uploadCustomRequest,
uploadCustomRequestImageX,
} from '@/utils/upload-custom-request';
import { toDisplayScreenOption } from '@/publish-main/utils/display-screen-option';
import { useProductCategoryOptions } from '@/publish-main/hooks/use-product-category-options';
import { OptionWithTooltip } from '../../option-with-tooltip';
import { type TemplateForm } from './types';
import { TemplatePreviewCard } from './template-preview-card';
import s from './index.module.less';
function getRequiredRules(type: I18nKeysNoOptionsType) {
return [
{
required: true,
message: I18n.t('project_release_template_info_not', {
template_info_type: I18n.t(type),
}),
},
];
}
export interface TemplateConfigFormRef {
fillInitialValues: (values: Partial<TemplateForm>) => void;
validate: () => Promise<TemplateForm> | undefined;
}
export interface TemplateConfigFormProps {
record: PublishConnectorInfo;
userInfo?: UserInfo;
}
export const TemplateConfigForm = forwardRef<
TemplateConfigFormRef,
TemplateConfigFormProps
// eslint-disable-next-line @coze-arch/max-line-per-function
>(({ record, userInfo }, ref) => {
const [FLAGS] = useFlags();
// 社区版暂不支持该功能
const customRequest = FLAGS['bot.studio.project_publish_imagex']
? uploadCustomRequestImageX
: uploadCustomRequest;
const uiChannelOptions = record.UIOptions?.map(toDisplayScreenOption) ?? [];
const formRef = useRef<Form<TemplateForm>>(null);
const [formValues, setFormValues] = useState<Partial<TemplateForm>>({});
const editorRef = useRef<Editor>();
const onEditorInit = (editor: Editor) => {
editorRef.current = editor;
// EditorFullInput 的 form value 为纯文本,但这里需要提交 editor-kit 富文本内容
editor.on(EditorEventType.CONTENT_CHANGE, _ => {
formRef.current?.formApi?.setValue(
'readme',
JSON.stringify(editor.getContent().deltas),
);
});
};
const { categoryOptions } = useProductCategoryOptions(
ProductEntityType.TemplateCommon,
);
useImperativeHandle(ref, () => ({
fillInitialValues: values => {
const formApi = formRef.current?.formApi;
if (!formApi) {
return;
}
formApi.setValues(values, { isOverride: true });
const readme = typeSafeJSONParse(values.readme);
if (readme) {
editorRef.current?.setContent(
new DeltaSet(normalizeSchema(readme as DeltaSetOptions)),
);
}
// @ts-expect-error -- values 就是 TemplateForm 类型
Object.keys(values).forEach(key => formApi.setError(key, null));
},
validate: () => formRef.current?.formApi?.validate(),
}));
const isZh = I18n.language.startsWith('zh');
return (
<Form<TemplateForm>
ref={formRef}
className={classNames('flex gap-[24px]', s['template-form'])}
onValueChange={values => setFormValues({ ...values })}
>
<div className="w-[320px] absolute flex flex-col h-full justify-center">
<TemplatePreviewCard
userInfo={userInfo}
cover={formValues?.covers?.[0]?.url}
name={formValues?.name}
description={formValues?.description}
/>
<Form.Checkbox
field="agreement"
noLabel
className="mt-[16px]"
rules={[
{
// 必须勾选同意协议才能通过验证
validator: (_rule: unknown, value: unknown) =>
(value as boolean) === true,
message: I18n.t('template_buy_paid_agreement_toast'),
},
]}
>
<Typography.Text>
{I18n.t('template_buy_paid_agreement_action')}
<Typography.Text
className="ml-[4px]"
link={{
href: '/docs/guides/terms_of_template',
target: '_blank',
}}
>
{I18n.t('template_buy_paid_agreement_detail')}
</Typography.Text>
</Typography.Text>
</Form.Checkbox>
</div>
<div className="w-[320px] shrink-0"></div>
<div className="grow">
<FormInput
field="name"
label={I18n.t('project_release_template_info_name')}
maxLength={isZh ? 10 : 30}
rules={getRequiredRules('project_release_template_info_name')}
/>
<Form.Upload
field="covers"
label={I18n.t('project_release_template_info_poster')}
listType="picture"
accept=".jpeg,.jpg,.png,.webp"
limit={1}
maxSize={5 * 1024}
action=""
customRequest={customRequest}
picWidth={80}
picHeight={80}
rules={[
...getRequiredRules('project_release_template_info_poster'),
{
validator: (_rule: unknown, value: unknown) =>
(value as FileItem[] | undefined)?.every(
item => !item._sizeInvalid && item.status === 'success',
) === true,
message: '', // 校验文件大小是否符合限制 && 上传是否成功Upload 组件会显示错误信息
},
]}
>
<IconCozPlus className="w-[24px] h-[24px] coz-fg-primary" />
</Form.Upload>
<FormInput
field="description"
label={I18n.t('project_release_template_info_desc')}
maxLength={isZh ? 100 : 300}
rules={getRequiredRules('project_release_template_info_desc')}
/>
<Suspense fallback={null}>
<LazyEditorFullInput
field="readme_text"
label={I18n.t('project_release_template_info_info')}
className={classNames('h-[132px]', s['editor-container'])}
maxCount={isZh ? 1000 : 3000}
getEditor={onEditorInit}
rules={getRequiredRules('project_release_template_info_info')}
/>
</Suspense>
<FormSelect
field="preview_type"
label={I18n.t('project_release_template_info_display')}
optionList={uiChannelOptions}
renderOptionItem={(option: optionRenderProps) => (
<OptionWithTooltip option={option} tooltip={option.tooltip} />
)}
fieldClassName="w-full"
className="w-full"
rules={getRequiredRules('project_release_template_info_display')}
/>
<FormSelect
field="category"
label={I18n.t('project_release_template_info_category')}
optionList={categoryOptions}
fieldClassName="w-full"
className="w-full"
rules={getRequiredRules('project_release_template_info_category')}
/>
</div>
</Form>
);
});

View File

@@ -0,0 +1,86 @@
/*
* 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.
*/
import { AvatarName } from '@coze-studio/components';
import { type UserInfo } from '@coze-arch/idl/product_api';
import { I18n } from '@coze-arch/i18n';
import { IconCozImage } from '@coze-arch/coze-design/icons';
import { Image, Typography } from '@coze-arch/coze-design';
export interface TemplatePreviewCardProps {
userInfo?: UserInfo;
cover?: string;
name?: string;
description?: string;
}
// 基本是复制的 packages/studio/template/pages/src/components/template-list-card/index.tsx
export function TemplatePreviewCard({
userInfo,
cover,
name,
description,
}: TemplatePreviewCardProps) {
const userLabel = userInfo?.user_label
? {
name: userInfo.user_label.label_name,
icon: userInfo.user_label.icon_url,
href: userInfo.user_label.jump_link,
}
: undefined;
return (
<div className="flex flex-col overflow-hidden p-[12px] pb-[16px] rounded-[16px] border border-solid coz-stroke-primary coz-bg-max coz-shadow-small">
<div className="relative w-full h-[140px] rounded-[8px] overflow-hidden">
<Image
preview={false}
src={cover}
className="w-full h-full"
imgCls="w-full h-full object-cover object-center"
placeholder={<IconCozImage className="w-[32px] h-[32px]" />}
/>
</div>
<div className="mt-[8px] px-[4px] grow flex flex-col">
<div className="flex items-center gap-[8px] overflow-hidden">
<Typography.Text
className="!font-medium text-[16px] leading-[22px] coz-fg-primary !max-w-[180px]"
ellipsis={{ showTooltip: true, rows: 1 }}
>
{name ?? I18n.t('project_release_template_info_name')}
</Typography.Text>
</div>
<AvatarName
className="mt-[4px]"
avatar={userInfo?.avatar_url}
name={userInfo?.name}
username={userInfo?.user_name}
label={userLabel}
/>
<div className="mt-[8px] flex flex-col justify-between grow">
<Typography.Text
className="min-h-[44px] leading-[20px] coz-fg-secondary"
ellipsis={{ showTooltip: true, rows: 2 }}
>
{description ?? I18n.t('project_release_template_info_desc')}
</Typography.Text>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
/*
* 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.
*/
import {
ProductStatus,
type EntityInfoData,
} from '@coze-arch/idl/product_api';
import { type UIOption } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { type FileItem } from '@coze-arch/bot-semi/Upload';
import {
type DisplayScreen,
toDisplayScreenOption,
} from '@/publish-main/utils/display-screen-option';
export interface TemplateBindInfo {
title: string;
cover_uri: string;
description: string;
readme: string;
display_screen: DisplayScreen;
category_id: string;
[k: string]: string;
}
export interface TemplateForm {
agreement: boolean;
name: string;
covers: Partial<FileItem>[];
description: string;
// EditorFullInput 的纯文本 form value ,仅为满足类型要求,不在业务中使用
readme_text: string;
// 实际需要传给后端的 editor-kit 富文本内容
readme: string;
preview_type: DisplayScreen;
category: string;
}
function stringToDeltaSet(str?: string) {
if (!str) {
return '';
}
return `{"0":{"ops":[{"insert":"${str}\\n"}],"zoneId":"0","zoneType":"Z"}}`;
}
export function entityInfoToTemplateForm(
info: EntityInfoData,
uiOption?: UIOption,
): Partial<TemplateForm> {
const isZh = I18n.language.startsWith('zh');
const meta = info.meta_info ?? {};
const form: Partial<TemplateForm> = {
// 默认勾选同意模板付费协议:已经上架过 或 已经配置过模板信息readme 非空)
agreement: meta.status !== ProductStatus.NeverListed || meta.readme !== '',
name: meta.name,
covers: meta.covers?.map(c => ({
url: c.url,
response: c,
// 补充 FileItem 的其他属性,供表单校验使用
status: 'success',
_sizeInvalid: false,
})),
description: meta.description?.substring(0, isZh ? 100 : 300),
readme: meta.readme || stringToDeltaSet(meta.description),
category: meta.category?.id,
};
if (uiOption) {
form.preview_type = toDisplayScreenOption(uiOption).value;
}
return form;
}
export function templateFormToBindInfo(form: TemplateForm): TemplateBindInfo {
return {
title: form.name,
cover_uri: form.covers?.[0].response?.uri ?? '',
description: form.description,
readme: form.readme,
display_screen: form.preview_type,
category_id: form.category ?? '',
};
}

View File

@@ -0,0 +1,102 @@
/*
* 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.
*/
import { type MouseEventHandler } from 'react';
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Button, Modal } from '@coze-arch/coze-design';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { DeveloperApi } from '@coze-arch/bot-api';
import { useParams } from 'react-router-dom';
import { useProjectPublishStore } from '../../../store';
interface UnbindButtonProps {
bindId: string;
checked: boolean;
connectorId: string;
className?: string;
onClick: MouseEventHandler;
}
const PROJECT_AGENT_TYPE = 1;
// 用于Api or WebSdk 的撤销发布
export const UndoButton = (props: UnbindButtonProps) => {
const {
bindId,
checked,
connectorId,
className,
onClick: inputOnclick,
} = props;
const { space_id = '', project_id = '' } = useParams<DynamicParams>();
const { setProjectPublishInfo, selectedConnectorIds, connectorList } =
useProjectPublishStore(
useShallow(state => ({
selectedConnectorIds: state.selectedConnectorIds,
setProjectPublishInfo: state.setProjectPublishInfo,
connectorList: state.connectorList,
})),
);
const handleUnbind: MouseEventHandler = e => {
inputOnclick(e);
Modal.confirm({
title: I18n.t('project_release_cancel1'),
content: I18n.t('project_release_cancel1_desc'),
okText: I18n.t('project_release_cancel'),
okButtonColor: 'red',
cancelText: I18n.t('Cancel'),
onOk: async () => {
await DeveloperApi.UnBindConnector({
bind_id: bindId,
agent_type: PROJECT_AGENT_TYPE,
space_id,
bot_id: project_id,
connector_id: connectorId,
});
setProjectPublishInfo({
selectedConnectorIds: selectedConnectorIds.filter(
id => id !== connectorId,
),
connectorList: connectorList.map(item => {
if (item.id === connectorId) {
return {
...item,
bind_id: '',
};
}
return item;
}),
});
},
});
};
return bindId && checked ? (
<Button
onClick={handleUnbind}
size="small"
color="primary"
className={classNames('w-max', className)}
>
{I18n.t('project_release_cancel')}
</Button>
) : null;
};

View File

@@ -0,0 +1,131 @@
/*
* 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.
*/
import { type MouseEventHandler } from 'react';
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { type PublishConnectorInfo } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { FormSelect, type optionRenderProps } from '@coze-arch/coze-design';
import { useProjectPublishStore } from '@/store';
import {
type ChatflowOptionProps,
useChatflowOptions,
} from '@/publish-main/hooks/use-chatflow-options';
import { OptionWithTooltip } from '../option-with-tooltip';
export interface WebSdkBindProps {
checked: boolean;
record: PublishConnectorInfo;
onClick: MouseEventHandler;
}
export function WebSdkBind({ checked, record, onClick }: WebSdkBindProps) {
const { connectorPublishConfig, setProjectPublishInfo } =
useProjectPublishStore(
useShallow(state => ({
connectorPublishConfig: state.connectorPublishConfig,
setProjectPublishInfo: state.setProjectPublishInfo,
})),
);
const lastSelectedChatflow =
connectorPublishConfig?.[record.id]?.selected_workflows?.[0]?.workflow_id;
const { chatflowOptions } = useChatflowOptions();
const handleChatflowSelect = (option: ChatflowOptionProps) => {
setProjectPublishInfo({
connectorPublishConfig: {
...connectorPublishConfig,
[record.id]: {
selected_workflows: [
{
workflow_id: option.value,
workflow_name: option.label,
},
],
},
},
});
};
const removePublishConfig = () => {
useProjectPublishStore.getState().setProjectPublishInfoByImmer(draft => {
const target = draft[record.id];
if (!target?.selected_workflows) {
return;
}
delete target.selected_workflows;
});
};
return (
<div className={classNames('flex mt-auto')} onClick={onClick}>
<FormSelect
field="sdk_chatflow"
noLabel
showClear
noErrorMessage
fieldClassName="w-[172px]"
className="w-full"
placeholder={I18n.t('project_release_Please_select')}
initValue={lastSelectedChatflow}
optionList={chatflowOptions}
renderOptionItem={(option: optionRenderProps) => (
<OptionWithTooltip option={option} tooltip={option.tooltip} />
)}
// onChange 负责处理数据清空的逻辑
// onSelect 处理数据选择的逻辑
onChange={values => {
if (typeof values !== 'undefined') {
return;
}
removePublishConfig();
}}
onSelect={(_: unknown, option: unknown) =>
handleChatflowSelect(option as ChatflowOptionProps)
}
rules={[
{
required: checked,
message: I18n.t('project_release_Please_select'),
},
// 校验已选择的 chatflow 是否存在 && 未被禁用
{
validator: (_rule: unknown, value: unknown) => {
if (!checked) {
return true;
}
const selected = chatflowOptions?.find(
option => option.value === (value as string),
);
if (!selected) {
return new Error(I18n.t('project_release_chatflow3'));
}
if (selected.disabled) {
return new Error(selected.tooltip);
}
return true;
},
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,89 @@
/*
* 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.
*/
import {
ConnectorBindType,
ConnectorClassification,
ConnectorStatus,
type PublishConnectorInfo,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { Tag, type TagProps, Tooltip } from '@coze-arch/coze-design';
import { getConfigStatus } from '../utils/get-config-status';
interface TipTagProps {
showText: string;
tip: string;
tagProps?: TagProps;
}
const TipTag: React.FC<TipTagProps> = ({ showText, tip, tagProps }) => (
<Tooltip content={tip}>
{showText ? (
<Tag color="yellow" {...tagProps} size="mini" className="font-[500]">
{showText}
<IconCozInfoCircle />
</Tag>
) : (
<IconCozInfoCircle />
)}
</Tooltip>
);
/** 需要展示配置状态的渠道类别 */
const Classes = [
ConnectorClassification.SocialPlatform,
ConnectorClassification.MiniProgram,
ConnectorClassification.CozeSpaceExtensionLibrary,
];
export const ConfigStatus = ({ record }: { record: PublishConnectorInfo }) => {
if (
!Classes.includes(record.connector_classification) ||
record.bind_type === ConnectorBindType.NoBindRequired
) {
return null;
}
const { text, color } = getConfigStatus(record);
return (
<div className="flex gap-[6px]">
{/* 配置状态 */}
<Tag color={color} size="mini" className="font-[500]">
{text}
</Tag>
{record?.connector_status === ConnectorStatus.Normal ? null : (
<TipTag
showText={
record?.connector_status === ConnectorStatus.InReview
? I18n.t('bot_publish_columns_status_in_review')
: I18n.t('bot_publish_columns_status_offline')
}
tip={
record?.connector_status === ConnectorStatus.InReview
? I18n.t('bot_publish_in_review_notice')
: I18n.t('bot_publish_offline_notice_no_certain_time', {
platform: record?.name,
})
}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,179 @@
/*
* 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.
*/
import { type MouseEventHandler } from 'react';
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import {
ConnectorBindType,
type PublishConnectorInfo,
ConnectorConfigStatus,
ConnectorStatus,
} from '@coze-arch/idl/intelligence_api';
import { KvBindButton } from '@coze-agent-ide/space-bot/component/connector-action';
import { AuthorizeButton } from '@coze-agent-ide/space-bot/component/authorize-button';
import { TEMPLATE_CONNECTOR_ID } from '@/utils/constants';
import { useProjectPublishStore } from '../../store';
import { useBizConnectorAnchor } from '../../hooks/use-biz-connector-anchor';
import { WebSdkBind } from './bind-actions/web-sdk-bind';
import { TemplateBind } from './bind-actions/template-bind';
import { StoreBind } from './bind-actions/store-bind';
// import { ApiBind } from './bind-actions/api-bind';
interface ConnectorActionProps {
record: PublishConnectorInfo;
checked: boolean;
authActionWrapperClassName?: string;
}
export function ConnectorAction(props: ConnectorActionProps) {
const { record, checked, authActionWrapperClassName } = props;
const { setProjectPublishInfo, connectorList, selectedConnectorIds } =
useProjectPublishStore(
useShallow(state => ({
setProjectPublishInfo: state.setProjectPublishInfo,
connectorList: state.connectorList,
selectedConnectorIds: state.selectedConnectorIds,
})),
);
const { setAnchor } = useBizConnectorAnchor();
const stopEventPropagation: MouseEventHandler = mouseEvent => {
mouseEvent.stopPropagation();
};
// 绑定/解除绑定是同一个回调,可以根据 bind_id 是否为空来区分
const kvBindSuccessCallback = (value?: PublishConnectorInfo) => {
if (value) {
const isUnbind = !value.bind_id;
const newValue: PublishConnectorInfo = {
...value,
config_status: isUnbind
? ConnectorConfigStatus.NotConfigured
: ConnectorConfigStatus.Configured,
connector_status: isUnbind
? ConnectorStatus.Normal
: value.connector_status,
};
setProjectPublishInfo({
connectorList: connectorList.map(item =>
item.id === value.id ? newValue : item,
),
});
}
};
const authRevokeSuccess = () => {
setProjectPublishInfo({
connectorList: connectorList.map(item => {
if (item.id === record.id) {
return {
...item,
config_status: ConnectorConfigStatus.NotConfigured,
};
}
return item;
}),
selectedConnectorIds: selectedConnectorIds.filter(
item => item !== record.id,
),
});
};
switch (record.bind_type) {
case ConnectorBindType.KvBind:
case ConnectorBindType.KvAuthBind:
return (
// 使用 basis-full 强制 flex row 换行
<div
className={classNames(
'basis-full self-end',
authActionWrapperClassName,
)}
>
<div className="inline-flex" onClick={stopEventPropagation}>
<KvBindButton
record={record}
bindSuccessCallback={kvBindSuccessCallback}
origin="project"
/>
</div>
</div>
);
case ConnectorBindType.AuthBind:
return (
<div
className={classNames(
'basis-full self-end',
authActionWrapperClassName,
)}
>
<div className="inline-flex" onClick={stopEventPropagation}>
<AuthorizeButton
origin="publish"
id={record.id}
agentType="project"
channelName={record.name}
status={
record.config_status ?? ConnectorConfigStatus.NotConfigured
}
onBeforeAuthRedirect={({ id }) => {
setAnchor(id);
}}
revokeSuccess={authRevokeSuccess}
authInfo={record?.auth_login_info ?? {}}
isV2
v2ButtonProps={{
color: 'primary',
size: 'small',
}}
/>
</div>
</div>
);
case ConnectorBindType.WebSDKBind:
return (
<WebSdkBind
checked={checked}
record={record}
onClick={stopEventPropagation}
/>
);
// 社区版暂不支持商店渠道绑定,用于未来拓展
case ConnectorBindType.StoreBind:
return (
<StoreBind
checked={checked}
record={record}
onClick={stopEventPropagation}
/>
);
// 社区版暂不支持模板渠道绑定,用于未来拓展
// bind_type=9 用作扣子第一方渠道的标识,需要按照渠道 ID 展示绑定方式
// TODO 后端更新 ConnectorBindType 类型定义
case ConnectorBindType.TemplateBind: {
if (record.id === TEMPLATE_CONNECTOR_ID) {
return <TemplateBind record={record} onClick={stopEventPropagation} />;
}
return null;
}
default:
return null;
}
}

View File

@@ -0,0 +1,368 @@
/*
* 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 complexity */
import ReactMarkdown from 'react-markdown';
import { type MouseEventHandler, useEffect, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { useHover } from 'ahooks';
import {
ConnectorBindType,
ConnectorClassification,
ConnectorConfigStatus,
type ConnectorPublishConfig,
type PublishConnectorInfo,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { Checkbox, Tooltip, Typography } from '@coze-arch/coze-design';
import { typeSafeJSONParse } from '@coze-arch/bot-utils';
import { type TextProps } from '@coze-arch/bot-semi/Typography';
import { TEMPLATE_CONNECTOR_ID } from '@/utils/constants';
import { useProjectPublishStore } from '@/store';
import { isStoreBindConfigured } from '../utils/is-store-bind-configured';
import { getConnectorNotConfigured } from '../utils/connector-disabled-publish';
import { McpConfigBtn } from './mcp-config-btn';
import { UnionSelect } from './connector-union-select';
import { ConnectorAction } from './connector-action';
import { ConfigStatus } from './config-status';
import { UndoButton } from './bind-actions/undo-button';
enum DisabledReason {
/** 社交渠道未选择 chatflow */
SocialPlatform,
/** 未绑定 未授权 */
NotConfigured,
/** 后端下发的原因 */
NotAllowed,
/** 未配置模板 */
Template,
}
interface ConnectorDisabledConfig {
reason: DisabledReason;
text?: string;
}
function getConnectorDisabledConfig({
connector,
socialPlatformConfig,
templateConfigured,
connectorPublishConfig,
connectorConfigMap,
}: {
connector: PublishConnectorInfo;
socialPlatformConfig: ConnectorPublishConfig | undefined;
templateConfigured: boolean | undefined;
connectorPublishConfig: Record<string, ConnectorPublishConfig>;
connectorConfigMap: Record<string, Record<string, string>>;
}): ConnectorDisabledConfig | undefined {
if (
connector.connector_classification ===
ConnectorClassification.SocialPlatform &&
!socialPlatformConfig?.selected_workflows?.[0]?.workflow_id
) {
// 发布到社交渠道,且未选择 chatflow
return {
reason: DisabledReason.SocialPlatform,
text: I18n.t('project_release_chatflow4'),
};
}
const notConfigured = {
reason: DisabledReason.NotConfigured,
text: I18n.t('project_release_set_desc'),
};
// 未绑定 未授权
if (getConnectorNotConfigured(connector)) {
return notConfigured;
}
// 后端下发的不能发布的原因
if (!connector.allow_publish && connector.not_allow_publish_reason) {
return {
reason: DisabledReason.NotAllowed,
text: connector.not_allow_publish_reason,
};
}
// 未配置模板
if (connector.id === TEMPLATE_CONNECTOR_ID && !templateConfigured) {
return {
reason: DisabledReason.Template,
text: I18n.t('project_release_template_info'),
};
}
const isWebSDK =
connector.connector_classification === ConnectorClassification.APIOrSDK &&
connector.bind_type === ConnectorBindType.WebSDKBind;
const isSdkChatFlowConfigured = Boolean(
connectorPublishConfig[connector.id]?.selected_workflows,
);
if (isWebSDK && !isSdkChatFlowConfigured) {
return notConfigured;
}
const isStorePublish =
connector.connector_classification === ConnectorClassification.Coze &&
connector.bind_type === ConnectorBindType.StoreBind;
const storeConfig = connectorConfigMap[connector.id];
if (isStorePublish && (!storeConfig || !isStoreBindConfigured(storeConfig))) {
return notConfigured;
}
}
// 与后端约定的额外描述信息
interface DescriptionExtra {
// 渠道名称 hover 的 tooltip
text?: string;
}
export interface ConnectorCardProps {
connectorInfo: PublishConnectorInfo;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
onShowWebSdkGuide: () => void;
}
// eslint-disable-next-line @coze-arch/max-line-per-function -- it's complex
export function ConnectorCard({
connectorInfo,
checked,
onCheckedChange,
onShowWebSdkGuide,
}: ConnectorCardProps) {
const { id, name, description, bind_id = '' } = connectorInfo;
const {
templateConfigured,
socialPlatformChatflow,
connectorPublishConfig,
connectors,
} = useProjectPublishStore(
useShallow(state => ({
templateConfigured: state.templateConfigured,
socialPlatformChatflow: state.socialPlatformChatflow,
connectorPublishConfig: state.connectorPublishConfig,
connectors: state.connectors,
})),
);
const divRef = useRef<HTMLDivElement>(null);
const isHover = useHover(divRef.current);
const disabledConfig = getConnectorDisabledConfig({
connector: connectorInfo,
socialPlatformConfig: socialPlatformChatflow,
templateConfigured,
connectorPublishConfig,
connectorConfigMap: connectors,
});
// 社区版暂不支持社交平台渠道,用于未来拓展。
// 社交渠道未选择“处理消息的对话流”时,需要将整个卡片展示为禁用态
const cardDisabled = disabledConfig?.reason === DisabledReason.SocialPlatform;
const descriptionExtra = (typeSafeJSONParse(
connectorInfo.description_extra,
) ?? {}) as DescriptionExtra;
// 如果禁用状态发生了变动,取消勾选当前渠道
useEffect(() => {
if (checked && disabledConfig) {
onCheckedChange(false);
}
}, [checked, disabledConfig]);
const connectorCheckbox = (
<Checkbox
checked={checked}
onChange={() => {
if (!disabledConfig) {
onCheckedChange(!checked);
}
}}
data-testid="project.publish.select.connector"
disabled={Boolean(disabledConfig)}
/>
);
const stopEventPropagation: MouseEventHandler = mouseEvent => {
mouseEvent.stopPropagation();
};
const getIsShowAction = () => {
const isConnectorAuth = [
ConnectorBindType.KvBind,
ConnectorBindType.KvAuthBind,
ConnectorBindType.AuthBind,
].includes(connectorInfo.bind_type);
const isConnectorAuthConfigured =
connectorInfo.config_status === ConnectorConfigStatus.Configured;
if (isConnectorAuth) {
return (
!isConnectorAuthConfigured || (isConnectorAuthConfigured && isHover)
);
}
return true;
};
const isShowAction = getIsShowAction();
const connectorCard = (
<div
ref={divRef}
className={classNames(
'w-full min-h-[132px] rounded-[8px] coz-stroke-primary border border-solid coz-mg-card p-[12px] flex',
cardDisabled || disabledConfig
? 'cursor-not-allowed'
: 'cursor-pointer',
isHover && 'coz-shadow-default',
)}
onClick={() => {
if (!disabledConfig) {
onCheckedChange(!checked);
}
}}
>
<div
className={classNames('h-full w-full flex flex-col', {
'opacity-30 pointer-events-none': cardDisabled,
})}
>
<div className="flex gap-[6px] items-center font-medium">
<img
src={connectorInfo.icon_url}
className="w-[24px] h-[24px] rounded-[6px] coz-stroke-primary border-[0.5px] border-solid border-solid"
/>
{descriptionExtra.text ? (
<Tooltip
theme="dark"
style={{ minWidth: '240px', textAlign: 'center' }}
content={
<ReactMarkdown
skipHtml={true}
linkTarget="_blank"
components={{
img: props => (
<img {...props} className="max-w-[224px] max-h-[208px]" />
),
}}
>
{descriptionExtra.text}
</ReactMarkdown>
}
>
<div className="coz-fg-primary cursor-default">{name}</div>
</Tooltip>
) : (
<div className="coz-fg-primary cursor-default">{name}</div>
)}
<ConfigStatus record={connectorInfo} />
</div>
<div className="mt-[4px] mb-[8px]">
{description ? (
<ReactMarkdown
skipHtml={true}
transformLinkUri={false}
components={{
p: props => (
<Typography.Paragraph type="secondary" fontSize="12px">
{props.children}
</Typography.Paragraph>
),
a: props => {
const textProps: TextProps =
props.href === 'coze://web-sdk-guide'
? {
link: true,
onClick: e => {
stopEventPropagation(e);
onShowWebSdkGuide();
},
}
: {
link: {
href: props.href,
target: '_blank',
onClick: stopEventPropagation,
},
};
return (
<Typography.Text fontSize="12px" {...textProps}>
{props.children}
</Typography.Text>
);
},
}}
>
{description}
</ReactMarkdown>
) : null}
</div>
<div className="flex flex-wrap grow gap-[6px]">
{connectorInfo.connector_union_id ? (
<UnionSelect record={connectorInfo} />
) : null}
<ConnectorAction
record={connectorInfo}
checked={checked}
// 这个组件内部有 modal 不能使用条件渲染
authActionWrapperClassName={classNames(!isShowAction && 'hidden')}
/>
{connectorInfo.connector_classification ===
ConnectorClassification.APIOrSDK ? (
<UndoButton
bindId={bind_id}
checked={checked}
connectorId={id}
className={classNames(!isShowAction && 'hidden', 'mt-auto')}
onClick={stopEventPropagation}
/>
) : null}
{/* 社区版暂不支持MCP服务渠道用于未来拓展 */}
{connectorInfo.connector_classification ===
ConnectorClassification.CozeSpaceExtensionLibrary &&
connectorInfo.bind_type === ConnectorBindType.TemplateBind && (
<McpConfigBtn record={connectorInfo} />
)}
</div>
</div>
<div>
{disabledConfig && !cardDisabled ? (
<Tooltip theme="dark" content={disabledConfig.text}>
{connectorCheckbox}
</Tooltip>
) : (
connectorCheckbox
)}
</div>
</div>
);
return (
<Tooltip
theme="dark"
visible={cardDisabled && isHover}
content={disabledConfig?.text}
trigger="custom"
>
{connectorCard}
</Tooltip>
);
}

View File

@@ -0,0 +1,14 @@
.highlight {
border-radius: 4px;
animation: highlight-animation 3s forwards; /* 持续3秒的动画 */
}
@keyframes highlight-animation {
from {
@apply coz-mg-hglt-secondary;
}
to {
background-color: transparent;
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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.
*/
import { type AnimationEventHandler } from 'react';
import classNames from 'classnames';
import { ConnectorClassification } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import {
IconCozInfoCircle,
IconCozDiamondFill,
} from '@coze-arch/coze-design/icons';
import { Tooltip, Space } from '@coze-arch/coze-design';
import {
useBenefitAvailable,
PremiumPaywallScene,
usePremiumPaywallModal,
} from '@coze-studio/premium-components-adapter';
import styles from './connector-group-header.module.less';
interface ConnectorGroupHeaderProps {
label: string;
tooltipContent: string;
showTooltipInfo: boolean;
isHighlight: boolean;
type: ConnectorClassification;
onAnimationEnd: AnimationEventHandler<HTMLDivElement>;
}
export function ConnectorGroupHeader({
label,
tooltipContent,
showTooltipInfo,
isHighlight,
type,
onAnimationEnd,
}: ConnectorGroupHeaderProps) {
// 付费墙
const isAPIOrSDK = type === ConnectorClassification.APIOrSDK;
const isAvailable = useBenefitAvailable({
scene: PremiumPaywallScene.API,
});
const { node: premiumPaywallModal, open: openPremiumPaywallModal } =
usePremiumPaywallModal({ scene: PremiumPaywallScene.API });
return (
<div
className={classNames(
'mb-8px px-2px coz-fg-secondary flex items-center gap-x-4px',
isHighlight && styles.highlight,
)}
onAnimationEnd={onAnimationEnd}
>
<p className="text-[14px] font-[500] leading-[20px]">{label}</p>
{isAPIOrSDK && !isAvailable ? (
<Space className="text-[12px] ml-[8px]" spacing={2}>
<IconCozDiamondFill className="coz-fg-hglt" />
{I18n.t('coze_quota_exemption_notice', {
link: (
<div
className="coz-fg-hglt cursor-pointer"
onClick={openPremiumPaywallModal}
>
{I18n.t('coze_upgrade_package')}
</div>
),
})}
</Space>
) : null}
{showTooltipInfo ? (
<Tooltip theme="dark" trigger="hover" content={tooltipContent}>
<IconCozInfoCircle className="text-xxl" />
</Tooltip>
) : null}
{premiumPaywallModal}
</div>
);
}

View File

@@ -0,0 +1,4 @@
.mask {
// 百分比根据高度 80px 计算
background: linear-gradient(180deg, rgba(var(--coze-bg-2),var(--coze-bg-2-alpha)) 0 85%, rgba(0, 0, 0, 0%) 100%);
}

View File

@@ -0,0 +1,73 @@
/*
* 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.
*/
import { type CSSProperties, forwardRef, type PropsWithChildren } from 'react';
import classNames from 'classnames';
import { Badge, Button } from '@coze-arch/coze-design';
import styles from './index.module.less';
export interface TabbarItemProps {
onClick: () => void;
isActive: boolean;
selectedConnectorCount: number;
}
export const ConnectorTabbarItem: React.FC<
PropsWithChildren<TabbarItemProps>
> = ({ onClick, isActive, children, selectedConnectorCount }) => (
<Button
onClick={onClick}
color={isActive ? 'highlight' : 'secondary'}
className="!px-8px !font-medium"
>
{children}
{selectedConnectorCount > 0 ? (
<Badge
countClassName={classNames(
!isActive && '!coz-mg-plus !coz-fg-secondary',
'!font-medium',
)}
className="ml-4px"
count={selectedConnectorCount}
type="alt"
/>
) : null}
</Button>
);
export interface ConnectorTabbarProps {
className?: string;
style?: CSSProperties;
}
export const ConnectorTabbar = forwardRef<
HTMLDivElement,
PropsWithChildren<ConnectorTabbarProps>
>(({ className, style, children }, ref) => (
<div
ref={ref}
className={classNames(
// ! 80px 高度影响 styles.mask 计算
'flex items-center gap-x-8px h-[80px] relative',
styles.mask,
className,
)}
style={style}
>
{children}
</div>
));

View File

@@ -0,0 +1,67 @@
/*
* 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.
*/
import { useShallow } from 'zustand/react/shallow';
import { type PublishConnectorInfo } from '@coze-arch/idl/intelligence_api';
import { FormSelect } from '@coze-arch/coze-design';
import { IconCozArrowDown } from '@coze-arch/bot-icons';
import { useProjectPublishStore } from '@/store';
interface UnionSelectProps {
record: PublishConnectorInfo;
}
export const UnionSelect = ({ record }: UnionSelectProps) => {
const { connectorUnionMap, unions, setProjectPublishInfo } =
useProjectPublishStore(
useShallow(state => ({
connectorUnionMap: state.connectorUnionMap,
unions: state.unions,
setProjectPublishInfo: state.setProjectPublishInfo,
})),
);
const unionId = record.connector_union_id ?? '';
const unionConnectors = connectorUnionMap[unionId]?.connector_options ?? [];
const unionOptionList = unionConnectors.map(c => ({
label: c.show_name,
value: c.connector_id,
}));
const onSelectUnion = (selectedId: string) => {
setProjectPublishInfo({
unions: {
...unions,
[unionId]: selectedId,
},
});
};
return (
<div className="flex" onClick={e => e.stopPropagation()}>
<FormSelect
noLabel
field={`union_select_${unionId}`}
fieldClassName="w-[172px]"
className="w-full"
optionList={unionOptionList}
initValue={unions[unionId]}
arrowIcon={<IconCozArrowDown />}
onSelect={(val: unknown) => onSelectUnion(val as string)}
/>
</div>
);
};

View File

@@ -0,0 +1,50 @@
/*
* 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.
*/
import {
type PublishConnectorInfo,
ConnectorConfigStatus,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import { UseMcpConfigModal } from '@/hooks/use-mcp-config-modal';
/** MCP配置按钮+弹窗 */
export const McpConfigBtn = ({ record }: { record: PublishConnectorInfo }) => {
const { node, open } = UseMcpConfigModal({ record });
return (
<div
className="basis-full self-end"
onClick={e => {
e.stopPropagation();
}}
>
<Button
color="primary"
size="small"
onClick={() => {
open();
}}
>
{record.config_status === ConnectorConfigStatus.Configured
? I18n.t('enterprise_sso_seetings_page_desc_button1')
: I18n.t('bot_publish_action_configure')}
</Button>
{node}
</div>
);
};

View File

@@ -0,0 +1,66 @@
/*
* 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.
*/
import { type PropsWithChildren } from 'react';
import classNames from 'classnames';
import { IconCozCheckMarkFill } from '@coze-arch/coze-design/icons';
import {
type optionRenderProps,
Tooltip,
Typography,
} from '@coze-arch/coze-design';
export type OptionWithTooltipProps = PropsWithChildren<{
option: optionRenderProps;
tooltip?: string;
}>;
export function OptionWithTooltip({
option,
tooltip,
children,
}: OptionWithTooltipProps) {
const optionNode = (
<div
className={classNames(
'coz-select-option-item p-[8px] gap-x-[8px] items-center',
{
'!cursor-not-allowed': option.disabled,
},
)}
onClick={option.onClick}
>
<div className="w-[16px] h-[16px] shrink-0">
{option.selected ? (
<IconCozCheckMarkFill className="coz-fg-hglt" />
) : null}
</div>
{children ?? (
<Typography.Text className="leading-[16px]" disabled={option.disabled}>
{option.label}
</Typography.Text>
)}
</div>
);
return tooltip ? (
<Tooltip theme="dark" position="right" trigger="hover" content={tooltip}>
{optionNode}
</Tooltip>
) : (
optionNode
);
}

View File

@@ -0,0 +1,42 @@
/*
* 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.
*/
import { type PropsWithChildren, useRef, useState } from 'react';
import { DEFAULT_PUBLISH_HEADER_HEIGHT } from '../../../utils/constants';
import { PublishContainerContext } from '../../../context/publish-container-context';
export const PublishContainer: React.FC<PropsWithChildren> = ({ children }) => {
const ref = useRef<HTMLDivElement>(null);
const [publishHeaderHeight, setPublishHeaderHeight] = useState(
DEFAULT_PUBLISH_HEADER_HEIGHT,
);
const getContainerRef = () => ref;
return (
<PublishContainerContext.Provider
value={{ getContainerRef, publishHeaderHeight, setPublishHeaderHeight }}
>
<div
ref={ref}
className="flex-[1] w-full h-full overflow-x-hidden coz-bg-primary"
>
{children}
</div>
</PublishContainerContext.Provider>
);
};

View File

@@ -0,0 +1,110 @@
/*
* 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.
*/
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { CheckType } from '@coze-arch/idl/workflow_api';
import { ConnectorClassification } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { FormSelect, type optionRenderProps } from '@coze-arch/coze-design';
import { useProjectPublishStore } from '@/store';
import {
type ChatflowOptionProps,
useChatflowOptions,
} from '../hooks/use-chatflow-options';
import { OptionWithTooltip } from './option-with-tooltip';
export const SocialPlatformChatflow: React.FC<{ className?: string }> = ({
className,
}) => {
const {
connectorList,
selectedConnectorIds,
socialPlatformChatflow,
setProjectPublishInfo,
} = useProjectPublishStore(
useShallow(state => ({
connectorList: state.connectorList,
selectedConnectorIds: state.selectedConnectorIds,
socialPlatformChatflow: state.socialPlatformChatflow,
setProjectPublishInfo: state.setProjectPublishInfo,
})),
);
const hasSelectedSocialPlatforms = connectorList.some(
c =>
selectedConnectorIds.includes(c.id) &&
c.connector_classification === ConnectorClassification.SocialPlatform,
);
const { chatflowOptions } = useChatflowOptions(CheckType.SocialPublish);
const handleSelectChatflow = (option: ChatflowOptionProps) => {
setProjectPublishInfo({
socialPlatformChatflow: {
selected_workflows: [
{
workflow_id: option.value,
workflow_name: option.label,
},
],
},
});
};
return (
<div className={classNames('w-[50%] pr-[6px]', className)}>
<FormSelect
field="social_platform_chatflow"
noLabel
label={I18n.t('project_release_chatflow2')}
placeholder={I18n.t('project_release_chatflow_choose')}
optionList={chatflowOptions}
initValue={socialPlatformChatflow?.selected_workflows?.[0]?.workflow_id}
className="w-full mb-[4px]"
renderOptionItem={(option: optionRenderProps) => (
<OptionWithTooltip option={option} tooltip={option.tooltip} />
)}
onSelect={(_: unknown, option: unknown) =>
handleSelectChatflow(option as ChatflowOptionProps)
}
rules={[
// 有选择 SocialPlatform 时, chatflow 必填
{ required: hasSelectedSocialPlatforms },
// 校验已选择的 chatflow 是否存在 && 未被禁用
{
validator: (_rule: unknown, value: unknown) => {
if (!hasSelectedSocialPlatforms) {
return true;
}
const selected = chatflowOptions?.find(
option => option.value === (value as string),
);
if (!selected) {
return new Error(I18n.t('project_release_chatflow3'));
}
if (selected.disabled) {
return new Error(selected.tooltip);
}
return true;
},
},
]}
/>
</div>
);
};

View File

@@ -0,0 +1,5 @@
.desc-input {
> input {
text-overflow: ellipsis;
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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.
*/
import { type CSSProperties, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import {
type CommonFieldProps,
Input,
TextArea,
type TextAreaProps,
withField,
} from '@coze-arch/coze-design';
import styles from './index.module.less';
export interface VersionDescInputProps
extends Pick<
TextAreaProps,
'placeholder' | 'maxLength' | 'maxCount' | 'wrapperClassName' | 'value'
> {
onChange?: (value: string) => void;
inputClassName?: string;
textAreaClassName?: string;
textAreaStyle?: CSSProperties;
}
const VersionDescInput: React.FC<VersionDescInputProps> = ({
inputClassName,
textAreaClassName,
wrapperClassName,
textAreaStyle,
...props
}) => {
const [mode, setMode] = useState<'input' | 'textarea'>('input');
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const target = textAreaRef.current;
if (mode !== 'textarea' || !target) {
return;
}
const valueLength = props.value?.length;
target.focus();
if (!valueLength) {
return;
}
target.setSelectionRange(valueLength, valueLength);
}, [mode]);
if (mode === 'input') {
return (
<Input
{...props}
className={classNames(styles['desc-input'], inputClassName)}
onFocus={() => {
setMode('textarea');
}}
/>
);
}
return (
<div className={wrapperClassName}>
<TextArea
{...props}
ref={textAreaRef}
className={textAreaClassName}
style={textAreaStyle}
autoFocus
autosize={{ minRows: 1, maxRows: 10 }}
onBlur={() => {
setMode('input');
}}
/>
</div>
);
};
export const FormVersionDescInput: React.FC<
CommonFieldProps & VersionDescInputProps
> = withField(VersionDescInput);

View File

@@ -0,0 +1,68 @@
/*
* 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.
*/
import { useEffect } from 'react';
import { useProjectPublishStore } from '@/store';
import { useBizConnectorAnchor } from '@/hooks/use-biz-connector-anchor';
import { type ConnectorGroup } from '../utils/format-connector-groups';
import { usePublishContainer } from '../../context/publish-container-context';
import { type ConnectorRefMap } from './use-connector-scroll';
export const useAutoScrollToConnector = ({
connectorGroupList,
connectorRefMap,
}: {
connectorRefMap: ConnectorRefMap;
connectorGroupList: ConnectorGroup[];
}) => {
const { getAnchor, removeAnchor } = useBizConnectorAnchor();
const { getContainerRef } = usePublishContainer();
useEffect(() => {
const anchor = getAnchor();
if (!anchor) {
return;
}
const targetGroup = connectorGroupList.find(group =>
group.connectors.some(
connector => connector.id === anchor.connectorIdBeforeRedirect,
),
);
if (!targetGroup) {
return;
}
const connectorRef = connectorRefMap[targetGroup.type];
const { updateSelectedConnectorIds } = useProjectPublishStore.getState();
updateSelectedConnectorIds(prev => {
if (prev.some(id => id === anchor.connectorIdBeforeRedirect)) {
return prev;
}
return prev.concat(anchor.connectorIdBeforeRedirect);
});
getContainerRef()?.current?.scrollTo({
top: connectorRef.current?.offsetTop,
behavior: 'smooth',
});
removeAnchor();
}, [connectorGroupList, connectorRefMap]);
};

View File

@@ -0,0 +1,52 @@
/*
* 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.
*/
import { useRequest } from 'ahooks';
import { type CheckType, WorkflowMode } from '@coze-arch/idl/workflow_api';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { workflowApi } from '@coze-arch/bot-api';
import { useParams } from 'react-router-dom';
export interface ChatflowOptionProps {
value: string;
label: string;
disabled?: boolean;
tooltip?: string;
}
export function useChatflowOptions(checkType?: CheckType) {
const needsCheck = typeof checkType !== 'undefined';
const { space_id = '', project_id = '' } = useParams<DynamicParams>();
const { data: chatflowOptions, loading } = useRequest(async () => {
const res = await workflowApi.GetWorkFlowList({
space_id,
project_id,
flow_mode: WorkflowMode.ChatFlow,
page: 1,
size: 100,
checker: needsCheck ? [checkType] : undefined,
});
return res.data.workflow_list?.map(item => ({
label: item.name,
value: item.workflow_id,
disabled: needsCheck
? item.check_result?.find(r => r.type === checkType)?.is_pass !== true
: false,
tooltip: item.check_result?.find(r => r.type === checkType)?.reason,
})) as ChatflowOptionProps[];
});
return { chatflowOptions, loading };
}

View File

@@ -0,0 +1,201 @@
/*
* 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-per-function */
import { type RefObject, useEffect, useRef, useState } from 'react';
import { useDebounceFn } from 'ahooks';
import { ConnectorClassification } from '@coze-arch/idl/intelligence_api';
import { usePublishContainer } from '../../context/publish-container-context';
export type ConnectorRefMap = Record<
ConnectorClassification,
RefObject<HTMLDivElement>
>;
const useConnectorRefMap = (): ConnectorRefMap => ({
[ConnectorClassification.APIOrSDK]: useRef<HTMLDivElement>(null),
[ConnectorClassification.MiniProgram]: useRef<HTMLDivElement>(null),
[ConnectorClassification.SocialPlatform]: useRef<HTMLDivElement>(null),
[ConnectorClassification.Coze]: useRef<HTMLDivElement>(null),
[ConnectorClassification.CozeSpaceExtensionLibrary]:
useRef<HTMLDivElement>(null),
});
const getActiveConnectorTarget = ({
containerScrollTop,
connectorRefMap,
connectorBarHeight,
publishHeaderHeight,
}: {
containerScrollTop: number;
connectorBarHeight: number;
connectorRefMap: ConnectorRefMap;
publishHeaderHeight: number;
}) => {
const connectorTargetLis = Object.entries(connectorRefMap)
.map(([, ref]) => ref.current)
.filter((element): element is HTMLDivElement => Boolean(element));
const offsetTopList = connectorTargetLis.map(target => ({
offsetTop:
target.offsetTop -
containerScrollTop -
publishHeaderHeight -
connectorBarHeight,
target,
}));
const sortedTopList = offsetTopList.sort(
(prev, cur) => prev.offsetTop - cur.offsetTop,
);
const preActiveConnectorList = sortedTopList.filter(
item => item.offsetTop <= 0,
);
const activeConnector = preActiveConnectorList.length
? preActiveConnectorList.at(-1)
: sortedTopList.at(0);
return activeConnector?.target;
};
/** 经验值 */
const LOCK_TIME = 300;
export const useConnectorScroll = () => {
const [activeConnectorTarget, setActiveConnectorTarget] =
useState<HTMLDivElement>();
const connectorBarRef = useRef<HTMLDivElement>(null);
const { getContainerRef } = usePublishContainer();
const connectorRefMap = useConnectorRefMap();
const { publishHeaderHeight } = usePublishContainer();
const [animationStateMap, setAnimationMap] = useState<
Record<ConnectorClassification, boolean>
>({
[ConnectorClassification.APIOrSDK]: false,
[ConnectorClassification.MiniProgram]: false,
[ConnectorClassification.SocialPlatform]: false,
[ConnectorClassification.Coze]: false,
[ConnectorClassification.CozeSpaceExtensionLibrary]: false,
});
/**
* 需要 3 个条件同时满足
* 1. 随着页面滚动到不同锚点tab 栏激活对应区域的按钮
* 2. 点击 tab 栏 对应按钮激活 直接滚动到页面对应区域
* 3. 当页面高度不足滚动时,点击 tab 栏也要激活对应按钮
*
* 由于滚动是 smooth 效果 条件 2 与 3 会发生冲突 当用户点击 tab 栏滚动时需要进行 lock
* lock 时 条件 1 不触发
* 需要给条件 3 一个兜底的解锁机制
*/
const manualScrollLockRef = useRef(false);
const manualScrollLock = () => {
manualScrollLockRef.current = true;
};
const manualScrollUnLock = () => {
manualScrollLockRef.current = false;
};
/** 停止滚动 LOCK_TIME 后解锁 */
const manualScrollUnLockDebounce = useDebounceFn(manualScrollUnLock, {
wait: LOCK_TIME,
});
/** 兜底解锁机制 如果用户点击 tab 栏但没有解锁 LOCK_TIME 后也应该解锁 */
const baseUnLockDebounce = useDebounceFn(manualScrollUnLock, {
wait: LOCK_TIME,
});
useEffect(() => {
const containerTarget = getContainerRef()?.current;
const connectorBarTarget = connectorBarRef.current;
if (!containerTarget || !connectorBarTarget) {
return;
}
const changeActiveConnectorTarget = () => {
if (manualScrollLockRef.current) {
return;
}
setActiveConnectorTarget(
getActiveConnectorTarget({
containerScrollTop: containerTarget.scrollTop,
connectorRefMap,
connectorBarHeight: connectorBarTarget.offsetHeight,
publishHeaderHeight,
}),
);
};
changeActiveConnectorTarget();
const onScroll = () => {
// 页面发生了滚动则不需要兜底机制
baseUnLockDebounce.cancel();
manualScrollUnLockDebounce.run();
changeActiveConnectorTarget();
};
containerTarget.addEventListener('scroll', onScroll);
return () => {
containerTarget.removeEventListener('scroll', onScroll);
};
}, [getContainerRef, connectorRefMap, publishHeaderHeight]);
const startAnimation = (classification: ConnectorClassification) => {
setAnimationMap(prev => ({ ...prev, [classification]: true }));
};
const closeAnimation = (classification: ConnectorClassification) => {
setAnimationMap(prev => ({ ...prev, [classification]: false }));
};
const scrollToConnector = (classification: ConnectorClassification) => {
const barTarget = connectorBarRef.current;
const containerTarget = getContainerRef()?.current;
const connectorTarget = connectorRefMap[classification].current;
if (!barTarget || !containerTarget || !connectorTarget) {
return;
}
containerTarget.scrollTo({
behavior: 'smooth',
top:
connectorTarget.offsetTop -
publishHeaderHeight -
barTarget.offsetHeight,
});
startAnimation(classification);
setActiveConnectorTarget(connectorTarget);
manualScrollLock();
baseUnLockDebounce.run();
};
return {
connectorRefMap,
activeConnectorTarget,
connectorBarRef,
scrollToConnector,
closeAnimation,
animationStateMap,
};
};

View File

@@ -0,0 +1,38 @@
/*
* 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.
*/
import { useRequest } from 'ahooks';
import { type ProductEntityType } from '@coze-arch/bot-api/product_api';
import { ProductApi } from '@coze-arch/bot-api';
export interface CategoryOptions {
label: string;
value: string;
}
export function useProductCategoryOptions(entityType: ProductEntityType) {
const { data: categoryOptions, loading } = useRequest(async () => {
const res = await ProductApi.PublicGetProductCategoryList({
need_empty_category: true,
entity_type: entityType,
});
return res.data?.categories?.map(item => ({
label: item.name,
value: item.id,
})) as CategoryOptions[];
});
return { categoryOptions, loading };
}

View File

@@ -0,0 +1,135 @@
// 修改semi样式 适配UI规范
.publish-collapse {
:global {
.semi-collapse-item {
position: relative;
border-bottom: 1px solid var(--coz-stroke-primary);
}
.semi-collapse-item:hover {
border-color: var(--coz-bg-primary);
}
.semi-collapse-header:hover::before {
content: '';
position: absolute;
top: -1px;
left: 0;
width: 100%;
height: 1px;
background-color: var(--coz-bg-primary);
}
.semi-collapse-header {
margin: 0;
padding: 8px 0;
border-radius: 0;
&:hover {
background: var(--coz-mg-secondary-hovered);
border-radius: 8px;
}
&:active {
background: var(--coz-mg-secondary-pressed);
}
}
.semi-collapse-header-icon {
margin: 4px;
}
.semi-collapse-content {
padding: 4px 0 16px;
}
.semi-collapse-item:last-child {
border: none;
}
.semi-form-field-label {
line-height: 20px;
}
}
}
.publish-cards {
:global {
.semi-select.coz-select {
height: 24px;
border-radius: var(--small, 6px);
.semi-select-inset-label {
margin: 0 4px;
font-size: 12px;
font-weight: 400;
color: var(--coz-fg-secondary);
}
.semi-select-selection .semi-select-selection-text {
font-size: 12px;
}
.semi-select-arrow {
width: 22px;
}
.semi-select-content-wrapper-empty {
margin-left: 0;
}
.semi-select-content-wrapper-collapse {
gap: 2px;
align-items: center;
line-height: normal;
.semi-overflow-list-item, .semi-overflow-list-overflow {
display: flex;
}
}
.semi-tag {
height: 16px;
padding: 2px;
background: var(--coz-mg_primary, rgba(6, 7, 9, 8%));
border-radius: 2px;
}
}
.semi-select-error {
border: 1px solid var(--coz-stroke-hglt-red);
}
// stylelint-disable-next-line selector-class-pattern -- 暂时修复 checkbox 边框样式
.coz-checkbox.semi-checkbox-unChecked {
.semi-checkbox-inner-display {
border-color: var(--coz-stroke-plus);
}
}
}
}
.project {
:global {
.semi-form-field-error-message {
font-size: 12px;
line-height: 16px;
color: var(--coz-fg-hglt-red);
}
.semi-input {
border-radius: 8px;
}
.semi-form-field {
padding: 0;
}
.semi-input-textarea {
height: 72px;
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* 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.
*/
import React, { useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useErrorHandler } from '@coze-arch/logger';
import { Form, Spin } from '@coze-arch/coze-design';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { useParams } from 'react-router-dom';
import { useProjectPublishStore } from '../store';
import {
loadProjectPublishDraft,
saveProjectPublishDraft,
} from './utils/publish-draft';
import { initPublishStore } from './utils/init-publish-store';
import { PublishTitleBar } from './publish-title-bar';
import { PublishRecord } from './publish-record';
import { PublishConnectors } from './publish-connectors';
import { PublishBasicInfo } from './publish-basic-info';
import { PublishContainer } from './components/publish-container';
import s from './index.module.less';
export function ProjectPublish(): JSX.Element {
const { project_id = '', space_id = '' } = useParams<DynamicParams>();
const {
showPublishResult,
pageLoading,
resetProjectPublishInfo,
exportDraft,
} = useProjectPublishStore(
useShallow(state => ({
showPublishResult: state.showPublishResult,
pageLoading: state.pageLoading,
resetProjectPublishInfo: state.resetProjectPublishInfo,
exportDraft: state.exportDraft,
})),
);
const errorHandle = useErrorHandler();
useEffect(() => {
const saveDraft = () => {
saveProjectPublishDraft(exportDraft(project_id));
};
window.addEventListener('beforeunload', saveDraft);
return () => {
window.removeEventListener('beforeunload', saveDraft);
};
}, [exportDraft, project_id]);
useEffect(() => {
initPublishStore(
project_id,
errorHandle,
loadProjectPublishDraft(project_id),
);
return () => {
resetProjectPublishInfo();
};
}, []);
return !pageLoading ? (
<PublishContainer>
<Form<Record<string, unknown>>
className={s.project}
showValidateIcon={false}
>
<PublishTitleBar />
{!showPublishResult ? (
<div className="flex justify-center pt-[32px] pb-[48px] coz-bg-primary">
<div className="w-[800px]">
<PublishBasicInfo />
<PublishConnectors />
</div>
</div>
) : (
<PublishRecord projectId={project_id} spaceId={space_id} />
)}
</Form>
</PublishContainer>
) : (
<Spin
spinning
wrapperClassName="flex justify-center h-full w-full items-center"
/>
);
}

View File

@@ -0,0 +1,114 @@
/*
* 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.
*/
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Form } from '@coze-arch/coze-design';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { useParams } from 'react-router-dom';
import { CONNECTOR_TAB_BAR_Z_INDEX } from '../utils/constants';
import { useProjectPublishStore } from '../store';
import { checkVersionNum } from './utils/version-number-check';
import { FormVersionDescInput } from './components/version-desc-input';
export function PublishBasicInfo() {
const { project_id = '' } = useParams<DynamicParams>();
const {
lastVersionNumber,
versionNumber,
versionDescription,
setProjectPublishInfo,
} = useProjectPublishStore(
useShallow(state => ({
lastVersionNumber: state.lastVersionNumber,
versionNumber: state.versionNumber,
versionDescription: state.versionDescription,
setProjectPublishInfo: state.setProjectPublishInfo,
})),
);
const inputBaseCls = '';
return (
<div className="flex flex-col gap-[8px] w-full coz-bg-plus rounded-md p-24px">
<div className="text-[20px] coz-fg-plus font-[500] leading-[28px]">
{I18n.t('project_release_version_info')}
</div>
<div className="flex gap-x-12px">
<Form.Input
field="version_num"
fieldClassName="!p-0 flex-[1]"
label={
<span className="text-14px font-medium">
{I18n.t('builder_publish_version_label')}
</span>
}
placeholder={
lastVersionNumber
? I18n.t('project_release_example1', {
version: lastVersionNumber,
})
: I18n.t('project_release_example')
}
initValue={versionNumber}
className="bg-transparent coz-stroke-plus"
rules={[
{ required: true, message: I18n.t('project_release_example2') },
]}
onChange={value => {
setProjectPublishInfo({
versionNumber: value,
});
}}
validate={val => checkVersionNum(val, project_id)}
trigger={'blur'}
maxLength={20}
/>
<FormVersionDescInput
field="version_desc"
fieldClassName="!p-0 !overflow-visible flex-[1]"
label={
<span className="text-14px font-medium">
{I18n.t('builder_publish_changelog_label')}
</span>
}
placeholder={I18n.t('builder_publish_changelog_placeholder')}
initValue={versionDescription}
maxLength={800}
maxCount={800}
wrapperClassName="relative overflow-visible"
inputClassName={inputBaseCls}
textAreaClassName={classNames(
inputBaseCls,
'absolute',
'top-0',
'left-0',
'!coz-bg-max',
)}
// 比渠道 tab 高就可以, 避免遮挡
textAreaStyle={{ zIndex: CONNECTOR_TAB_BAR_Z_INDEX + 1 }}
onChange={value => {
setProjectPublishInfo({
versionDescription: value,
});
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
/*
* 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.
*/
import { useParams } from 'react-router-dom';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { MonetizePublishInfo } from '@coze-studio/components/monetize';
import {
ConnectorClassification,
type PublishConnectorInfo,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { PublishTermService } from '@coze-agent-ide/agent-ide-commons';
import { IconCozEmpty } from '@coze-arch/coze-design/icons';
import { Form } from '@coze-arch/coze-design';
import { useWebSdkGuideModal } from '@/web-sdk-guide';
import { WEB_SDK_CONNECTOR_ID } from '@/utils/constants';
import { useProjectPublishStore } from '@/store';
import { CONNECTOR_TAB_BAR_Z_INDEX } from '../utils/constants';
import { usePublishContainer } from '../context/publish-container-context';
import { formatConnectorGroups } from './utils/format-connector-groups';
import { useConnectorScroll } from './hooks/use-connector-scroll';
import { useAutoScrollToConnector } from './hooks/use-auto-scroll-to-connector';
import { SocialPlatformChatflow } from './components/social-platform-chatflow';
import {
ConnectorTabbar,
ConnectorTabbarItem,
} from './components/connector-tab-bar';
import { ConnectorGroupHeader } from './components/connector-group-header';
import { ConnectorCard } from './components/connector-card';
import s from './index.module.less';
const getTermServiceData = (connectorList: PublishConnectorInfo[]) =>
connectorList
.filter(item => item.privacy_policy || item.user_agreement)
.map(i => ({
name: i.name,
icon: i.icon_url,
privacy_policy: i.privacy_policy,
user_agreement: i.user_agreement,
}));
// eslint-disable-next-line @coze-arch/max-line-per-function
export function PublishConnectors() {
const { project_id = '' } = useParams<DynamicParams>();
const {
connectorList, // 社区版仅支持API 和 Chat SDK 渠道
connectorUnionMap,
monetizeConfig,
selectedConnectorIds,
connectorPublishConfig,
unions,
updateSelectedConnectorIds,
} = useProjectPublishStore(
useShallow(state => ({
connectorList: state.connectorList,
connectorUnionMap: state.connectorUnionMap,
monetizeConfig: state.monetizeConfig,
selectedConnectorIds: state.selectedConnectorIds,
connectorPublishConfig: state.connectorPublishConfig,
unions: state.unions,
updateSelectedConnectorIds: state.updateSelectedConnectorIds,
})),
);
const termServiceData = useMemo(
() => getTermServiceData(connectorList),
[connectorList],
);
const connectorGroups = useMemo(
() =>
formatConnectorGroups(connectorList, connectorUnionMap, unions).filter(
item => item.connectors.length > 0,
),
[connectorList, connectorUnionMap, unions],
);
const { publishHeaderHeight } = usePublishContainer();
const {
connectorRefMap,
activeConnectorTarget,
connectorBarRef,
scrollToConnector,
closeAnimation,
animationStateMap,
} = useConnectorScroll();
useAutoScrollToConnector({
connectorGroupList: connectorGroups,
connectorRefMap,
});
const isConnectorChecked = (c: PublishConnectorInfo) =>
selectedConnectorIds.includes(c.connector_union_id ?? c.id);
const onCheckConnector = (c: PublishConnectorInfo, checked: boolean) => {
const id = c.connector_union_id ?? c.id;
if (checked) {
updateSelectedConnectorIds(prev => prev.concat(id));
} else {
updateSelectedConnectorIds(prev => prev.filter(i => i !== id));
}
};
// Collapse Panel 展开状态下,不在 header 中展示已选渠道的图标
const getGroupHeaderList = (groupId: ConnectorClassification) => {
const group = connectorGroups.find(g => g.type === groupId);
if (!group) {
return [];
}
return group.connectors.filter(c =>
selectedConnectorIds.includes(c.connector_union_id ?? c.id),
);
};
const { node: guideModal, show: showWebSdkGuide } = useWebSdkGuideModal();
const onShowWebSdkGuide = () => {
const sdkConnector = connectorList.find(c => c.id === WEB_SDK_CONNECTOR_ID);
const sdkConfig = connectorPublishConfig[WEB_SDK_CONNECTOR_ID];
showWebSdkGuide({
projectId: project_id,
workflowId: sdkConfig?.selected_workflows?.[0]?.workflow_id ?? '',
version: sdkConnector?.bind_info?.sdk_version ?? '',
});
};
return (
<div className="flex flex-col mt-[24px] w-full coz-bg-plus rounded-md p-24px">
<div className="flex items-center justify-between">
<Form.Label required className="text-20px font-medium leading-[24px]">
{I18n.t('bot_publish_select_title')}
</Form.Label>
{IS_OVERSEA && monetizeConfig ? (
<MonetizePublishInfo
className="pr-[8px] text-[12px]"
monetizeConfig={monetizeConfig}
supportPlatforms={connectorList
.filter(c => c.support_monetization)
.map(c => ({ id: c.id, name: c.name, icon: c.icon_url }))}
/>
) : null}
</div>
{termServiceData.length ? (
<PublishTermService
scene="project"
termServiceData={termServiceData}
className="mt-8px py-0 coz-fg-secondary"
/>
) : null}
<ConnectorTabbar
className="sticky"
ref={connectorBarRef}
style={{
top: publishHeaderHeight,
zIndex: CONNECTOR_TAB_BAR_Z_INDEX,
}}
>
{connectorGroups.map(connector => (
<ConnectorTabbarItem
key={connector.type}
selectedConnectorCount={getGroupHeaderList(connector.type).length}
isActive={
activeConnectorTarget === connectorRefMap[connector.type].current
}
onClick={() => scrollToConnector(connector.type)}
>
{connector.label}
</ConnectorTabbarItem>
))}
</ConnectorTabbar>
{connectorGroups.map((i, index) => {
// 社区版暂不支持社交平台渠道
const isSocialPlatform =
i.type === ConnectorClassification.SocialPlatform;
return (
<div
key={i.type}
ref={connectorRefMap[i.type]}
style={{ marginTop: index === 0 ? 0 : 24 }}
>
<ConnectorGroupHeader
label={i.label}
tooltipContent={i.desc}
showTooltipInfo={isSocialPlatform}
isHighlight={animationStateMap[i.type]}
type={i.type}
onAnimationEnd={() => {
closeAnimation(i.type);
}}
/>
{/* 社区版暂不支持社交平台渠道 */}
{isSocialPlatform ? (
<SocialPlatformChatflow className="mb-8px" />
) : null}
<div
className={classNames(
'grid grid-cols-2 gap-[12px]',
s['publish-cards'],
)}
>
{i.connectors.map(c => (
<ConnectorCard
key={c.id}
connectorInfo={c}
checked={isConnectorChecked(c)}
onCheckedChange={checked => onCheckConnector(c, checked)}
onShowWebSdkGuide={onShowWebSdkGuide}
/>
))}
</div>
</div>
);
})}
{!connectorList.length ? (
<div className="flex flex-col justify-center items-center w-full h-full gap-[4px]">
<IconCozEmpty className="w-[32px] h-[32px] coz-fg-dim" />
<div className="text-[14px] font-medium coz-fg-primary">
{I18n.t('publish_page_no_channel_status_title')}
</div>
<div className="text-[12px] coz-fg-primary">
{I18n.t('publish_page_no_channel_status_desc')}
</div>
</div>
) : null}
{guideModal}
</div>
);
}

View File

@@ -0,0 +1,96 @@
/*
* 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.
*/
import { NavLink } from 'react-router-dom';
import { type FC } from 'react';
import { useShallow } from 'zustand/react/shallow';
import classNames from 'classnames';
import { IntelligenceType } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { useFlags } from '@coze-arch/bot-flags';
import { useIsPublishRecordReady } from '@coze-studio/publish-manage-hooks';
import { IconCozCheckMarkCircle } from '@coze-arch/coze-design/icons';
import { useProjectPublishStore } from '../store';
import { ProjectPublishProgress } from '../publish-progress';
export const PublishRecord: FC<{
projectId: string;
spaceId: string;
}> = ({ projectId, spaceId }) => {
const { publishRecordDetail } = useProjectPublishStore(
useShallow(state => ({
publishRecordDetail: state.publishRecordDetail,
})),
);
const [FLAGS] = useFlags();
const { ready, inited } = useIsPublishRecordReady({
type: IntelligenceType.Project,
spaceId,
intelligenceId: projectId,
// 社区版暂不支持该功能
enable: FLAGS['bot.studio.publish_management'] && !IS_OPEN_SOURCE,
});
return (
<div>
<div className="my-[32px] p-[16px] flex flex-col items-center">
<IconCozCheckMarkCircle className="text-[48px] coz-fg-dim" />
<div className="text-[16px] font-medium mt-[8px] leading-[22px]">
{I18n.t('project_release_already_released')}
</div>
<div className="text-[12px] coz-fg-dim leading-[16px]">
{I18n.t('project_release_already_released_desc')}
</div>
{/* 社区版暂不支持该功能 */}
{FLAGS['bot.studio.publish_management'] && !IS_OPEN_SOURCE ? (
<div className="text-[12px] coz-fg-dim leading-[16px]">
{I18n.t('release_management_detail1', {
button: (
<NavLink
className={classNames(
'no-underline',
ready || !inited
? 'coz-fg-hglt'
: 'coz-fg-secondary cursor-not-allowed',
)}
onClick={e => {
if (!ready) {
e.preventDefault();
}
}}
to={`/space/${spaceId}/publish/app/${projectId}`}
>
{I18n.t('release_management')}
{ready || !inited
? null
: `(${I18n.t('release_management_generating')})`}
</NavLink>
),
})}
</div>
) : null}
</div>
<div className="rounded-[12px] w-[480px] coz-stroke-primary coz-bg-max border border-solid px-[24px] pt-[16px] m-auto mb-[48px]">
<ProjectPublishProgress record={publishRecordDetail} />
</div>
</div>
);
};

View File

@@ -0,0 +1,206 @@
/*
* 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 @coze-arch/max-line-per-function */
import { useNavigate, useParams } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useRequest } from 'ahooks';
import {
ConnectorClassification,
type ConnectorPublishConfig,
type GetPublishRecordDetailRequest,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { IconCozArrowLeft } from '@coze-arch/coze-design/icons';
import {
Banner,
Button,
IconButton,
Toast,
useFormApi,
} from '@coze-arch/coze-design';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { intelligenceApi } from '@coze-arch/bot-api';
import { isPublishFinish } from '../utils/is-publish-finish';
import { MIN_PUBLISH_HEADER_HEIGHT } from '../utils/constants';
import { useProjectPublishStore } from '../store';
import { usePublishContainer } from '../context/publish-container-context';
export function PublishTitleBar() {
const navigate = useNavigate();
const formApi = useFormApi();
const { space_id = '', project_id = '' } = useParams<DynamicParams>();
const goBack = () => navigate(`/space/${space_id}/project-ide/${project_id}`);
const {
connectorList,
showPublishResult,
setShowPublishResult,
versionDescription,
versionNumber,
connectors,
unions,
connectorPublishConfig,
socialPlatformChatflow,
selectedConnectorIds,
setPublishRecordDetail,
} = useProjectPublishStore(
useShallow(s => ({
connectorList: s.connectorList,
showPublishResult: s.showPublishResult,
setShowPublishResult: s.setShowPublishResult,
versionNumber: s.versionNumber,
versionDescription: s.versionDescription,
connectors: s.connectors,
unions: s.unions,
connectorPublishConfig: s.connectorPublishConfig,
socialPlatformChatflow: s.socialPlatformChatflow,
selectedConnectorIds: s.selectedConnectorIds,
setPublishRecordDetail: s.setPublishRecordDetail,
})),
);
const [publishing, setPublishing] = useState(false);
const { publishHeaderHeight, setPublishHeaderHeight } = usePublishContainer();
// 发布结果轮询
const { run: getPublishRecordDetail, cancel } = useRequest(
async (params: GetPublishRecordDetailRequest) =>
await intelligenceApi.GetPublishRecordDetail(params),
{
pollingInterval: 3000,
pollingWhenHidden: false,
pollingErrorRetryCount: 3,
manual: true,
onSuccess: res => {
if (res?.data) {
setPublishRecordDetail(res.data);
if (isPublishFinish(res.data)) {
cancel();
}
if (!showPublishResult) {
setShowPublishResult(true);
}
} else {
cancel();
}
},
onError: () => {
cancel();
},
},
);
const handlePublishProject = async () => {
if (showPublishResult) {
goBack();
} else {
try {
setPublishing(true);
await formApi.validate();
if (!selectedConnectorIds.length) {
Toast.error(I18n.t('publish_tooltip_select_platform'));
return;
}
const publishConnectors: Record<string, Record<string, string>> = {};
const publishConnectorConfigs: Record<string, ConnectorPublishConfig> =
{};
selectedConnectorIds.forEach(id => {
const connectorId = unions[id] ?? id;
publishConnectors[connectorId] = connectors[connectorId] ?? {};
// 社交平台的 chatflow 选项统一
if (
connectorList.find(c => c.id === connectorId)
?.connector_classification ===
ConnectorClassification.SocialPlatform
) {
publishConnectorConfigs[connectorId] = socialPlatformChatflow;
} else if (connectorPublishConfig[connectorId]) {
publishConnectorConfigs[connectorId] =
connectorPublishConfig[connectorId];
}
});
const { data } = await intelligenceApi.PublishProject({
project_id,
version_number: versionNumber,
description: versionDescription,
connectors: publishConnectors,
connector_publish_config: publishConnectorConfigs,
});
setPublishRecordDetail({
publish_monetization_result: data?.publish_monetization_result,
});
if (data?.publish_record_id) {
getPublishRecordDetail({
publish_record_id: data.publish_record_id,
project_id,
});
}
} catch (error) {
// 校验的错误还需要Toast提示出来
if (typeof Object.values(error as Error)[0] === 'string') {
Toast.error(Object.values(error as Error)[0]);
}
} finally {
setPublishing(false);
}
}
};
return (
<div
className="sticky top-0 z-[100] coz-bg-primary overflow-y-hidden"
style={{ height: publishHeaderHeight }}
>
<Helmet>
<title>{I18n.t('Publish')}</title>
</Helmet>
<div className="flex p-[16px] justify-between items-center coz-stroke-primary border-b border-x-0 border-t-0 border-solid pl-2">
<div className="flex items-center">
<IconButton
icon={<IconCozArrowLeft className="h-[18px] w-[18px]" />}
color="secondary"
iconSize="large"
onClick={goBack}
/>
<span className="ml-[8px] font-medium text-[20px]">
{I18n.t('Publish')}
</span>
</div>
<Button
onClick={handlePublishProject}
loading={publishing}
data-testid="project.publish"
>
{showPublishResult
? I18n.t('bot_publish_success_back')
: I18n.t('Publish')}
</Button>
</div>
<Banner
type="info"
description={I18n.t('project_release_notify')}
onClose={() => {
setPublishHeaderHeight(MIN_PUBLISH_HEADER_HEIGHT);
}}
></Banner>
</div>
);
}

View File

@@ -0,0 +1,54 @@
/*
* 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.
*/
import {
type PublishConnectorInfo,
ConnectorBindType,
ConnectorConfigStatus,
} from '@coze-arch/idl/intelligence_api';
// 未配置/授权场景
export const getConnectorNotConfigured = (
connector: PublishConnectorInfo,
): boolean => {
const { bind_type, config_status } = connector;
// 未绑定&未授权
const notConfigured =
[
ConnectorBindType.KvBind,
ConnectorBindType.AuthBind,
ConnectorBindType.KvAuthBind,
ConnectorBindType.TemplateBind, // mcp未配置时禁用模版始终为已配置
].includes(bind_type) &&
config_status === ConnectorConfigStatus.NotConfigured;
return notConfigured;
};
// 不能发布的场景:
// 1. 未绑定&未授权
// 2. 后端下发的不能发布没有workflow不能发api有私有插件不能发模板审核中不能发布的渠道
export const getDisabledPublish = (
connector: PublishConnectorInfo,
): boolean => {
const { allow_publish } = connector;
// 未绑定&未授权
const notConfigured = getConnectorNotConfigured(connector);
const connectorDisabled = notConfigured || !allow_publish;
// 审核中不能发布渠道的场景后端下发 allow_publish
return connectorDisabled;
};

View File

@@ -0,0 +1,50 @@
/*
* 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.
*/
import { UIPreviewType } from '@coze-arch/idl/product_api';
import { type UIOption } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
export enum DisplayScreen {
Web = 'web',
Mobile = 'mobile',
}
export interface DisplayScreenOption {
label: string;
value: DisplayScreen;
disabled?: boolean;
tooltip?: string;
}
export function toDisplayScreenOption(uiOption: UIOption): DisplayScreenOption {
const publicProps = {
disabled: uiOption.available === false,
tooltip: uiOption.unavailable_reason,
};
if (uiOption.ui_channel === UIPreviewType.Web.toString()) {
return {
value: DisplayScreen.Web,
label: I18n.t('builder_canvas_tools_pc'),
...publicProps,
};
}
return {
value: DisplayScreen.Mobile,
label: I18n.t('builder_canvas_tools_phone'),
...publicProps,
};
}

View File

@@ -0,0 +1,103 @@
/*
* 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.
*/
import {
type ConnectorUnionInfo,
ConnectorClassification,
type PublishConnectorInfo,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
export interface ConnectorGroup {
type: ConnectorClassification;
label: string;
desc: string;
connectors: PublishConnectorInfo[];
}
export function formatConnectorGroups(
connectors: PublishConnectorInfo[],
unionMap: Record<string, ConnectorUnionInfo>,
unions: Record<string, string>,
) {
const groups: ConnectorGroup[] = [
{
type: ConnectorClassification.APIOrSDK,
label: I18n.t('project_release_api1'),
desc: I18n.t('project_release_api_sdk_desc'),
connectors: [],
},
{
type: ConnectorClassification.MiniProgram,
label: I18n.t('project_release_miniprogram1'),
desc: I18n.t('project_release_h5_desc'),
connectors: [],
},
{
type: ConnectorClassification.SocialPlatform,
label: I18n.t('project_release_social1'),
desc: I18n.t('project_release_social_desc1'),
connectors: [],
},
{
type: ConnectorClassification.Coze,
label: I18n.t('project_release_coze1'),
desc: I18n.t('project_release_ts_desc'),
connectors: [],
},
{
type: ConnectorClassification.CozeSpaceExtensionLibrary,
label: I18n.t('app_publish_connector_mcp'),
desc: I18n.t('app_publish_connector_mcp'),
connectors: [],
},
];
for (const c of connectors) {
const group = groups.find(g => g.type === c.connector_classification);
if (!group) {
continue;
}
if (c.connector_union_id) {
const unionId = c.connector_union_id;
// 如果当前 union_id 已经被添加到分组中,则跳过
if (group.connectors.some(i => i.connector_union_id === unionId)) {
continue;
}
let connectorInfo = c;
// 优先取 union 选中的 connector否则取第一个
const unionSelection = connectors.find(i => i.id === unions[unionId]);
if (unionSelection) {
connectorInfo = unionSelection;
} else {
const firstId = unionMap[unionId].connector_options[0].connector_id;
const firstConnector = connectors.find(i => i.id === firstId);
if (firstConnector) {
connectorInfo = firstConnector;
}
}
const unionInfo = unionMap[unionId];
group.connectors.push({
...connectorInfo,
name: unionInfo.name,
description: unionInfo.description,
icon_url: unionInfo.icon_url,
});
} else {
group.connectors.push(c);
}
}
return groups;
}

View File

@@ -0,0 +1,92 @@
/*
* 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.
*/
import {
ConnectorBindType,
ConnectorConfigStatus,
type PublishConnectorInfo,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { type TagProps } from '@coze-arch/coze-design';
interface ConfigStatusUI {
text: string;
color: TagProps['color'];
}
export const getConfigStatus = (
record: PublishConnectorInfo,
): ConfigStatusUI => {
const { bind_type } = record;
if (
bind_type === ConnectorBindType.KvBind ||
bind_type === ConnectorBindType.KvAuthBind ||
bind_type === ConnectorBindType.TemplateBind
) {
return getKvBindStatus(record);
}
return getDefaultStatus(record);
};
const getKvBindStatus = (record: PublishConnectorInfo): ConfigStatusUI => {
const { config_status = ConnectorConfigStatus.Configured } = record;
const couldPublish = config_status === ConnectorConfigStatus.Configured;
const color = couldPublish ? 'green' : 'primary';
const textMap = {
[ConnectorConfigStatus.Configured]: I18n.t(
'bot_publish_columns_status_configured',
),
[ConnectorConfigStatus.NotConfigured]: I18n.t(
'bot_publish_columns_status_not_configured',
),
// 业务不会走到下面3个case
[ConnectorConfigStatus.Configuring]: '',
[ConnectorConfigStatus.Disconnected]: '',
[ConnectorConfigStatus.NeedReconfiguring]: '',
};
return {
text: textMap[config_status],
color,
};
};
const getDefaultStatus = (record: PublishConnectorInfo): ConfigStatusUI => {
const { config_status = ConnectorConfigStatus.Configured } = record;
const couldPublish = config_status === ConnectorConfigStatus.Configured;
const color = couldPublish ? 'green' : 'primary';
const textMap = {
[ConnectorConfigStatus.Configured]: I18n.t(
'bot_publish_columns_status_authorized',
),
[ConnectorConfigStatus.NotConfigured]: I18n.t(
'bot_publish_columns_status_unauthorized',
),
[ConnectorConfigStatus.Configuring]: I18n.t('publish_douyin_config_ing'),
[ConnectorConfigStatus.Disconnected]: '',
[ConnectorConfigStatus.NeedReconfiguring]: '',
};
return {
text: textMap[config_status],
color,
};
};

View File

@@ -0,0 +1,35 @@
/*
* 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.
*/
import { incrementVersionNumber } from './increment-version-number';
export const getFixedVersionNumber = ({
lastPublishVersionNumber,
draftVersionNumber,
defaultVersionNumber,
}: {
lastPublishVersionNumber: string | undefined;
draftVersionNumber: string | undefined;
defaultVersionNumber: string;
}): string => {
if (lastPublishVersionNumber && !draftVersionNumber) {
return incrementVersionNumber(lastPublishVersionNumber);
}
if (draftVersionNumber) {
return draftVersionNumber;
}
return defaultVersionNumber;
};

View File

@@ -0,0 +1,31 @@
/*
* 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.
*/
export const incrementVersionNumber = (input: string) => {
// 定义正则表达式,匹配 "数字.数字.数字" 的模式
const regex = /(\d+)\.(\d+)\.(\d+)/g;
// 使用 replace 方法和回调函数对匹配的部分进行替换
// eslint-disable-next-line max-params
const result = input.replace(regex, (_match, p1, p2, p3) => {
// 将最后一个数字加 1
const incrementedP3 = parseInt(String(p3), 10) + 1;
// 返回新的字符串
return `${p1}.${p2}.${incrementedP3}`;
});
return result;
};

View File

@@ -0,0 +1,146 @@
/*
* 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.
*/
import {
ConnectorClassification,
type ConnectorPublishConfig,
} from '@coze-arch/idl/intelligence_api';
import { MonetizationEntityType } from '@coze-arch/idl/benefit';
import { benefitApi, intelligenceApi } from '@coze-arch/bot-api';
import {
DEFAULT_VERSION_NUMBER,
WEB_SDK_CONNECTOR_ID,
} from '@/utils/constants';
import { useProjectPublishStore } from '@/store';
import { type ProjectPublishDraft } from './publish-draft';
import { getFixedVersionNumber } from './get-fixed-version-number';
import { getDisabledPublish } from './connector-disabled-publish';
// eslint-disable-next-line complexity -- it's complex
export async function initPublishStore(
projectId: string,
errorHandle: (e: unknown) => void,
draft?: ProjectPublishDraft,
) {
const { setProjectPublishInfo, setSelectedConnectorIds, setMonetizeConfig } =
useProjectPublishStore.getState();
setProjectPublishInfo({ pageLoading: true });
try {
const [publishResp, monetizeConfigResp] = await Promise.all([
intelligenceApi.PublishConnectorList({
project_id: projectId,
}),
IS_OVERSEA
? benefitApi.PublicGetBotMonetizationConfig({
entity_id: projectId,
entity_type: MonetizationEntityType.Project,
})
: Promise.resolve(undefined),
]);
const {
connector_list = [],
last_publish_info = {},
connector_union_info_map = {},
} = publishResp.data ?? {};
const { connector_ids = [], connector_publish_config = {} } =
last_publish_info;
// 初始化默认选中的渠道
const initSelectedConnectors: string[] = [];
const initConnectors: Record<string, Record<string, string>> = {};
for (const id of connector_ids) {
const connector = connector_list.find(c => c.id === id);
// 过滤掉不允许发布的渠道
if (!connector || getDisabledPublish(connector)) {
continue;
}
if (connector.connector_union_id) {
// 对于 union 的 connector ,选中其 union id
initSelectedConnectors.push(connector.connector_union_id);
initConnectors[connector.id] = connector.bind_info;
} else {
initSelectedConnectors.push(connector.id);
initConnectors[connector.id] = connector.bind_info;
}
}
// 初始化每个 union 选中的 connector如果上次没发布该渠道则选中第一个
const initUnions: Record<string, string> = {};
for (const [unionId, info] of Object.entries(connector_union_info_map)) {
initUnions[unionId] =
info.connector_options.find(o => connector_ids.includes(o.connector_id))
?.connector_id ?? info.connector_options[0].connector_id;
}
// 回填社交渠道选择的 chatflow优先级
// 1. draft 中保存的 chatflow
// 2. 上次发布的第一个 SocialPlatform 选择的 chatflow
let lastSocialPlatformChatflow: ConnectorPublishConfig | undefined;
if (draft?.socialPlatformConfig?.selected_workflows?.[0].workflow_id) {
lastSocialPlatformChatflow = draft.socialPlatformConfig;
} else {
for (const c of connector_list) {
if (
!initSelectedConnectors.includes(c.id) ||
c.connector_classification !== ConnectorClassification.SocialPlatform
) {
continue;
}
const lastConfig = connector_publish_config[c.id];
if (lastConfig?.selected_workflows?.[0].workflow_id) {
lastSocialPlatformChatflow = lastConfig;
break;
}
}
}
// 根据 draft 中保存的信息回填 WebSDK 渠道选择的 chatflow
if (draft?.sdkConfig?.selected_workflows?.[0].workflow_id) {
connector_publish_config[WEB_SDK_CONNECTOR_ID] = draft.sdkConfig;
}
setSelectedConnectorIds(
draft?.selectedConnectorIds ?? initSelectedConnectors,
);
const lastPublishVersionNumber = last_publish_info.version_number;
// 用户没有 draft 并且存在发布过的版本 则将上一次发布的版本号进行处理
const fixedVersionNumber = getFixedVersionNumber({
lastPublishVersionNumber,
draftVersionNumber: draft?.versionNumber,
defaultVersionNumber: DEFAULT_VERSION_NUMBER,
});
setProjectPublishInfo({
lastVersionNumber: lastPublishVersionNumber,
versionNumber: fixedVersionNumber,
versionDescription: draft?.versionDescription,
connectorPublishConfig: connector_publish_config,
connectorList: connector_list,
connectorUnionMap: connector_union_info_map,
connectors: initConnectors,
unions: draft?.unions ?? initUnions,
socialPlatformChatflow: lastSocialPlatformChatflow,
});
setMonetizeConfig(monetizeConfigResp?.data);
} catch (e) {
errorHandle(e);
} finally {
setProjectPublishInfo({ pageLoading: false });
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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.
*/
import { type StoreBindKey } from '@/store';
type SelfMapping<T extends string> = {
[K in T]: K; // 关键语法:将每个字面量类型映射为自己
};
type KeyMapping = SelfMapping<StoreBindKey>;
export const isStoreBindConfigured = (
config: Record<string, string>,
): boolean => {
// 防止 StoreBindKey 有变动导致 bug
const { category_id, display_screen }: KeyMapping = {
category_id: 'category_id',
display_screen: 'display_screen',
};
return Boolean(config[category_id]) && Boolean(config[display_screen]);
};

View File

@@ -0,0 +1,47 @@
/*
* 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.
*/
import { type ConnectorPublishConfig } from '@coze-arch/idl/intelligence_api';
import { typeSafeJSONParse } from '@coze-arch/bot-utils';
export const PUBLISH_DRAFT_KEY = 'coz_project_publish_draft';
export interface ProjectPublishDraft {
projectId: string;
versionNumber: string;
versionDescription: string;
selectedConnectorIds: string[];
unions: Record<string, string>;
sdkConfig?: ConnectorPublishConfig;
socialPlatformConfig?: ConnectorPublishConfig;
}
export function loadProjectPublishDraft(projectId: string) {
const str = localStorage.getItem(PUBLISH_DRAFT_KEY);
localStorage.removeItem(PUBLISH_DRAFT_KEY);
if (!str) {
return undefined;
}
const draft = typeSafeJSONParse(str) as ProjectPublishDraft | undefined;
if (draft?.projectId === projectId) {
return draft;
}
return undefined;
}
export function saveProjectPublishDraft(draft: ProjectPublishDraft) {
localStorage.setItem(PUBLISH_DRAFT_KEY, JSON.stringify(draft));
}

View File

@@ -0,0 +1,37 @@
/*
* 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.
*/
import { I18n } from '@coze-arch/i18n';
import { intelligenceApi } from '@coze-arch/bot-api';
export async function checkVersionNum(
versionNumber: string,
projectId: string,
) {
if (!versionNumber) {
return I18n.t('project_release_example2');
}
const { data } = await intelligenceApi.CheckProjectVersionNumber({
project_id: projectId,
version_number: versionNumber,
});
if (data?.is_duplicate) {
return I18n.t('project_release_example3');
} else {
return '';
}
}

View File

@@ -0,0 +1,173 @@
/*
* 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.
*/
import {
type publish,
ConnectorPublishStatus,
} from '@coze-arch/idl/intelligence_api';
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
import {
CozAvatar,
Tag,
type TagProps,
Typography,
} from '@coze-arch/coze-design';
import {
MINI_PROGRAM_DOUYIN_DOWNLOAD_CONNECTOR_ID,
MINI_PROGRAM_WECHAT_DOWNLOAD_CONNECTOR_ID,
WEB_SDK_CONNECTOR_ID,
} from '@/utils/constants';
function getMiniProgramGuideUrl(connectorId?: string) {
switch (connectorId) {
case MINI_PROGRAM_DOUYIN_DOWNLOAD_CONNECTOR_ID:
return '/docs/guides/publish_app_to_douyin_microapp';
case MINI_PROGRAM_WECHAT_DOWNLOAD_CONNECTOR_ID:
return '/docs/guides/publish_app_to_wechat_mini_program';
default:
return '';
}
}
export interface ConnectorStatusProps {
result: publish.ConnectorPublishResult;
showTag?: boolean;
onShowWebSdkGuide?: (workflowId: string) => void;
}
const ConnectorTagMap: Record<
ConnectorPublishStatus,
(Pick<TagProps, 'color'> & { text: I18nKeysNoOptionsType }) | null
> = {
[ConnectorPublishStatus.Default]: {
color: 'brand',
text: 'project_releasing',
},
[ConnectorPublishStatus.Auditing]: {
color: 'brand',
text: 'under_review',
},
[ConnectorPublishStatus.Failed]: {
color: 'red',
text: 'project_release_failed',
},
[ConnectorPublishStatus.Success]: {
color: 'green',
text: 'project_release_success',
},
[ConnectorPublishStatus.Disable]: null, // 对应状态不会返回给前端,不进行适配
};
export function ConnectorStatus({
result,
showTag,
onShowWebSdkGuide,
}: ConnectorStatusProps) {
const tagConfig = ConnectorTagMap[result.connector_publish_status ?? 0];
// Web SDK 渠道发布成功时,展示安装指引
const shouldShowWebSdkGuide =
result.connector_id === WEB_SDK_CONNECTOR_ID &&
result.connector_publish_status === ConnectorPublishStatus.Success;
const workflowId =
result.connector_publish_config?.selected_workflows?.[0]?.workflow_id;
const showWebSdkGuide = () => onShowWebSdkGuide?.(workflowId ?? '');
return (
<div
className="h-[40px] flex items-center border-0 border-b border-solid coz-stroke-primary last:border-none"
key={result.connector_id}
>
<CozAvatar
size="small"
type="platform"
className="mr-[8px] rounded-[6px] shrink-0"
src={result.connector_icon_url}
/>
<Typography.Text
weight={500}
className="whitespace-nowrap"
ellipsis={{ showTooltip: true }}
>
{result.connector_name}
</Typography.Text>
{showTag !== false && tagConfig ? (
<Tag
size="mini"
color={tagConfig.color}
className="!px-[4px] ml-[4px] shrink-0"
>
{I18n.t(tagConfig.text)}
</Tag>
) : null}
<div className="px-[8px] ml-auto max-w-[206px]">
{result.connector_publish_status_msg ? (
<Typography.Text
type="secondary"
fontSize="14px"
ellipsis={{
showTooltip: {
type: 'tooltip',
opts: {
theme: 'dark',
style: {
maxWidth: 200,
},
},
},
}}
>
{result.connector_publish_status_msg}
</Typography.Text>
) : null}
{/* Web SDK 渠道 - 安装指引 */}
{shouldShowWebSdkGuide ? (
<Typography.Text fontSize="14px" link onClick={showWebSdkGuide}>
{I18n.t('project_release_guide')}
</Typography.Text>
) : null}
{/* 小程序渠道 - 下载代码 & 安装指引 */}
{result.download_link ? (
<>
<Typography.Text
fontSize="14px"
link={{ href: result.download_link, target: '_blank' }}
>
{I18n.t('project_release_download_code')}
</Typography.Text>
<Typography.Text
className="ml-[12px]"
fontSize="14px"
link={{
href: getMiniProgramGuideUrl(result.connector_id),
target: '_blank',
}}
>
{I18n.t('project_release_guide')}
</Typography.Text>
</>
) : null}
{result.share_link ? (
<Typography.Text
fontSize="14px"
link={{ href: result.share_link, target: '_blank' }}
>
{I18n.t('project_release_open_in_store')}
</Typography.Text>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
/*
* 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.
*/
import {
IconCozCheckMarkCircle,
IconCozClock,
IconCozClockFill,
IconCozCrossCircle,
IconCozWarningCircle,
type OriginIconProps,
} from '@coze-arch/coze-design/icons';
import { type StepProps } from '@coze-arch/coze-design';
export interface PublishStepIconProps {
status: StepProps['status'] | 'warn';
}
export function PublishStepIcon({ status }: PublishStepIconProps) {
const iconProps: Pick<OriginIconProps, 'width' | 'height'> = {
width: '16px',
height: '16px',
};
switch (status) {
case 'wait':
return <IconCozClock className="coz-fg-secondary" {...iconProps} />;
case 'process':
return <IconCozClockFill className="coz-fg-hglt" {...iconProps} />;
case 'finish':
return (
<IconCozCheckMarkCircle className="coz-fg-hglt-green" {...iconProps} />
);
case 'warn':
return (
<IconCozWarningCircle className="coz-fg-hglt-yellow" {...iconProps} />
);
case 'error':
return <IconCozCrossCircle className="coz-fg-hglt-red" {...iconProps} />;
default:
return null;
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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.
*/
import { Tag, type TagProps, Typography } from '@coze-arch/coze-design';
export interface PublishStepTitleProps {
title: string;
tag?: string;
color?: TagProps['color'];
}
export function PublishStepTitle({ title, tag, color }: PublishStepTitleProps) {
return (
<div className="flex items-center gap-[4px]">
<Typography.Text
className="leading-[20px] font-normal"
data-testid="project.publish.result"
>
{title}
</Typography.Text>
{typeof tag === 'string' ? (
<Tag size="mini" color={color}>
{tag}
</Tag>
) : null}
</div>
);
}

View File

@@ -0,0 +1,323 @@
/*
* 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.
*/
import React, { useLayoutEffect, useRef, useState } from 'react';
import {
ConnectorPublishStatus,
type PublishRecordDetail,
PublishRecordStatus,
ResourceType,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { IconCozPlugin, IconCozWorkflow } from '@coze-arch/coze-design/icons';
import {
type StepProps,
Steps,
TagGroup,
Typography,
} from '@coze-arch/coze-design';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { type UITagProps } from '@coze-arch/bot-semi';
import { useParams } from 'react-router-dom';
import { useWebSdkGuideModal } from '@/web-sdk-guide';
import { WEB_SDK_CONNECTOR_ID } from '@/utils/constants';
import { type ProjectPublishStore } from '@/store';
import { PublishStepTitle } from './components/publish-step-title';
import { PublishStepIcon } from './components/publish-step-icon';
import {
ConnectorStatus,
type ConnectorStatusProps,
} from './components/connector-status';
function getDefaultStepProps(title: string): StepProps {
return {
icon: <PublishStepIcon status="wait" />,
title: <PublishStepTitle title={title} />,
};
}
function toPackStepProps(
record: PublishRecordDetail,
tagGroupRef: React.RefObject<HTMLDivElement>,
maxTagCount?: number,
): StepProps {
const title = I18n.t('project_release_package');
if (typeof record.publish_status !== 'number') {
return getDefaultStepProps(title);
}
switch (record.publish_status) {
case PublishRecordStatus.Packing: {
return {
icon: <PublishStepIcon status="process" />,
title: (
<PublishStepTitle
title={title}
tag={I18n.t('project_release_in_progress')}
color="brand"
/>
),
};
}
case PublishRecordStatus.PackFailed: {
const tags: UITagProps[] | undefined =
record.publish_status_detail?.pack_failed_detail?.map(item => ({
tagKey: item.entity_id,
className: 'pack-status-tag',
prefixIcon:
item.entity_type === ResourceType.Workflow ? (
<IconCozWorkflow />
) : (
<IconCozPlugin />
),
children: item.entity_name,
}));
return {
icon: <PublishStepIcon status="error" />,
title: (
<PublishStepTitle
title={title}
tag={I18n.t('project_release_package_failed')}
color="red"
/>
),
description: tags ? (
<div ref={tagGroupRef}>
<Typography.Paragraph className="coz-fg-secondary mb-[4px]">
{I18n.t('project_release_pack_fail_reason')}
</Typography.Paragraph>
<TagGroup
tagList={tags}
maxTagCount={maxTagCount}
showPopover
popoverProps={{
position: 'top',
style: {
padding: 8,
backgroundColor: 'rgb(var(--black-5))',
borderColor: 'rgb(var(--black-5))',
},
}}
/>
</div>
) : null,
};
}
default: {
return {
icon: <PublishStepIcon status="finish" />,
title: (
<PublishStepTitle
title={title}
tag={I18n.t('project_release_finish')}
color="green"
/>
),
};
}
}
}
function PackStep(props: { record: PublishRecordDetail }) {
const ref = useRef<HTMLDivElement>(null);
const [maxTagCount, setMaxTagCount] = useState<number | undefined>(undefined);
const stepProps = toPackStepProps(props.record, ref, maxTagCount);
/**
* TagGroup 仅支持设置 maxTagCount 控制展示 Tag 的数量,不支持设置展示行数。
* 这里通过遍历所有 Tag 的 offsetTop 来判断其所在的行数,并将 maxTagCount
* 设置为刚好能展示两行的数量。
*/
useLayoutEffect(() => {
if (!ref.current) {
return;
}
const tags = ref.current.getElementsByClassName(
'pack-status-tag',
) as HTMLCollectionOf<HTMLElement>;
if (tags.length <= 0) {
return;
}
let top = -1;
let rowCount = 0;
for (let i = 0; i < tags.length; i++) {
const tagTop = tags[i].offsetTop;
if (top !== tagTop) {
top = tagTop;
rowCount++;
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- offsetTop 第三次变化,当前 Tag 处于第三行
if (rowCount >= 3) {
setMaxTagCount(i);
break;
}
}
}
}, [props.record.publish_status_detail?.pack_failed_detail]);
return <Steps.Step {...stepProps} />;
}
function toAuditStepProps(record: PublishRecordDetail): StepProps {
const title = I18n.t('project_release_coze_audit');
if (typeof record.publish_status !== 'number') {
return getDefaultStepProps(title);
}
switch (record.publish_status) {
case PublishRecordStatus.Packing:
case PublishRecordStatus.PackFailed: {
return getDefaultStepProps(title);
}
case PublishRecordStatus.Auditing: {
return {
status: 'process',
icon: <PublishStepIcon status="process" />,
title: (
<PublishStepTitle
title={title}
tag={I18n.t('project_release_in_progress')}
color="brand"
/>
),
};
}
case PublishRecordStatus.AuditNotPass: {
return {
status: 'error',
icon: <PublishStepIcon status="error" />,
title: (
<PublishStepTitle
title={title}
tag={I18n.t('project_release_not_pass')}
color="red"
/>
),
};
}
default: {
return {
status: 'finish',
icon: <PublishStepIcon status="finish" />,
title: (
<PublishStepTitle
title={title}
tag={I18n.t('project_release_pass')}
color="green"
/>
),
};
}
}
}
function getConnectorsPublishStatus(
record: ProjectPublishStore['publishRecordDetail'],
) {
const connectorResults = record.connector_publish_result ?? [];
if (connectorResults.length <= 0) {
return 'wait';
}
const failedCount = connectorResults.filter(
item => item.connector_publish_status === ConnectorPublishStatus.Failed,
).length;
if (failedCount > 0) {
// 所有渠道均失败,显示红色叉号;部分渠道失败,显示黄色感叹号
return failedCount === connectorResults.length ? 'error' : 'warn';
}
const publishingCount = connectorResults.filter(
item =>
item.connector_publish_status === ConnectorPublishStatus.Default ||
item.connector_publish_status === ConnectorPublishStatus.Auditing,
).length;
if (publishingCount > 0) {
// 部分渠道在发布中,显示时钟图标
return 'process';
}
return 'finish';
}
function toPublishStepProps(
record: ProjectPublishStore['publishRecordDetail'],
onShowWebSdkGuide: ConnectorStatusProps['onShowWebSdkGuide'],
): StepProps {
const title = I18n.t('project_release_channel');
if (typeof record.publish_status !== 'number') {
return getDefaultStepProps(title);
}
// 未到达“渠道审核与发布”步骤,显示默认的灰色时钟图标
if (record.publish_status < PublishRecordStatus.ConnectorPublishing) {
return {
...getDefaultStepProps(title),
description: record.connector_publish_result?.map(item => (
<ConnectorStatus result={item} showTag={false} />
)),
};
}
return {
icon: <PublishStepIcon status={getConnectorsPublishStatus(record)} />,
title: (
<PublishStepTitle
title={title}
{...(IS_OVERSEA &&
// publish_monetization_result 为 nil 表示可能是接口失败,也不展示
record.publish_monetization_result === false && {
tag: I18n.t('monetization_publish_fail'),
color: 'red',
})}
/>
),
description: record.connector_publish_result?.map(item => (
<ConnectorStatus result={item} onShowWebSdkGuide={onShowWebSdkGuide} />
)),
};
}
function PublishStep(props: { record: PublishRecordDetail }) {
const { project_id = '' } = useParams<DynamicParams>();
const { node, show: showWebSdkGuideModal } = useWebSdkGuideModal();
const onShowWebSdkGuide = (workflowId: string) =>
showWebSdkGuideModal({
projectId: project_id,
workflowId,
version: props.record.connector_publish_result?.find(
item => item.connector_id === WEB_SDK_CONNECTOR_ID,
)?.connector_bind_info?.sdk_version,
});
const stepProps = toPublishStepProps(props.record, onShowWebSdkGuide);
return (
<>
<Steps.Step {...stepProps} />
{node}
</>
);
}
export interface ProjectPublishProgressProps {
record: ProjectPublishStore['publishRecordDetail'];
}
export function ProjectPublishProgress({
record,
}: ProjectPublishProgressProps) {
return (
<Steps type="basic" direction="vertical" size="small">
<PackStep record={record} />
<Steps.Step {...toAuditStepProps(record)} />
<PublishStep record={record} />
</Steps>
);
}

View File

@@ -0,0 +1,100 @@
/*
* 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.
*/
import { z } from 'zod';
import { produce } from 'immer';
import { typeSafeJSONParse } from '@coze-arch/bot-utils';
const publishAnchorDataSchema = z.object({
projectId: z.string(),
connectorIdBeforeRedirect: z.string(),
});
const publishAnchorSchema = z.record(publishAnchorDataSchema);
type PublishAnchorData = z.infer<typeof publishAnchorDataSchema>;
type PublishAnchorType = z.infer<typeof publishAnchorSchema>;
class PublishAnchorService {
private PUBLISH_ANCHOR_KEY = 'coz_project_publish_anchor';
anchorValues: PublishAnchorType = {};
private load = () => {
try {
const stringifyLocalData = localStorage.getItem(this.PUBLISH_ANCHOR_KEY);
const localData = typeSafeJSONParse(stringifyLocalData);
const validData = publishAnchorSchema.parse(localData);
// 使用 zod 对 localData 进行类型校验
this.anchorValues = validData;
} catch {
this.anchorValues = {};
}
};
private save = () => {
localStorage.setItem(
this.PUBLISH_ANCHOR_KEY,
JSON.stringify(this.anchorValues),
);
};
setAnchor: (params: {
userId: string;
projectId: string;
connectorId: string;
}) => void = ({ userId, projectId, connectorId }) => {
this.anchorValues = produce(this.anchorValues, draft => {
draft[userId] = {
projectId,
connectorIdBeforeRedirect: connectorId,
};
});
this.save();
};
getAnchor: (params: {
userId: string;
projectId: string;
}) => PublishAnchorData | undefined = ({ userId, projectId }) => {
const userData = this.anchorValues[userId];
if (userData?.projectId === projectId) {
return userData;
}
};
removeAnchor: (params: { userId: string; projectId: string }) => void = ({
userId,
projectId,
}) => {
const hasAnchor = Boolean(this.getAnchor({ userId, projectId }));
if (!hasAnchor) {
return;
}
delete this.anchorValues[userId];
this.save();
};
clearAll = () => {
localStorage.removeItem(this.PUBLISH_ANCHOR_KEY);
this.anchorValues = {};
};
constructor() {
this.load();
}
}
export const publishAnchorService = new PublishAnchorService();

View File

@@ -0,0 +1,183 @@
/*
* 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.
*/
import { devtools } from 'zustand/middleware';
import { create } from 'zustand';
import { produce as immerProduce } from 'immer';
import {
type PublishConnectorInfo,
type ConnectorPublishConfig,
type PublishRecordDetail,
type PublishProjectData,
type ConnectorUnionInfo,
} from '@coze-arch/idl/intelligence_api';
import { type BotMonetizationConfigData } from '@coze-arch/bot-api/benefit';
import { setterActionFactory, type SetterAction } from './utils/setter-factory';
import { WEB_SDK_CONNECTOR_ID } from './utils/constants';
import { type ProjectPublishDraft } from './publish-main/utils/publish-draft';
export type StoreBindKey = 'display_screen' | 'category_id';
export interface ProjectPublishStore {
/** 页面加载状态 */
pageLoading: boolean;
/** 渠道列表 */
connectorList: PublishConnectorInfo[];
/** 需要聚合的渠道列表key 是 PublishConnectorInfo['connector_union_id'] */
connectorUnionMap: Record<string, ConnectorUnionInfo>;
/** 渠道选择的id */
selectedConnectorIds: string[];
/** 是否展示发布结果 */
showPublishResult: boolean;
/** 上次发布的版本号 */
lastVersionNumber: string;
/** 版本号 */
versionNumber: string;
/** 版本描述 */
versionDescription: string;
/** 渠道选择的Workflow/ChatFlow */
connectorPublishConfig: Record<string, ConnectorPublishConfig>;
/** 社交平台渠道统一选择的 chatflow */
socialPlatformChatflow: ConnectorPublishConfig;
/** 发布配置信息key代表connector_idvalue是渠道发布的参数 */
connectors: Record<string, Record<string, string>>;
/** 聚合渠道的选择信息key代表connector_union_idvalue是union的选择信息 */
unions: Record<string, string>;
/** 是否已经配置过模板信息 */
templateConfigured: boolean;
/** 发布结果详情(轮询接口返回值) */
publishRecordDetail: PublishRecordDetail &
// 该信息由 PublishProject 接口返回
// 但为了符合现有数据流转逻辑PublishProject 拿到 id用 id 轮询 GetPublishRecordDetail 拿到结果作为唯一数据源)
// 因此前端手动将该值拼到轮询结果中
Pick<PublishProjectData, 'publish_monetization_result'>;
/** 付费配置 */
monetizeConfig?: BotMonetizationConfigData;
}
interface ProjectPublishAction {
reset: () => void;
setMonetizeConfig: (
monetizeConfig: BotMonetizationConfigData | undefined,
) => void;
setConnectorList: (connectorList: PublishConnectorInfo[]) => void;
setSelectedConnectorIds: (selectedConnectorIds: string[]) => void;
updateSelectedConnectorIds: (produce: (prev: string[]) => string[]) => void;
setShowPublishResult: (showPublishResult: boolean) => void;
setProjectPublishInfo: SetterAction<ProjectPublishStore>;
setProjectPublishInfoByImmer: (
updateFn: (draft: ProjectPublishStore['connectorPublishConfig']) => void,
) => void;
setPublishRecordDetail: (
val: Partial<ProjectPublishStore['publishRecordDetail']>,
) => void;
resetProjectPublishInfo: () => void;
exportDraft: (projectId: string) => ProjectPublishDraft;
}
const initialStore: ProjectPublishStore = {
connectorList: [],
connectorUnionMap: {},
selectedConnectorIds: [],
showPublishResult: false,
lastVersionNumber: '',
versionNumber: '',
versionDescription: '',
connectorPublishConfig: {},
socialPlatformChatflow: {},
// 默认已经配置过模板信息(不影响模板渠道默认勾选),当 template-bind 组件初始化获取到模板信息后,再按需设置为 false
templateConfigured: true,
connectors: {},
unions: {},
publishRecordDetail: {},
pageLoading: false,
};
export const useProjectPublishStore = create<
ProjectPublishStore & ProjectPublishAction
>()(
devtools(
(set, get) => ({
...initialStore,
reset: () => {
set(initialStore);
},
setMonetizeConfig: monetizeConfig => set({ monetizeConfig }),
setConnectorList: connectorList => {
set({ connectorList });
},
setSelectedConnectorIds: selectedConnectorIds => {
set({ selectedConnectorIds });
},
updateSelectedConnectorIds: produce => {
set(prev => ({
selectedConnectorIds: produce(prev.selectedConnectorIds),
}));
},
setShowPublishResult: showPublishResult => {
set({ showPublishResult });
},
setProjectPublishInfo: setterActionFactory<ProjectPublishStore>(set),
setProjectPublishInfoByImmer: updateFn => {
set(
{
connectorPublishConfig: immerProduce(
get().connectorPublishConfig,
updateFn,
),
},
false,
'setProjectPublishInfoByImmer',
);
},
setPublishRecordDetail: publishRecordDetail =>
set(prev => ({
publishRecordDetail: {
...prev.publishRecordDetail,
...publishRecordDetail,
},
})),
resetProjectPublishInfo: () => {
set(initialStore);
},
exportDraft: (projectId: string) => {
const {
versionNumber,
versionDescription,
selectedConnectorIds,
connectorPublishConfig,
unions,
socialPlatformChatflow,
} = get();
return {
projectId,
versionNumber,
versionDescription,
selectedConnectorIds,
unions,
sdkConfig: connectorPublishConfig[WEB_SDK_CONNECTOR_ID],
socialPlatformConfig: socialPlatformChatflow,
};
},
}),
{
enabled: IS_DEV_MODE,
name: 'botStudio.projectPublishStore',
},
),
);

View File

@@ -0,0 +1,17 @@
/*
* 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.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,32 @@
/*
* 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.
*/
/** 发布为 Web SDK 的渠道 ID */
export const WEB_SDK_CONNECTOR_ID = '999';
/** 发布到扣子 - 模板渠道的 ID */
export const TEMPLATE_CONNECTOR_ID = '10000129';
/** 发布为小程序 - 抖音小程序(自行下载)的渠道 ID */
export const MINI_PROGRAM_DOUYIN_DOWNLOAD_CONNECTOR_ID = '10000130';
/** 发布为小程序 - 微信小程序(自行下载)的渠道 ID */
export const MINI_PROGRAM_WECHAT_DOWNLOAD_CONNECTOR_ID = '10000131';
/** banner 存在时发布渠道 header 高度 */
export const DEFAULT_PUBLISH_HEADER_HEIGHT = 97;
/** banner 关闭时发布渠道 header 高度 */
export const MIN_PUBLISH_HEADER_HEIGHT = 65;
/** 渠道 tab 的 z-index */
export const CONNECTOR_TAB_BAR_Z_INDEX = 1;
/** 用户新发布时默认填写的 version_number */
export const DEFAULT_VERSION_NUMBER = 'v0.0.1';

View File

@@ -0,0 +1,40 @@
/*
* 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.
*/
import {
type PublishRecordDetail,
PublishRecordStatus,
ConnectorPublishStatus,
} from '@coze-arch/idl/intelligence_api';
/**
* 判断发布过程是否已经结束,可以停止轮询
*/
export function isPublishFinish(record: PublishRecordDetail) {
// project 打包失败/审核不通过
const projectFinish =
record.publish_status === PublishRecordStatus.PackFailed ||
record.publish_status === PublishRecordStatus.AuditNotPass;
// 所有渠道均处于 审核中/失败/成功 状态
const connectorsFinish =
record.connector_publish_result?.every(
item =>
item.connector_publish_status === ConnectorPublishStatus.Auditing ||
item.connector_publish_status === ConnectorPublishStatus.Failed ||
item.connector_publish_status === ConnectorPublishStatus.Success,
) ?? false;
return projectFinish || connectorsFinish;
}

View File

@@ -0,0 +1,50 @@
/*
* 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.
*/
import { type create } from 'zustand';
export interface SetterAction<T> {
/**
* 增量更新
*
* @example
* // store.x: { a: 1, b: 2 }
* setX({a: 2});
* // store.x: { a: 2, b: 2 }
*/
(state: Partial<T>): void;
/**
* 全量更新
*
* @example
* // store.x: { a: 1, b: 2 }
* setX({a: 2}, { replace: true });
* // store.x: { a: 2 }
*/
(state: T, config: { replace: true }): void;
}
export function setterActionFactory<T>(
set: Parameters<Parameters<typeof create<T, []>>[0]>[0],
): SetterAction<T> {
return (state: Partial<T>, config?: { replace: true }) => {
if (config?.replace) {
set(state);
} else {
set(prevState => ({ ...prevState, ...state }));
}
};
}

View File

@@ -0,0 +1,100 @@
/*
* 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.
*/
import { AxiosError } from 'axios';
import { type UploadProps } from '@coze-arch/coze-design';
import { upLoadFile } from '@coze-arch/bot-utils';
import { ProductApi } from '@coze-arch/bot-api';
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = event => {
const result = event.target?.result;
if (typeof result === 'string') {
resolve(result.slice(result.indexOf(',') + 1));
} else {
reject(new Error('readAsDataURL failed'));
}
};
fileReader.readAsDataURL(file);
});
}
export const uploadCustomRequest: UploadProps['customRequest'] = async args => {
const { fileInstance, onProgress, onSuccess, onError } = args;
try {
if (!fileInstance) {
throw new Error('no file to upload');
}
const result = await ProductApi.PublicUploadImage(
{ data: await fileToBase64(fileInstance) },
{
onUploadProgress: e =>
onProgress({ total: e.total ?? fileInstance.size, loaded: e.loaded }),
},
);
onSuccess(result.data);
} catch (e) {
if (e instanceof AxiosError) {
onError(e.request);
} else {
onError({});
}
}
};
export const uploadCustomRequestImageX: UploadProps['customRequest'] =
async args => {
const { fileInstance, onProgress, onSuccess, onError } = args;
try {
if (!fileInstance) {
throw new Error('no file to upload');
}
const uri = await upLoadFile({
biz: 'store',
file: fileInstance,
fileType: 'image',
getUploadAuthToken: async () => {
const { data } = await ProductApi.PublicGetImageUploadToken();
return {
data: {
service_id: data?.service_id || '',
upload_host: data?.upload_host || '',
auth: {
current_time: data?.current_time || '',
expired_time: data?.expired_time || '',
session_token: data?.session_token || '',
access_key_id: data?.access_key_id || '',
secret_access_key: data?.secret_access_key || '',
},
},
};
},
getProgress: progress => {
onProgress({ total: fileInstance.size, loaded: progress });
},
});
const res = await ProductApi.PublicGetImageURL({ uri });
onSuccess({ uri, url: res.data?.url });
} catch (e) {
if (e instanceof AxiosError) {
onError(e.request);
} else {
onError({});
}
}
};

View File

@@ -0,0 +1,21 @@
.web-sdk-guide {
:global {
ol li::marker {
font-weight: 500;
color: var(--coz-fg-secondary);
}
[class^="code-block-element"] {
height: 160px;
[class^="content"] {
overflow: auto;
flex-shrink: 1;
pre {
overflow: visible;
}
}
}
}
}

View File

@@ -0,0 +1,125 @@
/*
* 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.
*/
import { useState } from 'react';
import { getOpenSDKUrl } from '@coze-studio/open-env-adapter';
import { I18n } from '@coze-arch/i18n';
import { MdBoxLazy } from '@coze-arch/bot-md-box-adapter/lazy';
import { Button, Modal, Typography } from '@coze-arch/coze-design';
import s from './index.module.less';
export interface WebSdkGuideParams {
projectId: string;
workflowId: string;
token?: string;
version?: string;
}
function getWebSdkScriptTagMD({
projectId,
workflowId,
version = '<version>',
}: WebSdkGuideParams) {
return `${'```'}html
<script type="text/javascript">
var webSdkScript = document.createElement('script');
webSdkScript.src = '${getOpenSDKUrl(version)}';
document.head.appendChild(webSdkScript);
webSdkScript.onload = function () {
new CozeWebSDK.WebChatClient({
"config": {
"type": "app",
"appInfo": {
"appId": "${projectId}",
"workflowId": "${workflowId}"
}
},
"auth": {
"type": "token",
"token": "pat_********",
onRefreshToken: function () {
return "pat_********"
}
}
});
}
</script>
${'```'}`;
}
function ListIndex({ index }: { index: number }) {
return (
<div className="w-[20px] h-[20px] rounded-full inline-flex items-center justify-center mr-[4px] coz-mg-primary">
<span className="font-medium coz-fg-secondary">{index}</span>
</div>
);
}
export function useWebSdkGuideModal() {
const [visible, setVisible] = useState(false);
const [scriptTagMd, setScriptTagMd] = useState('');
const show = (params: WebSdkGuideParams) => {
const md = getWebSdkScriptTagMD(params);
setScriptTagMd(md);
setVisible(true);
};
const close = () => setVisible(false);
const node = (
<Modal
title={I18n.t('app_publish_sdk_title')}
closable
visible={visible}
width={640}
onCancel={close}
footer={
<Button onClick={close}>{I18n.t('app_publish_sdk_confirm')}</Button>
}
// z-index 需要大于 publish-status 的 Popover
zIndex={2000}
>
<Typography.Paragraph className="font-medium mb-[8px]">
<ListIndex index={1} />
{I18n.t('app_publish_sdk_step_1', {
doc_link: (
<Typography.Text
link={{
href: '/docs/developer_guides/oauth_apps',
target: '_blank',
}}
>
{I18n.t('app_publish_sdk_step_1_doc')}
</Typography.Text>
),
})}
</Typography.Paragraph>
<Typography.Paragraph className="font-medium mb-[8px]">
<ListIndex index={2} />
{I18n.t('app_publish_sdk_step_2')}
</Typography.Paragraph>
<MdBoxLazy className={s['web-sdk-guide']} markDown={scriptTagMd} />
<Typography.Paragraph className="font-medium mb-[8px]">
<ListIndex index={3} />
{I18n.t('app_publish_sdk_step_3')}
</Typography.Paragraph>
</Modal>
);
return { node, show };
}