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,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,181 @@
/*
* 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 {
useRef,
useState,
type ComponentProps,
type CSSProperties,
} from 'react';
import classNames from 'classnames';
import { useHover } from 'ahooks';
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 { type TriggerAction } from '../../type';
import { getIsFileFormatValid } from '../../helpers/get-is-file-format-valid';
import {
FILE_EXTENSION_LIST,
getFileSizeReachLimitI18n,
MAX_FILE_SIZE,
} from '../../constant/file';
import { getFixedVariableTemplate } from '../../../../utils/onboarding-variable';
import { OnboardingVariable } from '../../../../constant/onboarding-variable';
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';
export interface ActionBarProps {
className?: string;
style?: CSSProperties;
onTriggerAction?: (action: TriggerAction) => void;
}
const iconButtonProps: ComponentProps<typeof UIButton> = {
size: 'small',
type: 'tertiary',
theme: 'borderless',
className: styles['icon-button'],
};
export const ActionBar: React.FC<ActionBarProps> = ({
className,
style,
onTriggerAction,
}) => {
const [visible, setVisible] = useState(false);
const uploadButtonRef = useRef(null);
const isHover = useHover(uploadButtonRef);
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={FILE_EXTENSION_LIST.join(',')}
onAcceptInvalid={showFileTypeInvalidToast}
limit={1}
onFileChange={onFileChange}
action="/"
fileList={[]}
>
<Tooltip
visible={isHover}
trigger="custom"
content={I18n.t('add_image')}
>
<div ref={uploadButtonRef}>
<UIButton
ref={uploadButtonRef}
{...iconButtonProps}
icon={<IconImageOutlined />}
/>
</div>
</Tooltip>
</Upload>
<InsertLinkPopover
visible={visible}
onClickOutSide={closePopover}
onConfirm={onConfirmInsertLink}
>
<span>
<Tooltip content={I18n.t('add_link')}>
<UIButton
onClick={togglePopoverVisible}
{...iconButtonProps}
className={classNames(
visible && styles['icon-button-active'],
styles['icon-button'],
)}
icon={<IconLinkStroked />}
/>
</Tooltip>
</span>
</InsertLinkPopover>
<Tooltip content={I18n.t('add_nickname')}>
<UIButton
{...iconButtonProps}
icon={<IconMemberOutlined />}
onClick={onInsertVariable}
/>
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,26 @@
.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;
border-radius: 12px;
.input-content {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
}
.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,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.
*/
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',
});
export const FILE_EXTENSION_LIST = ['.jpeg', '.jpg', '.png'];

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 { type SyncAction } from '../type';
import { primitiveExhaustiveCheck } from '../../../utils/exhaustive-check';
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,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 { FILE_EXTENSION_LIST } from '../constant/file';
export const getIsFileFormatValid = (file: File) =>
FILE_EXTENSION_LIST.some(extension => file.name.endsWith(extension));

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,157 @@
/*
* 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 } from 'react';
import useEventCallback from 'use-event-callback';
import { I18n } from '@coze-arch/i18n';
import { useDragAndPasteUpload } from '@coze-arch/bot-hooks';
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 { primitiveExhaustiveCheck } from '../../../utils/exhaustive-check';
import { type MarkdownEditorProps } from '..';
import { useUpload } from './use-upload-file';
// eslint-disable-next-line max-lines-per-function
export const useMarkdownEditor = ({
value,
onChange,
getUserId,
maxLength,
getValueLength,
getSlicedTextOnExceed,
}: 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,
}),
);
},
);
const { uploadFileList, uploadState } = useUpload({
getUserId,
onUploadAllSuccess,
});
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]);
const onTextareaChange = (text: string) => {
onChange(text);
};
const handleInsertText = (insertText: string) => {
if (!ref.current) {
return;
}
ref.current.focus();
const { selectionEnd } = ref.current;
/**
* 选中文字时点击 action bar, 将内容插入到文字的末尾
*/
const insertTextAtPosition = getInsertTextAtPosition({
text: value,
insertText,
position: selectionEnd,
});
const valueLength =
getValueLength?.(insertTextAtPosition) ?? insertTextAtPosition.length;
if (maxLength && valueLength > maxLength) {
const slicedText =
getSlicedTextOnExceed?.(insertTextAtPosition) ??
insertTextAtPosition.slice(0, maxLength);
onChange(slicedText);
} else {
onChange(insertTextAtPosition);
}
setWrapInsertionIndex(selectionEnd + insertText.length);
};
return {
textAreaRef: ref,
onTextareaChange,
onTriggerAction,
dragTargetRef,
uploadState,
isDragOver,
};
};

