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,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 { FileTypeEnum } from '@coze-common/chat-core/shared/const';
import EXCELSuccess from '../../../../../assets/file/xlsx-success.svg';
import EXCELFail from '../../../../../assets/file/xlsx-fail.svg';
import VIDEOSuccess from '../../../../../assets/file/video-success.svg';
import VIDEOFail from '../../../../../assets/file/video-fail.svg';
import TXTSuccess from '../../../../../assets/file/txt-success.svg';
import TXTFail from '../../../../../assets/file/txt-fail.svg';
import PPTSuccess from '../../../../../assets/file/ppt-success.svg';
import PPTFail from '../../../../../assets/file/ppt-fail.svg';
import PDFSuccess from '../../../../../assets/file/pdf-success.svg';
import PDFFail from '../../../../../assets/file/pdf-fail.svg';
import DOCXSuccess from '../../../../../assets/file/docx-success.svg';
import DOCXFail from '../../../../../assets/file/docx-fail.svg';
import DefaultUnknownSuccess from '../../../../../assets/file/default-unknown-success.svg';
import DefaultUnknownFail from '../../../../../assets/file/default-unknown-fail.svg';
import CSVSuccess from '../../../../../assets/file/csv-success.svg';
import CSVFail from '../../../../../assets/file/csv-fail.svg';
import CODESuccess from '../../../../../assets/file/code-success.svg';
import CODEFail from '../../../../../assets/file/code-fail.svg';
import AUDIOSuccess from '../../../../../assets/file/audio-success.svg';
import AUDIOFail from '../../../../../assets/file/audio-fail.svg';
import ARCHIVESuccess from '../../../../../assets/file/archive-success.svg';
import ARCHIVEFail from '../../../../../assets/file/archive-fail.svg';
export const SUCCESS_FILE_ICON_MAP = {
[FileTypeEnum.CSV]: CSVSuccess,
[FileTypeEnum.DOCX]: DOCXSuccess,
[FileTypeEnum.EXCEL]: EXCELSuccess,
[FileTypeEnum.PDF]: PDFSuccess,
[FileTypeEnum.AUDIO]: AUDIOSuccess,
[FileTypeEnum.VIDEO]: VIDEOSuccess,
[FileTypeEnum.ARCHIVE]: ARCHIVESuccess,
[FileTypeEnum.CODE]: CODESuccess,
[FileTypeEnum.TXT]: TXTSuccess,
[FileTypeEnum.PPT]: PPTSuccess,
[FileTypeEnum.DEFAULT_UNKNOWN]: DefaultUnknownSuccess,
};
export const FAIL_FILE_ICON_MAP = {
[FileTypeEnum.CSV]: CSVFail,
[FileTypeEnum.DOCX]: DOCXFail,
[FileTypeEnum.EXCEL]: EXCELFail,
[FileTypeEnum.PDF]: PDFFail,
[FileTypeEnum.AUDIO]: AUDIOFail,
[FileTypeEnum.VIDEO]: VIDEOFail,
[FileTypeEnum.ARCHIVE]: ARCHIVEFail,
[FileTypeEnum.CODE]: CODEFail,
[FileTypeEnum.TXT]: TXTFail,
[FileTypeEnum.PPT]: PPTFail,
[FileTypeEnum.DEFAULT_UNKNOWN]: DefaultUnknownFail,
};
export const FILE_CARD_WIDTH = 280;
export const PERCENT_DENOMINATOR = 100;

View File

@@ -0,0 +1,3 @@
.chat-uikit-file-card-progress-animation {
transition: width 0.3s linear;
}

View File

