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,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-studio/components
> Project template for react component with storybook.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,48 @@
/*
* 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 { render } from '@testing-library/react';
import { AvatarName } from '../src/avatar-name';
describe('AvatarName', () => {
it('should one image and @username', () => {
const wrapper = render(
<AvatarName
name="BotNickName"
username="BotUserName"
avatar="https://sf-coze-web-cdn.coze.com/obj/coze-web-sg/obric/coze/favicon.1970.png"
/>,
);
expect(wrapper.getAllByRole('img').length).toBe(1);
expect(wrapper.getByText(/^@BotUserName/)).toBeInTheDocument();
});
it('should two image', () => {
const wrapper = render(
<AvatarName
name="BotNickName"
username="BotUserName"
avatar="https://sf-coze-web-cdn.coze.com/obj/coze-web-sg/obric/coze/favicon.1970.png"
label={{
icon: 'https://sf-coze-web-cdn.coze.com/obj/coze-web-sg/obric/coze/favicon.1970.png',
name: 'test',
}}
/>,
);
expect(wrapper.getAllByRole('img').length).toBe(2);
});
});

View File

@@ -0,0 +1,113 @@
/*
* 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 { render, fireEvent, act } from '@testing-library/react';
import { SelectSpaceModal } from '../src/select-space-modal';
const spaces = [
{
id: 'space0',
name: 'space0',
hide_operation: false,
},
{
id: 'space1',
name: 'space1',
hide_operation: true,
},
{
id: 'space2',
name: 'space2',
hide_operation: false,
},
];
vi.mock('@coze-arch/bot-studio-store', () => ({
useSpaceStore: () => ({
space: { ...spaces[0], id: spaces[0].id },
spaces: {
bot_space_list: spaces,
},
getState: () => ({
getPersonalSpaceID: () => 'personal-space-id',
}),
}),
useSpaceList: () => ({ spaces, loading: false }),
}));
vi.mock('@coze-studio/bot-detail-store/page-runtime', () => ({
usePageRuntimeStore: () => ({
pageFrom: 'test',
}),
}));
vi.mock('@coze-studio/bot-detail-store/bot-skill', () => ({
useBotSkillStore: () => ({
hasWorkflow: false,
}),
}));
describe('SelectSpaceModal', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should render botName and spaces', () => {
const wrapper = render(<SelectSpaceModal visible botName="mockBot" />);
expect(wrapper.getByRole('dialog')).toBeInTheDocument();
expect(
wrapper.getByDisplayValue('mockBot(duplicate_rename_copy)'),
).toBeInTheDocument();
// 检查表单是否存在
expect(wrapper.getByRole('form')).toBeInTheDocument();
// 检查确定和取消按钮
expect(
wrapper.getByRole('button', { name: 'confirm' }),
).toBeInTheDocument();
expect(wrapper.getByRole('button', { name: 'cancel' })).toBeInTheDocument();
});
it('should fire events', async () => {
const mockCancel = vi.fn();
const mockConfirm = vi.fn();
render(
<SelectSpaceModal
visible
botName="mockBot"
onCancel={mockCancel}
onConfirm={mockConfirm}
/>,
);
fireEvent.click(document.body.querySelector('[aria-label="cancel"]')!);
expect(mockCancel).toHaveBeenCalled();
await act(async () => {
await fireEvent.click(
document.body.querySelector('[aria-label="confirm"]')!,
);
});
expect(mockConfirm).toHaveBeenCalledWith(
'space0',
'mockBot(duplicate_rename_copy)',
);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" fill="none">
<path
d="M6.76033 11.9922L3.91012 11.9854C3.79967 11.9851 3.71034 11.8954 3.7106 11.7849C3.71073 11.732 3.73179 11.6814 3.76918 11.644L6.52438 8.88878C6.62852 8.78464 6.79737 8.78464 6.90151 8.88878L8.17289 10.1602L11.5444 6.78861C11.6486 6.68447 11.8174 6.68447 11.9216 6.78861C11.9716 6.83862 11.9997 6.90645 11.9997 6.97717V11.8C11.9997 11.9105 11.9101 12 11.7997 12H6.81585C6.79659 12 6.77796 11.9973 6.76033 11.9922ZM2.66634 14.6667C1.93301 14.6667 1.33301 14.0667 1.33301 13.3334V2.66671C1.33301 1.93337 1.93301 1.33337 2.66634 1.33337H13.333C14.0663 1.33337 14.6663 1.93337 14.6663 2.66671V13.3334C14.6663 14.0667 14.0663 14.6667 13.333 14.6667H2.66634ZM2.66634 13.3334H13.333V2.66671H2.66634V13.3334ZM3.99967 4.00004H5.99967V6.00004H3.99967V4.00004Z"
fill="currentColor" fill-opacity="0.96" />
</svg>

After

Width:  |  Height:  |  Size: 917 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" fill="none">
<path
d="M6 8.66671H10C11.841 8.66671 14 9.95241 14 12.2667V13.3334C14 14.0667 13.4 14.6667 12.6667 14.6667H3.33333C2.6 14.6667 2 14.0667 2 13.3334V12.2667C2 9.95436 4.15905 8.66671 6 8.66671ZM12.6667 13.3334V12.2223C12.6667 10.8213 11.2473 10 10 10H6C4.77998 10 3.33333 10.7812 3.33333 12.2223V13.3334H12.6667ZM8 8.00004C6.15905 8.00004 4.66667 6.50766 4.66667 4.66671C4.66667 2.82576 6.15905 1.33337 8 1.33337C9.84095 1.33337 11.3333 2.82576 11.3333 4.66671C11.3333 6.50766 9.84095 8.00004 8 8.00004ZM8 6.66671C9.10457 6.66671 10 5.77128 10 4.66671C10 3.56214 9.10457 2.66671 8 2.66671C6.89543 2.66671 6 3.56214 6 4.66671C6 5.77128 6.89543 6.66671 8 6.66671Z"
fill="currentColor" fill-opacity="0.96" />
</svg>

After

Width:  |  Height:  |  Size: 820 B

View File

@@ -0,0 +1,8 @@
{
"operationSettings": [
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View File

@@ -0,0 +1,141 @@
{
"name": "@coze-studio/components",
"version": "0.0.1",
"description": "biz components extract from apps/bot/src/components",
"license": "Apache-2.0",
"author": "fanwenjie.fe@bytedance.com",
"maintainers": [],
"exports": {
".": "./src/index.ts",
"./coze-brand": "./src/coze-brand/index.tsx",
"./search-no-result": "./src/search-no-result/index.tsx",
"./sortable-list": "./src/sortable-list/index.tsx",
"./dnd-provider": "./src/dnd-provider/index.tsx",
"./sortable-list-hooks": "./src/sortable-list/hooks.ts",
"./generate-gif": "./src/generate-gif/index.tsx",
"./markdown-editor": "./src/markdown-editor/index.tsx",
"./parameters-popover": "./src/plugins/parameters-popover/index.tsx",
"./collapsible-role-list": "./src/social-scene/collapsible-role-list/index.tsx",
"./monetize": "./src/monetize/index.ts",
"./collapsible-icon-button": "./src/collapsible-icon-button/index.tsx",
"./table-select-all-popover": "./src/table-select-all-popover/index.tsx"
},
"main": "src/index.ts",
"typesVersions": {
"*": {
"coze-brand": [
"./src/coze-brand/index.tsx"
],
"search-no-result": [
"./src/search-no-result/index.tsx"
],
"sortable-list": [
"./src/sortable-list/index.tsx"
],
"dnd-provider": [
"./src/dnd-provider/index.tsx"
],
"sortable-list-hooks": [
"./src/sortable-list/hooks.ts"
],
"generate-gif": [
"./src/generate-gif/index.tsx"
],
"markdown-editor": [
"./src/markdown-editor/index.tsx"
],
"parameters-popover": [
"./src/plugins/parameters-popover/index.tsx"
],
"collapsible-role-list": [
"./src/social-scene/collapsible-role-list/index.tsx"
],
"monetize": [
"./src/monetize/index.ts"
],
"collapsible-icon-button": [
"./src/collapsible-icon-button/index.tsx"
],
"table-select-all-popover": [
"./src/table-select-all-popover/index.tsx"
]
}
},
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "exit 0"
},
"dependencies": {
"@blueprintjs/core": "^5.1.5",
"@coze-agent-ide/bot-input-length-limit": "workspace:*",
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/bot-error": "workspace:*",
"@coze-arch/bot-flags": "workspace:*",
"@coze-arch/bot-hooks": "workspace:*",
"@coze-arch/bot-icons": "workspace:*",
"@coze-arch/bot-md-box-adapter": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/bot-studio-store": "workspace:*",
"@coze-arch/bot-tea": "workspace:*",
"@coze-arch/bot-utils": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/logger": "workspace:*",
"@coze-arch/report-events": "workspace:*",
"@coze-arch/web-context": "workspace:*",
"@coze-common/assets": "workspace:*",
"@coze-common/biz-components": "workspace:*",
"@coze-common/websocket-manager-adapter": "workspace:*",
"@coze-data/e2e": "workspace:*",
"@coze-foundation/space-store": "workspace:*",
"@coze-studio/bot-utils": "workspace:*",
"@douyinfe/semi-icons": "^2.36.0",
"ahooks": "^3.7.8",
"axios": "^1.4.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.7",
"immer": "^10.0.3",
"lodash-es": "^4.17.21",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-markdown": "^8.0.3",
"zustand": "^4.4.7"
},
"devDependencies": {
"@coze-arch/bot-space-api": "workspace:*",
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@coze-studio/bot-detail-store": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/lodash-es": "^4.17.10",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"debug": "^4.3.4",
"nanoid": "^4.0.2",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"react-router-dom": "^6.22.0",
"stylelint": "^15.11.0",
"use-event-callback": "~0.1.0",
"utility-types": "^3.10.0",
"vite": "^4.3.9",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5",
"webpack": "~5.91.0"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0",
"utility-types": "^3.10.0"
},
"// deps": "immer@^10.0.3 为脚本自动补齐,请勿改动"
}

View File

@@ -0,0 +1,51 @@
.ctn {
cursor: pointer;
position: absolute;
right: -1px;
bottom: -1px;
box-sizing: content-box;
width: 12px;
height: 12px;
font-size: 12px;
background-color: var(--coz-fg-white, #fff);
border: 1.5px solid var(--coz-fg-white, #fff);
border-radius: 50%;
&.loading {
width: 8px;
height: 8px;
padding: 2px;
font-size: 8px;
background-color: var(--coz-mg-hglt-plus-green, #00B83E);
}
.icon {
position: relative;
top: -1px;
&.icon-generating {
color: var(--coz-fg-white, #fff);
}
&.icon-success {
color: var(--coz-mg-color-plus-emerald, #00B83E);
}
&.icon-fail {
color: var(--coz-mg-color-plus-orange, #FF811A);
}
}
:global {
.icon-icon-coz_loading.icon-icon-loading {
animation: semi-animation-rotate .6s linear infinite;
animation-fill-mode: forwards;
}
}
}

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 classNames from 'classnames';
import { DotStatus } from '@coze-studio/bot-detail-store';
import { I18n } from '@coze-arch/i18n';
import {
IconCozCheckMarkCircleFillPalette,
IconCozLoading,
IconCozWarningCircleFillPalette,
} from '@coze-arch/coze-design/icons';
import { Tooltip } from '@coze-arch/coze-design';
import s from './index.module.less';
export interface AvatarBackgroundNoticeDotProps {
status: DotStatus;
}
export const AvatarBackgroundNoticeDot: React.FC<
AvatarBackgroundNoticeDotProps
> = ({ status }) => {
if (status === DotStatus.None || status === DotStatus.Cancel) {
return null;
}
const dot = {
[DotStatus.Generating]: (
<Tooltip content={I18n.t('profilepicture_hover_generating')}>
<IconCozLoading
className={classNames(s.icon, s['icon-generating'])}
spin={true}
/>
</Tooltip>
),
[DotStatus.Success]: (
<Tooltip content={I18n.t('profilepicture_hover_generated')}>
<IconCozCheckMarkCircleFillPalette
className={classNames(s.icon, s['icon-success'])}
/>
</Tooltip>
),
[DotStatus.Fail]: (
<Tooltip content={I18n.t('profilepicture_hover_failed')}>
<IconCozWarningCircleFillPalette
className={classNames(s.icon, s['icon-fail'])}
/>
</Tooltip>
),
};
return (
<div
className={classNames(
s.ctn,
status === DotStatus.Generating ? s.loading : undefined,
)}
>
{dot[status]}
</div>
);
};

View File

@@ -0,0 +1,84 @@
.container {
flex-shrink: 0;
max-width: 100%;
height: 18px;
.avatar {
overflow: hidden;
border-radius: 0;
border-radius: 12px;
img {
vertical-align: top;
}
}
.label-icon {
cursor: pointer;
width: 12px;
height: 12px;
border-radius: 0;
}
.txt {
flex: 1;
font-size: 12px;
font-weight: 400;
line-height: 18px;
@apply coz-fg-dim;
&.name {
@apply coz-fg-secondary;
}
}
&.light {
.txt {
color: rgba(255, 255, 255, 39%);
&.name {
color: rgba(255, 255, 255, 79%);
}
}
}
&.white {
.txt {
color: #FFF;
&.name {
color: #FFF;
}
}
}
&.middle {
height: 20px;
.txt {
font-size: 14px;
line-height: 20px;
&.username {
margin-left: 4px;
}
}
}
&.large {
height: 20px;
.label-icon {
width: 14px;
height: 14px;
}
.txt {
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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 classNames from 'classnames';
import { Space, Typography, Tooltip } from '@coze-arch/coze-design';
import { Image } from '@coze-arch/bot-semi';
import AvatarDefault from '../../assets/avatar_default.png';
import s from './index.module.less';
const { Text } = Typography;
interface AvatarNameProps {
avatar?: string;
username?: string;
name?: string;
label?: {
name?: string;
icon?: string;
href?: string;
};
theme?: 'default' | 'light' | 'white';
className?: string;
nameMaxWidth?: number;
size?: 'default' | 'large' | 'small';
renderCenterSlot?: React.ReactNode;
}
export const AvatarSizeMap = {
small: 12,
default: 14,
large: 16,
};
export const AvatarName = ({
avatar,
username,
name,
label,
theme,
className,
nameMaxWidth,
size = 'default',
renderCenterSlot = null,
}: AvatarNameProps) => (
<Space
spacing={4}
className={classNames(
s.container,
theme && s[theme],
{ [s.large]: size === 'large' },
className,
)}
>
<Image
width={AvatarSizeMap[size]}
height={AvatarSizeMap[size]}
src={avatar || AvatarDefault}
fallback={<img src={AvatarDefault} width={'100%'} height={'100%'} />}
preview={false}
className={s.avatar}
/>
<Space spacing={2}>
<Text
className={classNames(s.txt, s.name)}
ellipsis={{ showTooltip: false, rows: 1 }}
style={
typeof nameMaxWidth === 'number' ? { maxWidth: nameMaxWidth } : {}
}
>
{name}
</Text>
{label?.icon ? (
<Tooltip
showArrow
content={label?.name}
position={'top'}
trigger={label?.name ? 'hover' : 'custom'}
>
<img
src={label?.icon}
className={s['label-icon']}
tabIndex={-1}
onMouseDown={event => {
if (label?.href) {
event?.preventDefault();
event?.stopPropagation();
window.open(label.href, '_blank');
}
}}
/>
</Tooltip>
) : null}
</Space>
{renderCenterSlot}
{username ? (
<Text
className={classNames(s.txt, s.username)}
ellipsis={{ showTooltip: false, rows: 1 }}
>
@{username}
</Text>
) : null}
</Space>
);

View File

@@ -0,0 +1,30 @@
.popover-content {
overflow: hidden;
box-sizing: border-box;
max-width: 400px;
}
.popover-card-title {
margin-bottom: 24px;
font-size: 16px;
font-weight: 600;
line-height: 22px;
color: #1C1D24;
word-wrap: break-word;
}
.popover-card-img {
max-width: 360px;
border: 1px solid #E5E5E5;
border-radius: 8px;
img {
width: 100%;
height: 100%;
}
}
.popover-card-icon {
color: rgba(29, 28, 35, 35%)
}

View File

@@ -0,0 +1,75 @@
/*
* 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, useCallback } from 'react';
import { get } from 'lodash-es';
import cls from 'classnames';
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
import { type ImageProps } from '@coze-arch/bot-semi/Image';
import { Popover, Image } from '@coze-arch/bot-semi';
import { IconGroupCardOutlined } from '@coze-arch/bot-icons';
import s from './index.module.less';
interface CardThumbnailPopoverProps extends PopoverProps {
title?: string;
url?: string;
className?: string;
imgProps?: ImageProps;
}
export const CardThumbnailPopover: React.FC<
PropsWithChildren<CardThumbnailPopoverProps>
> = ({ children, url, title = '卡片预览', className, imgProps, ...props }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popoverRef = useRef<any>();
const onImageLoad = useCallback(() => {
const calcPosition = get(
popoverRef.current,
'tooltipRef.current.foundation.calcPosition',
);
if (typeof calcPosition === 'function') {
calcPosition?.();
}
}, []);
return (
<Popover
position="top"
showArrow
ref={popoverRef}
content={
<div className={s['popover-content']}>
<div className={s['popover-card-title']}>{title}</div>
{url && (
<div className={s['popover-card-img']}>
<Image src={url} {...imgProps} onLoad={onImageLoad} />
</div>
)}
</div>
}
{...props}
>
{children || (
<IconGroupCardOutlined
className={cls(className, s['popover-card-icon'])}
/>
)}
</Popover>
);
};

View File

@@ -0,0 +1,3 @@
.carousel-item {
flex: 0 0 auto;
}

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 React from 'react';
import cls from 'classnames';
import styles from './index.module.less';
export interface CarouselItemProps {
className?: string;
children: React.ReactNode;
}
export const CarouselItem: React.FC<CarouselItemProps> = props => {
const { children, className } = props;
return (
<div className={cls(styles['carousel-item'], className, 'carousel-item')}>
{children}
</div>
);
};

View File

@@ -0,0 +1,90 @@
.carousel {
position: relative;
.arrow-container {
&::before {
content: '';
position: absolute;
z-index: 1;
top: 0;
width: 52px;
height: 100%;
}
&.left {
&::before {
left: 0;
background: linear-gradient(90deg,
var(--coz-bg-max) 7.46%,
rgba(255, 255, 255, 0%) 100%);
}
}
&.right {
&::before {
right: 0;
background: linear-gradient(270deg,
var(--coz-bg-max) 7.46%,
rgba(255, 255, 255, 0%) 100%);
}
}
}
.arrow {
cursor: pointer;
position: absolute;
z-index: 999;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 20px;
background: var(--coz-mg-primary);
border-radius: 2px;
box-shadow: 0 2px 16px 0 #0000001a;
svg {
width: 16px;
height: 16px;
}
&.no-border {
background: transparent;
border: none;
box-shadow: none;
}
}
.left-arrow {
left: 0;
}
.right-arrow {
right: 0;
}
&-content {
overflow: auto;
flex: 1 0 auto;
&::-webkit-scrollbar {
display: none;
}
}
&-row {
display: flex;
flex-wrap: nowrap;
}
}

View File

@@ -0,0 +1,243 @@
/*
* 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, { useState, useEffect, useRef } from 'react';
import { chunk } from 'lodash-es';
import cls from 'classnames';
import {
IconCozArrowRight,
IconCozArrowLeft,
} from '@coze-arch/coze-design/icons';
import { CarouselItem } from './carousel-item';
import styles from './index.module.less';
export interface CarouselProps {
/** 元素布局行数默认为1 */
rows?: number;
/** 元素布局列数,默认为均分数组 */
column?: number;
/** 每次点击箭头滚动的百分比0~1. 默认值为0.5 */
scrollStep?: number;
/** 滚动回调 */
onScroll?: () => void;
/** 箭头是否显示边框 */
enableArrowBorder?: boolean;
/** 箭头是否显示阴影渐变 */
enableArrowShalldow?: boolean;
/** 子元素样式 */
itemClassName?: string;
/** 左箭头样式 */
leftArrowClassName?: string;
/** 右箭头样式 */
rightArrowClassName?: string;
children: React.ReactNode;
}
interface ArrowProps {
className?: string;
enableArrowBorder?: boolean;
enableArrowShalldow?: boolean;
onClick: (e: React.MouseEvent) => void;
}
const LeftArrow = ({
enableArrowBorder,
enableArrowShalldow,
className,
onClick,
}: ArrowProps) => (
<div
className={cls(
styles['arrow-container'],
{ [styles.left]: enableArrowShalldow },
'arrow-container-left',
)}
>
<div
onClick={onClick}
className={cls(
className,
styles['left-arrow'],
styles.arrow,
'left-arrow',
{
[styles['no-border']]: !enableArrowBorder,
},
)}
>
<IconCozArrowLeft />
</div>
</div>
);
const RightArrow = ({
enableArrowBorder,
enableArrowShalldow,
className,
onClick,
}: ArrowProps) => (
<div
className={cls(
styles['arrow-container'],
{ [styles.right]: enableArrowShalldow },
'arrow-container-right',
)}
>
<div
onClick={onClick}
className={cls(
className,
styles['right-arrow'],
styles.arrow,
'right-arrow',
{
[styles['no-border']]: !enableArrowBorder,
},
)}
>
<IconCozArrowRight />
</div>
</div>
);
export const Carousel: React.FC<CarouselProps> = ({
rows = 1,
column,
itemClassName = '',
leftArrowClassName = '',
rightArrowClassName = '',
children,
enableArrowShalldow = true,
scrollStep = 0.5,
enableArrowBorder = true,
onScroll,
}) => {
const itemsContainerRef = useRef<HTMLDivElement>(null);
const [leftArrowVisible, setLeftArrowVisible] = useState(false);
const [rightArrowVisible, setRightArrowVisible] = useState(false);
if (!children) {
return null;
}
const carouselItems = React.Children.map(
children,
(child: React.ReactNode, idx: number) => (
<CarouselItem className={itemClassName} key={idx}>
{child}
</CarouselItem>
),
);
const chunkedCarouselItems: React.ReactNode[][] = chunk(
carouselItems,
column ?? Math.ceil((carouselItems?.length || 0) / rows),
);
const rowItems = Array.from(Array(rows).fill(null)).map((_row, idx) => (
<div className={cls(styles['carousel-row'], 'carousel-row')} key={idx}>
{chunkedCarouselItems[idx]}
</div>
));
const handleScrollLeft = () => {
if (
itemsContainerRef?.current?.scrollLeft !== undefined &&
itemsContainerRef?.current?.clientWidth
) {
// 部分浏览器不支持 scrollTo 方法
itemsContainerRef.current.scrollTo?.({
left: Math.max(
itemsContainerRef.current.scrollLeft -
itemsContainerRef.current.clientWidth * scrollStep,
0,
),
behavior: 'smooth',
});
}
};
const handleScrollRight = () => {
const containWidth = itemsContainerRef?.current?.clientWidth ?? 0;
if (itemsContainerRef?.current?.scrollLeft !== undefined && containWidth) {
const scrollLeftMax =
(itemsContainerRef?.current?.scrollWidth ?? 0) -
(itemsContainerRef?.current?.clientWidth ?? 0);
itemsContainerRef.current.scrollTo?.({
left: Math.min(
itemsContainerRef.current.scrollLeft + containWidth * scrollStep,
scrollLeftMax,
),
behavior: 'smooth',
});
}
};
// eslint-disable-next-line react-hooks/rules-of-hooks -- linter-disable-autofix
useEffect(() => {
const updateArrowVisible = () => {
const scrollLeft = Math.ceil(itemsContainerRef?.current?.scrollLeft ?? 0);
const scrollRight =
(itemsContainerRef?.current?.scrollWidth ?? 0) -
(itemsContainerRef?.current?.clientWidth ?? 0) -
scrollLeft;
const shouldShowArrowLeft = scrollLeft > 0;
// 极端场景下存在 1px 偏差
const shouldShowArrowRight = Math.abs(scrollRight) > 2;
setLeftArrowVisible(shouldShowArrowLeft);
setRightArrowVisible(shouldShowArrowRight);
};
const scrollEvent = () => {
onScroll?.();
updateArrowVisible();
};
// 初始化时判读一次是否显示箭头
updateArrowVisible();
itemsContainerRef?.current?.addEventListener('scroll', scrollEvent);
window?.addEventListener('resize', updateArrowVisible);
return () => {
itemsContainerRef?.current?.removeEventListener('scroll', scrollEvent);
window?.removeEventListener('resize', updateArrowVisible);
};
}, [children]);
return (
<div className={cls(styles.carousel, 'carousel')}>
{leftArrowVisible ? (
<LeftArrow
onClick={handleScrollLeft}
enableArrowBorder={enableArrowBorder}
enableArrowShalldow={enableArrowShalldow}
className={leftArrowClassName}
/>
) : null}
<div
className={cls(styles['carousel-content'], 'carousel-content')}
ref={itemsContainerRef}
>
{rowItems}
</div>
{rightArrowVisible ? (
<RightArrow
onClick={handleScrollRight}
enableArrowBorder={enableArrowBorder}
enableArrowShalldow={enableArrowShalldow}
className={rightArrowClassName}
/>
) : null}
</div>
);
};