View File

@@ -0,0 +1,132 @@
/*
* 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 handleAuditFailed = (controllerId: string) => {
clearState();
cancelUploadById(controllerId);
Toast.error({
content: withSlardarIdButton(I18n.t('inappropriate_contents')),
showClose: false,
});
};
const handleUploadSuccess = () => {
clearState();
};
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,
onAuditFailed: handleAuditFailed,
});
};
const clearAllSideEffect = () => {
Object.entries(uploadControllerMap.current).forEach(([, controller]) =>
controller.cancel(),
);
uploadControllerMap.current = {};
};
useEffect(() => clearAllSideEffect, []);
return {
uploadState,
uploadFileList,
};
};

View File

@@ -0,0 +1,72 @@
.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 {
flex-shrink: 0;
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-wrapper {
flex: 1;
}
.markdown-editor-content {
resize: none;
width: 100%;
height: 100%;
padding: 0;
// 这里不需要背景色 强行覆盖
/* stylelint-disable-next-line declaration-no-important */
background-color: inherit !important;
border: none;
outline: none;
&::selection {
background: rgb(77 83 232 / 20%);
}
>textarea {
width: 100%;
height: 100%;
/* stylelint-disable-next-line declaration-no-important */
padding: 0 !important;
font-size: 14px;
font-weight: 400;
line-height: 22px;
// rgb mark
color: #383743;
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type CSSProperties } from 'react';
import classNames from 'classnames';
import { TextArea } from '@coze-arch/coze-design';
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;
onChange: (value: string) => void;
getUserId: () => string;
className?: string;
style?: CSSProperties;
maxLength?: number | undefined;
getValueLength?: (value: string) => number;
/** 超过最大长度时截断函数 */
getSlicedTextOnExceed?: (value: string) => string;
}
/**
* 全受控组件
*/
export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
className,
style,
maxLength,
getValueLength,
getSlicedTextOnExceed,
getUserId,
onChange,
}) => {
const {
textAreaRef,
dragTargetRef,
onTextareaChange,
onTriggerAction,
isDragOver,
uploadState,
} = useMarkdownEditor({
value,
maxLength,
getValueLength,
getSlicedTextOnExceed,
getUserId,
onChange,
});
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}
/>
<TextArea
ref={textAreaRef}
value={value}
onChange={onTextareaChange}
className={styles['markdown-editor-content']}
wrapperClassName={styles['markdown-editor-wrapper']}
maxLength={maxLength}
getValueLength={getValueLength}
/>
{uploadState ? <UploadProgressMask {...uploadState} /> : null}
</div>
);
};

View File

@@ -0,0 +1,151 @@
/*
* 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;
onAuditFailed?: (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,
onAuditFailed,
}: 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 urlAndAudit = result.data?.url_info?.[uri];
const audit = urlAndAudit?.review_status;
const url = urlAndAudit?.url;
if (!audit) {
onAuditFailed?.(controllerId);
return;
}
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,51 @@
/*
* 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;
}

View File

@@ -0,0 +1,84 @@
.content {
overflow: hidden;
width: 276px;
.title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 16px;
font-size: 18px;
font-weight: 600;
line-height: 24px;
color: rgb(6 7 9 / 80%);
}
.description {
margin-bottom: 16px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: rgb(6 7 9 / 50%);
}
.table {
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: rgb(6 7 9 / 80%);
}
.icon {
font-size: 16px;
color: #060709;
}
}
.row {
&:last-child {
* {
border-bottom: none !important;
}
}
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.icon-cell {
width: 24px;
height: 24px;
padding: 0 !important;
}
.cell-column {
width: 24px !important;
min-width: none !important;
}
.mark-column {
width: 68px;
min-width: none !important;
padding-right: 0 !important;
/* stylelint-disable-next-line declaration-no-important */
padding-left: 0 !important
}
.example-column {
/* stylelint-disable-next-line declaration-no-important */
padding-right: 0 !important;
padding-left: 0 !important;
}