@@ -0,0 +1,226 @@
/*
* 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 } from 'react';
import classNames from 'classnames';
import {
IconCozCopy,
IconCozCross,
IconCozRefresh,
} from '@coze-arch/coze-design/icons';
import { IconButton, Typography } from '@coze-arch/coze-design';
import { Layout } from '@coze-common/chat-uikit-shared';
import { UIKitTooltip } from '../../../../common/tooltips';
import { getFileExtensionAndName } from '../../../../../utils/file-name';
import { convertBytes } from '../../../../../utils/convert-bytes';
import {
typeSafeFileCardNameVariants,
typeSafeFileCardVariants,
} from './variants';
import { type IFileCardProps } from './type';
import {
FAIL_FILE_ICON_MAP,
FILE_CARD_WIDTH,
PERCENT_DENOMINATOR,
SUCCESS_FILE_ICON_MAP,
} from './constants';
import './file-card.less';
// eslint-disable-next-line @coze-arch/max-line-per-function
const FileCard: FC<IFileCardProps> = props => {
const {
file,
attributeKeys,
tooltipsCopywriting,
readonly,
onCancel,
onCopy,
onRetry,
className,
layout,
showBackground,
} = props;
const { statusKey, statusEnum, percentKey } = attributeKeys;
const percent = file[percentKey];
const fileIconMap = [statusEnum.cancelEnum, statusEnum.failEnum].includes(
file[statusKey],
)
? FAIL_FILE_ICON_MAP
: SUCCESS_FILE_ICON_MAP;
const buttonsVisible = !readonly;
const { extension, nameWithoutExtension } = getFileExtensionAndName(
file.file_name,
);
const isCanceled = file[statusKey] === statusEnum.cancelEnum;
return (
<div
// className={classNames(className, 'chat-uikit-file-card', {
// 'chat-uikit-file-card--error': file[statusKey] === statusEnum.failEnum,
// 'chat-uikit-file-card-pc': layout === Layout.PC,
// 'chat-uikit-file-card-mobile': layout === Layout.MOBILE,
// '!coz-bg-image-bots !coz-stroke-image-bots':
// showBackground && file[statusKey] !== statusEnum.failEnum,
// })}
className={classNames(
typeSafeFileCardVariants({
isError: file[statusKey] === statusEnum.failEnum,
layout: layout === Layout.PC ? 'pc' : 'mobile',
showBackground,
}),
className,
)}
>
<img
src={fileIconMap[file.file_type]}
// chat-uikit-file-card__icon
className="h-[32px] w-[32px]"
></img>
<div
// chat-uikit-file-card__info
className="flex flex-1 flex-col ml-8px overflow-hidden"
>
<Typography.Text
ellipsis={{
showTooltip:
layout === Layout.MOBILE
? false
: {
opts: {
content: file.file_name,
style: { wordWrap: 'break-word' },
},
},
suffix: extension,
}}
// chat-uikit-file-card__info__name
// chat-uikit-file-card__info__name_pc
// chat-uikit-file-card__info__name_mobile
className={typeSafeFileCardNameVariants({
isCanceled,
layout: layout === Layout.PC ? 'pc' : 'mobile',
})}
>
{nameWithoutExtension}
</Typography.Text>
<span
// chat-uikit-file-card__info__size
className={classNames(
'text-base font-normal leading-[16px]',
isCanceled ? 'coz-fg-dim' : 'coz-fg-secondary',
)}
>
{convertBytes(file.file_size)}
</span>
</div>
{buttonsVisible ? (
<>
<div
// chat-uikit-file-card__buttons
className="ml-8px"
>
{file[statusKey] === statusEnum.uploadingEnum && (
<UIKitTooltip
theme="light"
position="top"
content={tooltipsCopywriting?.cancel}
hideToolTip={layout === Layout.MOBILE}
>
<IconButton
// chat-uikit-file-card__buttons__button
icon={
<IconCozCross // chat-uikit-file-card__buttons__icon
/>
}
size="small"
color="secondary"
onClick={onCancel}
/>
</UIKitTooltip>
)}
{[statusEnum.cancelEnum, statusEnum.failEnum].includes(
file[statusKey],
) && (
<UIKitTooltip
theme="light"
position="top"
content={tooltipsCopywriting?.retry}
hideToolTip={layout === Layout.MOBILE}
>
<IconButton
// chat-uikit-file-card__buttons__button
icon={
<IconCozRefresh // chat-uikit-file-card__buttons__icon
/>
}
size="small"
color="secondary"
onClick={onRetry}
/>
</UIKitTooltip>
)}
{file[statusKey] === statusEnum.successEnum && (
<UIKitTooltip
theme="light"
position="top"
content={tooltipsCopywriting?.copy}
hideToolTip={layout === Layout.MOBILE}
>
<IconButton
// chat-uikit-file-card__buttons__button
icon={
<IconCozCopy // chat-uikit-file-card__buttons__icon
/>
}
size="small"
color="secondary"
onClick={onCopy}
/>
</UIKitTooltip>
)}
</div>
{file[statusKey] === statusEnum.uploadingEnum && (
<div
// chat-uikit-file-card__progress-wrap
className={classNames(
// TODO: ui 补充进度条颜色
'coz-fg-hglt-dim absolute top-0 left-0 w-[280px] h-[72px]',
'chat-uikit-file-card-progress-animation',
)}
style={{
width: `${FILE_CARD_WIDTH * (percent / PERCENT_DENOMINATOR)}px`,
}}
/>
)}
</>
) : null}
</div>
);
};
FileCard.displayName = 'FileCard';
export default FileCard;

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 {
type IFileAttributeKeys,
type IFileCardTooltipsCopyWritingConfig,
type IFileInfo,
type Layout,
} from '@coze-common/chat-uikit-shared';
export interface IFileCardProps {
file: IFileInfo;
/**
* 用于识别成功 / 失败状态的key
*/
attributeKeys: IFileAttributeKeys;
/**
* 文案配置
*/
tooltipsCopywriting?: IFileCardTooltipsCopyWritingConfig;
/**
* 是否只读
*/
readonly?: boolean;
/**
* 取消上传事件回调
*/
onCancel: () => void;
/**
* 重试上传事件回调
*/
onRetry: () => void;
/**
* 拷贝url事件回调
*/
onCopy: () => void;
className?: string;
layout: Layout;
showBackground: boolean;
}

View File