View File

@@ -0,0 +1,90 @@
/*
* 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 RefObject,
createContext,
useContext,
useEffect,
type SetStateAction,
type Dispatch,
useState,
useMemo,
} from 'react';
import { noop, omit } from 'lodash-es';
import { useSize } from 'ahooks';
interface TConfigItem {
width?: number;
}
type ContextItems = Record<symbol, TConfigItem>;
interface CollapsibleIconButtonContextValue {
showText: boolean;
setItems: Dispatch<SetStateAction<ContextItems | undefined>>;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- 这是 context
export const CollapsibleIconButtonContext =
createContext<CollapsibleIconButtonContextValue>({
showText: true,
setItems: noop,
});
export const useItem = (key: symbol, ref: RefObject<HTMLElement>) => {
const { showText, setItems } = useContext(CollapsibleIconButtonContext);
const size = useSize(ref);
useEffect(() => {
setItems(items => ({
...items,
[key]: { width: size?.width ?? 0 },
}));
}, [size?.width]);
// 组件销毁后移除
useEffect(
() => () => {
setItems(items => omit(items, key));
},
[],
);
return showText;
};
export const useWrapper = (ref: RefObject<HTMLElement>, gap = 0) => {
const [items, setItems] = useState<ContextItems>();
const size = useSize(ref);
const totalWidth = Object.getOwnPropertySymbols(items || {}).reduce<number>(
(res, key, index) =>
res + (items?.[key]?.width ?? 0) + (index > 0 ? gap : 0),
0,
);
const showText = !!size?.width && size.width >= totalWidth;
const contextValue = useMemo<CollapsibleIconButtonContextValue>(
() => ({
showText,
setItems,
}),
[showText],
);
return contextValue;
};

View File

@@ -0,0 +1,130 @@
/*
* 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,
type FC,
useRef,
forwardRef,
type ReactNode,
} from 'react';
import { omit } from 'lodash-es';
import {
Button,
type ButtonProps,
IconButton,
Tooltip,
} from '@coze-arch/coze-design';
import { CollapsibleIconButtonContext, useWrapper, useItem } from './context';
/** 能让 Group 内的所有 CollapsibleIconButton 根据空余宽度自动展开(露出文案)收起(隐藏文案只剩图标) */
export const CollapsibleIconButtonGroup: FC<
PropsWithChildren<{
/** @default 12 */
gap?: number;
}>
> = ({ children, gap = 12 }) => {
const wrapperDomRef = useRef<HTMLDivElement>(null);
const contextValue = useWrapper(wrapperDomRef, gap);
return (
<div
ref={wrapperDomRef}
className="flex items-center justify-end flex-1 overflow-hidden"
style={{ gap }}
>
<CollapsibleIconButtonContext.Provider value={contextValue}>
{children}
</CollapsibleIconButtonContext.Provider>
</div>
);
};
export const CollapsibleIconButton = forwardRef<
HTMLSpanElement,
{
itemKey: symbol;
text: string;
} & ButtonProps
>(({ itemKey, text, ...rest }, ref) => {
const contentRef = useRef<HTMLDivElement>(null);
const showText = useItem(itemKey, contentRef);
return (
<span ref={ref}>
{/* 不可见时渲染到屏幕外侧,用于获取宽度 */}
<div className={showText ? '' : 'fixed left-[-999px]'} ref={contentRef}>
<Button
size="default"
color="secondary"
// 不可见时不附带 testid避免对 E2E 产生影响
{...(showText ? rest : omit(rest, 'data-testid'))}
>
{text}
</Button>
</div>
{!showText && (
<Tooltip content={text}>
<IconButton size="default" color="secondary" {...rest} />
</Tooltip>
)}
</span>
);
});
/** 更为通用的版本 */
export const Collapsible = forwardRef<
HTMLSpanElement,
{
itemKey: symbol;
collapsedContent: ReactNode;
fullContent: ReactNode;
collapsedTooltip?: ReactNode;
}
>(({ itemKey, fullContent, collapsedContent, collapsedTooltip }, ref) => {
const contentRef = useRef<HTMLDivElement>(null);
const showFull = useItem(itemKey, contentRef);
return (
<span ref={ref}>
{/* 不可见时渲染到屏幕外侧,用于获取宽度 */}
<div className={showFull ? '' : 'fixed left-[-999px]'} ref={contentRef}>
{fullContent}
</div>
{!showFull && (
<Tooltip
trigger={collapsedTooltip ? 'hover' : 'custom'}
content={collapsedTooltip}
>
<span>{collapsedContent}</span>
</Tooltip>
)}
</span>
);
});
/** 不会折叠,但参与宽度计算的元素 */
export function PlaceholderContainer({
itemKey,
children,
}: PropsWithChildren<{ itemKey: symbol }>) {
const ref = useRef<HTMLSpanElement>(null);
useItem(itemKey, ref);
return <span ref={ref}>{children}</span>;
}

View File