View File

@@ -0,0 +1,209 @@
/*
* 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 -- 纯 ui 渲染 */
import { type PropsWithChildren, type ComponentProps } from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconButton, Popover, Table } from '@coze-arch/bot-semi';
import { IconCloseNoCycle } from '@coze-arch/bot-icons';
import { ReactComponent as Strikethrough } from '../../../../assets/markdown-icon/strikethrough.svg';
import { ReactComponent as Quote } from '../../../../assets/markdown-icon/quote.svg';
import { ReactComponent as NumberedList } from '../../../../assets/markdown-icon/numbered-list.svg';
import { ReactComponent as Italic } from '../../../../assets/markdown-icon/italic.svg';
import { ReactComponent as H3 } from '../../../../assets/markdown-icon/h3.svg';
import { ReactComponent as H2 } from '../../../../assets/markdown-icon/h2.svg';
import { ReactComponent as H1 } from '../../../../assets/markdown-icon/h1.svg';
import { ReactComponent as Code } from '../../../../assets/markdown-icon/code.svg';
import { ReactComponent as CodeBlock } from '../../../../assets/markdown-icon/code-block.svg';
import { ReactComponent as BulletedList } from '../../../../assets/markdown-icon/bulleted-list.svg';
import { ReactComponent as Bold } from '../../../../assets/markdown-icon/bold.svg';
import styles from './index.module.less';
export type MarkdownDescriptionPopoverProps = Pick<
ComponentProps<typeof Popover>,
'visible' | 'onVisibleChange'
>;
interface MarkdownDescription {
mark: string;
example: string;
iconKey: string;
}
type TableProps = ComponentProps<typeof Table>;
const IconMap: Record<
string,
React.FunctionComponent<React.SVGProps<SVGSVGElement>>
> = {
h1: props => <H1 {...props} />,
h2: props => <H2 {...props} />,
h3: props => <H3 {...props} />,
bold: props => <Bold {...props} />,
italic: props => <Italic {...props} />,
strikethrough: props => <Strikethrough {...props} />,
quote: props => <Quote {...props} />,
code: props => <Code {...props} />,
codeBlock: props => <CodeBlock {...props} />,
numberedList: props => <NumberedList {...props} />,
bulletedList: props => <BulletedList {...props} />,
};
const columns: Required<TableProps>['columns'] = [
{
title: '',
dataIndex: 'icon',
className: styles['cell-column'],
onCell: () => ({
className: styles['icon-cell'],
}),
render: (_, record: MarkdownDescription) => {
const IconComponent = IconMap[record.iconKey];
if (!IconComponent) {
return null;
}
return (
<div className={styles['icon-wrapper']}>
<IconComponent className={styles.icon} />
</div>
);
},
},
{
title: '',
dataIndex: 'mark',
className: styles['mark-column'],
align: 'left',
},
{
title: '',
dataIndex: 'example',
align: 'right',
className: styles['example-column'],
},
];
const getData: () => MarkdownDescription[] = () => [
{
mark: I18n.t('markdown_heading1'),
example: I18n.t('markdown_heading1_syntax', { space: I18n.t('space') }),
iconKey: 'h1',
},
{
mark: I18n.t('markdown_heading2'),
example: I18n.t('markdown_heading2_syntax', { space: I18n.t('space') }),
iconKey: 'h2',
},
{
mark: I18n.t('markdown_heading3'),
example: I18n.t('markdown_heading3_syntax', { space: I18n.t('space') }),
iconKey: 'h3',
},
{
mark: I18n.t('markdown_bold'),
example: I18n.t('markdown_bold_syntax', {
space: I18n.t('space'),
text: I18n.t('text'),
}),
iconKey: 'bold',
},
{
mark: I18n.t('markdown_italic'),
example: I18n.t('markdown_italic_syntax', {
space: I18n.t('space'),
text: I18n.t('text'),
}),
iconKey: 'italic',
},
{
mark: I18n.t('markdown_strickthrough'),
example: I18n.t('markdown_strickthrough_syntax', {
space: I18n.t('space'),
text: I18n.t('text'),
}),
iconKey: 'strikethrough',
},
{
mark: I18n.t('markdown_quote'),
example: I18n.t('markdown_quote_syntax', { space: I18n.t('space') }),
iconKey: 'quote',
},
{
mark: I18n.t('markdown_code'),
example: I18n.t('markdown_code_syntax', {
space: I18n.t('space'),
code: I18n.t('code'),
}),
iconKey: 'code',
},
{
mark: I18n.t('markdown_codeblock'),
example: I18n.t('markdown_codeblock_syntax', {
space: I18n.t('space'),
}),
iconKey: 'codeBlock',
},
{
mark: I18n.t('markdown_numberedlist'),
example: I18n.t('markdown_numberedlist_syntax', { space: I18n.t('space') }),
iconKey: 'numberedList',
},
{
mark: I18n.t('markdown_bulletedlist'),
example: I18n.t('markdown_bulletedlist_syntax', { space: I18n.t('space') }),
iconKey: 'bulletedList',
},
];
export const MarkdownDescriptionPopover: React.FC<
PropsWithChildren<MarkdownDescriptionPopoverProps>
> = ({ children, visible, onVisibleChange }) => (
<Popover
trigger="custom"
visible={visible}
showArrow
position="rightTop"
content={
<div className={styles.content}>
<div className={styles.title}>
<div>Markdown</div>
<IconButton
size="small"
theme="borderless"
icon={<IconCloseNoCycle />}
onClick={() => onVisibleChange?.(false)}
/>
</div>
<div className={styles.description}>{I18n.t('markdown_intro')}</div>
<Table
className={styles.table}
showHeader={false}
columns={columns}
dataSource={getData()}
pagination={false}
size="small"
onRow={() => ({
className: styles.row,
})}
/>
</div>
}
>
{children}
</Popover>
);

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 { type SuggestQuestionMessage } from '@coze-studio/bot-detail-store';
import { type BotEditorOnboardingSuggestion } from '@coze-agent-ide/bot-editor-context-store';
import { OnboardingSuggestion } from '../../../onboarding-suggestion';
export interface OnboardingSuggestionContentProps {
onSuggestionChange: (param: SuggestQuestionMessage) => void;
onDeleteSuggestion: (id: string) => void;
onboardingSuggestions: BotEditorOnboardingSuggestion[];
}
export const OnboardingSuggestionContent: React.FC<
OnboardingSuggestionContentProps
> = ({ onDeleteSuggestion, onSuggestionChange, onboardingSuggestions }) => (
<>
{onboardingSuggestions.map(({ id, content, highlight }) => (
<OnboardingSuggestion
key={id}
id={id}
value={content}
onChange={(changedId, value) => {
onSuggestionChange({ id: changedId, content: value, highlight });
}}
onDelete={onDeleteSuggestion}
/>
))}
</>
);

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 const ONBOARDING_PREVIEW_DELAY = 500;