@@ -0,0 +1,79 @@
/*
* 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 { cva, type VariantProps } from 'class-variance-authority';
const fileCardVariants = cva(
[
'select-none',
'relative',
'overflow-hidden',
'flex',
'flex-row',
'items-center',
'box-border',
'p-12px',
'border-[1px]',
'border-solid',
'rounded-normal',
'coz-mg-card',
'w-full',
],
{
variants: {
layout: {
pc: ['min-w-[282px]', 'max-w-[320px]'],
mobile: ['w-full'],
},
isError: {
true: ['coz-stroke-hglt-red'],
false: ['coz-stroke-primary'],
},
showBackground: {
true: ['!coz-bg-image-bots', '!coz-stroke-image-bots'],
false: [],
},
},
compoundVariants: [
{
showBackground: true,
isError: false,
className: [],
},
],
},
);
const fileCardNameVariants = cva(['text-lg', 'font-normal', 'leading-[20px]'], {
variants: {
layout: {
pc: ['w-[180px]'],
mobile: ['w-full', 'max-w-[calc(100vw-170px)]'],
},
isCanceled: {
true: ['coz-fg-dim'],
false: ['coz-fg-primary'],
},
},
});
export const typeSafeFileCardVariants: (
props: Required<VariantProps<typeof fileCardVariants>>,
) => string = fileCardVariants;
export const typeSafeFileCardNameVariants: (
props: Required<VariantProps<typeof fileCardNameVariants>>,
) => string = fileCardNameVariants;

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 { type FC } from 'react';
import classNames from 'classnames';
import {
type IFileAttributeKeys,
type IOnRetryUploadParams,
type IOnCancelUploadParams,
type IOnCopyUploadParams,
type IFileCopywritingConfig,
type IBaseContentProps,
type Layout,
} from '@coze-common/chat-uikit-shared';
import { safeJSONParse } from '../../../utils/safe-json-parse';
import { isFile } from '../../../utils/is-file';
import FileCard from './components/FileCard';
export type IProps = IBaseContentProps & {
copywriting?: IFileCopywritingConfig;
fileAttributeKeys?: IFileAttributeKeys;
onCancel?: (params: IOnCancelUploadParams) => void;
onRetry?: (params: IOnRetryUploadParams) => void;
onCopy?: (params: IOnCopyUploadParams) => void;
layout: Layout;
showBackground: boolean;
};
export const FileContent: FC<IProps> = props => {
const {
message,
copywriting,
fileAttributeKeys,
readonly,
onCancel,
onCopy,
onRetry,
layout,
showBackground,
} = props;
const { content_obj = safeJSONParse(message.content) } = message;
/**
* 判断是否为文件类型的卡片 或者 没有配置file属性config则拒绝使用该卡片
*/
if (
!isFile(content_obj) ||
!fileAttributeKeys ||
content_obj.file_list.length <= 0
) {
return null;
}
/**
* 处理点击取消上传的事件
*/
const handleCancel = () => {
onCancel?.({ message, extra: {} });
};
/**
* 处理重试上传的事件
*/
const handleRetry = () => {
onRetry?.({ message, extra: {} });
};
/**
* 处理拷贝文件地址的事件
*/
const handleCopy = (fileIndex?: number) => {
onCopy?.({ message, extra: { fileIndex } });
};
return (
<>
{content_obj.file_list.map((file, index) => (
<FileCard
file={file}
attributeKeys={fileAttributeKeys}
tooltipsCopywriting={copywriting?.tooltips}
readonly={readonly}
onCancel={handleCancel}
onCopy={() => handleCopy(index)}
onRetry={handleRetry}
layout={layout}
showBackground={showBackground}
className={classNames({
'mb-[8px]': index < content_obj.file_list.length - 1,
})}
/>
))}
</>
);
};
FileContent.displayName = 'FileContent';

View File

@@ -0,0 +1,115 @@
/*
* 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 } from 'react';
import classNames from 'classnames';
import { Row, Col } from '@coze-arch/coze-design';
import { Image } from '@coze-arch/bot-md-box-adapter/slots';
import {
type OnImageClickCallback,
type OnImageRenderCallback,
} from '@coze-arch/bot-md-box-adapter';
import './index.less';
export enum CompressAlgorithm {
None = 0,
Snappy = 1,
Zstd = 2,
}
export interface MsgContentData {
card_data?: string;
compress?: CompressAlgorithm;
}
export interface ContentBoxEvents {
onError?: (err: unknown) => void;
onLoadStart?: () => void;
onLoadEnd?: () => void;
onLoad?: () => Promise<MsgContentData | undefined>;
}
export interface BaseContentBoxProps {
/** 是否在浏览器视窗内truefalse不在undefined未检测 */
inView?: boolean;
contentBoxEvents?: ContentBoxEvents;
}
export interface ImageMessageContent {
key: string;
image_thumb: {
url: string;
width: number;
height: number;
};
image_ori: {
url: string;
width: number;
height: number;
};
request_id?: string;
}
export interface ImageContent {
image_list: ImageMessageContent[];
}
export interface ImageBoxProps extends BaseContentBoxProps {
data: ImageContent;
eventCallbacks?: {
onImageClick?: OnImageClickCallback;
onImageRender?: OnImageRenderCallback;
};
}
const getImageBoxGutterAndSpan = (
length: number,
): {
gutter: React.ComponentProps<typeof Row>['gutter'];
span: React.ComponentProps<typeof Col>['span'];
} => {
if (length === 1) {
return { gutter: [1, 1], span: 24 };
}
return { gutter: [2, 2], span: 12 };
};
export const ImageBox: FC<ImageBoxProps> = ({ data, eventCallbacks }) => {
const { onImageClick, onImageRender } = eventCallbacks || {};
const { image_list = [] } = data || {};
const layout = getImageBoxGutterAndSpan(image_list?.length);
return (
<div className={classNames('chat-uikit-image-box', 'rounded-normal')}>
<Row gutter={layout.gutter}>
{image_list.map(({ image_thumb }, index) => (
<Col span={layout.span} key={index}>
<Image
onImageClick={onImageClick}
onImageRender={onImageRender}
src={image_thumb.url}
imageOptions={{
squareContainer: true,
}}
className="object-cover"
/>
</Col>
))}
</Row>
</div>
);
};

View File

@@ -0,0 +1,21 @@
.chat-uikit-image-content {
user-select: none;
width: 240px;
img {
cursor: zoom-in;
}
}
.chat-uikit-image-error-boundary {
>.semi-image {
>img {
width: 280px;
}
}
}
.chat-uikit-image-box {
overflow: hidden;
}

View File