@@ -0,0 +1,8 @@
.coze-brand {
display: flex;
height: 32px;
& > svg {
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,71 @@
/*
* 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 from 'react';
import classNames from 'classnames';
import {
IconBrandCnWhiteRow,
IconBrandCnBlackRow,
IconBrandEnBlackRow,
} from '@coze-arch/bot-icons';
import { useNavigate } from 'react-router-dom';
import styles from './index.module.less';
export interface CozeBrandProps {
isOversea: boolean;
isWhite?: boolean;
className?: string;
style?: React.CSSProperties;
}
export function CozeBrand({
isOversea,
isWhite,
className,
style,
}: CozeBrandProps) {
const navigate = useNavigate();
const navBack = () => {
navigate('/');
};
if (isOversea) {
return (
<IconBrandEnBlackRow
onClick={navBack}
className={classNames(styles['coze-brand'], className)}
style={style}
/>
);
}
if (isWhite) {
return (
<IconBrandCnWhiteRow
onClick={navBack}
className={classNames(styles['coze-brand'], className)}
style={style}
/>
);
}
return (
<IconBrandCnBlackRow
onClick={navBack}
className={classNames(styles['coze-brand'], className)}
style={style}
/>
);
}

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 { HTML5Backend } from 'react-dnd-html5-backend';
import { DndProvider as Provider } from 'react-dnd';
import { type ReactNode, createContext, useContext } from 'react';
const DnDContext = createContext<{
isInProvider: boolean;
}>({
isInProvider: false,
});
export const DndProvider = ({ children }: { children: ReactNode }) => {
const context = useContext(DnDContext);
return (
<DnDContext.Provider
value={{
isInProvider: true,
}}
>
{context.isInProvider ? (
children
) : (
<Provider backend={HTML5Backend} context={window}>
{children}
</Provider>
)}
</DnDContext.Provider>
);
};

View File

@@ -0,0 +1,338 @@
/*
* 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 FC, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useRequest } from 'ahooks';
import { usePageRuntimeStore } from '@coze-studio/bot-detail-store/page-runtime';
import { useBotInfoStore } from '@coze-studio/bot-detail-store/bot-info';
import {
REPORT_EVENTS as ReportEventNames,
createReportEvent,
} from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import { openNewWindow, getParamsFromQuery } from '@coze-arch/bot-utils';
import { BotPageFromEnum } from '@coze-arch/bot-typings/common';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import { useSpaceList, useSpaceStore } from '@coze-arch/bot-studio-store';
import { SpaceApi } from '@coze-arch/bot-space-api';
import { type Size } from '@coze-arch/bot-semi/Button';
import { UIButton, Toast } from '@coze-arch/bot-semi';
import { CustomError } from '@coze-arch/bot-error';
import {
ProductEntityType,
type ProductMetaInfo,
} from '@coze-arch/bot-api/product_api';
import { DeveloperApi, PlaygroundApi, ProductApi } from '@coze-arch/bot-api';
import { SelectSpaceModal } from '../select-space-modal';
const botDuplicateEvent = createReportEvent({
eventName: ReportEventNames.botDuplicate,
});
interface DuplicateBotProps {
storeCategory?: ProductMetaInfo['category'];
botName?: string;
botID?: string;
isDisabled?: boolean;
btnTxt?: string;
pageFrom?: BotPageFromEnum;
version?: string;
buttonSize?: Size;
enableCozeDesign?: boolean;
/**
* cozeDesign 的情况下才生效
*/
isBlock?: boolean;
eventCallbacks?: Partial<{
clickButton: () => void;
duplicateFinished: ({ newBotId }: { newBotId: string }) => void;
}>;
}
// eslint-disable-next-line -- Needs to be refactored
export const DuplicateBot: FC<DuplicateBotProps> = ({
storeCategory,
botName,
botID,
isDisabled,
btnTxt,
pageFrom,
version,
buttonSize,
enableCozeDesign,
isBlock,
eventCallbacks,
}) => {
const {
space: { hide_operation, id: spaceID },
getPersonalSpaceID,
} = useSpaceStore();
const { spaces: list = [] } = useSpaceList();
const { pageFromFromStore } = usePageRuntimeStore(
useShallow(state => ({
pageFromFromStore: state.pageFrom,
})),
);
const { botIdFromStore, botNameFromStore } = useBotInfoStore(
useShallow(state => ({
botIdFromStore: state.botId,
botNameFromStore: state.name,
})),
);
const [showSpaceModal, setShowSpaceModal] = useState(false);
const { runAsync: copyAndOpenBot } = useRequest(
// eslint-disable-next-line complexity
async (targetSpaceId?: string, name?: string): Promise<string> => {
botDuplicateEvent.start();
let resp: {
code?: string | number;
msg?: string;
data?: { bot_id?: string };
};
if (
(pageFrom === BotPageFromEnum.Store ||
pageFrom === BotPageFromEnum.Template) &&
botID &&
version &&
targetSpaceId
) {
if (pageFrom === BotPageFromEnum.Template) {
const {
code,
message,
data: { new_entity_id: newBotId } = {},
} = await ProductApi.PublicDuplicateProduct({
product_id: botID,
entity_type: ProductEntityType.BotTemplate,
space_id: targetSpaceId,
name: name ?? '',
});
resp = {
code,
msg: message,
data: {
bot_id: newBotId,
},
};
} else {
resp = await PlaygroundApi.DuplicateBotVersionToSpace({
bot_id: botID,
version,
target_space_id: targetSpaceId,
name: name ?? '',
});
}
//复制完成,关闭空间弹窗
setShowSpaceModal(false);
} else if (pageFromFromStore === BotPageFromEnum.Explore) {
//explore时可以复制到某个空间下
resp = await DeveloperApi.DuplicateBotToSpace({
draft_bot_id: botIdFromStore,
target_space_id: targetSpaceId || '',
name,
});
//复制完成,关闭空间弹窗
setShowSpaceModal(false);
} else {
resp = await SpaceApi.DuplicateDraftBot({
bot_id: botIdFromStore,
});
}
eventCallbacks?.duplicateFinished?.({
newBotId: resp.data?.bot_id ?? '',
});
const botTeaparams = {
bot_type:
pageFromFromStore === BotPageFromEnum.Explore ||
pageFromFromStore === BotPageFromEnum.Store
? 'store_bot'
: 'team_bot',
bot_id: botID ?? botIdFromStore,
workspace_type:
pageFromFromStore === BotPageFromEnum.Store
? 'store_workspace'
: getPersonalSpaceID() === targetSpaceId
? 'personal_workspace'
: 'team_workspace',
bot_name: botName ?? botNameFromStore ?? '',
};
if (resp.code === 0) {
sendTeaEvent(EVENT_NAMES.bot_duplicate_result, {
...botTeaparams,
result: 'success',
});
} else {
sendTeaEvent(EVENT_NAMES.bot_duplicate_result, {
...botTeaparams,
result: 'failed',
error_code: resp.code,
error_message: resp.msg,
});
}
const respData = resp.data;
if (!respData) {
throw new CustomError(
ReportEventNames.botDuplicate,
I18n.t('bot_copy_info_error'),
);
}
const { bot_id: botId } = respData;
if (!botID && !botIdFromStore) {
throw new CustomError(
ReportEventNames.botDuplicate,
I18n.t('bot_copy_id_error'),
);
}
const url = `${location.origin}/space/${
targetSpaceId || spaceID
}/bot/${botId}?from=copy`;
return url;
},
{
manual: true,
onSuccess: () => {
botDuplicateEvent.success();
},
onError: e => {
botDuplicateEvent.error({ error: e, reason: e.message });
setShowSpaceModal(false);
},
},
);
const beforeCopyClick = () => {
eventCallbacks?.clickButton?.();
sendTeaEvent(EVENT_NAMES.bot_duplicate_click, {
bot_type:
pageFromFromStore === BotPageFromEnum.Bot ? 'team_bot' : 'store_bot',
});
if (pageFrom === BotPageFromEnum.Store) {
sendTeaEvent(EVENT_NAMES.bot_duplicate_click_front, {
bot_type: 'store_bot',
bot_id: botID,
bot_name: botName,
category_id: storeCategory?.id,
category_name: storeCategory?.name,
source: 'bots_store',
from: getParamsFromQuery({ key: 'from' }),
});
setShowSpaceModal(true);
} else if (pageFromFromStore === BotPageFromEnum.Explore) {
sendTeaEvent(EVENT_NAMES.bot_duplicate_click_front, {
bot_type: 'store_bot',
bot_id: botNameFromStore,
bot_name: botNameFromStore,
source: 'explore_bot_detailpage',
from: 'explore_card',
});
sendTeaEvent(EVENT_NAMES.click_bot_duplicate, {
bot_id: botIdFromStore,
bot_name: botNameFromStore,
from: 'explore_card',
source: 'explore_bot_detailpage',
});
//探索页面来源team>1时选择copy 空间否则copy到个人空间
if (list.length === 1) {
openNewWindow(() => copyAndOpenBot(list?.[0].id));
} else {
setShowSpaceModal(true);
}
} else if (pageFrom === BotPageFromEnum.Template) {
//探索页面来源team>1时选择copy 空间否则copy到个人空间
if (list.length === 1) {
openNewWindow(() => copyAndOpenBot(list?.[0].id));
} else {
setShowSpaceModal(true);
}
} else {
sendTeaEvent(EVENT_NAMES.bot_duplicate_click_front, {
bot_type: 'team_bot',
bot_id: botIdFromStore,
bot_name: botNameFromStore,
source: 'bots_detailpage',
from: 'bots_card',
});
// bot页面来源若有操作权限直接copy到当前空间下
if (hide_operation) {
Toast.warning('Bot in public space cannot duplicate');
return;
} else {
openNewWindow(copyAndOpenBot);
}
}
};
return (
<>
{enableCozeDesign ? (
<Button
type="primary"
theme="solid"
size={buttonSize}
onClick={beforeCopyClick}
disabled={isDisabled}
block={isBlock}
>
{btnTxt || I18n.t('duplicate')}
</Button>
) : (
<UIButton
type="primary"
theme="solid"
size={buttonSize}
onClick={beforeCopyClick}
disabled={isDisabled}
>
{btnTxt || I18n.t('duplicate')}
</UIButton>
)}
{/* 选择空间弹窗 */}
<SelectSpaceModal
botName={botName ?? botNameFromStore}
visible={showSpaceModal}
onCancel={() => {
setShowSpaceModal(false);
}}
onConfirm={(id, name) => {
sendTeaEvent(EVENT_NAMES.click_create_bot_confirm, {
click: 'success',
create_type: 'duplicate',
from: 'explore_card',
source: 'explore_bot_detailpage',
});
openNewWindow(() => copyAndOpenBot(id, name));
}}
/>
</>
);
};

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.
*/
const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(
navigator.userAgent,
);
export const SHORTCUTS = {
CTRL: isMacOS ? '⌘' : 'Ctrl',
SHIFT: isMacOS ? '⇧' : '⇧',
ALT: isMacOS ? '⌥' : 'Alt',
};

View File

@@ -0,0 +1,23 @@
.item {
display: flex;
font-size: 14px;
color: #1D1C23;
}
.itemTitle {
flex: 1;
display: flex;
align-items: center;
}
.itemContent {
display: flex;
align-items: center;
}
.close {
position: absolute;
top: 24px;
right: 24px;
cursor: pointer;
}

View File

@@ -0,0 +1,185 @@
/*
* 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, { type ReactNode } from 'react';
import { I18n } from '@coze-arch/i18n';
import { getIsIPad } from '@coze-arch/bot-utils';
import { Divider, Typography, Tag } from '@coze-arch/bot-semi';
import { IconCloseNoCycle } from '@coze-arch/bot-icons';
import { SHORTCUTS } from './constants';
import s from './index.module.less';
interface ShortcutItemProps {
title: ReactNode;
children?: ReactNode;
}
function ShortcutItem({ title, children }: ShortcutItemProps) {
return (
<div className={s.item}>
<div className={s.itemTitle}>{title}</div>
<div className={s.itemContent}>{children}</div>
</div>
);
}
function DividerWithMargin() {
return <Divider style={{ margin: '12px 0' }} />;
}
function ShortcutTag({ children }: { children: ReactNode }) {
return (
<Tag
style={{
margin: '0 4px',
height: 24,
padding: '0 8px',
fontSize: 14,
backgroundColor: '#f0f0f5',
}}
>
{children}
</Tag>
);
}
interface FlowShortcutsHelpProps {
closable?: boolean;
onClose?: () => void;
isAgentFlow?: boolean;
}
const isIPad = getIsIPad();
function FlowShortcutsHelp(props: FlowShortcutsHelpProps) {
const { closable = false, onClose, isAgentFlow = false } = props;
return (
<>
{closable ? (
<div className={s.close} onClick={() => onClose?.()}>
<IconCloseNoCycle />
</div>
) : null}
<Typography.Title heading={5} style={{ marginBottom: 16 }}>
{I18n.t('flowcanvas_shortcuts_shortcuts')}
</Typography.Title>
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_move_canvas')}>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_space')}</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem
title={
<>
{I18n.t('flowcanvas_shortcuts_multiple_select')}/
{I18n.t('flowcanvas_shortcuts_multiple_deselect')}
</>
}
>
<ShortcutTag>
{SHORTCUTS.CTRL}/{SHORTCUTS.SHIFT}
</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_click')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_zoom_in')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>+</ShortcutTag>
<span style={{ margin: '0 6px' }}>
{I18n.t('flowcanvas_shortcuts_or')}
</span>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_scroll')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_zoom_out')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>-</ShortcutTag>
<span style={{ margin: '0 6px' }}>
{I18n.t('flowcanvas_shortcuts_or')}
</span>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_scroll')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
{isIPad || isAgentFlow ? null : (
<>
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_duplicate')}>
<ShortcutTag>{SHORTCUTS.ALT}</ShortcutTag>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_drag')}</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
</>
)}
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_copy')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>C</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_paste')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>V</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<UndoRedoShortcuts />
<ShortcutItem title={I18n.t('flowcanvas_shortcuts_delete')}>
<ShortcutTag>{I18n.t('flowcanvas_shortcuts_backspace')}</ShortcutTag>
</ShortcutItem>
</>
);
}
function UndoRedoShortcuts() {
return (
<>
<ShortcutItem title={I18n.t('workflow_detail_undo_tooltip')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>Z</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
<ShortcutItem title={I18n.t('workflow_detail_redo_tooltip')}>
<ShortcutTag>{SHORTCUTS.CTRL}</ShortcutTag>
<ShortcutTag>{SHORTCUTS.SHIFT}</ShortcutTag>
<ShortcutTag>Z</ShortcutTag>
</ShortcutItem>
<DividerWithMargin />
</>
);
}
export { FlowShortcutsHelp };

View File

@@ -0,0 +1,35 @@
.btn {
min-width: 80px;
font-size: 14px;
line-height: 20px;
&.grey {
background: linear-gradient(90deg, rgba(var(--coze-brand-1), var(--coze-brand-1-alpha)) 0%, rgba(var(--coze-purple-1), var(--coze-purple-1-alpha)) 100%) !important;
:global {
.coz-ai-button-icon {
color: rgba(var(--coze-brand-3), var(--coze-brand-3-alpha)) !important;
}
.coz-ai-button-text {
background: linear-gradient(90deg, rgba(var(--coze-brand-3), var(--coze-brand-3-alpha)) 0%, rgba(var(--coze-purple-3), var(--coze-purple-3-alpha)) 100%) !important;
background-clip: text !important;
-webkit-text-fill-color: transparent !important;
}
}
}
.generate-icon {
position: relative;
top: 2px;
margin-right: 8px;
}
:global {
.icon-icon-coz_loading {
position: relative;
top: 1px;
}
}
}

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 { type CSSProperties, useState, useEffect } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { AIButton, Tooltip } from '@coze-arch/coze-design';
import { PicType } from '@coze-arch/bot-api/playground_api';
import { PlaygroundApi } from '@coze-arch/bot-api';
import s from './index.module.less';
export interface GenerateButtonProps {
scene?: 'gif' | 'static_image';
loading?: boolean;
disabled?: boolean;
size?: 'small' | 'default' | 'large';
text?: string;
cancelText?: string;
tooltipText?: string;
className?: string;
transparent?: boolean;
style?: CSSProperties;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
onCancel?: React.MouseEventHandler<HTMLButtonElement>;
}
export const GenerateButton: React.FC<GenerateButtonProps> = ({
scene,
loading = false,
disabled: outerDisabled = false,
tooltipText: outerTooltipText,
className,
transparent = false,
style,
onCancel,
onClick,
size,
text,
cancelText,
}) => {
const [exceedImageGenCountLimit, setExceedImageGenCountLimit] =
useState(false);
const [hovering, setHovering] = useState(false);
const handleClick = loading ? onCancel : onClick;
const innerLoading = hovering ? false : loading;
const disabled = exceedImageGenCountLimit || outerDisabled;
const exceedLimitTooltipText = {
gif: I18n.t('profilepicture_popup_toast_daymax_gif'),
static_image: I18n.t('profilepicture_popup_toast_daymax_image'),
};
const tooltipText =
exceedImageGenCountLimit && scene
? exceedLimitTooltipText[scene]
: outerTooltipText;
const getGenPicTimes = async () => {
if (!scene) {
return;
}
try {
const { data } = await PlaygroundApi.GetGenPicTimes();
if (data?.infos) {
let gifCount = 0;
let staticImageCount = 0;
data.infos.forEach(({ type, times }) => {
if (
[PicType.IconGif, PicType.BackgroundGif].includes(type as PicType)
) {
gifCount += times || 0;
} else if (
[PicType.IconStatic, PicType.BackgroundStatic].includes(
type as PicType,
)
) {
staticImageCount += times || 0;
}
});
if (
(scene === 'gif' && gifCount >= 10) ||
(scene === 'static_image' && staticImageCount >= 20)
) {
// 达到上限,禁用按钮
setExceedImageGenCountLimit(true);
}
}
// eslint-disable-next-line @coze-arch/no-empty-catch
} catch (error) {
// empty
}
};
useEffect(() => {
// 获取图片限制每天限制10个gif20个静态图根据scene来判断是否达到上限
if (!loading) {
getGenPicTimes();
}
}, [loading]);
const button = (
<AIButton
color="aihglt"
className={classNames(s.btn, {
[s.grey]: disabled || (loading && !hovering),
})}
style={
disabled
? { cursor: 'not-allowed', ...style }
: { cursor: 'pointer', ...style }
}
loading={innerLoading}
size={size}
onClick={disabled ? undefined : handleClick}
>
{hovering && loading
? cancelText || I18n.t('profilepicture_popup_cancel')
: text || I18n.t('profilepicture_popup_generate')}
</AIButton>
);
return (
<div
className={classNames(
className,
'pointer-events-auto inline-block leading-none rounded-lg',
{
'coz-bg-max': !transparent,
},
)}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
{tooltipText ? <Tooltip content={tooltipText}>{button}</Tooltip> : button}
</div>
);
};

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 classNames from 'classnames';
import { withSlardarIdButton } from '@coze-studio/bot-utils';
import { I18n } from '@coze-arch/i18n';
import { IconCozImage, IconCozUpload } from '@coze-arch/coze-design/icons';
import { Button, Image, Popover, Toast, Upload } from '@coze-arch/coze-design';
import { FileBizType } from '@coze-arch/bot-api/developer_api';
import { customUploadRequest } from '@coze-common/biz-components/picture-upload';
import { type ImageItem, ImageList } from '../image-list';
import s from './index.module.less';
interface ImagePickerProps {
setImage: (image: ImageItem) => void;
imageList: ImageItem[];
url?: string;
}
export default function ImagePicker(props: ImagePickerProps) {
const { setImage, imageList, url } = props;
const [uploadBtnLoading, setUploadBtnLoading] = useState(false);
return (
<div className={s['image-ctn']}>
<Popover
position="right"
trigger="hover"
keepDOM
content={
<div className={s['upload-panel']}>
<ImageList
data={imageList || []}
className={s['image-list']}
imageItemClassName={s['image-item']}
showDeleteIcon={false}
showSelectedIcon={false}
onClick={({ item }) => {
setImage(item);
}}
/>
<Upload
action=""
limit={1}
customRequest={options => {
customUploadRequest({
...options,
fileBizType: FileBizType.BIZ_BOT_ICON,
onSuccess(data) {
setImage({
img_info: {
tar_uri: data?.upload_uri || '',
tar_url: data?.upload_url || '',
},
});
},
beforeUploadCustom() {
setUploadBtnLoading(true);
},
afterUploadCustom() {
setUploadBtnLoading(false);
},
});
}}
fileList={[]}
accept=".jpeg,.jpg,.png"
showReplace={false}
showUploadList={false}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
maxSize={2 * 1024}
onSizeError={() => {
Toast.error({
// starling 切换
content: withSlardarIdButton(
I18n.t(
'dataset_upload_image_warning',
{},
'Please upload an image less than 2MB',
),
),
showClose: false,
});
}}
>
<Button
color="primary"
size="small"
loading={uploadBtnLoading}
icon={<IconCozUpload />}
>
{I18n.t('creat_popup_profilepicture_upload')}
</Button>
</Upload>
</div>
}
>
{url ? (
<div>
<Image src={url} className={s['show-image']} preview={false} />
</div>
) : (
<div className={classNames(s['empty-image'])}>
<IconCozImage />
</div>
)}
</Popover>
</div>
);
}

View File

@@ -0,0 +1,84 @@
.ctn {
display: flex;
width: 100%;
.image-ctn {
line-height: 0;
.show-image img,
.empty-image {
width: 108px;
height: 108px;
object-fit: cover;
border-radius: 8px;
}
.empty-image {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--coz-fg-secondary, #06070980);
background-color: var(--coz-mg-primary, #0607090A);
}
}
.text-ctn {
position: relative;
flex: 1 0 0;
height: 108px;
margin-left: 16px;
:global {
.semi-input-textarea-wrapper {
padding: 0;
background-color: transparent;
border: none;
.semi-input-textarea {
height: 108px;
padding: 0;
font-size: 14px;
line-height: 20px;
}
.semi-input-textarea::-webkit-scrollbar {
display: none;
}
}
}
.generate-btn {
position: absolute;
right: 0;
bottom: 0;
}
}
}
.upload-panel {
padding: 16px;
background: var(--coz-bg-max, #FFF);
border: 0.5px solid var(--coz-stroke-primary, rgba(6, 7, 9, 10%));
border-radius: 8px;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
.image-list {
margin-bottom: 12px;
.image-item {
width: 40px;
height: 40px;
}
}
:global {
.semi-button-content-right {
margin-left: 4px;
line-height: 20px;
}
}
}

View File

@@ -0,0 +1,153 @@
/*
* 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 { type CSSProperties, useMemo } from 'react';
import classNames from 'classnames';
import { avatarBackgroundWebSocket } from '@coze-studio/bot-detail-store';
import { logger } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { PicType } from '@coze-arch/bot-api/playground_api';
import { PlaygroundApi } from '@coze-arch/bot-api';
import webSocketManager from '@coze-common/websocket-manager-adapter';
import { TextArea } from '@coze-arch/coze-design';
import { type ImageItem } from '../image-list';
import { GenerateButton } from '../generate-button';
import ImagePicker from './image-picker';
import s from './index.module.less';
interface GenerateGifProps {
scene: 'background' | 'avatar';
image: ImageItem; // 默认图片
text: string; // 默认文本
loading: boolean; // 生成时socket全局监听服务端响应因此loading需要受控
imageList?: ImageItem[]; // 图片候选列表(只包含静态图)
generatingTaskId: string; // 生成中的任务id
exceedMaxImageCount?: boolean;
className?: string;
style?: CSSProperties;
setImage: (image: ImageItem) => void;
setText: (text: string) => void;
setLoading: (loading: boolean) => void;
setGeneratingTaskId: (id: string) => void;
}
export const GenerateGif: React.FC<GenerateGifProps> = ({
image = {
id: '',
img_info: {
tar_uri: '',
tar_url: '',
},
},
text = '',
loading,
imageList = [],
exceedMaxImageCount = false,
className,
generatingTaskId,
style,
setLoading,
setImage,
setText,
setGeneratingTaskId,
scene,
}) => {
const { bot_id = '0' } = useParams<DynamicParams>();
const { tar_url: url, tar_uri: uri } = image.img_info ?? {};
const hasEmptyValue = !image?.img_info?.tar_url || !text?.trim();
const filterImageList = useMemo(
() => imageList.filter(item => item?.img_info?.tar_url !== url),
[imageList, url],
);
let tooltipText = '';
if (exceedMaxImageCount) {
tooltipText = I18n.t('profilepicture_popup_toast_picturemax');
}
return (
<div className={classNames(s.ctn, className)} style={style}>
<ImagePicker setImage={setImage} url={url} imageList={filterImageList} />
<div className={s['text-ctn']}>
<TextArea
rows={5}
value={text}
maxLength={400}
className={s['text-area']}
placeholder={I18n.t('profilepicture_popup_generategif_default')}
onChange={value => {
setText(value);
}}
/>
<GenerateButton
scene="gif"
className={s['generate-btn']}
disabled={exceedMaxImageCount || hasEmptyValue}
tooltipText={tooltipText}
loading={loading}
onClick={async () => {
setLoading?.(true);
try {
avatarBackgroundWebSocket.createConnection();
// 只负责发送请求响应在WebSocket中接收
const { data } = await PlaygroundApi.GeneratePic({
gen_prompt: {
ori_prompt: text.trim(),
},
image_uri: uri,
image_url: url,
pic_type:
scene === 'avatar' ? PicType.IconGif : PicType.BackgroundGif,
bot_id,
device_id: webSocketManager.deviceId,
});
if (data?.task_id) {
setGeneratingTaskId?.(data.task_id);
}
} catch (error) {
logger.error({
eventName: 'fail_to_generate_gif',
error: error as Error,
});
setLoading?.(false);
setGeneratingTaskId?.('');
}
}}
onCancel={async () => {
if (generatingTaskId) {
try {
const { code } = await PlaygroundApi.CancelGenerateGif({
task_id: generatingTaskId,
});
if (code === 0) {
setLoading?.(false);
}
} catch (error) {
const e =
error instanceof Error ? error : new Error(error as string);
logger.error({ error: e });
}
}
}}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
.segment-tab {
:global {
.semi-radio-content {
white-space: nowrap;
}
}
}

View File

@@ -0,0 +1,136 @@
/*
* 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 classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozArrowLeft } from '@coze-arch/coze-design/icons';
import {
Collapsible,
IconButton,
SegmentTab,
Space,
} from '@coze-arch/coze-design';
import { type TabItem } from './type';
import s from './index.module.less';
export interface GenerateImageTabProps {
// tab列表
tabs: TabItem[];
// 是否可折叠
enableCollapsible?: boolean;
// 当前激活的tab
activeKey?: string;
// 当前激活的tab变化回调
onTabChange?: (tabKey: string) => void;
// 是否展示wait文案
showWaitTip?: boolean;
disabled?: boolean;
}
export enum GenerateType {
Static = 'static',
Gif = 'gif',
}
export const GenerateImageTab: React.FC<GenerateImageTabProps> = ({
tabs = [],
enableCollapsible = false,
activeKey,
onTabChange,
showWaitTip = true,
disabled = false,
}) => {
const [isOpen, setOpen] = useState(true);
const toggle = () => {
setOpen(!isOpen);
};
// tabPane 不卸载
const component = (
<div>
{tabs.map(item => (
<div
key={item.value}
className={classNames({
hidden: activeKey !== item.value,
'border-0 border-t border-solid mt-2 coz-stroke-primary': isOpen,
})}
>
{item.component}
</div>
))}
</div>
);
return (
<div
className={classNames(
'border border-solid coz-stroke-plus coz-bg-max rounded-md w-full coz-fg-plus mt-3 pt-2 pb-4 px-4',
{
'coz-bg-primary pointer-events-none': disabled,
},
)}
>
<div className=" flex items-center gap-2 justify-between ">
<Space>
{enableCollapsible ? (
<IconButton
className="!bg-transparent hover:!coz-mg-primary-hovered"
icon={
<IconCozArrowLeft
className={classNames(
isOpen ? 'rotate-90' : '-rotate-90',
'coz-fg-secondary',
)}
onClick={toggle}
/>
}
/>
) : null}
<SegmentTab
className={s['segment-tab']}
onChange={e => {
onTabChange?.(e.target.value);
}}
options={tabs.map(item => ({
label: item.label,
value: item.value,
}))}
defaultValue={activeKey ?? tabs[0]?.value}
/>
</Space>
{showWaitTip ? (
<div className="coz-fg-dim text-xs flex-1 truncate">
{I18n.t('profilepicture_popup_async')}
</div>
) : null}
</div>
{enableCollapsible ? (
// keepDOM 异常失效使用collapseHeight 不销毁dom保留状态
<Collapsible isOpen={isOpen} keepDOM collapseHeight={1}>
<div> {component} </div>
</Collapsible>
) : (
<div> {component} </div>
)}
</div>
);
};

View File

@@ -0,0 +1,23 @@
/*
* 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 ReactElement } from 'react';
export interface TabItem {
label: string;
value: string;
component: ReactElement;
}

View File

@@ -0,0 +1,67 @@
.ctn {
display: flex;
flex-wrap: nowrap;
gap: 7px;
justify-content: center;
.image-item {
cursor: pointer;
position: relative;
width: 60px;
height: 60px;
border-radius: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
border: 1.5px solid transparent;
border-radius: 8px;
&.selected {
border: 1.5px solid var(--coz-stroke-hglt, #4E40E5);
}
}
.delete-icon {
position: absolute;
top: -4px;
right: -4px;
display: none;
font-size: 16px;
color: var(--coz-fg-hglt-red, #F54A45);
}
&:hover .delete-icon {
display: block;
}
.check-icon {
position: absolute;
right: 4px;
bottom: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background-color: var(--coz-mg-hglt-plus, #4E40E5);
border-radius: 4px;
svg {
font-size: 12px;
color: white;
}
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* 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 } from 'react';
import classNames from 'classnames';
import {
IconCozCheckMarkFill,
IconCozMinusCircleFillPalette,
} from '@coze-arch/coze-design/icons';
import { type PicTask } from '@coze-arch/bot-api/playground_api';
import s from './index.module.less';
export type ImageItem = PicTask;
export interface ImageListProps {
selectedKey?: string; // 选中的key
data: ImageItem[]; // 列表数据
className?: string;
imageItemClassName?: string;
showDeleteIcon?: boolean;
showSelectedIcon?: boolean;
style?: CSSProperties;
onRemove?: (params: {
index?: number;
item?: ImageItem;
data: ImageItem[];
}) => void; // 删除图片data是此次删除之后的数据
onSelect?: (params: {
index?: number;
item: ImageItem;
data: ImageItem[];
selected: boolean;
}) => void; // 选中图片其中item和data都是此次选中之前的数据selected表示在本次选中之前此图片是否已是选中状态
onClick?: (params: {
index: number;
item: ImageItem;
data: ImageItem[];
}) => void; // 点击图片
}
export const ImageList: React.FC<ImageListProps> = ({
data,
showDeleteIcon = true,
showSelectedIcon = true,
className,
imageItemClassName,
style,
onSelect,
onRemove,
onClick,
selectedKey,
}) => {
if (!data || data.length === 0) {
return null;
}
return (
<div className={classNames(className, s.ctn)} style={style}>
{data.map((item, index) => {
const { img_info } = item;
const { tar_uri, tar_url } = img_info ?? {};
return (
<div
key={tar_uri}
className={classNames(s['image-item'], imageItemClassName)}
>
<img
src={tar_url}
alt="图片"
className={classNames({
[s.selected]: showSelectedIcon && selectedKey === tar_uri,
})}
onClick={() => {
onClick?.({ index, item, data });
onSelect?.({
index,
item,
data,
selected: selectedKey === tar_uri,
});
}}
/>
{showDeleteIcon ? (
<IconCozMinusCircleFillPalette
className={s['delete-icon']}
onClick={() => {
onRemove?.({
index,
item,
data: data.filter(i => i !== item),
});
}}
/>
) : null}
{showSelectedIcon && selectedKey === tar_uri ? (
<div className={s['check-icon']}>
<IconCozCheckMarkFill />
</div>
) : null}
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,72 @@
/*
* 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 { AvatarBackgroundNoticeDot } from './avatar-background-notice-dot';
export { ImageList, type ImageItem, type ImageListProps } from './image-list';
export { GenerateButton } from './generate-button';
export {
InputWithCountField,
InputWithCount,
type InputWithCountProps,
} from './input-with-count';
export { UIBreadcrumb, type BreadCrumbProps } from './ui-breadcrumb';
export { type UISearchProps, UISearch } from './ui-search';
export { PopoverContent } from './popover-content';
export { SelectSpaceModal } from './select-space-modal';
export { DuplicateBot } from './duplicate-bot';
export { CozeBrand, type CozeBrandProps } from './coze-brand';
export { CardThumbnailPopover } from './card-thumbnail-popover';
export { LinkList, type LinkListItem } from './link-list';
export { AvatarName } from './avatar-name';
export { TopBar as PersonalHeader } from './personal-header';
export { Carousel } from './carousel';
export {
GenerateImageTab,
GenerateType,
type GenerateImageTabProps,
} from './generate-img-tab';
export { FlowShortcutsHelp } from './flow-shortcuts-help';
export { LoadingButton } from './loading-button';
export { Search, SearchProps } from './search';
export { ResizableLayout } from './resizable-layout';
export { ModelOptionItem } from './model-option/option-item';
export { InputSlider, InputSliderProps } from './input-controls/input-slider';
export { UploadGenerateButton } from './upload-generate-button';
export { usePluginLimitModal, transPricingRules } from './plugin-limit-info';
// 曝光埋点上报组件,进入视图上报
export { TeaExposure } from './tea-exposure';
export { Sticky } from './sticky';
export {
ProjectTemplateCopyModal,
type ProjectTemplateCopyValue,
useProjectTemplateCopyModal,
appendCopySuffix,
} from './project-duplicate-modal';
export { SpaceFormSelect } from './space-form-select';
// !Notice 以下模块只允许导出类型,避免首屏加载 react-dnd,@blueprintjs/core 等相关代码
export { type TItemRender, type ITemRenderProps } from './sortable-list';
export { type ConnectDnd, type OnMove } from './sortable-list/hooks';

View File

@@ -0,0 +1,21 @@
.input-slider {
display: flex;
column-gap: 8px;
align-items: center;
align-items: flex-start;
:global {
.semi-slider {
flex: 1;
}
}
.slider {
width: 100%;
}
.input-number {
width: 120px;
}
}

View File

@@ -0,0 +1,166 @@
/*
* 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, useMemo, useRef, useState } from 'react';
import { nanoid } from 'nanoid';
import { isInteger, isUndefined } from 'lodash-es';
import classNames from 'classnames';
import { useHover } from 'ahooks';
import { InputNumber } from '@coze-arch/coze-design';
import { type SliderProps } from '@coze-arch/bot-semi/Slider';
import { Slider } from '@coze-arch/bot-semi';
import styles from './index.module.less';
export interface InputSliderProps {
value?: number;
onChange?: (v: number) => void;
max?: number;
min?: number;
step?: number;
disabled?: boolean;
decimalPlaces?: number;
marks?: SliderProps['marks'];
className?: string;
}
const POWVAL = 10;
const formateDecimalPlacesString = (
value: string | number,
prevValue?: number,
decimalPlaces?: number,
) => {
if (isUndefined(decimalPlaces)) {
return value.toString();
}
const numberValue = Number(value);
const stringValue = value.toString();
if (Number.isNaN(numberValue)) {
return `${value}`;
}
if (decimalPlaces === 0 && !isInteger(Number(value)) && prevValue) {
return `${prevValue}`;
}
const decimalPointIndex = stringValue.indexOf('.');
if (decimalPointIndex < 0) {
return stringValue;
}
const formattedValue = stringValue.substring(
0,
decimalPointIndex + 1 + decimalPlaces,
);
if (formattedValue.endsWith('.') && decimalPlaces === 0) {
return formattedValue.substring(0, formattedValue.length - 1);
}
return formattedValue;
};
const formateDecimalPlacesNumber = (
value: number,
prevValue?: number,
decimalPlaces?: number,
) => {
if (isUndefined(decimalPlaces)) {
return value;
}
if (decimalPlaces === 0 && !isInteger(value) && prevValue) {
return prevValue;
}
const pow = Math.pow(POWVAL, decimalPlaces);
return Math.round(value * pow) / pow;
};
export const InputSlider: React.FC<InputSliderProps> = ({
value,
onChange,
max = 1,
min = 0,
step = 1,
disabled,
decimalPlaces = 0,
className,
}) => {
const ref = useRef<HTMLDivElement>(null);
const hover = useHover(ref);
const sliderRenderId = useMemo(() => nanoid(), [max, min, hover]);
const [isFocus, setFocus] = useState(false);
const [inputRenderId, setInputRenderId] = useState(nanoid());
const updateInputNumber = () => {
if (isFocus) {
return;
}
setInputRenderId(nanoid());
};
const onNumberChange = (numberValue: number) => {
updateInputNumber();
// 防止 -0
if (numberValue === 0) {
onChange?.(0);
return;
}
const expectedFormattedValue = formateDecimalPlacesNumber(
numberValue,
value,
decimalPlaces,
);
onChange?.(expectedFormattedValue);
};
// 防止 -0 导致 InputNumber 无限循环更新
const fixedValue = Object.is(value, -0) ? 0 : value;
useEffect(() => {
updateInputNumber();
}, [isFocus]);
return (
<div ref={ref} className={classNames(styles['input-slider'], className)}>
<Slider
key={sliderRenderId}
className={styles.slider}
disabled={disabled}
value={fixedValue}
max={max}
min={min}
step={step}
showBoundary
onChange={v => {
if (typeof v === 'number') {
onChange?.(v);
}
}}
/>
<InputNumber
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
key={inputRenderId}
className={styles['input-number']}
value={fixedValue}
disabled={disabled}
formatter={inputValue => formateDecimalPlacesString(inputValue, value)}
onNumberChange={onNumberChange}
max={max}
min={min}
step={step}
/>
</div>
);
};

View File

@@ -0,0 +1,12 @@
.limit-count {
overflow: hidden;
padding-right: 12px;
padding-left: 8px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px;
color: rgb(29 28 35 / 60%);
}

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 { useMemo } from 'react';
import { type InputProps } from '@coze-arch/bot-semi/Input';
import { UIInput, withField } from '@coze-arch/bot-semi';
import 'utility-types';
import s from './index.module.less';
interface LimitCountProps {
maxLen: number;
len: number;
}
const LimitCount: React.FC<LimitCountProps> = ({ maxLen, len }) => (
<span className={s['limit-count']}>
<span>{len}</span>
<span>/</span>
<span>{maxLen}</span>
</span>
);
export interface InputWithCountProps extends InputProps {
// 设置字数限制并显示字数统计
getValueLength?: (value?: InputProps['value'] | string) => number;
}
export const InputWithCount: React.FC<InputWithCountProps> = props => {
const { value, maxLength, getValueLength } = props;
const len = useMemo(() => {
if (getValueLength) {
return getValueLength(value);
} else if (value) {
return value.toString().length;
} else {
return 0;
}
}, [value, getValueLength]);
return (
<UIInput
{...props}
suffix={
Boolean(maxLength) && <LimitCount maxLen={maxLength ?? 0} len={len} />
}
/>
);
};
export const InputWithCountField = withField(InputWithCount);

View File

@@ -0,0 +1,29 @@
.link-list {
flex-shrink: 0;
display: flex;
align-items: center;
.link-list-item {
padding: 0 18px;
color: rgb(255 255 255 / 60%);
font-size: 14px;
line-height: 22px;
display: flex;
align-items: center;
.click-area {
display: flex;
align-items: center;
&.pointer {
cursor: pointer;
user-select: none;
&:hover,
&:active {
color: #fff;
}
}
}
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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, { type ReactNode, type CSSProperties } from 'react';
import classNames from 'classnames';
import { Space } from '@coze-arch/bot-semi';
import s from './index.module.less';
export interface LinkListItem {
extra?: string;
icon?: ReactNode;
label: string;
link?: string;
onClick?: () => void;
}
export const LinkList = ({
className,
style,
data,
pointerClassName,
itemClassName,
}: {
className?: string;
style?: CSSProperties;
data: LinkListItem[];
pointerClassName?: string;
itemClassName?: string;
}) => (
<div className={classNames(s['link-list'], className)} style={style}>
{data?.map(item => (
<div
className={classNames(s['link-list-item'], itemClassName)}
key={`link-list-${item.label}`}
>
{!!item.extra && <span style={{ marginRight: 4 }}>{item.extra}</span>}
<div
className={classNames(
s['click-area'],
(item.link || item.onClick) && s.pointer,
(item.link || item.onClick) && pointerClassName,
)}
onClick={() => {
if (item.link) {
window.open(item.link);
} else {
item.onClick?.();
}
}}
>
<Space spacing={4}>
{item.icon}
{item.label}
</Space>
</div>
</div>
))}
</div>
);

View File

@@ -0,0 +1,12 @@
.header {
display: flex;
align-items: flex-start;
background-color: #f7f7fa;
padding: 6px 36px;
}
.tool-bar {
display: flex;
align-items: center;
margin-left: auto;
}

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.
*/
export { ListTab, type BotListHeaderProps } from './list-tab';

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 PropsWithChildren, type ReactNode } from 'react';
import cs from 'classnames';
import { type TabsProps } from '@coze-arch/bot-semi/Tabs';
import { Tabs } from '@coze-arch/bot-semi';
import s from './index.module.less';
export interface BotListHeaderProps extends TabsProps {
toolbar?: ReactNode;
containerClass?: string;
}
export const ListTab: React.FC<PropsWithChildren<BotListHeaderProps>> = ({
children,
toolbar,
containerClass,
...props
}) => (
<Tabs
{...props}
tabPaneMotion={false}
type="button"
// eslint-disable-next-line @typescript-eslint/naming-convention -- react 组件
renderTabBar={(innerProps, Node) => (
<div className={cs(s.header, containerClass)}>
<Node {...innerProps} />
<div className={s['tool-bar']}>{toolbar}</div>
</div>
)}
>
{children}
</Tabs>
);

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 React, { forwardRef, useState } from 'react';
import { isString } from 'lodash-es';
import { type ToastReactProps } from '@coze-arch/bot-semi/Toast';
import { type ButtonProps } from '@coze-arch/bot-semi/Button';
import { UIButton, Toast, Spin } from '@coze-arch/bot-semi';
export type LoadingButtonProps = ButtonProps & {
/** 加载中的 toast 文案 */
loadingToast?: string | Omit<ToastReactProps, 'type'>;
};
export const LoadingButton: React.ForwardRefExoticComponent<LoadingButtonProps> =
forwardRef<UIButton, LoadingButtonProps>(
({ loadingToast, ...buttonProps }, ref) => {
const [loading, setLoading] = useState(false);
const onClick: React.MouseEventHandler<
HTMLButtonElement
> = async event => {
let toastId = '';
try {
if (loadingToast) {
toastId = Toast.info({
icon: <Spin />,
showClose: false,
duration: 0,
...(isString(loadingToast)
? { content: loadingToast }
: loadingToast),
});
}
setLoading(true);
if (buttonProps.onClick) {
await buttonProps.onClick(event);
}
} finally {
setLoading(false);
if (toastId) {
Toast.close(toastId);
}
}
};
return (
<UIButton
ref={ref}
loading={loading}
{...buttonProps}
onClick={onClick}
/>
);
},
);

