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,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';