@@ -0,0 +1,79 @@
/*
* 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 { ErrorBoundary } from 'react-error-boundary';
import { type FC } from 'react';
import { Image } from '@coze-arch/coze-design';
import {
type IOnImageClickParams,
type IBaseContentProps,
} from '@coze-common/chat-uikit-shared';
import { safeJSONParse } from '../../../utils/safe-json-parse';
import { isImage } from '../../../utils/is-image';
import defaultImage from '../../../assets/image-empty.png';
import { ImageBox } from './image-box';
import './index.less';
export type IImageMessageContentProps = IBaseContentProps & {
onImageClick?: (params: IOnImageClickParams) => void;
};
export const ImageContentImpl: FC<IImageMessageContentProps> = props => {
const { message, onImageClick } = props;
const { content_obj = safeJSONParse(message.content) } = message;
if (!isImage(content_obj)) {
return null;
}
return (
<div className="chat-uikit-image-content">
<ImageBox
data={{
image_list: content_obj?.image_list ?? [],
}}
eventCallbacks={{
onImageClick: (e, eventData) => {
onImageClick?.({
message,
extra: { url: eventData.src as string },
});
},
}}
/>
</div>
);
};
ImageContentImpl.displayName = 'ImageContentImpl';
export const ImageContent: FC<IImageMessageContentProps> = props => (
<ErrorBoundary
fallback={
<div className="chat-uikit-image-error-boundary">
<Image src={defaultImage} preview={false} />
</div>
}
>
<ImageContentImpl {...props} />
</ErrorBoundary>
);
ImageContent.displayName = 'ImageContent';

View File

@@ -0,0 +1,22 @@
/*
* 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 * from './file-content';
export * from './image-content';
export * from './plain-text-content';
export * from './simple-function-content';
export * from './suggestion-content';
export * from './text-content';

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { type FileMixItem } from '@coze-common/chat-core';
import {
type IFileAttributeKeys,
type IOnCopyUploadParams,
type IOnRetryUploadParams,
type IOnCancelUploadParams,
type IMessage,
type IFileCopywritingConfig,
type Layout,
} from '@coze-common/chat-uikit-shared';
import FileCard from '../file-content/components/FileCard';
import { isFileMixItem } from '../../../utils/multimodal';
export interface FileItemListProps {
message: IMessage;
fileItemList: FileMixItem[];
fileAttributeKeys?: IFileAttributeKeys;
fileCopywriting?: IFileCopywritingConfig;
readonly?: boolean;
layout: Layout;
showBackground: boolean;
onCancel?: (params: IOnCancelUploadParams) => void;
onCopy?: (params: IOnCopyUploadParams) => void;
onRetry?: (params: IOnRetryUploadParams) => void;
}
export const FileItemList: FC<FileItemListProps> = ({
fileItemList,
fileAttributeKeys,
fileCopywriting,
readonly,
onRetry,
onCancel,
onCopy,
message,
layout,
showBackground,
}) => {
/**
* 处理点击取消上传的事件
*/
const handleCancel = () => {
onCancel?.({ message, extra: {} });
};
/**
* 处理重试上传的事件
*/
const handleRetry = () => {
onRetry?.({ message, extra: {} });
};
/**
* 处理拷贝文件地址的事件
*/
const handleCopy = () => {
onCopy?.({ message, extra: {} });
};
return (
<>
{fileItemList.map(item => {
if (isFileMixItem(item) && fileAttributeKeys) {
return (
<FileCard
className="chat-uikit-multi-modal-file-image-content select-none"
key={item.file.file_key}
file={item.file}
attributeKeys={fileAttributeKeys}
tooltipsCopywriting={fileCopywriting?.tooltips}
readonly={readonly}
onCancel={handleCancel}
onCopy={handleCopy}
onRetry={handleRetry}
layout={layout}
showBackground={showBackground}
/>
);
}
return null;
})}
</>
);
};

View File