View File

@@ -0,0 +1,18 @@
.action-bar {
display: flex;
column-gap: 8px;
align-items: center;
.icon-button {
font-size: 16px;
// rgb mark
color: #060709;
&.icon-button-active {
// rgb mark
background-color: var(--semi-color-fill-1);
}
}
}

View File

@@ -0,0 +1,172 @@
/*
* 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/no-deep-relative-import -- svg */
import { useState, type ComponentProps, type CSSProperties } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Toast, Tooltip, UIButton, Upload } from '@coze-arch/bot-semi';
import { IconLinkStroked } from '@coze-arch/bot-icons';
import {
InsertLinkPopover,
type InsertLinkPopoverProps,
} from '../insert-link-popover';
import { getFixedVariableTemplate } from '../../utils/onboarding-variable';
import { type TriggerAction } from '../../type';
import { getIsFileFormatValid } from '../../helpers/get-is-file-format-valid';
import { OnboardingVariable } from '../../constant/onboarding-variable';
import { getFileSizeReachLimitI18n, MAX_FILE_SIZE } from '../../constant/file';
import { ReactComponent as IconMemberOutlined } from '../../../../assets/icon_member_outlined.svg';
import { ReactComponent as IconImageOutlined } from '../../../../assets/icon_image_outlined.svg';
import styles from './index.module.less';
const SHOW_ADD_NAME_BTN = false;
export interface ActionBarProps {
className?: string;
style?: CSSProperties;
onTriggerAction?: (action: TriggerAction) => void;
disabled?: boolean;
}
const iconButtonProps: ComponentProps<typeof UIButton> = {
size: 'small',
type: 'tertiary',
theme: 'borderless',
className: styles['icon-button'],
};
export const ActionBar: React.FC<ActionBarProps> = ({
className,
style,
onTriggerAction,
disabled,
}) => {
const [visible, setVisible] = useState(false);
const togglePopoverVisible = () => {
setVisible(e => !e);
};
const closePopover = () => {
setVisible(false);
};
const onConfirmInsertLink: InsertLinkPopoverProps['onConfirm'] = param => {
closePopover();
onTriggerAction?.({ type: 'link', sync: true, payload: param });
};
const onInsertImage = (file: File) => {
onTriggerAction?.({ type: 'image', payload: { file }, sync: false });
};
const onInsertVariable = () => {
onTriggerAction?.({
type: 'variable',
payload: {
variableTemplate: getFixedVariableTemplate(
OnboardingVariable.USER_NAME,
),
},
sync: true,
});
};
const showFileTypeInvalidToast = () =>
Toast.warning({
showClose: false,
content: I18n.t('file_format_not_supported'),
});
const showFileSizeInvalidToast = () =>
Toast.warning({
showClose: false,
content: getFileSizeReachLimitI18n(),
});
const onFileChange = (files: File[]) => {
const file = files.at(0);
if (!file) {
return;
}
if (!getIsFileFormatValid(file)) {
showFileTypeInvalidToast();
return;
}
if (file.size > MAX_FILE_SIZE) {
showFileSizeInvalidToast();
return;
}
onInsertImage(file);
};
return (
<div className={classNames(className, styles['action-bar'])} style={style}>
<Upload
accept="image/*"
limit={1}
onFileChange={onFileChange}
action="/"
fileList={[]}
disabled={disabled}
>
<Tooltip disableFocusListener content={I18n.t('add_image')}>
<UIButton
{...iconButtonProps}
icon={<IconImageOutlined />}
disabled={disabled}
/>
</Tooltip>
</Upload>
<InsertLinkPopover
visible={visible}
onClickOutSide={closePopover}
onConfirm={onConfirmInsertLink}
>
<span>
<Tooltip disableFocusListener content={I18n.t('add_link')}>
<UIButton
onClick={togglePopoverVisible}
{...iconButtonProps}
className={classNames(
visible && styles['icon-button-active'],
styles['icon-button'],
)}
icon={<IconLinkStroked />}
disabled={disabled}
/>
</Tooltip>
</span>
</InsertLinkPopover>
{/* 暂时禁用,后续放开 */}
{SHOW_ADD_NAME_BTN && (
<Tooltip content={I18n.t('add_nickname')}>
<UIButton
{...iconButtonProps}
icon={<IconMemberOutlined />}
disabled={disabled}
onClick={onInsertVariable}
/>
</Tooltip>
)}
</div>
);
};