View File

@@ -0,0 +1,88 @@
.onboarding-markdown {
display: flex;
width: 100%;
height: 100%;
.edit-content {
overflow-y: auto;
flex-shrink: 0;
padding: 16px 24px;
}
.preview-content {
overflow-y: auto;
display: flex;
flex: 1;
align-items: center;
padding: 24px;
background-color: #FFF;
border-left: 1px solid rgb(6 7 9 / 10%);
}
.preview-content-scroll {
align-items: flex-start;
}
}
.opening-text {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 24px;
margin-bottom: 8px;
}
.opening-question {
display: flex;
column-gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.strong-text {
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: rgb(6 7 9 / 96%);
}
.markdown-editor {
box-sizing: border-box;
width: 590px;
height: 50%;
min-height: 200px;
margin-bottom: 24px;
}
.modal {
:global(.semi-modal-content) {
padding: 0;
:global(.semi-modal-header) {
margin: 0;
padding: 24px;
border-bottom: 1px solid rgb(6 7 9 / 10%);
}
:global(.semi-modal-body) {
padding: 0;
}
}
}
.markdown-tag {
cursor: pointer;
height: 24px;
color: #4E40E5;
background: rgba(128, 138, 255, 20%);
}
.modal-icon {
width: 16px;
height: 16px;
}

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 { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { useDebounce } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { Tag, Tooltip, UIModal, type UIModalProps } from '@coze-arch/bot-semi';
import { IconInfo } from '@coze-arch/bot-icons';
import { type BotEditorOnboardingSuggestion } from '@coze-agent-ide/bot-editor-context-store';
import {
OnboardingPreview,
type OnboardingPreviewProps,
} from '../onboarding-preview';
import { MarkdownEditor, type MarkdownEditorProps } from '../markdown-editor';
import { ReactComponent as IconMinimizeOutlined } from '../../assets/icon_minimize_outlined.svg';
import { ONBOARDING_PREVIEW_DELAY } from './constant';
import {
OnboardingSuggestionContent,
type OnboardingSuggestionContentProps,
} from './components/onboarding-suggestion-content';
import { MarkdownDescriptionPopover } from './components/markdown-description-popover';
import styles from './index.module.less';
export interface OnboardingMarkdownModalProps
extends UIModalProps,
OnboardingSuggestionContentProps,
Pick<
MarkdownEditorProps,
'getValueLength' | 'maxLength' | 'getSlicedTextOnExceed'
> {
getBotInfo: OnboardingPreviewProps['getBotInfo'];
getUserInfo: () => {
userName: string;
userId: string;
};
prologue: string;
onPrologueChange: (prologue: string) => void;
/**
* 要预览的和能编辑的可能不一样
*/
previewSuggestions?: BotEditorOnboardingSuggestion[];
}
export const OnboardingMarkdownModal: React.FC<
OnboardingMarkdownModalProps
> = ({
getBotInfo,
getUserInfo,
onDeleteSuggestion,
onPrologueChange,
onSuggestionChange,
onboardingSuggestions,
previewSuggestions = onboardingSuggestions,
prologue,
getValueLength,
maxLength,
getSlicedTextOnExceed,
...modalProps
}) => {
const [visible, setVisible] = useState(false);
const [previewScrollable, setScrollable] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const onShowPopover = () => setVisible(true);
const debouncedPrologue = useDebounce(prologue, {
wait: ONBOARDING_PREVIEW_DELAY,
});
useEffect(() => {
if (!ref.current) {
return;
}
const { scrollHeight, clientHeight } = ref.current;
setScrollable(scrollHeight > clientHeight);
}, [debouncedPrologue, onboardingSuggestions, modalProps.visible]);
return (
<UIModal
{...modalProps}
title={I18n.t('bot_preview_opening_remarks')}
centered
footer={null}
type="base-composition"
className={styles.modal}
closeIcon={
<Tooltip content={I18n.t('collapse')}>
<span className={styles['modal-icon']}>
<IconMinimizeOutlined className={styles['modal-icon']} />
</span>
</Tooltip>
}
>
<div className={styles['onboarding-markdown']}>
<div className={styles['edit-content']}>
<div className={styles['opening-text']}>
<div className={styles['strong-text']}>
{I18n.t('bot_edit_opening_text_title')}
</div>
<MarkdownDescriptionPopover
visible={visible}
onVisibleChange={setVisible}
>
<Tag
onClick={onShowPopover}
className={styles['markdown-tag']}
color="indigo"
>
<IconInfo style={{ marginRight: 4 }} />
{I18n.t('markdown_is_supported')}
</Tag>
</MarkdownDescriptionPopover>
</div>
<MarkdownEditor
className={styles['markdown-editor']}
getUserId={() => getUserInfo().userId}
value={prologue}
onChange={onPrologueChange}
getValueLength={getValueLength}
maxLength={maxLength}
getSlicedTextOnExceed={getSlicedTextOnExceed}
/>
<div
className={classNames(
styles['opening-question'],
styles['strong-text'],
)}
>
{I18n.t('bot_edit_opening_question_title')}
<Tooltip content={I18n.t('bot_edit_opening_questions_tooltip')}>
<IconInfo />
</Tooltip>
</div>
<OnboardingSuggestionContent
onDeleteSuggestion={onDeleteSuggestion}
onSuggestionChange={onSuggestionChange}
onboardingSuggestions={onboardingSuggestions}
/>
</div>
<div
className={classNames(
styles['preview-content'],
previewScrollable && styles['preview-content-scroll'],
)}
ref={ref}
>
<OnboardingPreview
content={debouncedPrologue}
suggestions={previewSuggestions}
getBotInfo={getBotInfo}
getUserName={() => getUserInfo().userName}
/>
</div>
</div>
</UIModal>
);
};

View File

@@ -0,0 +1,3 @@
.onboarding {
height: auto;
}

View File

@@ -0,0 +1,60 @@
/*
* 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 { OnBoarding } from '@coze-common/chat-uikit';
import { type BotEditorOnboardingSuggestion } from '@coze-agent-ide/bot-editor-context-store';
import { useRenderVariable } from '../../hooks/onboarding/use-render-variable-element';
import { OnboardingVariable } from '../../constant/onboarding-variable';
import styles from './index.module.less';
export interface OnboardingPreviewProps {
content: string;
suggestions: BotEditorOnboardingSuggestion[];
getBotInfo: () => {
avatarUrl: string;
botName: string;
};
getUserName: () => string;
}
export const OnboardingPreview: React.FC<OnboardingPreviewProps> = ({
content,
suggestions,
getBotInfo,
getUserName,
}) => {
const username = getUserName();
const { botName, avatarUrl } = getBotInfo();
const renderVariable = useRenderVariable({
[OnboardingVariable.USER_NAME]: username,
});
return (
<OnBoarding
className={styles.onboarding}
name={botName}
avatar={avatarUrl}
suggestionListWithString={suggestions.map(item => item.content)}
prologue={content}
mdBoxProps={{
insertedElements: renderVariable(content),
}}
/>
);
};

View File

@@ -0,0 +1,43 @@
.suggestion-message-item {
position: relative;
display: flex;
align-items: center;
width: 100%;
margin-bottom: 8px;
.no-icon {
position: absolute;
right: 16px;
}
:global {
.semi-input-textarea-wrapper {
padding-right: 48px;
}
}
}
.tooltip-wrapper {
position: relative;
}
.icon-button-16 {
&:hover {
border-radius: 4px;
}
:global {
.semi-button {
&.semi-button-size-small {
height: 16px;
padding: 1px;
}
}
}
}
.icon-no-disabled {
color: rgb(29 28 35 / 20%);
}

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 ComponentProps, forwardRef } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { TextArea, Tooltip, UIIconButton } from '@coze-arch/bot-semi';
import { IconNo } from '@coze-arch/bot-icons';
import styles from './index.module.less';
export interface OnboardingSuggestionProps
extends Omit<
ComponentProps<typeof TextArea>,
'autosize' | 'rows' | 'placeholder' | 'onChange'
> {
id: string;
onDelete?: (id: string) => void;
onChange?: (id: string, value: string) => void;
}
export const OnboardingSuggestion = forwardRef<
HTMLTextAreaElement,
OnboardingSuggestionProps
>(({ value, onChange, id, onDelete, className, ...restProps }, ref) => (
<div className={styles['suggestion-message-item']}>
<TextArea
autosize
rows={1}
ref={ref}
className={className}
placeholder={I18n.t('opening_question_placeholder')}
value={value}
onChange={v => {
onChange?.(id, v);
}}
{...restProps}
/>
<Tooltip content={I18n.t('bot_edit_plugin_delete_tooltip')}>
<UIIconButton
wrapperClass={classNames(styles['icon-button-16'], styles['no-icon'])}
iconSize="small"
icon={
<IconNo
className={classNames(!value && styles['icon-no-disabled'])}
/>
}
onClick={() => {
if (!value) {
return;
}
onDelete?.(id);
}}
/>
</Tooltip>
</div>
));