@@ -0,0 +1,84 @@
/*
* 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 } from 'react';
import { type ImageMixItem } from '@coze-common/chat-core';
import {
type IOnImageClickParams,
type IMessage,
} from '@coze-common/chat-uikit-shared';
import { SingleImageContentWithAutoSize } from '../single-image-content/auto-size';
import { ImageBox } from '../image-content/image-box';
import { typeSafeMessageBoxInnerVariants } from '../../../variants/message-box-inner-variants';
import { makeFakeImageMessage } from '../../../utils/make-fake-image-message';
interface ImageItemListProps {
imageItemList: ImageMixItem[];
message: IMessage;
onImageClick?: (params: IOnImageClickParams) => void;
}
export const ImageItemList: FC<ImageItemListProps> = ({
imageItemList,
message,
onImageClick,
}) => {
const handleImageClick = (originUrl: string) => {
onImageClick?.({ message, extra: { url: originUrl } });
};
return (
<>
{Boolean(imageItemList.length) &&
(imageItemList.length === 1 ? (
<SingleImageContentWithAutoSize
key={imageItemList[0].image.image_thumb.url}
message={makeFakeImageMessage({
originMessage: message,
url: imageItemList[0].image.image_ori.url,
key: imageItemList[0].image.image_ori.url,
width: imageItemList[0].image.image_ori.width,
height: imageItemList[0].image.image_ori.height,
})}
onImageClick={onImageClick}
className="mb-[16px] rounded-[16px] overflow-hidden"
/>
) : (
<div
// 这里借用了 messageBoxInner 的样式风格
className={typeSafeMessageBoxInnerVariants({
color: 'whiteness',
border: null,
tight: true,
showBackground: false,
})}
style={{ width: '240px' }}
key={imageItemList[0].image.image_thumb.url}
>
<ImageBox
data={{ image_list: imageItemList.map(item => item.image) }}
eventCallbacks={{
onImageClick: (_, eventData) =>
handleImageClick(eventData.src ?? ''),
}}
/>
</div>
))}
</>
);
};

View File

@@ -0,0 +1,4 @@
.chat-uikit-multi-modal-file-image-content {
justify-content: flex-start;
margin-bottom: 8px;
}

View File

@@ -0,0 +1,118 @@
/*
* 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 ReactNode } from 'react';
import {
type FileMixItem,
type TextMixItem,
type ImageMixItem,
} from '@coze-common/chat-core';
import { type GetBotInfo } from '@coze-common/chat-uikit-shared';
import { type IImageMessageContentProps } from '../image-content';
import { type IProps as IFileContentProps } from '../file-content';
import {
isFileMixItem,
isImageMixItem,
isMultimodalContentListLike,
isTextMixItem,
} from '../../../utils/multimodal';
import { TextItemList } from './text-item-list';
import { ImageItemList } from './image-item-list';
import { FileItemList } from './file-item-list';
import './index.less';
export type MultimodalContentProps = IImageMessageContentProps &
IFileContentProps & {
getBotInfo: GetBotInfo;
renderTextContentAddonTop?: ReactNode;
isContentLoading: boolean | undefined;
};
/**
* 这个组件并不单纯 实际上并不应该叫 Content
*/
// TODO: @liushuoyan 提供开关啊~~
export const MultimodalContent: React.FC<MultimodalContentProps> = ({
renderTextContentAddonTop,
message,
getBotInfo,
fileAttributeKeys,
copywriting: fileCopywriting,
onCancel,
onCopy,
onRetry,
readonly,
onImageClick,
layout,
showBackground,
isContentLoading,
}) => {
const { content_obj } = message;
if (!isMultimodalContentListLike(content_obj)) {
// TODO: broke 的消息应该需要加一个统一的兜底和上报
return null;
}
const fileItemList = content_obj.item_list.filter(
(item): item is FileMixItem => isFileMixItem(item),
);
const textItemList = content_obj.item_list.filter(
(item): item is TextMixItem => isTextMixItem(item),
);
const imageItemList = content_obj.item_list.filter(
(item): item is ImageMixItem => isImageMixItem(item),
);
return (
<>
<FileItemList
fileItemList={fileItemList}
fileAttributeKeys={fileAttributeKeys}
fileCopywriting={fileCopywriting}
readonly={readonly}
onRetry={onRetry}
onCancel={onCancel}
onCopy={onCopy}
message={message}
layout={layout}
showBackground={showBackground}
/>
<ImageItemList
imageItemList={imageItemList}
message={message}
onImageClick={onImageClick}
/>
<TextItemList
textItemList={textItemList}
renderTextContentAddonTop={renderTextContentAddonTop}
message={message}
showBackground={showBackground}
getBotInfo={getBotInfo}
isContentLoading={isContentLoading}
/>
</>
);
};
MultimodalContent.displayName = 'MultimodalContent';

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 { type ReactNode, type FC } from 'react';
import { type TextMixItem } from '@coze-common/chat-core';
import { type GetBotInfo, type IMessage } from '@coze-common/chat-uikit-shared';
import { PlainTextContent } from '../plain-text-content';
import { typeSafeMessageBoxInnerVariants } from '../../../variants/message-box-inner-variants';
import { isTextMixItem } from '../../../utils/multimodal';
export interface TextItemListProps {
textItemList: TextMixItem[];
renderTextContentAddonTop: ReactNode;
message: IMessage;
showBackground: boolean;
getBotInfo: GetBotInfo;
isContentLoading: boolean | undefined;
}
export const TextItemList: FC<TextItemListProps> = ({
textItemList,
renderTextContentAddonTop,
message,
showBackground,
getBotInfo,
isContentLoading,
}) => (
<>
{textItemList.map(item => {
if (isTextMixItem(item)) {
const TextContentAddonTop = renderTextContentAddonTop;
const isTextAndMentionedEmpty =
!item.text && !message.mention_list.at(0);
if (isTextAndMentionedEmpty) {
return null;
}
return (
/**
* TODO: 由于目前设计不支持一条 message 渲染多个 content 这里需要借用一下发送消息的文字气泡背景色
* 目前只有用户才能发送 multimodal 消息
*/
<div
className={typeSafeMessageBoxInnerVariants({
color: 'primary',
border: null,
tight: false,
showBackground,
})}
style={{ width: 'fit-content' }}
key={item.text}
>
{TextContentAddonTop}
<PlainTextContent
isContentLoading={isContentLoading}
content={item.text}
mentioned={message.mention_list.at(0)}
getBotInfo={getBotInfo}
/>
</div>
);
}
})}
</>
);

View File

@@ -0,0 +1,2 @@
# 介绍
该user-text-content比较特殊目前用户输入的文本应当不支持以markdown语法的富文本因此需要特殊使用user-text-content这种content类型

View File