View File

@@ -0,0 +1,25 @@
.popover-content {
display: flex;
column-gap: 12px;
justify-content: space-between;
box-sizing: content-box;
width: 398px;
height: 128px;
padding: 24px;
// rgb mark
background: #F4F4F6;
.input-content {
display: flex;
flex: 1;
flex-direction: column;
row-gap: 16px;
}
.confirm-button {
align-self: flex-end;
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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, type ComponentProps, type PropsWithChildren } from 'react';
import { Form, Popover, UIButton, UIInput } from '@coze-arch/bot-semi';
import styles from './index.module.less';
export interface InsertLinkPopoverProps
extends Pick<ComponentProps<typeof Popover>, 'onClickOutSide' | 'visible'> {
onConfirm?: (param: { link: string; text: string }) => void;
}
/**
* 全受控
*/
export const InsertLinkPopover: React.FC<
PropsWithChildren<InsertLinkPopoverProps>
> = ({ children, visible, onClickOutSide, onConfirm }) => (
<Popover
trigger="custom"
visible={visible}
onClickOutSide={onClickOutSide}
showArrow={false}
position="topRight"
content={<Content onConfirm={onConfirm} />}
>
{children}
</Popover>
);
const Content: React.FC<Pick<InsertLinkPopoverProps, 'onConfirm'>> = ({
onConfirm: inputOnConfirm,
}) => {
const [text, setText] = useState('');
const [link, setLink] = useState('');
const onConfirm = () => {
clearInput();
inputOnConfirm?.({ text, link });
};
const clearInput = () => {
setLink('');
setText('');
};
return (
<div className={styles['popover-content']}>
<div className={styles['input-content']}>
<div className={styles['input-row']}>
<Form.Label required text="Text" />
<UIInput value={text} onChange={setText} />
</div>
<div className={styles['input-row']}>
<Form.Label required text="Link" />
<UIInput value={link} onChange={setLink} />
</div>
</div>
<UIButton
onClick={onConfirm}
disabled={!text || !link}
theme="solid"
className={styles['confirm-button']}
>
Confirm
</UIButton>
</div>
);
};

View File

@@ -0,0 +1,26 @@
.mask {
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
row-gap: 16px;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: rgb(255 255 255 / 90%);
}
.text {
font-size: 14px;
color: rgba(6, 7, 9, 50%);
}
.progress {
width: 250px;
height: 6px;
}

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 { I18n } from '@coze-arch/i18n';
import { Progress } from '@coze-arch/bot-semi';
import { type UploadState } from '../../type';
import styles from './index.module.less';
export const UploadProgressMask: React.FC<UploadState> = ({
fileName,
percent,
}) => (
<div className={styles.mask}>
<div className={styles.text}>
{I18n.t('uploading_filename', { filename: fileName })}
</div>
<Progress className={styles.progress} percent={percent} />
</div>
);

View File

@@ -0,0 +1,23 @@
/*
* 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';
export const MAX_FILE_SIZE = 20 * 1024 * 1024;
export const getFileSizeReachLimitI18n = () =>
I18n.t('file_too_large', {
max_size: '20MB',
});

View File

@@ -0,0 +1,21 @@
/*
* 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 enum OnboardingVariable {
USER_NAME = 'user_name',
}
export type OnboardingVariableMap = Record<OnboardingVariable, string>;

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 const getInsertTextAtPosition = ({
text,
insertText,
position,
}: {
text: string;
insertText: string;
position: number;
}): string => `${text.slice(0, position)}${insertText}${text.slice(position)}`;

View File

@@ -0,0 +1,36 @@
/*
* 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 { primitiveExhaustiveCheck } from '../utils/exhaustive-check';
import { type SyncAction } from '../type';
import { getMarkdownLink } from './get-markdown-link';
export const getSyncInsertText = (action: SyncAction): string => {
const { type, payload } = action;
if (type === 'link') {
const { text, link } = payload;
return getMarkdownLink({ text, link });
}
if (type === 'variable') {
return payload.variableTemplate;
}
/**
* 不应该走到这里
*/
primitiveExhaustiveCheck(type);
return '';
};

View File

@@ -0,0 +1,18 @@
/*
* 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 getIsFileFormatValid = (file: File) =>
file.type.startsWith('image/') && file.type !== 'image/svg+xml';

View File

@@ -0,0 +1,23 @@
/*
* 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 getMarkdownImageLink = ({
fileName,
link,
}: {
fileName: string;
link: string;
}) => `![${fileName}](${link})`;

View File

@@ -0,0 +1,23 @@
/*
* 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 getMarkdownLink = ({
text,
link,
}: {
text: string;
link: string;
}) => `[${text}](${link})`;

View File

@@ -0,0 +1,167 @@
/*
* 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 {
useLayoutEffect,
useRef,
useState,
type ChangeEventHandler,
} from 'react';
import useEventCallback from 'use-event-callback';
import { I18n } from '@coze-arch/i18n';
import { useDragAndPasteUpload } from '@coze-arch/bot-hooks';
import { primitiveExhaustiveCheck } from '../utils/exhaustive-check';
import { type AsyncAction, type SyncAction } from '../type';
import { getMarkdownImageLink } from '../helpers/get-markdown-image-link';
import { getIsFileFormatValid } from '../helpers/get-is-file-format-valid';
import { getInsertTextAtPosition } from '../helpers/get-insert-text-at-position';
import { getSyncInsertText } from '../helpers/get-insert-text';
import { MAX_FILE_SIZE, getFileSizeReachLimitI18n } from '../constant/file';
import { type ActionBarProps } from '../components/action-bar';
import { type MarkdownEditorProps } from '..';
import { useUpload } from './use-upload-file';
// eslint-disable-next-line max-lines-per-function
export const useMarkdownEditor = ({
value,
onChange,
getUserId = () => '',
customUpload,
}: MarkdownEditorProps) => {
const onTriggerSyncAction = (action: SyncAction) => {
handleInsertText(getSyncInsertText(action));
};
const onTriggerAsyncAction = (action: AsyncAction) => {
const { type, payload } = action;
if (type === 'image') {
const { file } = payload;
return uploadFileList([file]);
}
primitiveExhaustiveCheck(type);
};
const onTriggerAction: ActionBarProps['onTriggerAction'] = props => {
if (props.sync) {
return onTriggerSyncAction(props);
}
return onTriggerAsyncAction(props);
};
const ref = useRef<HTMLTextAreaElement>(null);
const dragTargetRef = useRef<HTMLDivElement>(null);
const onUploadAllSuccess = useEventCallback(
({ url, fileName }: { url: string; fileName: string }) => {
handleInsertText(
getMarkdownImageLink({
fileName,
link: url,
}),
);
},
);
// 判断使用内置上传方法 or 自定义
const selectUploadMethod = () => {
if (customUpload) {
return customUpload({
onUploadAllSuccess,
});
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks -- linter-disable-autofix
return useUpload({
getUserId,
onUploadAllSuccess,
});
}
};
const { uploadFileList, uploadState } = selectUploadMethod();
const { isDragOver } = useDragAndPasteUpload({
ref: dragTargetRef,
onUpload: fileList => {
const file = fileList.at(0);
if (!file) {
return;
}
onTriggerAction({ type: 'image', sync: false, payload: { file } });
},
disableDrag: Boolean(uploadState),
disablePaste: Boolean(uploadState),
fileLimit: 1,
maxFileSize: MAX_FILE_SIZE,
isFileFormatValid: getIsFileFormatValid,
getExistingFileCount: () => 0,
closeDelay: undefined,
invalidFormatMessage: I18n.t('file_format_not_supported'),
invalidSizeMessage: getFileSizeReachLimitI18n(),
fileExceedsMessage: I18n.t('files_exceeds_limit'),
});
const [wrapInsertionIndex, setWrapInsertionIndex] = useState<number | null>(
null,
);
useLayoutEffect(() => {
if (wrapInsertionIndex === null || !ref.current) {
return;
}
ref.current.selectionStart = wrapInsertionIndex;
ref.current.selectionEnd = wrapInsertionIndex;
setWrapInsertionIndex(null);
}, [ref.current, wrapInsertionIndex, value]);
console.log('outter value', { value });
const onTextareaChange: ChangeEventHandler<HTMLTextAreaElement> = e => {
console.log('onTextareaChange', { value: e.target.value });
onChange(e.target.value);
};
const handleInsertText = (insertText: string) => {
if (!ref.current) {
return;
}
ref.current.focus();
const { selectionEnd } = ref.current;
/**
* 选中文字时点击 action bar, 将内容插入到文字的末尾
*/
console.log('handleInsertText', { value, insertText, selectionEnd });
const insertTextAtPosition = getInsertTextAtPosition({
text: value,
insertText,
position: selectionEnd,
});
onChange(insertTextAtPosition);
setWrapInsertionIndex(selectionEnd + insertText.length);
};
return {
textAreaRef: ref,
onTextareaChange,
onTriggerAction,
dragTargetRef,
uploadState,
isDragOver,
};
};

View File

@@ -0,0 +1,123 @@
/*
* 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, useRef, useState } from 'react';
import { nanoid } from 'nanoid';
import { withSlardarIdButton } from '@coze-studio/bot-utils';
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/bot-semi';
import { type UploadState } from '../type';
import { UploadController } from '../service/upload-controller';
/**
* 暂时没有场景,所以这里将多实例、一次行上传多文件的能力屏蔽了
*/
export const useUpload = ({
getUserId,
onUploadAllSuccess,
}: {
getUserId: () => string;
onUploadAllSuccess: (param: { url: string; fileName: string }) => void;
}) => {
const [uploadState, setUploadState] = useState<UploadState | null>(null);
const uploadControllerMap = useRef<Record<string, UploadController>>({});
const clearState = () => setUploadState(null);
const deleteUploadControllerById = (id: string) => {
delete uploadControllerMap.current[id];
};
const cancelUploadById = (id: string) => {
const controller = uploadControllerMap.current[id];
if (!controller) {
return;
}
controller.cancel();
deleteUploadControllerById(id);
};
const handleError = (_e: unknown, controllerId: string) => {
clearState();
cancelUploadById(controllerId);
Toast.error({
content: withSlardarIdButton(I18n.t('Upload_failed')),
showClose: false,
});
};
const handleUploadSuccess = () => {
setUploadState(null);
};
const handleProgress = (percent: number) => {
setUploadState(state => {
if (!state) {
return state;
}
return { ...state, percent };
});
};
const handleStartUpload = (fileName: string) =>
setUploadState({ fileName, percent: 0 });
const uploadFileList = (fileList: File[]) => {
if (uploadState) {
return;
}
const controllerId = nanoid();
const file = fileList.at(0);
if (!file) {
return;
}
handleStartUpload(file.name);
uploadControllerMap.current[controllerId] = new UploadController({
fileList,
controllerId,
userId: getUserId(),
onProgress: event => {
handleProgress(event.percent);
},
onComplete: event => {
handleUploadSuccess();
onUploadAllSuccess(event);
},
onUploadError: handleError,
onGetTokenError: handleError,
onGetUploadInstanceError: handleError,
});
};
const clearAllSideEffect = () => {
Object.entries(uploadControllerMap.current).forEach(([, controller]) =>
controller.cancel(),
);
uploadControllerMap.current = {};
};
useEffect(() => clearAllSideEffect, []);
return {
uploadState,
uploadFileList,
};
};

View File

@@ -0,0 +1,56 @@
.markdown-editor {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 6px 12px;
background-color: #fff;
// rgb mark
border: 1px solid;
border-color: rgb(6 7 9 / 10%);
border-radius: 8px;
&.markdown-editor-drag {
box-shadow: 0 0 0 2px #DADCFB;
}
&:focus-within {
border-color: #34F;
}
.markdown-action-bar {
box-sizing: border-box;
width: 100%;
height: 40px;
margin-bottom: 8px;
padding: 8px 0;
border-bottom: 1px solid rgb(6 7 9 / 10%);
}
.markdown-editor-content {
resize: none;
width: 100%;
height: 240px;
padding: 0;
font-size: 14px;
font-weight: 400;
line-height: 22px;
// rgb mark
color: #383743;
border: none;
outline: none;
&::selection {
background: rgb(77 83 232 / 20%);
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* 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 } from 'react';
import classNames from 'classnames';
import { type CustomUploadParams, type CustomUploadRes } from './type';
import { useMarkdownEditor } from './hooks/use-markdown-editor';
import { UploadProgressMask } from './components/upload-progress-mask';
import { ActionBar } from './components/action-bar';
import styles from './index.module.less';
export interface MarkdownEditorProps {
value: string;
placeholder?: string;
onChange: (value: string) => void;
getUserId?: () => string;
className?: string;
disabled?: boolean;
style?: CSSProperties;
customUpload?: (params: CustomUploadParams) => CustomUploadRes;
}
/**
* 全受控组件
*/
export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value = '',
placeholder = '',
className,
disabled,
style,
...props
}) => {
const {
textAreaRef,
dragTargetRef,
onTextareaChange,
onTriggerAction,
isDragOver,
uploadState,
} = useMarkdownEditor({
value,
...props,
});
return (
<div
className={classNames(
styles['markdown-editor'],
isDragOver && styles['markdown-editor-drag'],
className,
)}
style={style}
ref={dragTargetRef}
>
<ActionBar
className={styles['markdown-action-bar']}
onTriggerAction={onTriggerAction}
disabled={disabled}
/>
<textarea
ref={textAreaRef}
disabled={disabled}
value={value}
placeholder={placeholder}
onChange={onTextareaChange}
className={styles['markdown-editor-content']}
/>
{uploadState && <UploadProgressMask {...uploadState} />}
</div>
);
};

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 {
uploadFileV2,
type EventPayloadMaps as BaseEventPayloadMap,
type UploaderInstance,
type UploadFileV2Param,
type FileItem,
} from '@coze-arch/bot-utils/upload-file-v2';
import { PlaygroundApi } from '@coze-arch/bot-api';
export type EventPayloadMap = BaseEventPayloadMap & {
ready: boolean;
};
export interface UploadControllerProps {
controllerId: string;
fileList: File[];
userId: string;
onProgress?: (
event: EventPayloadMap['progress'],
controllerId: string,
) => void;
onComplete?: (
event: {
url: string;
fileName: string;
},
controllerId: string,
) => void;
onUploadError?: (event: Error, controllerId: string) => void;
onUploaderReady?: (
event: EventPayloadMap['ready'],
controllerId: string,
) => void;
onStartUpload?: (
param: Parameters<Required<UploadFileV2Param>['onStartUpload']>[number],
controllerId: string,
) => void;
onGetUploadInstanceError?: (error: Error, controllerId: string) => void;
onGetTokenError?: (error: Error, controllerId: string) => void;
}
const isImage = (file: File) => file.type.startsWith('image/');
export class UploadController {
controllerId: string;
abortController: AbortController;
uploader: UploaderInstance | null;
fileItemList: FileItem[];
constructor({
controllerId,
fileList,
userId,
onProgress,
onComplete,
onUploadError,
onUploaderReady,
onStartUpload,
onGetTokenError,
onGetUploadInstanceError,
}: UploadControllerProps) {
this.fileItemList = fileList.map(file => ({
file,
fileType: isImage(file) ? 'image' : 'object',
}));
this.controllerId = controllerId;
this.abortController = new AbortController();
this.uploader = null;
uploadFileV2({
fileItemList: this.fileItemList,
userId,
signal: this.abortController.signal,
timeout: undefined,
onUploaderReady: uploader => {
this.uploader = uploader;
onUploaderReady?.(true, controllerId);
},
onProgress: event => onProgress?.(event, controllerId),
onSuccess: async event => {
const uri = event.uploadResult.Uri;
try {
if (!uri) {
throw new Error(
`upload success without uri, uploadID ${event.uploadID}`,
);
}
const result = await PlaygroundApi.GetImagexShortUrl({
uris: [uri],
});
const url = result.data?.url_info?.[uri]?.url;
if (!url) {
throw new Error(`failed to get url, uri: ${uri}`);
}
onComplete?.(
{ url, fileName: event.uploadResult.FileName ?? '' },
controllerId,
);
} catch (e) {
onUploadError?.(
e instanceof Error ? e : new Error(String(e)),
controllerId,
);
}
},
onUploadError: event => onUploadError?.(event.extra.error, controllerId),
onStartUpload: event => onStartUpload?.(event, controllerId),
onGetUploadInstanceError: error =>
onGetUploadInstanceError?.(error, controllerId),
onGetTokenError: error => onGetTokenError?.(error, controllerId),
});
}
cancel = () => {
this.abortController.abort();
};
pause = () => {
this.uploader?.pause();
};
}

View File

@@ -0,0 +1,62 @@
/*
* 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 interface InsertImageAction {
type: 'image';
sync: false;
payload: {
file: File;
};
}
export interface InsertLinkAction {
type: 'link';
sync: true;
payload: {
text: string;
link: string;
};
}
export interface InsertVariableAction {
type: 'variable';
sync: true;
payload: {
variableTemplate: string;
};
}
export type SyncAction = InsertLinkAction | InsertVariableAction;
export type AsyncAction = InsertImageAction;
export type TriggerAction = SyncAction | AsyncAction;
export interface UploadState {
percent: number;
fileName: string;
}
// 自定义上传方法入参
export interface CustomUploadParams {
onUploadAllSuccess: (param: { url: string; fileName: string }) => void;
}
// 自定义上传方法出参
export interface CustomUploadRes {
uploadFileList: (fileList: File[]) => void;
//null表示已完成
uploadState: UploadState | null;
}

View File

@@ -0,0 +1,18 @@
/*
* 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 primitiveExhaustiveCheck = (_: never) => 0;
export const recordExhaustiveCheck = (_: Record<string, never>) => 0;

View File

@@ -0,0 +1,71 @@
/*
* 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 OnboardingVariable,
type OnboardingVariableMap,
} from '../constant/onboarding-variable';
import { typedKeys } from './typed-keys';
export interface VariableWithRange {
range: [number, number];
variable: OnboardingVariable;
}
export const getFixedVariableTemplate = (template: string) => `{{${template}}}`;
export const matchAllTemplateRanges = (
text: string,
template: string,
): { start: number; end: number }[] => {
// 正则表达式,用于匹配双花括号内的内容
const templateRegex = new RegExp(getFixedVariableTemplate(template), 'g');
const matches: { start: number; end: number }[] = [];
// 循环查找所有匹配项
while (true) {
const match = templateRegex.exec(text);
if (!match) {
break;
}
const templateString = match[0];
const start = match.index;
const end = templateString.length + start;
matches.push({ start, end });
}
return matches;
};
export const getVariableRangeList = (
content: string,
variableMap: OnboardingVariableMap,
) => {
const result: VariableWithRange[] = [];
typedKeys(variableMap).forEach(variable => {
const allMatchedRanges = matchAllTemplateRanges(content, variable);
const variableWithRangeList: VariableWithRange[] = allMatchedRanges.map(
({ start, end }) => ({
variable,
range: [start, end],
}),
);
result.push(...variableWithRangeList);
});
return result;
};

View File

@@ -0,0 +1,19 @@
/*
* 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 typedKeys = <T extends Parameters<typeof Object.keys>[number]>(
o: T,
): Array<keyof T> => Object.keys(o) as Array<keyof T>;

View File

@@ -0,0 +1,29 @@
/*
* 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 { MdBoxLazy } from '@coze-arch/bot-md-box-adapter/lazy';
import { type ModelDescGroup } from '@coze-arch/bot-api/developer_api';
export const ModelDescription: React.FC<{
descriptionGroupList: ModelDescGroup[];
}> = ({ descriptionGroupList }) => (
<MdBoxLazy
autoFixSyntax={{ autoFixEnding: false }}
markDown={descriptionGroupList
.map(({ group_name, desc }) => `${group_name}\n${desc?.join('\n')}`)
.join('\n\n')}
/>
);

View File

@@ -0,0 +1,195 @@
/*
* 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 { lazy, Suspense } from 'react';
import classNames from 'classnames';
import {
Tag,
Highlight,
Typography,
Avatar,
type TagProps,
} from '@coze-arch/coze-design';
import { Popover } from '@coze-arch/bot-semi';
import { IconInfo } from '@coze-arch/bot-icons';
import { useFlags } from '@coze-arch/bot-flags';
import { type ModelDescGroup } from '@coze-arch/bot-api/developer_api';
const LazyModelDescription = lazy(async () => {
const { ModelDescription } = await import('./model-description');
return {
default: ModelDescription,
};
});
const ModelDescription = (props: {
descriptionGroupList: ModelDescGroup[];
}) => (
<Suspense>
<LazyModelDescription {...props} />
</Suspense>
);
export interface OptionItemTag {
label: string;
color?: TagProps['color'];
}
export interface OptionItemProps {
tokenLimit: number | undefined;
descriptionGroupList: ModelDescGroup[] | undefined;
avatar: string | undefined;
name: string | undefined;
searchWords?: string[];
endPointName?: string; // 接入点名称(专业版有)
showEndPointName?: boolean;
className?: string;
/**
* @deprecated
* 原先只会有「限额」标签M-5395720900 后会有大量新标签,避免兼容问题产品同意先简单隐藏掉标签展示
*/
tags?: OptionItemTag[];
}
export const ModelOptionItem: React.FC<OptionItemProps> = ({
avatar,
descriptionGroupList,
tokenLimit = 0,
name,
searchWords = [],
endPointName,
showEndPointName = false,
className,
}) => {
const [FLAGS] = useFlags();
const tags: OptionItemTag[] = [];
const shouldShowEndPoint = showEndPointName && endPointName;
// 社区版暂不支持该功能
const displayName = FLAGS['bot.studio.model_select_switch_end_point_name_pos']
? endPointName || name
: name;
// 社区版暂不支持该功能
const displayEndPointName = FLAGS[
'bot.studio.model_select_switch_end_point_name_pos'
]
? name
: endPointName;
return (
<div
className={classNames(
'w-full px-[8px] flex justify-between overflow-hidden gap-[16px]',
{
'py-2': showEndPointName,
},
className,
endPointName ? 'items-start' : 'items-center',
)}
>
<div
className="flex-1 flex items-center gap-[8px] overflow-hidden"
data-testid="bot.ide.bot_creator.select_model_formitem"
>
<Avatar
shape="square"
src={avatar}
className={classNames('shrink-0', {
'!h-4 !w-4': !showEndPointName,
'!h-8 !w-8': showEndPointName,
})}
data-testid="bot-detail.model-config-modal.model-avatar"
/>
<div
className={classNames('flex-1', {
'items-center': showEndPointName && !endPointName,
})}
>
<div className="flex items-center">
<span
className="inline-block truncate leading-[20px]"
data-testid="bot-detail.model-config-modal.model-name"
>
<Highlight
sourceString={displayName}
searchWords={searchWords}
highlightClassName="coz-fg-hglt-yellow bg-transparent"
/>
</span>
{descriptionGroupList?.length ? (
<Popover
trigger="hover"
className="max-w-[224px] py-[8px] px-[12px]"
content={
<ModelDescription
descriptionGroupList={descriptionGroupList}
data-testid="bot-detail.model-config-modal-model.description-popover"
/>
}
>
<IconInfo
data-testid="bot-detail.model-config-modal.model-info-button"
className="ml-[4px]"
/>
</Popover>
) : null}
<Tag
prefixIcon={null}
color="primary"
className="shrink-0 !ml-[8px]"
data-testid="bot-detail.model-config-modal.model-token-tag"
size="mini"
>
{(tokenLimit / 1024).toFixed(0)}K
</Tag>
</div>
{shouldShowEndPoint ? (
<Typography.Text
className="coz-fg-secondary text-[12px] leading-[16px]"
ellipsis={{
showTooltip: {
opts: {
content: displayEndPointName,
},
},
}}
>
<Highlight
sourceString={displayEndPointName}
searchWords={searchWords}
highlightClassName="coz-fg-hglt-yellow bg-transparent"
component="span"
/>
</Typography.Text>
) : null}
</div>
</div>
{tags?.length ? (
<div
className={classNames('flex shrink-0', {
'pt-[2px]': shouldShowEndPoint,
})}
>
{tags.map(tag => (
<Tag color={tag.color} key={tag.label} size="mini">
{tag.label}
</Tag>
))}
</div>
) : null}
</div>
);
};

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 {
MonetizeConfigPanel,
MonetizeConfigValue,
} from './monetize-config-panel';
export { MonetizeCreditRefreshCycle } from './monetize-credit-refresh-cycle';
export { MonetizeDescription } from './monetize-description';
export { MonetizeFreeChatCount } from './monetize-free-chat-count';
export { MonetizeSwitch } from './monetize-switch';
export { MonetizePublishInfo } from './monetize-publish-info';

View File