@@ -0,0 +1,4 @@
.chat-uikit-plain-text-content {
word-wrap: break-word;
white-space: pre-wrap;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { type MessageMentionListFields } from '@coze-common/chat-core/src/message/types';
import {
type IBaseContentProps,
type GetBotInfo,
} from '@coze-common/chat-uikit-shared';
import { ThinkingPlaceholder } from '../../chat';
import { isText } from '../../../utils/is-text';
import './index.less';
export type IPlainTextMessageContentProps = Omit<
IBaseContentProps,
'message'
> & {
getBotInfo: GetBotInfo;
content: string;
mentioned: MessageMentionListFields['mention_list'][0] | undefined;
isContentLoading: boolean | undefined;
};
export const PlainTextContent: FC<IPlainTextMessageContentProps> = props => {
const { content, isContentLoading } = props;
if (!isText(content)) {
return null;
}
return (
<div className="chat-uikit-plain-text-content">
{isContentLoading ? (
<ThinkingPlaceholder className="!p-0 !h-20px" />
) : (
<span>{`${getMentionBotContent(props)}${content}`}</span>
)}
</div>
);
};
PlainTextContent.displayName = 'PlainTextContent';
const getMentionBotContent = ({
mentioned,
getBotInfo,
}: IPlainTextMessageContentProps) => {
// 接口真不一定返回了 mention_list
if (!mentioned) {
return '';
}
const name = getBotInfo(mentioned.id)?.nickname;
if (!name) {
return '';
}
return `@${name} `;
};

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.
*/
import { type FC } from 'react';
import { typeSafeJsonParse } from '@coze-common/chat-area-utils';
import { IconCozLoading } from '@coze-arch/coze-design/icons';
import { Typography } from '@coze-arch/coze-design';
import { isFunctionCall } from '../../../utils/is-function-call';
import { type ISimpleFunctionMessageContentProps } from './type';
export const SimpleFunctionContent: FC<
ISimpleFunctionMessageContentProps
> = props => {
const { message, copywriting } = props;
const { content } = message;
const contentObj = typeSafeJsonParse(content, () => undefined);
if (!isFunctionCall(contentObj, message)) {
return null;
}
return (
<div
// chat-uikit-simple-function-content
className="coz-fg-hglt select-none flex items-center max-w-[230px] text-xxl leading-[26px]"
>
<IconCozLoading
// chat-uikit-simple-function-content__prefix-icon
className="animate-spin"
/>
<div
// chat-uikit-simple-function-content__prefix-text
className="mr-[4px] ml-[8px]"
>
{copywriting?.using ?? 'using'}
</div>
<Typography.Text
// chat-uikit-simple-function-content__plugin-name
className="coz-fg-hglt flex-1 text-xxl font-bold leading-[26px]"
ellipsis={{
showTooltip: {
opts: {
content: contentObj.name,
style: { wordWrap: 'inherit' },
},
},
}}
>
{contentObj.name}
</Typography.Text>
</div>
);
};
SimpleFunctionContent.displayName = 'SimpleFunctionContent';

View File

@@ -0,0 +1,24 @@
/*
* 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 ISimpleFunctionContentCopywriting,
type IBaseContentProps,
} from '@coze-common/chat-uikit-shared';
export type ISimpleFunctionMessageContentProps = IBaseContentProps & {
copywriting?: ISimpleFunctionContentCopywriting;
};

View File

@@ -0,0 +1,169 @@
/*
* 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, useState, type FC } from 'react';
import { isEmpty } from 'lodash-es';
import classNames from 'classnames';
import { type ContentType, type Message } from '@coze-common/chat-core';
import {
safeAsyncThrow,
typeSafeJsonParseEnhanced,
} from '@coze-common/chat-area-utils';
import { Skeleton } from '@coze-arch/coze-design';
import { type IImageContent } from '@coze-common/chat-uikit-shared';
import { type IImageMessageContentProps } from '../image-content';
import { isImage } from '../../../utils/is-image';
import { getImageDisplayAttribute } from '../../../utils/image/get-image-display-attribute';
import { useUiKitMessageBoxContext } from '../../../context/message-box';
import DefaultImage from '../../../assets/image-default.png';
import './index.less';
interface ImageInfo {
url: string;
displayWidth: number;
displayHeight: number;
}
type IBlobImageMap = Record<string, ImageInfo>;
interface SingleImageContentWithAutoSizeProps
extends IImageMessageContentProps {
content_obj: IImageContent;
}
export const SingleImageContentWithAutoSize: FC<
IImageMessageContentProps
> = props => {
const { message } = props;
const {
content_obj = typeSafeJsonParseEnhanced<Message<ContentType.Image>>({
str: message.content,
onParseError: e => {
safeAsyncThrow(e.message);
},
onVerifyError: e => {
safeAsyncThrow(e.message);
},
verifyStruct: (sth: unknown): sth is Message<ContentType.Image> =>
Boolean(sth && 'image_list' in { ...sth }),
}),
} = message;
// 类型守卫一般情况也不影响hooks的顺序问题
if (!isImage(content_obj)) {
return null;
}
return (
<SingleImageContentWithAutoSizeImpl content_obj={content_obj} {...props} />
);
};
/**
* 这里这么做是有原因的
* 前端计算groupId是通过replyId分组服务端未ack前是localMessageId
* 因此服务端ack后会导致循环的key发生变化导致组件unmount -> mount销毁重建
* 因此需要用比较trick的方式来实现图片展示优化的问题
*/
const blobImageMap: IBlobImageMap = {};
const isBlob = (url: string) => url?.startsWith('blob:');
const SingleImageContentWithAutoSizeImpl: FC<
SingleImageContentWithAutoSizeProps
> = props => {
const { message, onImageClick, className, content_obj } = props;
const { imageAutoSizeContainerWidth = 0 } = useUiKitMessageBoxContext();
const localMessageId = message.extra_info.local_message_id;
// 目前服务端下发的图片 ori = thumb 因此目前用一个就行
const currentImageUrl = content_obj?.image_list?.at(0)?.image_ori?.url ?? '';
const { displayHeight, displayWidth, isCover } = getImageDisplayAttribute(
content_obj.image_list.at(0)?.image_ori.width ?? 0,
content_obj.image_list.at(0)?.image_ori.height ?? 0,
imageAutoSizeContainerWidth,
);
if (isBlob(currentImageUrl) && imageAutoSizeContainerWidth > 0) {
blobImageMap[localMessageId] = {
url: currentImageUrl,
displayHeight,
displayWidth,
};
}
const [imageInfo, setImageInfo] = useState<ImageInfo>(
blobImageMap[localMessageId] ?? {
url: currentImageUrl,
displayWidth,
displayHeight,
},
);
useEffect(() => {
const preloadImage = new Image();
if (currentImageUrl.startsWith('http')) {
preloadImage.src = currentImageUrl;
preloadImage.onload = () => {
setImageInfo({
url: currentImageUrl,
displayHeight,
displayWidth,
});
};
}
return () => {
preloadImage.onload = null;
};
}, [currentImageUrl, imageAutoSizeContainerWidth]);
return (
<Skeleton
loading={isEmpty(imageInfo?.url)}
style={{
width: imageInfo?.displayWidth,
height: imageInfo?.displayHeight,
}}
>
<img
src={imageInfo?.url ?? DefaultImage}
style={{
width: imageInfo?.displayWidth,
height: imageInfo?.displayHeight,
maxWidth: '100%',
objectFit: isCover ? 'cover' : undefined,
objectPosition: 'left top',
}}
onClick={e =>
onImageClick?.({
message,
extra: {
url: imageInfo?.url,
},
})
}
className={classNames('block', className, {
'cursor-zoom-in': Boolean(onImageClick),
})}
/>
</Skeleton>
);
};
SingleImageContentWithAutoSize.displayName = 'SingleImageContentWithAutoSize';

View File

@@ -0,0 +1,19 @@
.chat-uikit-single-image-content {
user-select: none;
display: flex;
align-items: center;
justify-content: center;
&__image {
>img {
cursor: zoom-in;
min-width: 48px;
max-width: 240px;
min-height: 48px;
max-height: 240px;
}
}
}

View File

@@ -0,0 +1,91 @@
/*
* 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 FC, useEffect } from 'react';
import { type IImageMessageContentProps } from '../image-content';
import { safeJSONParse } from '../../../utils/safe-json-parse';
import { isImage } from '../../../utils/is-image';
import { SingleImageContentUI } from './single-image-content-ui';
import './index.less';
type IBlobImageMap = Record<string, string>;
/**
* 这里这么做是有原因的
* 前端计算groupId是通过replyId分组服务端未ack前是localMessageId
* 因此服务端ack后会导致循环的key发生变化导致组件unmount -> mount销毁重建
* 因此需要用比较trick的方式来实现图片展示优化的问题
*/
const blobImageMap: IBlobImageMap = {};
const isBlob = (url: string) => url?.startsWith('blob:');
/**
* @deprecated 废弃不再维护,请尽快迁移至 SingleImageContentWithAutoSize 组件
*/
export const SingleImageContent: FC<IImageMessageContentProps> = props => {
const { message, onImageClick } = props;
// @liushuoyan 这里类型大溃败,引入了 any
const { content_obj = safeJSONParse(message.content) } = message;
const localMessageId = message.extra_info.local_message_id;
// 目前服务端下发的图片 ori = thumb 因此目前用一个就行
const currentImageUrl = content_obj?.image_list?.at(0)?.image_ori?.url ?? '';
if (isBlob(currentImageUrl)) {
blobImageMap[localMessageId] = currentImageUrl;
}
const [imageUrl, setImageUrl] = useState<string>(
isBlob(currentImageUrl) ? currentImageUrl : blobImageMap[localMessageId],
);
useEffect(() => {
const preloadImage = new Image();
if (currentImageUrl.startsWith('http')) {
preloadImage.src = currentImageUrl;
preloadImage.onload = () => {
setImageUrl(currentImageUrl);
};
}
return () => {
preloadImage.onload = null;
};
}, [currentImageUrl]);
if (!isImage(content_obj)) {
return null;
}
return (
<SingleImageContentUI
onClick={originUrl => {
onImageClick?.({
message,
extra: { url: originUrl },
});
}}
thumbUrl={imageUrl}
originalUrl={imageUrl}
/>
);
};
SingleImageContent.displayName = 'SingleImageContent';

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { Image } from '@coze-arch/coze-design';
import EmptyImage from '../../../assets/image-empty.png';
import './index.less';
export interface SingleImageContentUIProps {
thumbUrl: string;
originalUrl: string;
onClick?: (originUrl: string) => void;
className?: string;
}
export const SingleImageContentUI: React.FC<SingleImageContentUIProps> = ({
thumbUrl,
originalUrl,
onClick,
className,
}) => (
<div
className={classNames(className, 'chat-uikit-single-image-content')}
onClick={() => onClick?.(originalUrl)}
>
<Image
src={thumbUrl || EmptyImage}
className="chat-uikit-single-image-content__image"
/**
* 这里不采用 semi Image 组件自带的 preview 功能。传入的 onImageClick 回调中有副作用会拉起 preview 组件
*/
preview={false}
/>
</div>
);
SingleImageContentUI.displayName = 'SingleImageContentUI';

View File

@@ -0,0 +1,12 @@
// 特别处理 有背景图的情况下 不区分主题深浅色
.chat-uikit-suggestion-item-background-mg {
&:hover {
background-color: rgba(235, 235, 235, 75%);
}
&:active {
background-color: rgba(235, 235, 235, 70%);;
}
}

View File

@@ -0,0 +1,83 @@
/*
* 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 } from 'react';
import classNames from 'classnames';
import { type IMessage } from '@coze-common/chat-uikit-shared';
import { isText } from '../../../../../utils/is-text';
import { typeSafeSuggestionItemVariants } from './variants';
import './index.less';
interface ISuggestionItemProps {
message?: Pick<IMessage, 'content_obj' | 'sender_id'>;
content?: string;
readonly?: boolean;
showBackground?: boolean;
className?: string;
color?: 'white' | 'grey';
onSuggestionClick?: (sugParam: {
text: string;
mentionList: { id: string }[];
}) => void;
}
export const SuggestionItem: FC<ISuggestionItemProps> = props => {
const {
content,
message,
readonly,
onSuggestionClick,
showBackground,
className,
color,
} = props;
const { content_obj = content } = message ?? {};
if (!isText(content_obj)) {
return null;
}
return (
<div
className={classNames(
className,
'!bg-[235, 235, 235, 0.75]',
typeSafeSuggestionItemVariants({
showBackground: Boolean(showBackground),
readonly: Boolean(readonly),
color: color ?? 'white',
}),
)}
onClick={() => {
if (readonly) {
return;
}
const senderId = message?.sender_id;
onSuggestionClick?.({
text: content_obj,
mentionList: senderId ? [{ id: senderId }] : [],
});
}}
>
<span className="w-full">{content_obj}</span>
</div>
);
};
SuggestionItem.displayName = 'SuggestionItem';

View File

@@ -0,0 +1,84 @@
/*
* 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 { cva, type VariantProps } from 'class-variance-authority';
const suggestionItemVariants = cva(
[
'w-fit',
'border-[1px]',
'border-solid',
'rounded-normal',
'coz-fg-primary',
'py-6px',
'px-16px',
'flex',
'items-center',
'justify-center',
'mb-8px',
'max-w-full',
'text-[14px]',
'font-normal',
'leading-[20px]',
'break-words',
],
{
variants: {
showBackground: {
true: ['coz-bg-image-question', 'coz-stroke-image-bots'],
false: ['coz-stroke-plus'],
},
color: {
white: [],
grey: [],
},
readonly: {
true: ['cursor-default'],
false: ['cursor-pointer'],
},
},
compoundVariants: [
{
showBackground: false,
color: 'white',
className: [],
},
{
showBackground: false,
color: 'grey',
className: ['bg-[var(--coz-mg-secondary)]'],
},
{
readonly: false,
showBackground: false,
className: [
'hover:bg-[var(--coz-mg-secondary-hovered)]',
'active:bg-[var(--coz-mg-secondary-pressed)]',
],
},
{
readonly: false,
showBackground: true,
className: ['chat-uikit-suggestion-item-background-mg'],
},
],
},
);
type SuggestionItemVariantsProps = VariantProps<typeof suggestionItemVariants>;
export const typeSafeSuggestionItemVariants: (
props: Required<SuggestionItemVariantsProps>,
) => string = suggestionItemVariants;

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.
*/
import { SuggestionItem } from './components/suggestion-item';
export { SuggestionItem };

View File

@@ -0,0 +1,3 @@
.chat-uikit-text-content {
word-break: break-word;
}

View File

@@ -0,0 +1,115 @@
/*
* 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 MouseEvent, type FC, useRef } from 'react';
import {
type IOnImageClickParams,
type IOnLinkClickParams,
type IBaseContentProps,
type MdBoxProps,
} from '@coze-common/chat-uikit-shared';
import { Image } from '@coze-arch/bot-md-box-adapter/slots';
import { type ImageOptions } from '@coze-arch/bot-md-box-adapter';
import { CozeLink } from '../../md-box-slots/link';
import { CozeImage } from '../../md-box-slots/coze-image';
import { LazyCozeMdBox } from '../../common/coze-md-box/lazy';
import { isText } from '../../../utils/is-text';
import './index.less';
export type IMessageContentProps = IBaseContentProps & {
onImageClick?: (params: IOnImageClickParams) => void;
mdBoxProps?: MdBoxProps;
enableAutoSizeImage?: boolean;
imageOptions?: ImageOptions;
onLinkClick?: (
params: IOnLinkClickParams,
event: MouseEvent<Element, globalThis.MouseEvent>,
) => void;
};
export const TextContent: FC<IMessageContentProps> = props => {
const {
message,
readonly,
onImageClick,
onLinkClick,
mdBoxProps,
enableAutoSizeImage,
imageOptions,
} = props;
const MdBoxLazy = LazyCozeMdBox;
const contentRef = useRef<HTMLDivElement | null>(null);
const { content } = message;
if (!isText(content)) {
return null;
}
const isStreaming = !message.is_finish;
const text = content.slice(0, message.broken_pos ?? Infinity);
return (
<div
className="chat-uikit-text-content"
data-testid="bot.ide.chat_area.message.text-answer-message-content"
ref={contentRef}
data-grab-mark={message.message_id}
data-grab-source={message.source}
>
<MdBoxLazy
markDown={text}
autoFixSyntax={{ autoFixEnding: isStreaming }}
showIndicator={isStreaming}
smooth={isStreaming}
imageOptions={{ forceHttps: !IS_OPEN_SOURCE, ...imageOptions }}
eventCallbacks={{
onImageClick: (e, eventData) => {
eventData.src &&
onImageClick?.({
message,
extra: { url: eventData.src },
});
},
onLinkClick: (e, eventData) => {
onLinkClick?.(
{
message,
extra: { ...eventData },
},
e,
);
if (readonly) {
e.preventDefault();
e.stopPropagation();
}
},
}}
{...mdBoxProps}
slots={{
Image: enableAutoSizeImage ? CozeImage : Image,
Link: CozeLink,
...mdBoxProps?.slots,
}}
></MdBoxLazy>
</div>
);
};
TextContent.displayName = 'TextContent';