@@ -0,0 +1,101 @@
/*
* 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 { useDebounceFn } from 'ahooks';
import { type BotMonetizationRefreshPeriod } from '@coze-arch/bot-api/benefit';
import { MonetizeSwitch } from '../monetize-switch';
import { MonetizeFreeChatCount } from '../monetize-free-chat-count';
import { MonetizeDescription } from '../monetize-description';
import { MonetizeCreditRefreshCycle } from '../monetize-credit-refresh-cycle';
export interface MonetizeConfigValue {
/** 是否开启付费 */
isOn: boolean;
/** 开启付费后,用户免费体验的次数 */
freeCount: number;
/** 刷新周期 */
refreshCycle: BotMonetizationRefreshPeriod;
}
interface MonetizeConfigPanelProps {
disabled?: boolean;
value: MonetizeConfigValue;
onChange: (value: MonetizeConfigValue) => void;
/**
* 内置防抖后的 onChange 事件,业务侧可选择性使用,正常只传 onChange 即可
* (由于该组件是完全受控组件,因此不能只传 onDebouncedChange必须传 onChange 实时更新视图)
*/
onDebouncedChange?: (value: MonetizeConfigValue) => void;
}
export function MonetizeConfigPanel({
disabled = false,
value,
onChange,
onDebouncedChange,
}: MonetizeConfigPanelProps) {
const { run: debouncedChange } = useDebounceFn(
({ isOn, freeCount, refreshCycle }: MonetizeConfigValue) => {
onDebouncedChange?.({
isOn,
freeCount,
refreshCycle,
});
},
{ wait: 300 },
);
const refreshCycleDisabled = !value.isOn || disabled || value.freeCount <= 0;
return (
<div className="w-[480px] p-[24px] flex flex-col gap-[24px]">
<MonetizeSwitch
disabled={disabled}
isOn={value.isOn}
onChange={v => {
onChange({ ...value, isOn: v });
debouncedChange({
...value,
isOn: v,
});
}}
/>
<MonetizeDescription isOn={value.isOn} />
<MonetizeFreeChatCount
isOn={value.isOn}
disabled={disabled}
freeCount={value.freeCount}
onFreeCountChange={v => {
onChange({ ...value, freeCount: v });
debouncedChange({
...value,
freeCount: v,
});
}}
/>
<MonetizeCreditRefreshCycle
freeCount={value.freeCount}
disabled={refreshCycleDisabled}
refreshCycle={value.refreshCycle}
onRefreshCycleChange={v => {
onChange({ ...value, refreshCycle: v });
debouncedChange({ ...value, refreshCycle: v });
}}
/>
</div>
);
}

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 { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { Tooltip, Select } from '@coze-arch/coze-design';
import { BotMonetizationRefreshPeriod } from '@coze-arch/bot-api/benefit';
const refreshCycleTextMap: Record<BotMonetizationRefreshPeriod, string> = {
[BotMonetizationRefreshPeriod.Unknown]: I18n.t(
'coze_premium_credits_cycle_4',
),
[BotMonetizationRefreshPeriod.Never]: I18n.t('coze_premium_credits_cycle_4'),
[BotMonetizationRefreshPeriod.Day]: I18n.t('coze_premium_credits_cycle_1'),
[BotMonetizationRefreshPeriod.Week]: I18n.t('coze_premium_credits_cycle_2'),
[BotMonetizationRefreshPeriod.Month]: I18n.t('coze_premium_credits_cycle_3'),
};
const getOptionList = () => [
{
value: BotMonetizationRefreshPeriod.Never,
text: refreshCycleTextMap[BotMonetizationRefreshPeriod.Never],
},
{
value: BotMonetizationRefreshPeriod.Day,
text: refreshCycleTextMap[BotMonetizationRefreshPeriod.Day],
tooltip: I18n.t('coze_premium_credits_cycle_tip6'),
},
{
value: BotMonetizationRefreshPeriod.Week,
text: refreshCycleTextMap[BotMonetizationRefreshPeriod.Week],
tooltip: I18n.t('coze_premium_credits_cycle_tip7'),
},
{
value: BotMonetizationRefreshPeriod.Month,
text: refreshCycleTextMap[BotMonetizationRefreshPeriod.Month],
tooltip: I18n.t('coze_premium_credits_cycle_tip8'),
},
];
export function MonetizeCreditRefreshCycle({
refreshCycle,
onRefreshCycleChange,
disabled,
freeCount,
}: {
freeCount: number;
disabled: boolean;
refreshCycle: number;
onRefreshCycleChange: (value: number) => void;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4px">
<div className="coz-fg-primary text-lg font-medium">
{I18n.t('coze_premium_credits_cycle_5')}
</div>
<Tooltip
theme="dark"
content={I18n.t('coze_premium_credits_cycle_tip1')}
>
<IconCozInfoCircle className="text-base coz-fg-secondary" />
</Tooltip>
</div>
<Tooltip
key={freeCount}
trigger={freeCount <= 0 ? 'hover' : 'custom'}
content={I18n.t('coze_premium_credits_cycle_tip4')}
>
<Select
disabled={disabled}
onChange={value => {
onRefreshCycleChange(Number(value));
}}
value={refreshCycle}
position="bottomRight"
className="w-[140px]"
renderSelectedItem={(item: Record<string, unknown>) =>
refreshCycleTextMap[item.value as BotMonetizationRefreshPeriod]
}
>
{getOptionList().map(item => (
<Select.Option key={item.value} value={item.value}>
<div className="mx-8px w-[100px]">{item.text}</div>
{item.tooltip ? (
<Tooltip theme="dark" position="right" content={item.tooltip}>
<IconCozInfoCircle className="mr-8px coz-fg-secondary text-base" />
</Tooltip>
) : null}
</Select.Option>
))}
</Select>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1,43 @@
/*
* 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 { Popover } from '@coze-arch/coze-design';
import previewCard from './preview-card.png';
export function MonetizeDescription({ isOn }: { isOn: boolean }) {
return (
<div className="coz-fg-primary">
<span>
{isOn ? I18n.t('monetization_on_des') : I18n.t('monetization_off_des')}
</span>
{isOn ? (
<Popover
content={
<div className="p-[12px] coz-bg-max rounded-[10px]">
<img width={320} src={previewCard} />
</div>
}
>
<span className="coz-fg-hglt cursor-pointer">
{I18n.t('monetization_on_viewbill')}
</span>
</Popover>
) : null}
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -0,0 +1,64 @@
/*
* 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 { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { InputNumber, Tooltip } from '@coze-arch/coze-design';
export function MonetizeFreeChatCount({
isOn,
disabled,
freeCount,
onFreeCountChange,
}: {
isOn: boolean;
disabled: boolean;
freeCount: number;
onFreeCountChange: (value: number) => void;
}) {
return (
<div className="flex items-center justify-between">
<div>
<div className="flex items-center font-semibold leading-[20px]">
<span className="coz-fg-primary">
{I18n.t('free_chat_allowance')}
</span>
<Tooltip theme="dark" content={I18n.t('free_chat_allowance_tips')}>
<span className="ml-[4px] h-[12px] w-[12px] text-[12px] leading-[12px] coz-fg-secondary">
<IconCozInfoCircle />
</span>
</Tooltip>
</div>
<div className="coz-fg-secondary text-base leading-[16px]">
{freeCount > 5
? I18n.t('coze_premium_credits_cycle_tip2')
: I18n.t('coze_premium_credits_cycle_tip3')}
</div>
</div>
<InputNumber
keepFocus
className="w-[140px]"
disabled={!isOn || disabled}
precision={0}
min={0}
max={100}
value={freeCount}
onNumberChange={onFreeCountChange}
/>
</div>
);
}

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 cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { Avatar, Tooltip } from '@coze-arch/coze-design';
import { type PublishConnectorInfo } from '@coze-arch/bot-api/developer_api';
import { type BotMonetizationConfigData } from '@coze-arch/bot-api/benefit';
export function MonetizePublishInfo({
className,
monetizeConfig,
supportPlatforms,
}: {
className?: string;
monetizeConfig: BotMonetizationConfigData;
supportPlatforms: Array<Pick<PublishConnectorInfo, 'id' | 'name' | 'icon'>>;
}) {
const supportPlatformsText = supportPlatforms.map(p => p.name).join(', ');
return (
<div className={cls('flex justify-end items-center gap-[12px]', className)}>
<div className="flex items-center gap-[4px]">
<span className="font-medium coz-fg-plus">
{`${I18n.t('monetization')}: ${
monetizeConfig.is_enable
? I18n.t('monetization_publish_on')
: I18n.t('monetization_publish_off')
}`}
</span>
<Tooltip
content={
<div className="flex flex-col">
<div>
{monetizeConfig.is_enable
? I18n.t('monetization_on_des')
: I18n.t('monetization_off_des')}
</div>
{monetizeConfig.is_enable ? (
<div className="mt-[8px] pt-[8px] border-0 border-t border-solid coz-stroke-primary">
{`${I18n.t('free_chat_allowance')} : ${
monetizeConfig.free_chat_allowance_count
}`}
</div>
) : null}
</div>
}
>
<IconCozInfoCircle className="w-[16px] h-[16px] coz-fg-secondary" />
</Tooltip>
</div>
<div className="flex items-center gap-[4px]">
<span className="font-normal coz-fg-tertiary">
{I18n.t('monetization_support')}:
</span>
<span className="flex items-center gap-[4px]">
{supportPlatforms.map(p => (
<Avatar
key={p.id}
className="h-[16px] w-[16px] rounded-[4px]"
size="extra-extra-small"
shape="square"
src={p.icon}
/>
))}
</span>
<Tooltip
content={`${I18n.t(
'monetization_support_tips',
)}: ${supportPlatformsText}`}
>
<IconCozInfoCircle className="w-[16px] h-[16px] coz-fg-secondary" />
</Tooltip>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
/*
* 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 { Switch } from '@coze-arch/coze-design';
export function MonetizeSwitch({
disabled,
isOn,
onChange,
}: {
disabled: boolean;
isOn: boolean;
onChange: (isOn: boolean) => void;
}) {
return (
<div className="flex justify-between">
<h3 className="m-0 text-[20px] font-medium coz-fg-plus">
{I18n.t('premium_monetization_config')}
</h3>
<Switch
disabled={disabled}
className="ml-[5px]"
size="small"
checked={isOn}
onChange={onChange}
/>
</div>
);
}

View File

@@ -0,0 +1,18 @@
/*
* 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 { TopBar } from './top-bar';
export { SpaceAppList } from './space-app-list';

View File

@@ -0,0 +1,49 @@
.item {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
min-height: 32px;
padding: 6px 14px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
@apply coz-fg-secondary;
&:hover {
@apply coz-mg-primary coz-fg-primary;
}
}
.active {
@apply coz-mg-primary coz-fg-primary;
}
.item-link {
margin-bottom: 0;
text-decoration: none;
}
.tag-container {
@apply flex items-center gap-4px;
.label {
@apply px-4px text-foreground-2 font-semibold text-lg;
}
.tag {
@apply rounded-mini;
padding: 1px 6px;
}
&.active {
.label {
@apply coz-fg-primary;
}
}
}

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 { NavLink } from 'react-router-dom';
import React, { type ReactNode } from 'react';
import { isString } from 'lodash-es';
import classNames from 'classnames';
import { SpaceAppEnum } from '@coze-arch/web-context';
import { I18n, type I18nKeysNoOptionsType } from '@coze-arch/i18n';
import { Space, Badge } from '@coze-arch/coze-design';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import { useSpaceStore } from '@coze-arch/bot-studio-store';
import { getFlags } from '@coze-arch/bot-flags';
import { KnowledgeE2e, BotE2e } from '@coze-data/e2e';
import { useSpaceApp } from '@coze-foundation/space-store';
import s from './index.module.less';
interface MenuItem {
/**
* 如果是string需传入starling key并且会由div包一层
* 如果是function则自定义label的实现active表示是否是选中态
*/
label: string | ((active: boolean) => React.ReactNode);
/** label 外的 badge未来再扩展配置项 */
badge?: string;
app: SpaceAppEnum;
/**
* Q为什么不叫 visibleFG 要取反filter() 也要取反,很麻烦
* A为了兼容旧配置缺省时认定为 visible。避免合码时无冲突 导致忽略掉新增配置的问题。
*/
invisible?: boolean;
/** 目前24.05.21)没发现用处,怀疑是以前的功能迭代掉了,@huangjian 说先留着 */
icon?: ReactNode;
/** 目前24.05.21)没发现用处,怀疑是以前的功能迭代掉了,@huangjian 说先留着 */
selectedIcon?: ReactNode;
/** 自动化打标 */
e2e?: string;
}
const GET_MENU_SPACE_APP = (): Array<MenuItem> => [
{
label: 'menu_bots',
app: SpaceAppEnum.BOT,
e2e: BotE2e.BotTab,
},
{
label: 'menu_plugins',
app: SpaceAppEnum.PLUGIN,
},
{
label: 'menu_workflows',
app: SpaceAppEnum.WORKFLOW,
},
{
label: 'imageflow_title',
app: SpaceAppEnum.IMAGEFLOW,
invisible: false,
},
{
label: 'menu_datasets',
app: SpaceAppEnum.KNOWLEDGE,
e2e: KnowledgeE2e.KnowledgeTab,
},
{
label: 'menu_widgets',
app: SpaceAppEnum.WIDGET,
invisible: !getFlags()['bot.builder.bot.builder.widget'],
},
{
label: 'scene_resource_name',
badge: 'scene_beta_sign',
app: SpaceAppEnum.SOCIAL_SCENE,
invisible: !getFlags()['bot.studio.social'],
},
];
export const SpaceAppList = () => {
const spaceApp = useSpaceApp();
const { id: spaceId } = useSpaceStore(store => store.space);
return (
<Space spacing={4}>
{GET_MENU_SPACE_APP()
.filter(item => !item.invisible)
.map(item => {
const active = item.app === spaceApp;
const tabContent = (
<NavLink
key={item.app}
data-testid={item.e2e}
to={`/space/${spaceId}/${item.app}`}
className={s['item-link']}
onClick={() => {
sendTeaEvent(EVENT_NAMES.workspace_tab_expose, {
tab_name: item.app,
});
}}
>
{isString(item.label) ? (
<div
className={classNames({
[s.item]: true,
[s.active]: active,
})}
>
{I18n.t(item.label as I18nKeysNoOptionsType)}
</div>
) : (
item.label(active)
)}
</NavLink>
);
return item.badge ? (
<Badge
type="alt"
key={item.app}
count={I18n.t(item.badge as I18nKeysNoOptionsType)}
countStyle={{
backgroundColor: 'var(--coz-mg-color-plus-emerald)',
}}
>
{tabContent}
</Badge>
) : (
tabContent
);
})}
</Space>
);
};

View File

@@ -0,0 +1,38 @@
.topBar {
display: flex;
width: 100%;
flex-direction: column;
.des {
font-size: 14px;
font-style: normal;
font-weight: 600;
display: flex;
align-items: center;
margin: 0;
}
.tabs {
width: 100%;
text-align: left;
}
.name {
max-width: calc(100% - 28px);
word-break: break-word;
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 28px;
height: 28px;
@apply coz-fg-plus;
}
.split {
width: 1px;
height: 20px;
margin: 0 4px;
border-bottom: none;
background-color: var(--coz-stroke-primary);
}
}

View File

@@ -0,0 +1,124 @@
/*
* 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, { type JSX } from 'react';
import classnames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozSetting } from '@coze-arch/coze-design/icons';
import {
Typography,
Space,
IconButton,
Divider,
Avatar,
} from '@coze-arch/coze-design';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import { useSpaceStore } from '@coze-arch/bot-studio-store';
import { useNavigate } from 'react-router-dom';
import { SpaceAppList } from '../space-app-list';
import s from './index.module.less';
interface TopBarProps {
showActions?: boolean;
showFilter?: boolean;
isPersonal?: boolean;
actions: JSX.Element;
children: React.ReactNode;
className?: string;
titleExtend?: JSX.Element;
}
export const TopBar = (props: TopBarProps) => {
const { Text } = Typography;
const navigate = useNavigate();
const {
space: { name: spaceName, id: spaceId, icon_url: spaceIconUrl },
} = useSpaceStore();
const {
showActions,
showFilter,
isPersonal,
actions,
children,
className,
titleExtend,
} = props;
const settingLabel = I18n.t('basic_setting');
return (
<div className={classnames(s.topBar, className)}>
<Space className="w-full flex justify-between mb-24px">
<Space>
<div className={s.des}>
<Avatar
src={spaceIconUrl}
size="small"
style={{ marginRight: 8 }}
/>
<Text
ellipsis={{
showTooltip: {
opts: { content: spaceName },
},
}}
className={classnames(s.name, '!max-w-[320px]')}
>
{spaceName}
</Text>
{titleExtend}
</div>
</Space>
<Space spacing={8} className="flex items-center align-right">
{showActions ? actions : null}
{!isPersonal && (
<>
<Divider layout="horizontal" className={s.split} />
<IconButton
color="primary"
type="primary"
size="large"
onClick={() => {
sendTeaEvent(EVENT_NAMES.workspace_tab_expose, {
tab_name: 'team_manage',
});
navigate(`/space/${spaceId}/team`);
}}
icon={<IconCozSetting />}
aria-label={settingLabel}
/>
</>
)}
</Space>
</Space>
<div className={s.tabs}>
<Space className="w-full flex justify-between">
<Space spacing={8} className="flex items-center align-left shrink-0">
<SpaceAppList />
</Space>
<Space
className="!flex items-center !overflow-hidden shrink-1"
spacing={8}
>
{showFilter ? children : null}
</Space>
</Space>
</div>
</div>
);
};

View File

@@ -0,0 +1,17 @@
.plugin-limit-table {
:global {
.semi-table-thead>.semi-table-row>.semi-table-row-head {
height: 16px;
padding: 4px 8px;
background-color: transparent;
}
.semi-table-row{
background-color: transparent !important;
}
.semi-table-tbody .semi-table-row .semi-table-row-cell {
padding: 8px !important;
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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, { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Avatar, Typography, UITable, useUIModal } from '@coze-arch/bot-semi';
import {
type PluginPricingRule,
PluginPricingStrategy,
} from '@coze-arch/bot-api/plugin_develop';
import styles from './index.module.less';
export const PluginLimitName: FC<{
name: string;
url: string;
}> = ({ name, url }) => (
<span className="flex items-center">
<Avatar
size="small"
className="h-6 flex-none flex-shrink-0 mr-2 w-6"
shape="square"
src={url}
></Avatar>
<Typography.Text ellipsis={{ showTooltip: true }}>{name}</Typography.Text>
</span>
);
export interface UsePluginLimitModalProps {
content: React.ReactNode;
dataSource: Array<{
info: {
name: string;
url: string;
};
price: number;
}>;
}
export const transPricingRules = (
pluginPricingRules?: Array<PluginPricingRule>,
) =>
Array.isArray(pluginPricingRules)
? pluginPricingRules
.filter(item => item.PricingStrategy !== PluginPricingStrategy.Free)
.map(item => ({
info: {
name: item?.PluginInfo?.name,
url: item?.PluginInfo?.plugin_icon,
},
price: parseInt(item?.PriceResult?.TokensForOnce ?? '0'),
}))
: [];
export const usePluginLimitModal = ({
content,
dataSource,
}: UsePluginLimitModalProps) => {
const { modal, open, close } = useUIModal({
okText: I18n.t('plugin_usage_limits_modal_got_it_button'),
onOk: () => {
close();
},
title: I18n.t('plugin_usage_limits_modal_title'),
hasCancel: false,
onCancel: () => {
close();
},
});
return {
node: modal(
<>
{content ? content : null}
<UITable
tableProps={{
className: styles['plugin-limit-table'],
columns: [
{
width: 192,
title: I18n.t('plugin_usage_limits_modal_table_header_plugin'),
dataIndex: 'info',
render: info => (
<PluginLimitName name={info.name} url={info.url} />
),
},
{
title: I18n.t('plugin_usage_limits_modal_table_header_price'),
dataIndex: 'price',
render: text => <span>{text} Coze tokens</span>,
},
],
dataSource,
size: 'small',
}}
/>
</>,
),
open: () => {
open();
},
close,
};
};

View File

@@ -0,0 +1,859 @@
/* stylelint-disable selector-class-pattern */
/* stylelint-disable no-descending-specificity */
/* stylelint-disable declaration-no-important */
/* stylelint-disable max-nesting-depth */
@import '@coze-common/assets/style/common.less';
@import '@coze-common/assets/style/mixins.less';
.text {
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 16px;
}
.plugin-item {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
width: calc(100% - 68px);
height: 98px;
margin: 14px 0 0 68px;
padding-right: 12px;
padding-bottom: 14px;
&:not(:last-child) {
position: relative;
border-bottom: 1px solid rgba(28, 31, 35, 8%);
}
.plugin-api-main {
flex: 1;
.plugin-api-name {
width: 100%;
:global {
.semi-typography {
font-size: 16px;
font-weight: 600;
line-height: 22px;
color: #1c1d23 !important;
}
}
}
.plugin-api-desc {
display: flex;
flex-direction: column;
width: 100%;
margin-top: 4px;
:global {
.semi-typography {
font-size: 12px;
line-height: 16px;
color:
rgba(28, 31, 35, 60%) !important;
}
}
.api-params {
display: flex;
align-items: center;
margin-top: 12px;
.params-tags {
max-width: 500px;
.tag-item {
width: fit-content;
min-width: fit-content;
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16px;
/* 133.333% */
color: rgba(28, 29, 35, 60%);
border-radius: 6px;
}
&> :not(:first-child) {
margin-left: 16px;
}
}
.params-desc {
cursor: pointer;
flex-shrink: 0;
font-size: 12px;
line-height: 16px;
color: #4d53e8;
letter-spacing: 0.12px !important;
}
}
}
}
.plugin-api-method {
display: flex;
flex-shrink: 0;
align-items: center;
margin-left: 20px;
}
}
.between {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.tools-content {
padding: 0 20px 24px;
.tools-table-thead {
padding-bottom: 6px;
th {
padding: 6px 0 8px;
}
}
}
.api-table {
table-layout: fixed;
border-collapse: collapse;
width: 100%;
height: 32px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
/* 133.333% */
color: rgba(28, 31, 35, 60%);
word-wrap: break-word;
thead {
th {
font-size: 12px;
font-weight: 400;
line-height: 16px;
/* 133.333% */
color: #888d92;
text-align: left;
}
}
.api-row {
&.border-top {
position: relative;
td {
margin-top: 4px;
}
}
&:last-child {
border-bottom: 1px solid #f9f9f9;
}
td {
overflow: hidden;
padding-bottom: 4px;
}
&-name {
vertical-align: middle;
}
:global {
.semi-tag-grey-light {
height: 20px;
background: #fff;
border-radius: 6px;
}
.semi-tag-content {
align-items: center;
justify-content: space-between;
}
}
}
.api-plugin {
display: flex;
align-items: center;
height: 24px;
padding-right: 10px;
&-image {
display: flex;
flex-shrink: 0;
margin-right: 8px;
>img {
width: 16px;
height: 16px;
}
}
:global {
.semi-image-status {
background-color: rgba(#fff, 0) !important;
}
}
&-name {
font-size: 12px;
font-weight: 400;
line-height: 22px;
color: rgba(28, 29, 35, 80%);
}
}
.api-method {
justify-content: space-between;
width: 100%;
height: 100%;
text-align: center;
span {
color: rgba(28, 31, 35, 35%);
}
.icon-config {
cursor: pointer;
color: rgba(107, 109, 117, 100%);
}
}
.api-method-read {
justify-content: flex-end;
}
.api-name {
display: flex;
align-items: center;
padding: 2px 0;
padding-right: 10px;
&-text {
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: rgba(29, 28, 35, 80%);
}
:global {
.semi-tag-grey-light {
background: #fff !important;
}
}
.api-divider {
height: 12px;
border-color: #b3c4ff;
}
.copy {
cursor: copy;
}
.icon-tips:hover {
background-color: var(--semi-color-fill-0);
}
.icon-tips {
cursor: pointer;
.common-svg-icon(14px, rgba(107, 109, 117, 1));
padding: 2px;
border-radius: 4px;
}
&-publish {
flex-shrink: 0;
margin-left: 4px;
}
}
}
.popover-content {
box-sizing: border-box;
max-width: 264px;
}
.popover-api-name {
font-size: 14px;
font-weight: 700;
line-height: 20px;
color: #1c1f23;
word-wrap: break-word;
}
.popover-api-desc {
padding: 4px 0;
font-size: 12px;
line-height: 16px;
color: rgba(28, 31, 35, 60%);
word-wrap: break-word;
}
.plugin-panel-header {
display: flex;
flex: 1;
flex-wrap: nowrap;
align-items: center;
width: 0;
min-width: 0;
height: 80px;
font-weight: 400;
.creator-icon {
display: flex;
flex-shrink: 0;
border-radius: 50%;
img {
width: 14px;
height: 14px;
}
}
.creator-time {
/* Paragraph/small/EN-Regular */
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px;
/* 133.333% */
color: rgba(28, 29, 35, 35%);
text-align: right;
letter-spacing: 0.12px;
}
.header-icon {
display: flex;
flex-shrink: 0;
margin-right: 16px;
img {
width: 36px;
height: 36px;
background: #fff;
}
}
.header-main {
overflow: hidden;
flex: 1;
width: 0;
min-width: 0;
margin-right: 16px;
// margin-top: -12px;
.header-name {
width: 100%;
:global {
.semi-typography {
font-size: 16px;
font-weight: 600;
line-height: 22px;
color: #1c1d23 !important;
word-wrap: break-word !important;
}
.semi-highlight-tag {
color: #fda633;
background-color: transparent;
}
}
}
.header-desc {
width: 100%;
:global {
.semi-typography {
font-size: 12px;
font-weight: 400;
color:
rgba(28, 29, 35, 80%) !important;
letter-spacing: 0.12px;
word-wrap: break-word !important;
}
}
}
}
.header-info {
/* Paragraph/small/EN-Regular */
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 16px;
/* 133.333% */
color: rgba(28, 29, 35, 35%);
text-align: right;
:global {
.semi-divider-vertical {
height: 10px;
color: rgba(28, 29, 35, 12%);
}
}
}
.header-tags {
margin: 6px 0;
}
.header-tag {
letter-spacing: 0.12px !important;
border-radius: 6px;
&:last-child {
margin-right: 0;
}
}
}
.plugin-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 24px;
font-size: 18px;
font-weight: 600;
line-height: 24px;
color: #1c1f23;
&-title {
width: 218px;
padding: 24px;
line-height: 16px;
background: #ebedf0;
}
}
.plugin-modal-filter {
display: flex;
gap: 12px;
}
.composition-modal-layout {
background: #ebedf0;
}
.plugin-modal {
height: 100%;
.iconSearch {
.common-svg-icon(16px, rgba(28, 29, 35, 0.35));
margin-right: 8px;
margin-left: 16px;
}
.tool-tag-list {
overflow: auto;
flex: 1;
flex-shrink: 0;
padding-top: 16px;
white-space: nowrap;
&-label {
height: 40px;
margin-bottom: 8px;
padding: 0 12px;
font-size: 12px;
font-weight: 600;
line-height: 40px;
color: rgba(28, 29, 35, 35%);
}
&-cell {
cursor: pointer;
position: relative;
display: flex;
align-items: center;
height: 44px;
margin-bottom: 4px;
padding: 0 10px 0 12px;
font-size: 14px;
line-height: 44px;
color: #1d1c23;
border-radius: 3px;
&-icon {
display: flex;
flex-shrink: 0;
margin-right: 8px;
.common-svg-icon(24px, #1d1c23);
>img {
width: 24px;
height: 24px;
padding: 4px;
}
}
&-divider {
width: calc(100% - 24px);
margin: 12px;
background: rgba(28, 29, 35, 12%);
}
&:hover {
color: #1c1f23;
background:
rgba(46, 50, 56, 5%);
border-radius: 8px;
}
&.active {
font-size: 14px;
font-weight: 600;
color: var(--light-usage-text-color-text-0, #1c1d23);
background:
rgba(46, 47, 56, 5%);
border-radius: 8px;
.tool-tag-list-cell-icon {
.common-svg-icon(24px, #4d53e8);
}
}
}
}
.addbtn {
margin-top: 24px;
}
.plugin-filter {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 218px;
background: #ebedf0;
}
.tool-content-area {
overflow-y: auto;
height: 100%;
}
.plugin-modal-filter {
display: flex;
}
.plugin-content {
overflow: auto;
width: 100%;
height: 100%;
padding-bottom: 12px;
.loading-more {
text-align: center;
}
.plugin-content-filter {
display: flex;
padding: 0 36px;
padding-left: 22px;
.plugin-content-sort {
width: 150px;
}
.bot-tag {
display: flex;
align-items: center;
}
:global {
.semi-tabs-content {
padding: 0;
}
.semi-tabs-tab-button.semi-tabs-tab-active {
color: rgba(28, 29, 35, 80%);
background-color: transparent;
.semi-icon {
.common-svg-icon(20px, rgba(28, 29, 35, 0.8));
}
}
.semi-tabs-tab-single.semi-tabs-tab-active .semi-icon:not(.semi-icon-checkbox_tick,
.semi-icon-radio,
.semi-icon-checkbox_indeterminate) {
top: 0;
color: rgba(28, 29, 35, 80%);
}
.semi-tabs-tab-single.semi-tabs-tab .semi-icon:not(.semi-icon-checkbox_tick,
.semi-icon-radio,
.semi-icon-checkbox_indeterminate) {
top: 0;
}
.semi-tabs-tab:last-child::before {
content: '';
position: absolute;
top: 12px;
left: -4px;
width: 1px;
height: 16px;
background-color:
rgba(28, 29, 35, 12%);
}
}
}
.plugin-collapse {
:global {
.semi-collapse-item {
position: relative;
border: none;
}
.semi-collapse-header:hover::before {
content: '';
position: absolute;
top: -1px;
left: 0;
width: 100%;
height: 1px;
background-color: #f7f7fa;
}
.semi-collapse-header {
margin: 0;
border-bottom: 1px solid #dfdfdf;
border-radius: 0;
&:hover {
background:
rgba(46, 47, 56, 5%);
border-bottom: 1px solid transparent;
border-radius: 8px;
}
&:active {
background:
rgba(46, 47, 56, 5%);
}
}
.semi-collapse-header-icon {
width: auto;
height: 24px;
&:hover {
background:
rgba(46, 47, 56, 9%);
border-radius: 5px;
}
}
}
.collapse-icon {
.common-svg-icon(16px, rgba(28, 29, 35, 0.35));
cursor: pointer;
padding: 4px;
}
.activePanel {
margin-bottom: 8px;
background:
rgba(46, 47, 56, 5%);
border: none;
border-radius: 8px;
:global {
.semi-collapse-header {
border-bottom: 1px solid #dfdfdf;
}
.semi-collapse-header:hover {
background: transparent;
}
}
}
}
}
:global {
.semi-modal-content {
padding: 0;
}
.semi-spin-children {
height: 100%;
}
}
}
.plugin-content,
.plugin-collapse {
:global {
.semi-collapse-header {
height: 120px !important;
margin: 0 !important;
padding: 14px 16px;
&[aria-expanded='true'] {
border-radius: 8px 8px 0 0 !important;
}
}
.semi-collapse {
padding: 16px 0 12px;
}
.semi-collapse-content {
// background-color: #fff;
padding: 0;
border-radius: 0 0 8px 8px;
// border-color: var(
// --light-usage-border-color-border,
// rgba(28, 31, 35, 0.08)
// );
// border-width: 0 1px 1px 1px;
// border-style: solid;
}
.semi-collapse-item {
// border: 0;
}
}
}
.parameter-item {
margin-top: 12px;
font-size: 12px;
line-height: 16px;
.parameter-text {
max-width: 100%;
margin-bottom: 4px;
color: #1c1f23;
.parameter-name {
font-weight: 700;
word-wrap: break-word;
}
.parameter-type {
color: rgba(28, 31, 35, 80%);
}
.parameter-required {
color: #fc8800;
}
}
.parameter-desc {
color: rgba(28, 31, 35, 60%);
word-wrap: break-word;
}
}
.apis-add-icon {
.common-svg-icon;
}
.default-text {
.text;
color: rgba(28, 29, 35, 60%);
}
.operator-btn {
width: 98px;
&.added {
color: #b4baf6;
background: #fff;
border: 1px solid #f0f0f5;
}
&.addedMouseIn {
color: #ff441e;
background: #fff;
border: 1px solid rgba(29, 28, 35, 12%);
}
}
.tab-select {
margin-right: 10px;
}
.hide-button-model-wrap {
.ml20 {
margin-left: 20px;
}
.h56 {
height: 56px;
}
.search-input {
position: absolute;
z-index: 9;
top: 20px;
width: 100%;
background: #fff;
}
}
.plugin-func-collapse {
.plugin-api-desc {
cursor: pointer;
width: 200px !important;
}
}

View File

@@ -0,0 +1,139 @@
/*
* 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 { sortBy } from 'lodash-es';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
import { Popover, Space } from '@coze-arch/bot-semi';
import {
PluginParamTypeFormat,
type PluginApi,
type PluginParameter,
} from '@coze-arch/bot-api/developer_api';
import s from './index.module.less';
interface ParametersPopoverProps extends PopoverProps {
pluginApi: PluginApi;
callback?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onVisibleChange?: (visible: boolean) => void;
}
enum AssistType {
File = 1,
Image = 2,
Doc = 3,
Code = 4,
Ppt = 5,
Txt = 6,
Excel = 7,
Audio = 8,
Zip = 9,
Video = 10,
Svg = 11,
}
const assistTypeToDisplayMap = {
[AssistType.File]: 'File',
[AssistType.Image]: 'Image',
[AssistType.Doc]: 'Doc',
[AssistType.Code]: 'Code',
[AssistType.Ppt]: 'PPT',
[AssistType.Txt]: 'Txt',
[AssistType.Excel]: 'Excel',
[AssistType.Audio]: 'Audio',
[AssistType.Zip]: 'Zip',
[AssistType.Video]: 'Video',
[AssistType.Svg]: 'Svg',
};
const getDisplayType = (parameter: Readonly<PluginParameter>) => {
const { type, format } = parameter;
const { assist_type } = parameter as { assist_type: AssistType };
let displayType = type;
if (type === 'string' && format === PluginParamTypeFormat.ImageUrl) {
displayType = 'image';
} else if (type === 'string' && assist_type) {
displayType = assistTypeToDisplayMap[assist_type];
}
return displayType;
};
const ParameterItem: React.FC<{ parameter: Readonly<PluginParameter> }> = ({
parameter,
}) => {
const { name, desc, required } = parameter;
return (
<div className={s['parameter-item']}>
<Space className={s['parameter-text']} wrap>
<span className={s['parameter-name']}>{name}</span>
<span className={s['parameter-type']}>{getDisplayType(parameter)}</span>
{required ? (
<span className={s['parameter-required']}>
{I18n.t('tool_para_required')}
</span>
) : null}
</Space>
<div className={s['parameter-desc']}>{desc}</div>
</div>
);
};
export const ParametersPopover: React.FC<
PropsWithChildren<ParametersPopoverProps>
> = ({ children, pluginApi, callback, onVisibleChange, ...props }) => (
<Popover
trigger={props?.trigger || 'hover'}
position="right"
showArrow
onVisibleChange={onVisibleChange}
content={
<div
className={classNames(
'max-h-[400px] overflow-x-hidden overflow-y-auto',
s['popover-content'],
)}
onClick={e => {
callback?.(e);
}}
>
{pluginApi.name ? (
<div className={s['popover-api-name']}>{pluginApi.name}</div>
) : null}
{pluginApi.desc ? (
<div className={s['popover-api-desc']}>{pluginApi.desc}</div>
) : null}
{sortBy(pluginApi.parameters || [], item => item.name?.length)?.map(
p => {
if (!p) {
return null;
}
return <ParameterItem parameter={p} key={p.name} />;
},
)}
</div>
}
{...props}
>
<div>{children}</div>
</Popover>
);

View File

@@ -0,0 +1,17 @@
.tip-content {
width: 180px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 18px;
color: var(--light-color-grey-grey-8, #2e3238);
li {
list-style-type: disc;
}
}
.markdown p {
margin: 4px 0;
}

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 React, {
Suspense,
lazy,
type PropsWithChildren,
type ReactNode,
type CSSProperties,
} from 'react';
import classNames from 'classnames';
import s from './index.module.less';
// react-markdown 20ms 左右的 longtask
const LazyReactMarkdown = lazy(() => import('react-markdown'));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ReactMarkdown = (props: any) => (
<Suspense fallback={null}>
<LazyReactMarkdown {...props} />
</Suspense>
);
export const PopoverContent: React.FC<
PropsWithChildren & {
text?: string;
node?: ReactNode;
className?: string;
style?: CSSProperties;
}
> = ({ children, className, style }) => (
<div className={classNames(s['tip-content'], className)} style={style}>
{typeof children === 'string' ? (
<ReactMarkdown skipHtml={true} className={s.markdown}>
{children}
</ReactMarkdown>
) : (
children
)}
</div>
);

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.
*/
import { useRef, useState } from 'react';
import { useRequest } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { extractTemplateActionCommonParams } from '@coze-arch/bot-tea/utils';
import {
EVENT_NAMES,
type ParamsTypeDefine,
sendTeaEvent,
} from '@coze-arch/bot-tea';
import {
ProductEntityType,
type ProductInfo,
} from '@coze-arch/bot-api/product_api';
import { ProductApi } from '@coze-arch/bot-api';
import { botInputLengthService } from '@coze-agent-ide/bot-input-length-limit';
import {
type BaseFormProps,
Form,
FormInput,
Modal,
type ModalProps,
type FormApi,
} from '@coze-arch/coze-design';
import { SpaceFormSelect } from '../space-form-select';
import { appendCopySuffix } from './utils';
export interface ProjectTemplateCopyValue {
productId: string;
name: string;
spaceId?: string;
}
const filedKeyMap: Record<
keyof ProjectTemplateCopyValue,
keyof ProjectTemplateCopyValue
> = {
name: 'name',
spaceId: 'spaceId',
productId: 'productId',
} as const;
interface ProjectTemplateCopyModalProps
extends Omit<ModalProps, 'size' | 'okText' | 'cancelText'> {
isSelectSpace: boolean;
formProps: BaseFormProps<ProjectTemplateCopyValue>;
}
export const ProjectTemplateCopyModal: React.FC<
ProjectTemplateCopyModalProps
> = ({ isSelectSpace, formProps, ...modalProps }) => (
<Modal
size="default"
okText={I18n.t('Confirm')}
cancelText={I18n.t('Cancel')}
{...modalProps}
>
<Form<ProjectTemplateCopyValue> {...formProps}>
<FormInput
label={I18n.t('creat_project_project_name')}
rules={[{ required: true }]}
field={filedKeyMap.name}
maxLength={botInputLengthService.getInputLengthLimit('projectName')}
getValueLength={botInputLengthService.getValueLength}
noErrorMessage
/>
{isSelectSpace ? <SpaceFormSelect field={filedKeyMap.spaceId} /> : null}
</Form>
</Modal>
);
export type ProjectTemplateCopySuccessCallback = (param: {
originProductId: string;
newEntityId: string;
spaceId: string;
}) => void;
export const useProjectTemplateCopyModal = (props: {
modalTitle: string;
/** 是否需要选择 space */
isSelectSpace: boolean;
onSuccess?: ProjectTemplateCopySuccessCallback;
/** 埋点参数 - 当前页面/来源 */
source: NonNullable<
ParamsTypeDefine[EVENT_NAMES.template_action_front]['source']
>;
}) => {
const [visible, setVisible] = useState(false);
const [initValues, setInitValues] = useState<ProjectTemplateCopyValue>();
const [sourceProduct, setSourceProduct] = useState<ProductInfo>();
const [isFormValid, setIsFormValid] = useState(true);
const formApi = useRef<FormApi<ProjectTemplateCopyValue>>();
const onModalClose = () => {
setVisible(false);
setInitValues(undefined);
formApi.current = undefined;
setIsFormValid(true);
};
const { run, loading } = useRequest(
async (copyRequestParam: ProjectTemplateCopyValue | undefined) => {
if (!copyRequestParam) {
throw new Error('duplicate project template values not provided');
}
const { productId, spaceId, name } = copyRequestParam;
return ProductApi.PublicDuplicateProduct({
product_id: productId,
space_id: spaceId,
name,
entity_type: ProductEntityType.ProjectTemplate,
});
},
{
manual: true,
onSuccess: (data, [inputParam]) => {
onModalClose();
sendTeaEvent(EVENT_NAMES.template_action_front, {
action: 'duplicate',
after_id: data.data?.new_entity_id,
source: props.source,
...extractTemplateActionCommonParams(sourceProduct),
});
props?.onSuccess?.({
originProductId: inputParam?.productId ?? '',
newEntityId: data.data?.new_entity_id ?? '',
spaceId: inputParam?.spaceId ?? '',
});
},
},
);
return {
modalContextHolder: (
<ProjectTemplateCopyModal
title={props.modalTitle}
isSelectSpace={props.isSelectSpace}
visible={visible}
okButtonProps={{
disabled: !isFormValid,
loading,
}}
onOk={async () => {
const val = await formApi.current?.validate();
if (val) {
run(val);
}
}}
onCancel={onModalClose}
formProps={{
initValues,
onValueChange: val => {
// 当用户删除 input 中所有字符时val.name 字段会消失,而不是空字符串,神秘
setIsFormValid(!!val.name?.trim());
},
getFormApi: api => {
formApi.current = api;
},
}}
/>
),
copyProject: ({
initValue,
sourceProduct: inputSourceProduct,
}: {
initValue: ProjectTemplateCopyValue;
/** 用于提取埋点参数 */
sourceProduct: ProductInfo;
}) => {
setInitValues({
...initValue,
name: botInputLengthService.sliceStringByMaxLength({
value: appendCopySuffix(initValue.name),
field: 'projectName',
}),
});
setSourceProduct(inputSourceProduct);
setVisible(true);
setIsFormValid(!!initValue?.name?.trim());
},
};
};
export { appendCopySuffix };

View File

@@ -0,0 +1,20 @@
/*
* 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';
export const appendCopySuffix = (name: string) =>
`${name}(${I18n.t('duplicate_rename_copy')})`;

View File

@@ -0,0 +1,9 @@
.handle {
width: 1px;
background-color: var(--coz-stroke-primary);
}
.hot-zone:hover .handle, .handle-moving {
width: 4px;
background-color: var(--coz-stroke-hglt);
}

View File

@@ -0,0 +1,134 @@
/*
* 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,
type FC,
useRef,
useCallback,
useState,
} from 'react';
import classnames from 'classnames';
import s from './handle.module.less';
// 目前只支持水平方向,按需扩展吧
export interface ResizableLayoutHandleProps {
className?: string;
hotZoneClassName?: string;
onMove: (offset: number) => void;
onMoveStart: () => void;
onMoveEnd: () => void;
}
interface HandleState {
startX: number;
moving: boolean;
}
const hotZoneStyle = classnames(
s['hot-zone'],
'flex items-stretch justify-center',
'cursor-col-resize',
'z-10',
'w-[8px] mx-[-3.5px]',
'bg-transparent',
);
const handleStyle = classnames('transition-width duration-300 ease-in-out');
export const ResizableLayoutHandle: FC<ResizableLayoutHandleProps> = ({
className,
hotZoneClassName,
onMove,
onMoveStart,
onMoveEnd,
}) => {
const [moving, setMoving] = useState(false);
const stateRef = useRef<HandleState>({
startX: 0,
moving: false,
});
const callbackRef = useRef({
onMove,
onMoveStart,
onMoveEnd,
});
callbackRef.current = {
onMove,
onMoveStart,
onMoveEnd,
};
const moveEnd = useCallback(() => {
setMoving(false);
stateRef.current = {
startX: 0,
moving: false,
};
offEvents();
callbackRef.current.onMoveEnd();
}, []);
const move = useCallback((e: PointerEvent) => {
if (stateRef.current.moving) {
callbackRef.current.onMove(e.clientX - stateRef.current.startX);
}
}, []);
const offEvents = () => {
window.removeEventListener('pointermove', move, false);
// 适配移动端出现多点触控的情况
window.removeEventListener('pointerdown', moveEnd, false);
window.removeEventListener('pointerup', moveEnd, false);
window.removeEventListener('pointercancel', moveEnd, false);
};
const onMouseDown: MouseEventHandler<HTMLDivElement> = e => {
stateRef.current = {
moving: true,
startX: e.pageX,
};
setMoving(true);
callbackRef.current.onMoveStart();
window.addEventListener('pointermove', move, false);
// 适配移动端出现多点触控的情况
window.addEventListener('pointerdown', moveEnd, false);
window.addEventListener('pointerup', moveEnd, false);
window.addEventListener('pointercancel', moveEnd, false);
};
// TODO hover 样式 & 热区宽度需要和 UI 对齐
return (
<div
className={classnames(hotZoneStyle, hotZoneClassName)}
onMouseDown={onMouseDown}
>
<div
className={classnames(
className,
s.handle,
moving && s['handle-moving'],
handleStyle,
)}
/>
</div>
);
};
ResizableLayoutHandle.displayName = 'ResizableLayoutHandle';

View File

@@ -0,0 +1,174 @@
/*
* 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 {
Children,
type PropsWithChildren,
useRef,
type FC,
isValidElement,
cloneElement,
type ReactNode,
useState,
} from 'react';
import { sum } from 'lodash-es';
import classnames from 'classnames';
import { useDebounceEffect, useSize } from 'ahooks';
import { type ResizableLayoutProps } from './types';
import { ResizableLayoutHandle } from './handle';
interface LayoutState {
moving: boolean;
itemWidth: number[];
}
const getDefaultState = () => ({
moving: false,
itemWidth: [],
});
export const ResizableLayout: FC<PropsWithChildren<ResizableLayoutProps>> = ({
className,
children,
handleClassName,
hotZoneClassName,
}) => {
const [state, setState] = useState<LayoutState>(getDefaultState());
const containerRef = useRef<HTMLDivElement>(null);
const childRef = useRef<HTMLElement[]>([]);
const size = useSize(containerRef);
useDebounceEffect(
() => {
if (!size?.width) {
return;
}
const totalSize = sum(state.itemWidth);
// 排除还没有进行过拖拽的情况,此时本地 state 中没有记录上次分配的宽度
if (totalSize <= 0) {
return;
}
const ratio = size.width / totalSize;
const newItemWidth = state.itemWidth.map(w => w * ratio);
childRef.current.forEach(
(item, index) => (item.style.width = `${newItemWidth[index]}px`),
);
setState({
...state,
itemWidth: newItemWidth,
});
},
[size?.width],
{
wait: 20,
maxWait: 100,
},
);
return (
<div
className={classnames(
'flex w-full items-stretch',
className,
state.moving && 'cursor-col-resize select-none',
)}
ref={containerRef}
>
{Children.map(children, (child, index) => {
let node: ReactNode;
if (isValidElement(child)) {
node = cloneElement(
child,
Object.assign({}, child.props, {
ref: (target: React.ReactNode) => {
if (target instanceof HTMLElement) {
childRef.current[index] = target;
} else {
if (!IS_PROD && target) {
throw Error(
'children of ResizableLayout need a ref of HTMLElement',
);
}
}
// @ts-expect-error -- 跳过类型体操
const { ref } = child;
if (typeof ref === 'function') {
ref(target);
} else if (ref && typeof ref === 'object') {
ref.current = target;
}
},
}),
);
} else {
node = (
<div
ref={elm => {
if (elm) {
childRef.current[index] = elm;
}
}}
>
{child}
</div>
);
}
return (
<>
{index > 0 && (
<ResizableLayoutHandle
className={handleClassName}
hotZoneClassName={hotZoneClassName}
onMoveStart={() => {
setState({
moving: true,
itemWidth: childRef.current.map(
item => item.clientWidth ?? 0,
),
});
}}
// 相对于初始位置的偏移量
onMove={offset => {
const pre = index - 1;
childRef.current[pre].style.width = `${
state.itemWidth[pre] + offset
}px`;
childRef.current[index].style.width = `${
state.itemWidth[index] - offset
}px`;
}}
onMoveEnd={() => {
setState({
// 拖拽结束后,记录真实宽度
itemWidth: childRef.current.map(
item => item.clientWidth ?? 0,
),
moving: false,
});
}}
/>
)}
{node}
</>
);
})}
</div>
);
};

View File

@@ -0,0 +1,21 @@
/*
* 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 interface ResizableLayoutProps {
className?: string;
handleClassName?: string;
hotZoneClassName?: string;
}

Some files were not shown because too many files have changed in this diff Show More