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,34 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type PropsWithChildren } from 'react';
import classNames from 'classnames';
import { typeSafeAudioStaticToastVariants } from './variant';
export interface AudioStaticToastProps {
theme?: 'danger' | 'primary' | 'background';
className?: string;
color?: 'primary' | 'danger';
}
export const AudioStaticToast: React.FC<
PropsWithChildren<AudioStaticToastProps>
> = ({ children, theme = 'primary', color = 'primary', className }) => {
const cvaClassNames = typeSafeAudioStaticToastVariants({ theme, color });
return <div className={classNames(cvaClassNames, className)}>{children}</div>;
};

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cva, type VariantProps } from 'class-variance-authority';
const audioStaticToastVariants = cva(['px-24px', 'py-10px', 'rounded-[99px]'], {
variants: {
theme: {
primary: ['bg-[#F2F3F7]'],
danger: ['bg-[#FFEFF1]'],
background: ['coz-bg-image-bots'],
},
color: {
primary: ['coz-fg-primary'],
danger: ['coz-fg-hglt-red'],
},
},
});
export type AudioStaticToastVariantsProps = Required<
VariantProps<typeof audioStaticToastVariants>
>;
export const typeSafeAudioStaticToastVariants: (
props: AudioStaticToastVariantsProps,
) => string = audioStaticToastVariants;

View File

@@ -0,0 +1,19 @@
.container {
overflow: hidden;
align-items: center;
justify-content: center;
width: 100%;
height: 24px;
.bar {
width: 4px;
min-height: 6px;
max-height: 24px;
background-color: #AAA;
border-radius: 8px;
transition: height 0.1s ease-in-out; /* 设置过渡效果 */
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { Space } from '@coze-arch/coze-design';
import { getBarBgColor, getBarHeights } from './utils';
import { type AudioWaveProps } from './type';
import styles from './index.module.less';
const waveBarNumberMap = {
large: 41,
medium: 29,
small: 4,
};
export const AudioWave = ({
size = 'medium',
volumeNumber = 0,
type = 'default',
wrapperClassName,
waveClassName,
}: AudioWaveProps) => {
const volumeRealNumber = Math.max(Math.min(volumeNumber, 100), 0);
const waveBarNumber = waveBarNumberMap[size] || 29;
const waveBarHeights = getBarHeights(waveBarNumber, volumeRealNumber);
return (
<Space
spacing={3}
align="center"
className={classNames(styles.container, wrapperClassName)}
>
{waveBarHeights.map((height, index) => (
<div
className={classNames(
styles[`audio-wave-${index}`],
styles[type],
styles.bar,
styles[size],
waveClassName,
)}
style={{
backgroundColor: getBarBgColor(index, waveBarNumber, type),
height,
}}
key={`${type}_${index}`}
/>
))}
</Space>
);
};

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.
*/
export interface AudioWaveProps {
size: 'small' | 'medium' | 'large';
type: 'default' | 'primary' | 'warning';
/** 0 ~ 100 */
volumeNumber: number;
wrapperClassName?: string;
waveClassName?: string;
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type AudioWaveProps } from './type';
export const getBarHeights = (
waveBarNumber: number,
volumeRealNumber: number,
) => {
if (volumeRealNumber <= 0) {
return new Array(waveBarNumber).fill(8);
}
const waveBarHeights = new Array(waveBarNumber)
.fill(0)
.map((_item, index) =>
getBarHeight3(index, waveBarNumber, volumeRealNumber),
);
const minHeight = Math.min(...waveBarHeights);
const maxHeight = Math.max(...waveBarHeights);
const heightSpan = maxHeight - minHeight;
return waveBarHeights.map(
item =>
8 +
((item - minHeight) / (maxHeight - minHeight)) * Math.min(12, heightSpan),
);
};
export const getBarHeight3 = (
index: number,
maxNumber: number,
volumeNumber: number,
) => {
const percent = index / maxNumber;
const maxHeight = 24;
let baseHeight = 2;
let randomMin = -2;
let randomMax = 2;
if (percent < 1 / 6) {
baseHeight = 1 + (4 - 1) * percent * 6;
randomMin = 0.1 + (-0.8 - 0.1) * percent * 6;
randomMax = 0.3 + (0.6 - 0.3) * percent * 6;
} else if (percent < 2 / 6) {
baseHeight = 4 + (2 - 4) * (percent - 1 / 6) * 6;
randomMin = -0.8 + (-0.0 + 0.8) * (percent - 1 / 6) * 6;
randomMax = 0.6 + (0.6 - 0.6) * (percent - 1 / 6) * 6;
} else if (percent < 3 / 6) {
baseHeight = 2 + (8 - 2) * (percent - 2 / 6) * 6;
randomMin = 0.0 + (Number(-1.6) - 0.0) * (percent - 2 / 6) * 6;
randomMax = 0.6 + (1.2 - 0.6) * (percent - 2 / 6) * 6;
} else if (percent < 4 / 6) {
baseHeight = 8 + (2 - 8) * (percent - 3 / 6) * 6;
randomMin = -1.6 + (0.0 + 1.6) * (percent - 3 / 6) * 6;
randomMax = 1.2 + (0.6 - 1.2) * (percent - 3 / 6) * 6;
} else if (percent < 5 / 6) {
baseHeight = 2 + (4 - 2) * (percent - 4 / 6) * 6;
randomMin = 0.0 + (Number(-0.8) - 0.0) * (percent - 4 / 6) * 6;
randomMax = 0.6 + (0.6 - 0.6) * (percent - 4 / 6) * 6;
} else if (percent < 1) {
baseHeight = 4 + (1 - 4) * (percent - 5 / 6) * 6;
randomMin = -0.8 + (0.1 + 0.8) * (percent - 5 / 6) * 6;
randomMax = 0.1 + (0.3 - 0.6) * (percent - 5 / 6) * 6;
}
const height =
baseHeight +
volumeNumber *
(Math.random() * (randomMax - randomMin) + randomMin) *
(maxHeight - baseHeight);
return height;
};
export const getBarBgColor = (
index: number,
maxNumber: number,
type: AudioWaveProps['type'],
) => {
let bgColor = '#FFF';
switch (type) {
case 'primary':
{
/*
* implement : fill: linear-gradient(90deg, rgba(83, 71, 255, 0.20) 0%, #5347FF 20%, #B125F1 80%, rgba(177, 37, 241, 0.20) 100%);
*/
let opacity = 0;
let rColor = 0;
let gColor = 0;
let bColor = 0;
const percent = index / maxNumber;
if (percent < 0.2) {
opacity = 0.2 + ((1 - 0.2) * percent) / 0.2;
rColor = 83;
gColor = 71;
bColor = 255;
} else if (percent < 0.8) {
opacity = 1;
rColor = Math.round(83 + ((177 - 83) * (percent - 0.2)) / 0.6);
gColor = Math.round(71 + ((37 - 71) * (percent - 0.2)) / 0.6);
bColor = Math.round(255 + ((241 - 255) * (percent - 0.2)) / 0.6);
} else {
opacity = 1 - ((1 - 0.2) * (percent - 0.8)) / 0.2;
rColor = 177;
gColor = 37;
bColor = 241;
}
bgColor = `rgba(${rColor}, ${gColor}, ${bColor}, ${opacity.toFixed(
2,
)})`;
}
break;
case 'warning':
{
bgColor = '#FF0030';
}
break;
default:
{
/*
* implement : fill: linear-gradient(90deg, rgba(255, 255, 255, 0.20) 0%, #FFF 20%, rgba(255, 255, 255, 0.90) 80%, rgba(255, 255, 255, 0.20) 100%);
*/
let opacity = 0;
const percent = index / maxNumber;
if (percent < 0.2) {
opacity = 0.2 + ((1 - 0.2) * percent) / 0.2;
} else if (percent < 0.8) {
opacity = 1 - ((1 - 0.9) * (percent - 0.2)) / 0.6;
} else {
opacity = 0.9 - ((0.9 - 0.2) * (percent - 0.8)) / 0.2;
}
bgColor = `rgba(255, 255, 255, ${opacity.toFixed(2)})`;
}
break;
}
return bgColor;
};

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef, useEffect, useRef, useState } from 'react';
import { useThrottleFn } from 'ahooks';
import { type AudioRecordProps, Layout } from '@coze-common/chat-uikit-shared';
import { type AudioWaveProps } from './audio-wave/type';
import { AudioWave } from './audio-wave';
export const AudioRecord = forwardRef<
HTMLDivElement,
AudioRecordProps & { layout: Layout }
>(({ isRecording, getVolume, isPointerMoveOut, layout, text }, ref) => {
const [volumeNumber, setVolumeNumber] = useState(0);
const animationIdRef = useRef<number | null>(null);
const { run, flush } = useThrottleFn(
() => {
setVolumeNumber(getVolume?.() ?? 0);
animationIdRef.current = requestAnimationFrame(run);
},
{ wait: 100 },
);
const getAudioWaveTheme = (): AudioWaveProps['type'] => {
if (layout === Layout.MOBILE) {
return 'default';
}
if (isPointerMoveOut) {
return 'warning';
}
return 'primary';
};
useEffect(() => {
if (!isRecording) {
return;
}
run();
return () => {
flush();
if (typeof animationIdRef.current !== 'number') {
return;
}
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
};
}, [isRecording]);
return (
<div ref={ref} className="w-full h-32px relative">
<div className="w-full h-full flex items-center justify-center pointer-events-none">
{isRecording ? (
<AudioWave
size="medium"
type={getAudioWaveTheme()}
volumeNumber={volumeNumber}
/>
) : (
<div className="coz-fg-primary text-lg font-medium leading-[20px]">
{text}
</div>
)}
</div>
</div>
);
});
export { AudioWave };

View File

@@ -0,0 +1,63 @@
/*
* 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 classNames from 'classnames';
import { IconCozChatPlus } from '@coze-arch/coze-design/icons';
import { Layout } from '@coze-common/chat-uikit-shared';
import { UIKitTooltip } from '../../../../common/tooltips';
import { OutlinedIconButton } from '../../../../common';
interface IProps {
isDisabled?: boolean;
tooltipContent?: ReactNode;
onClick: () => void;
layout: Layout;
className: string;
showBackground: boolean;
}
const ClearContextButton: FC<IProps> = props => {
const {
isDisabled,
tooltipContent,
onClick,
layout,
className,
showBackground,
} = props;
return (
<UIKitTooltip
content={tooltipContent}
hideToolTip={layout === Layout.MOBILE}
>
<OutlinedIconButton
data-testid="chat-input-clear-context-button"
showBackground={showBackground}
disabled={isDisabled}
icon={<IconCozChatPlus className="text-18px" />}
size="default"
onClick={onClick}
className={classNames('mr-12px', '!rounded-full', className)}
/>
</UIKitTooltip>
);
};
export default ClearContextButton;

View File

@@ -0,0 +1,63 @@
/*
* 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 classNames from 'classnames';
import { IconCozBroom } from '@coze-arch/coze-design/icons';
import { Layout } from '@coze-common/chat-uikit-shared';
import { UIKitTooltip } from '../../../../common/tooltips';
import { OutlinedIconButton } from '../../../../common';
interface IProps {
isDisabled?: boolean;
tooltipContent?: ReactNode;
onClick: () => void;
layout: Layout;
className: string;
showBackground: boolean;
}
const ClearHistoryButton: FC<IProps> = props => {
const {
isDisabled,
tooltipContent,
onClick,
layout,
className,
showBackground,
} = props;
return (
<UIKitTooltip
content={tooltipContent}
hideToolTip={layout === Layout.MOBILE}
>
<OutlinedIconButton
data-testid="bot-edit-debug-chat-clear-button"
showBackground={showBackground}
disabled={isDisabled}
icon={<IconCozBroom className="text-18px" />}
size="default"
onClick={onClick}
className={classNames('mr-12px', '!rounded-full', className)}
/>
</UIKitTooltip>
);
};
export default ClearHistoryButton;

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 { type ReactNode, type FC } from 'react';
import classNames from 'classnames';
import { IconCozPlusCircle } from '@coze-arch/coze-design/icons';
import { IconButton } from '@coze-arch/coze-design';
import { Layout } from '@coze-common/chat-uikit-shared';
import { UIKitTooltip } from '../../../../common/tooltips';
interface IProps {
isDisabled?: boolean;
tooltipContent?: ReactNode;
layout: Layout;
}
const MoreButton: FC<IProps> = props => {
const { isDisabled, tooltipContent, layout } = props;
return (
<UIKitTooltip
// 为了点调起选择文件的事件时收起 tooltip
disableFocusListener
content={tooltipContent}
hideToolTip={layout === Layout.MOBILE}
>
<IconButton
className="!rounded-full"
data-testid="chat-area.chat-upload-button"
color="secondary"
disabled={isDisabled}
icon={
<IconCozPlusCircle
className={classNames(
isDisabled ? 'coz-fg-dim' : 'coz-fg-primary',
'text-18px',
)}
/>
}
/>
</UIKitTooltip>
);
};
export default MoreButton;

View File

@@ -0,0 +1,46 @@
/*
* 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 { IconCozSendFill } from '@coze-arch/coze-design/icons';
import { IconButton } from '@coze-arch/coze-design';
import { Layout, type SendButtonProps } from '@coze-common/chat-uikit-shared';
import { UIKitTooltip } from '../../../../common/tooltips';
const SendButton: FC<SendButtonProps> = props => {
const { isDisabled, tooltipContent, onClick, layout } = props;
return (
<UIKitTooltip
content={tooltipContent}
hideToolTip={layout === Layout.MOBILE}
>
<IconButton
className={classNames('!rounded-full', !isDisabled && '!coz-fg-hglt')}
disabled={isDisabled}
data-testid="bot-home-chart-send-button"
size="default"
color="secondary"
icon={<IconCozSendFill className="text-18px" />}
onClick={onClick}
/>
</UIKitTooltip>
);
};
export default SendButton;

View File

@@ -0,0 +1,184 @@
.input-wrap {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
@keyframes move-left-fade-out {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes move-right-fade-in {
0% {
transform: translateX(-100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@fade-easing-time: 300ms;
@fade-easing-function: cubic-bezier(0.65, 0, 0.35, 1);
.animate-left {
animation: move-left-fade-out forwards @fade-easing-time @fade-easing-function;
}
.animate-left-revert {
animation: move-right-fade-in forwards @fade-easing-time @fade-easing-function;
}
.input-container {
display: flex;
flex-direction: column;
}
.input-tooltip-anchor {
position: absolute;
top: -10%;
}
.left-actions-container {
align-self: flex-end;
width: fit-content;
}
.textarea-with-top-rows {
display: flex;
flex-direction: column;
border-style: solid;
border-width: 1px;
border-radius: 24px;
transition: width @fade-easing-time;
}
.background-theme {
textarea {
// 背景图模式覆盖样式
/* stylelint-disable-next-line declaration-no-important */
background: transparent !important;
}
}
.textarea-with-actions-container {
position: relative;
flex: 1;
align-items: center;
min-height: 48px;
}
.input-focus {
border-style: solid;
border-width: 1px;
}
/* stylelint-disable-next-line selector-class-pattern */
.textarea-with-actions-container__col {
flex-direction: column;
}
/* stylelint-disable-next-line selector-class-pattern */
.textarea-with-actions-container__row {
display: flex;
flex-direction: row;
}
.textarea-actions-container {
display: flex;
flex-direction: row-reverse;
align-items: center;
align-self: flex-end;
}
.textarea-actions-container-transition {
transition: opacity @fade-easing-time @fade-easing-function
}
.textarea-actions-left {
display: flex;
gap: 4px;
align-items: center;
margin-right: 4px;
}
.textarea-actions-right {
margin-left: 4px;
}
.textarea {
display: flex;
width: 100%;
height: 20px;
min-height: 20px;
margin: 0;
font-family: inherit;
font-weight: 500;
line-height: 20px;
overflow-wrap: break-word;
white-space: pre-wrap;
border: none;
border-radius: 0;
outline: none;
&::-webkit-scrollbar {
width: 6px;
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: #e6e6e7;
border-radius: 4px;
}
}
.textarea::placeholder {
font-weight: 500;
}
.textarea:disabled {
background: #fff;
}
.bottom-tips {
width: 100%;
padding: 8px 24px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
text-align: center;
}
.danger-mask-transition {
transition: opacity 500ms cubic-bezier(0.33, 1, 0.68, 1);
}
.mobile-audio-bg {
/* stylelint-disable-next-line declaration-no-important */
background: linear-gradient(117deg, #5448FF 4.74%, #B026F1 131.42%) !important;
}
.mobile-audio-bg-danger {
@apply !coz-mg-hglt-plus-red;
}

View File

@@ -0,0 +1,527 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
forwardRef,
useImperativeHandle,
useState,
type Dispatch,
type ForwardRefExoticComponent,
type PropsWithChildren,
type PropsWithoutRef,
type RefAttributes,
type RefObject,
type SetStateAction,
useRef,
useEffect,
} from 'react';
import Textarea, { type TextAreaRef } from 'rc-textarea';
import { merge } from 'lodash-es';
import classNames from 'classnames';
import { useControllableValue, useUpdateEffect } from 'ahooks';
import {
IconCozKeyboard,
IconCozMicrophone,
} from '@coze-arch/coze-design/icons';
import { Divider, IconButton } from '@coze-arch/coze-design';
import {
type IChatInputProps,
type InputMode,
Layout,
} from '@coze-common/chat-uikit-shared';
import { AudioRecord } from '../audio-record';
import { UIKitTooltip } from '../../common';
import { useAudioRecordInteraction } from '../../../hooks/use-audio-record-interaction';
import { ChatUpload } from '../../../components/chat/chat-upload';
import { useTextSend } from './use-text-area';
import BuiltinSendButton from './components/send-button';
import MoreButton from './components/more-button';
import ClearHistoryButton from './components/clear-history-button';
import ClearContextButton from './components/clear-context-button';
import styles from './index.module.less';
const DEFAULT_HEIGHT = 24;
export interface UiKitChatInputButtonConfig {
isSendButtonVisible: boolean;
isClearHistoryButtonVisible: boolean;
isMoreButtonVisible: boolean;
isClearContextButtonVisible: boolean;
}
export type ChatInputTooltipComponent = ForwardRefExoticComponent<
PropsWithoutRef<PropsWithChildren> &
RefAttributes<{
close: () => void;
}>
>;
export interface InputRefObject {
input: RefObject<TextAreaRef>;
setValue: Dispatch<SetStateAction<string>>;
sendMessage: IChatInputProps['onSendMessage'];
}
export const ChatInput = forwardRef<InputRefObject, IChatInputProps>(
// eslint-disable-next-line complexity, @coze-arch/max-line-per-function, max-lines-per-function
(props, ref) => {
const {
onBeforeSubmit,
onFocus,
onBlur,
isReadonly,
leftActions,
rightActions,
addonTop,
addonLeft,
aboveOutside,
buildInButtonConfig,
buildInButtonStatus,
copywritingConfig,
onSendMessage,
onClearHistory,
onClearContext,
onUpload,
onInputClick,
hasOtherContentToSend,
layout,
isFileCountExceedsLimit,
inputTooltip: InputTooltip,
showBackground,
limitFileCount,
onPaste,
CustomSendButton,
isInputReadonly,
inputNativeCallbacks,
audioRecordEvents = {},
audioRecordState = {},
audioRecordOptions,
className: _className,
wrapperClassName,
...inputModeProps
} = props;
const {
isSendButtonVisible = true,
isClearHistoryButtonVisible = true,
isMoreButtonVisible = true,
isClearContextButtonVisible = false,
} = buildInButtonConfig ?? {};
const {
isClearHistoryButtonDisabled,
isMoreButtonDisabled,
isSendButtonDisabled,
isClearContextButtonDisabled,
} = buildInButtonStatus ?? {};
const { tooltip, inputPlaceholder, uploadConfig, bottomTips } =
copywritingConfig ?? {};
const {
sendButtonTooltipContent,
clearHistoryButtonTooltipContent,
clearContextButtonTooltipContent,
moreButtonTooltipContent,
audioButtonTooltipContent,
keyboardButtonTooltipContent,
} = tooltip ?? {};
const [isMultiLines, setIsMultiLines] = useState(false);
const [breakLength, setBreakLength] = useState(0);
const [isFocus, setIsFocus] = useState(false);
const [mode, setMode] = useControllableValue<InputMode>(inputModeProps, {
defaultValue: 'input',
valuePropName: 'inputMode',
trigger: 'onInputModeChange',
});
const audioButtonWrapperRef = useRef<HTMLDivElement>(null);
useAudioRecordInteraction({
target: audioButtonWrapperRef,
events: audioRecordEvents,
options: merge({}, audioRecordOptions, {
enabled: mode === 'audio' && audioRecordOptions?.enabled,
}),
});
const inputSizeRef = useRef<HTMLDivElement>(null);
const [inputWidth, setInputWidth] = useState<'100%' | number>('100%');
useEffect(() => {
const target = inputSizeRef.current;
if (!target) {
return;
}
const observer = new ResizeObserver(() => {
setInputWidth(target.clientWidth);
});
observer.observe(target);
return () => {
observer.disconnect();
};
}, []);
const isInputMode = mode === 'input';
const isAudioMode = mode === 'audio';
const handleClickContextButtonClick = () => {
onClearContext?.();
};
/**
* 处理清除历史记录按钮点击事件
*/
const handleClearHistoryButtonClick = () => {
onClearHistory?.();
};
/**
* 处理用户发送消息
* @param text 用户发送消息的文本
*/
const handleSendMessage = (text: string) => {
onSendMessage?.({
text,
mentionList: [],
});
setIsMultiLines(false);
setBreakLength(0);
};
const {
onChange,
setIsComposing,
onKeyDown,
inputText,
setInputText,
submit: handleSendButtonClick,
rcTextareaRef,
updateSelectPos,
onKeyUp,
} = useTextSend({
onSubmit: handleSendMessage,
onBeforeSubmit,
isDisabled: isSendButtonDisabled,
allowEmpty: hasOtherContentToSend,
inputNativeCallbacks,
});
const buttonClass = showBackground ? '!coz-fg-images-white' : '';
const handleResize = ({ height }: { height: number }) => {
if (
!isMultiLines &&
height > DEFAULT_HEIGHT &&
// 通过 inputText 长度判断,排除 placeholder 导致的 resize 不处理
inputText?.trim()?.length !== 0
) {
setIsMultiLines(true);
setBreakLength(inputText.length);
}
};
const handleOnChange = (evt: { target: { value: string } }) => {
onChange(evt);
if (isMultiLines && evt.target.value.length < breakLength) {
setIsMultiLines(false);
}
};
/** 计算出复合条件的readonly值 */
const finalClearHistoryButtonDisable =
isClearHistoryButtonDisabled || isReadonly;
const finalSendButtonDisable =
(Boolean(!inputText?.trim()) && !hasOtherContentToSend) ||
isSendButtonDisabled ||
isReadonly;
const finalMoreButtonDisable = isMoreButtonDisabled || isReadonly;
const finalClearContextButtonDisable =
isClearContextButtonDisabled || isReadonly;
const getFinalSendButtonVisible = () => {
const visibleCondition = isSendButtonVisible && isInputMode;
if (audioRecordOptions?.enabled) {
return visibleCondition && inputText;
}
return visibleCondition;
};
useUpdateEffect(() => {
if (mode !== 'input') {
return;
}
rcTextareaRef.current?.focus();
}, [mode]);
useImperativeHandle(ref, () => ({
input: rcTextareaRef,
setValue: setInputText,
sendMessage: onSendMessage,
}));
const SendButton = CustomSendButton ?? BuiltinSendButton;
return (
<div className={styles['input-container']}>
<div
className={classNames(
styles['input-wrap'],
'py-0',
layout === Layout.MOBILE ? 'px-[16px]' : 'px-[24px]',
'input-wraper-for-reset',
)}
>
{aboveOutside}
<div
className={classNames(
styles['left-actions-container'],
['mb-8px'],
typeof audioRecordState.isRecording === 'boolean' &&
audioRecordState.isRecording
? [styles['animate-left'], '!w-0']
: styles['animate-left-revert'],
)}
>
{leftActions}
{isClearContextButtonVisible ? (
<ClearContextButton
showBackground={Boolean(showBackground)}
tooltipContent={clearContextButtonTooltipContent}
isDisabled={finalClearContextButtonDisable}
onClick={handleClickContextButtonClick}
data-testid="bot-edit-debug-chat-clear-button"
layout={layout}
className={buttonClass}
></ClearContextButton>
) : null}
{isClearHistoryButtonVisible ? (
<ClearHistoryButton
showBackground={Boolean(showBackground)}
tooltipContent={clearHistoryButtonTooltipContent}
isDisabled={finalClearHistoryButtonDisable}
onClick={handleClearHistoryButtonClick}
data-testid="bot-edit-debug-chat-clear-button"
layout={layout}
className={buttonClass}
/>
) : null}
</div>
<div
ref={inputSizeRef}
className="flex-[1] flex justify-end overflow-hidden"
>
<div
style={{ width: inputWidth }}
className={classNames(
styles['textarea-with-top-rows'],
['coz-bg-max', 'coz-stroke-plus', 'relative'],
isFocus && styles['input-focus'],
(isFocus || audioRecordState.isRecording) && [
'!coz-stroke-hglt',
],
audioRecordState.isRecording &&
audioRecordState.isPointerMoveOut &&
'!coz-stroke-hglt-red',
audioRecordState.isRecording
? 'overflow-visible'
: 'overflow-hidden',
mode === 'audio' && 'cursor-pointer',
(mode === 'audio' || audioRecordState.isRecording) &&
'hover:coz-stroke-hglt',
showBackground && styles['background-theme'],
{ '!coz-bg-image-bots': showBackground },
layout === Layout.MOBILE &&
audioRecordState.isRecording &&
(audioRecordState.isPointerMoveOut
? styles['mobile-audio-bg-danger']
: styles['mobile-audio-bg']),
wrapperClassName,
)}
>
{addonTop}
<div
className={classNames(
styles['textarea-with-actions-container'],
styles['coz-textarea-with-actions-container-padding'],
'py-8px',
'pr-8px',
'pl-20px',
{
[styles['textarea-with-actions-container__row']]:
!isMultiLines,
[styles['textarea-with-actions-container__col']]:
isMultiLines,
},
)}
>
{InputTooltip ? (
<InputTooltip>
<i className={styles['input-tooltip-anchor']} />
</InputTooltip>
) : null}
{addonLeft}
{isInputMode ? (
<Textarea
data-testid="bot.ide.chat_area.chat_input.textarea"
disabled={isInputReadonly || isReadonly}
className={classNames(
styles.textarea,
[
'coz-fg-primary',
'coz-bg-max',
'disabled:coz-bg-max',
'placeholder:coz-fg-dim',
],
{
[styles['textarea--with-margin']]: isMultiLines,
},
)}
autoSize={{
minRows: 1,
maxRows: 5,
}}
classNames={{
textarea: classNames(
styles.textarea,
layout === Layout.MOBILE
? 'text-[16px]'
: 'text-[14px]',
),
}}
ref={rcTextareaRef}
placeholder={inputPlaceholder}
onChange={handleOnChange}
value={inputText}
onCompositionStart={evt => setIsComposing(evt, true)}
onCompositionEnd={evt => setIsComposing(evt, false)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onResize={handleResize}
onSelect={updateSelectPos}
onClick={onInputClick}
onFocus={e => {
onFocus?.(e);
setIsFocus(true);
}}
onBlur={e => {
onBlur?.(e);
setIsFocus(false);
}}
onPaste={onPaste}
/>
) : null}
{isAudioMode ? (
<AudioRecord
ref={audioButtonWrapperRef}
{...audioRecordState}
layout={layout}
/>
) : null}
<div
className={classNames(
audioRecordState.isRecording
? 'opacity-0 w-0'
: 'opacity-100',
styles['textarea-actions-container'],
styles['textarea-actions-container-transition'],
)}
>
<div className={styles['textarea-actions-right']}>
{getFinalSendButtonVisible() ? (
<SendButton
tooltipContent={sendButtonTooltipContent}
isDisabled={finalSendButtonDisable}
onClick={handleSendButtonClick}
layout={layout}
/>
) : null}
{!inputText &&
!isAudioMode &&
audioRecordOptions?.enabled ? (
<UIKitTooltip
content={audioButtonTooltipContent}
hideToolTip={layout === Layout.MOBILE}
>
<IconButton
className="!rounded-full"
color="secondary"
icon={<IconCozMicrophone className="text-18px" />}
onClick={() => {
setMode('audio');
}}
/>
</UIKitTooltip>
) : null}
{isAudioMode ? (
<UIKitTooltip
content={keyboardButtonTooltipContent}
hideToolTip={layout === Layout.MOBILE}
>
<IconButton
className="!rounded-full"
color="secondary"
icon={<IconCozKeyboard className="text-18px" />}
onClick={() => {
setMode('input');
}}
/>
</UIKitTooltip>
) : null}
</div>
{rightActions || isMoreButtonVisible ? (
<>
<Divider layout="vertical" style={{ height: '14px' }} />
<div className={styles['textarea-actions-left']}>
{rightActions}
{isMoreButtonVisible ? (
<ChatUpload
onUpload={(uploadType, file) =>
onUpload?.(uploadType, { file, mentionList: [] })
}
isFileCountExceedsLimit={isFileCountExceedsLimit}
copywritingConfig={uploadConfig}
isDisabled={finalMoreButtonDisable}
limitFileCount={limitFileCount}
>
<MoreButton
tooltipContent={moreButtonTooltipContent}
isDisabled={finalMoreButtonDisable}
layout={layout}
/>
</ChatUpload>
) : null}
</div>
</>
) : null}
</div>
</div>
</div>
</div>
</div>
{bottomTips ? (
<div
className={classNames(
styles['bottom-tips'],
'coz-fg-dim',
showBackground && '!coz-fg-images-secondary',
)}
>
<span>{bottomTips}</span>
</div>
) : null}
</div>
);
},
);
ChatInput.displayName = 'UiKitChatInput';

View File

@@ -0,0 +1,224 @@
/*
* 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 KeyboardEvent,
useRef,
useEffect,
type KeyboardEventHandler,
type CompositionEvent,
} from 'react';
import { type TextAreaRef } from 'rc-textarea';
import {
type InputNativeCallbacks,
type InputController,
type InputState,
} from '@coze-common/chat-uikit-shared';
import { useImperativeLayoutEffect } from '@coze-common/chat-hooks';
type KeyboardGeneralEvent = KeyboardEvent<HTMLElement>;
// eslint-disable-next-line max-lines-per-function, @coze-arch/max-line-per-function -- x.x
export const useTextSend = ({
onSubmit,
defaultValue = '',
allowEmpty = false,
onBeforeSubmit,
isDisabled = false,
inputNativeCallbacks = {},
}: {
onSubmit: (text: string) => void;
defaultValue?: string;
/**
* 是否允许空字符串提交
* @default false
*/
allowEmpty?: boolean;
onBeforeSubmit?: () => boolean;
isDisabled?: boolean;
inputNativeCallbacks?: InputNativeCallbacks;
}) => {
const [inputText, setInputText] = useState(defaultValue);
const composingRef = useRef(false);
const rcTextareaRef = useRef<TextAreaRef>(null);
const selectionRef = useRef<{ start: number; end: number }>({
start: 0,
end: 0,
});
const getTextarea = () => rcTextareaRef.current?.resizableTextArea.textArea;
const waitAndUpdateSelectPos = useImperativeLayoutEffect(() =>
updateSelectPos(),
);
const readState = (): InputState => {
const state: InputState = {
inputText,
isComposing: composingRef.current,
isDisabled,
selection: selectionRef.current,
hasSelection: selectionRef.current.start !== selectionRef.current.end,
};
return state;
};
const readStateRef = useRef<() => InputState>();
readStateRef.current = readState;
useEffect(() => {
if (!inputNativeCallbacks.getController) {
return;
}
const controller: InputController = {
requireSetMousePosition,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- .
readState: () => readStateRef.current!(),
setInputText: (updater: string | ((pre: string) => string)) => {
setInputText(updater);
waitAndUpdateSelectPos();
},
focus: () => {
getTextarea()?.focus();
},
};
inputNativeCallbacks.getController(controller);
}, [inputNativeCallbacks.getController]);
const requireSetMousePosition = useImperativeLayoutEffect((pos: number) => {
const textarea = getTextarea();
if (!textarea) {
return;
}
textarea.focus();
textarea.setSelectionRange(pos, pos);
});
const updateSelectPos = () => {
const textarea = getTextarea();
if (!textarea) {
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
selectionRef.current = { start, end };
};
const submit = () => {
if (!allowEmpty && !inputText.trim()) {
return;
}
if (isDisabled) {
return;
}
if (onBeforeSubmit && !onBeforeSubmit()) {
return;
}
onSubmit(inputText);
setInputText('');
};
const onKeydownToSubmit = (evt: KeyboardGeneralEvent) => {
if (evt.code !== 'Enter') {
return;
}
if (composingRef.current || isPressEnterToChangeLine(evt)) {
return;
}
evt.preventDefault();
submit();
};
const onKeyUp: KeyboardEventHandler<HTMLTextAreaElement> = e => {
updateSelectPos();
inputNativeCallbacks.onAfterProcessKeyUp?.(e);
};
const onKeyDown = (evt: KeyboardEvent<HTMLTextAreaElement>) => {
updateSelectPos();
const res = inputNativeCallbacks.onBeforeProcessKeyDown?.(evt);
if (res?.exit) {
return;
}
if ((evt.metaKey || evt.altKey || evt.ctrlKey) && evt.code === 'Enter') {
handleNewLine();
return;
}
onKeydownToSubmit(evt);
};
const handleNewLine = () => {
const textarea = getTextarea();
if (!textarea) {
return;
}
// 计算光标的当前位置
const cursorPosition = textarea.selectionStart;
// 在光标位置插入新行
const newValue = `${inputText.substring(
0,
cursorPosition,
)}\n${inputText.substring(cursorPosition)}`;
setInputText(newValue);
setTimeout(() => {
textarea.selectionStart = cursorPosition + 1;
textarea.selectionEnd = cursorPosition + 1;
}, 0);
};
const onChange = (evt: { target: { value: string } }) => {
updateSelectPos();
const val = evt.target.value;
setInputText(val);
Promise.resolve().then(() => {
inputNativeCallbacks?.onAfterOnChange?.();
});
};
return {
onChange,
setIsComposing: (
_: CompositionEvent<HTMLTextAreaElement>,
composing: boolean,
) => {
composingRef.current = composing;
},
submit,
onKeyDown,
inputText,
setInputText,
rcTextareaRef,
updateSelectPos,
onKeyUp,
};
};
const isPressEnterToChangeLine = (evt: KeyboardGeneralEvent) => {
if (evt.code !== 'Enter') {
return false;
}
return evt.shiftKey || evt.altKey || evt.metaKey;
};

View File

@@ -0,0 +1,141 @@
/*
* 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 {
FILE_TYPE_CONFIG,
FileTypeEnum,
} from '@coze-common/chat-core/shared/const';
import { Toast, Upload } from '@coze-arch/coze-design';
import {
type IChatUploadCopywritingConfig,
DEFAULT_MAX_FILE_SIZE,
UploadType,
} from '@coze-common/chat-uikit-shared';
interface IChatUploadProps {
/**
* 上传事件回调
* @param uploadType 上传类型 [IMAGE=0 FILE=1]
* @param file 文件
* @returns void
*/
onUpload: (uploadType: UploadType, file: File) => void;
/**
* 文案信息配置
*/
copywritingConfig?: IChatUploadCopywritingConfig;
/**
* 文件最大尺寸单位byte
*/
maxFileSize?: number;
isDisabled?: boolean;
children: JSX.Element;
limitFileCount?: number;
isFileCountExceedsLimit: (fileCount: number) => boolean;
}
const findFileTypeConfig = (file: File) =>
FILE_TYPE_CONFIG.find(
cnf => cnf.judge?.(file) || cnf.accept.some(ext => file.name.endsWith(ext)),
);
export const ChatUpload: FC<IChatUploadProps> = props => {
const {
copywritingConfig = {},
maxFileSize = DEFAULT_MAX_FILE_SIZE,
children,
onUpload,
isDisabled,
isFileCountExceedsLimit,
limitFileCount = 1,
} = props;
/**
* 处理上传
* @param fileList 文件List
* @returns void
*/
const handleUpload = (fileList: File[]) => {
const { fileSizeReachLimitToast, fileExceedsLimitToast, fileEmptyToast } =
copywritingConfig;
if (isFileCountExceedsLimit(fileList.length)) {
Toast.warning({
showClose: false,
content: fileExceedsLimitToast,
});
return;
}
if (!fileList.length) {
return;
}
// 是否存在超出大小的文件
const hasOverflowLimitFileSize = fileList.some(
file => file.size > maxFileSize,
);
const hasEmptyFile = fileList.some(file => file.size <= 0);
// 文件大小超过预期大小的错误处理
if (hasOverflowLimitFileSize) {
Toast.warning({
showClose: false,
content: fileSizeReachLimitToast,
});
}
if (hasEmptyFile) {
Toast.warning({
showClose: false,
content: fileEmptyToast,
});
}
const verifiedFileTypeConfigList = fileList
.filter(file => file.size <= maxFileSize && file.size > 0)
.map(file => ({
file,
fileTypeConfig: findFileTypeConfig(file),
}));
for (const fileConfig of verifiedFileTypeConfigList) {
if (fileConfig.fileTypeConfig?.fileType === FileTypeEnum.IMAGE) {
onUpload?.(UploadType.IMAGE, fileConfig.file);
} else {
onUpload?.(UploadType.FILE, fileConfig.file);
}
}
};
return (
<Upload
limit={limitFileCount === 1 ? 1 : undefined}
draggable={false}
action=""
fileList={[]}
onFileChange={handleUpload}
disabled={isDisabled}
multiple={limitFileCount > 1}
>
{children}
</Upload>
);
};
ChatUpload.displayName = 'UiKitChatUpload';

View File

@@ -0,0 +1,44 @@
/*
* 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 { Divider, Typography } from '@coze-arch/coze-design';
interface ContextDividerProps {
className?: string;
text?: string;
}
export const ContextDivider = ({ text, className }: ContextDividerProps) => (
<Divider className={classNames(className, 'w-full my-24px ')} align="center">
<Typography.Paragraph
ellipsis={{
showTooltip: {
opts: {
content: text,
style: {
wordBreak: 'break-word',
},
},
},
rows: 2,
}}
className="coz-fg-dim whitespace-pre-wrap text-center text-base leading-[16px] font-normal break-words"
>
{text}
</Typography.Paragraph>
</Divider>
);

View File

@@ -0,0 +1,26 @@
/*
* 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 './chat-input';
export * from './chat-upload';
export * from './on-boarding';
export * from './stop-respond-button';
export * from './thinking-placeholder';
export * from './context-divider';
export * from './with-rule-img-background';
export * from './audio-record';
export type { UiKitChatInputButtonConfig } from './chat-input/index';

View File

@@ -0,0 +1,63 @@
.chat-uikit-on-boarding {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
// max-width: 576px;
height: 100%;
padding: 0 10px;
&__bot {
display: flex;
flex-direction: column;
column-gap: 20px;
align-items: center;
justify-content: center;
width: 100%;
&.chat-uikit-on-boarding__bot__with__onboarding {
row-gap: 12px;
// justify-content: flex-start;
}
}
&__prologue-sug {
width: fit-content;
max-width: 100%;
}
&__prologue {
user-select: text;
width: fit-content;
max-width: 100%;
margin-top: 16px;
font-style: normal;
word-break: break-word;
}
&__suggestions {
display: flex;
flex-direction: column;
align-self: flex-start;
max-width: 100%;
}
}
// pc store页面开场白视觉与输入框对齐整体左偏差
.chat-uikit-on-boarding-pc {
.chat-uikit-on-boarding__bot {
padding-right: 14px;
padding-left: 58px;
}
.chat-uikit-on-boarding__prologue-sug {
padding-right: 14px;
padding-left: 58px;
}
}

View File

@@ -0,0 +1,270 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type CSSProperties,
useState,
forwardRef,
type FC,
useContext,
} from 'react';
import classNames from 'classnames';
import { useUpdateEffect } from 'ahooks';
import { Avatar, Typography } from '@coze-arch/coze-design';
import {
MdBoxLazy,
type MdBoxLazyProps,
} from '@coze-arch/bot-md-box-adapter/lazy';
import {
Layout,
type IMessage,
type IEventCallbacks,
} from '@coze-common/chat-uikit-shared';
import { CozeLink } from '../../md-box-slots/link';
import {
type CozeImageProps,
CozeImageWithPreview,
} from '../../md-box-slots/coze-image';
import { SuggestionItem } from '../../contents/suggestion-content/components/suggestion-item';
import { OnboardingContext } from '../../../context/onboarding';
import { NO_MESSAGE_ID_MARK } from '../../../constants/grab';
import defaultAvatar from '../../../assets/default-square-avatar.png';
import {
typeSafeBotInfoNameVariants,
type BotInfoVariantProps,
} from './variants';
import './index.less';
interface OnBoardingProps {
avatar?: string;
name?: string;
prologue?: string;
suggestionList?: IMessage[];
/**
* suggestionList是否换行展示, 默认false
*/
suggestionsWrap?: boolean;
readonly?: boolean;
suggestionListWithString?: string[];
/**
* suggestionListWithString是否换行展示, 默认false
*/
suggestionsWithStringWrap?: boolean;
onSuggestionClick?: (content: string) => void;
className?: string;
prologueClassName?: string;
mdBoxProps?: Pick<MdBoxLazyProps, 'insertedElements' | 'slots'>;
style?: CSSProperties;
showBackground?: boolean;
layout?: Layout;
enableAutoSizeImage?: boolean;
imageAutoSizeContainerWidth?: number;
eventCallbacks?: IEventCallbacks;
suggestionItemColor?: 'white' | 'grey';
}
interface BotInfoProps {
wrapperClassName?: string;
avatar: string | undefined;
onError: () => void;
name: string | undefined;
}
const BotInfo: React.FC<BotInfoProps & BotInfoVariantProps> = ({
avatar,
wrapperClassName,
onError,
name,
showBackground,
}) => (
<div className={wrapperClassName}>
<Avatar
className={classNames('h-[64px] w-[64px]', 'rounded-[16px]')}
src={avatar}
shape="square"
onError={onError}
></Avatar>
{name ? (
<Typography.Text
ellipsis
className={typeSafeBotInfoNameVariants({
showBackground: Boolean(showBackground),
})}
>
{name}
</Typography.Text>
) : null}
</div>
);
export const OnBoarding = forwardRef<HTMLDivElement, OnBoardingProps>(
(props, ref) => {
const {
avatar,
name,
prologue,
suggestionList,
readonly,
suggestionListWithString,
onSuggestionClick,
className,
prologueClassName,
mdBoxProps,
style,
showBackground,
layout,
enableAutoSizeImage,
imageAutoSizeContainerWidth,
eventCallbacks,
suggestionsWrap = false,
suggestionsWithStringWrap = false,
suggestionItemColor,
} = props;
const [botAvatar, setBotAvatar] = useState(avatar || defaultAvatar);
const suggestions = suggestionList || suggestionListWithString;
const isOnboardingEmpty = !prologue && !suggestions?.length;
useUpdateEffect(() => {
setBotAvatar(avatar || defaultAvatar);
}, [avatar]);
return (
<OnboardingContext.Provider
value={{
imageAutoSizeContainerWidth,
eventCallbacks,
}}
>
<div
ref={ref}
className={classNames('chat-uikit-on-boarding', className, {
'chat-uikit-on-boarding-pc': layout === Layout.PC,
})}
style={style}
>
<BotInfo
wrapperClassName={classNames(
'chat-uikit-on-boarding__bot',
!isOnboardingEmpty &&
'chat-uikit-on-boarding__bot__with__onboarding',
)}
avatar={botAvatar}
name={name}
showBackground={showBackground}
onError={() => setBotAvatar(defaultAvatar)}
/>
<div className={classNames('chat-uikit-on-boarding__prologue-sug')}>
{prologue ? (
<div
className={classNames(
[
'py-12px',
'px-16px',
layout === Layout.MOBILE ? 'text-[16px]' : 'text-lg',
'leading-[20px]',
'rounded-normal',
'bg-[var(--coz-mg-primary)]',
],
'chat-uikit-on-boarding__prologue',
prologueClassName,
{
'!coz-bg-image-bots !coz-stroke-image-bots': showBackground,
},
)}
data-grab-mark={NO_MESSAGE_ID_MARK}
>
<MdBoxLazy
markDown={prologue}
autoFixSyntax={{ autoFixEnding: false }}
slots={{
Image: enableAutoSizeImage
? CozeImageWithSizeProps
: undefined,
Link: CozeLink,
}}
{...mdBoxProps}
></MdBoxLazy>
</div>
) : null}
{Boolean(suggestionList?.length) && (
<div
className={classNames(
'chat-uikit-on-boarding__suggestions',
'mt-8px',
{
'flex-wrap !flex-row gap-2': suggestionsWrap,
},
)}
>
{suggestionList?.map((message, index) => (
<SuggestionItem
key={index}
className={classNames({
'!mb-0': suggestionsWrap,
})}
message={message}
readonly={readonly}
onSuggestionClick={({ text }) => onSuggestionClick?.(text)}
showBackground={showBackground}
color={suggestionItemColor}
></SuggestionItem>
))}
</div>
)}
{Boolean(suggestionListWithString?.length) && (
<div
className={classNames(
'chat-uikit-on-boarding__suggestions',
'mt-8px',
{
'flex-wrap !flex-row gap-2': suggestionsWithStringWrap,
},
)}
>
{suggestionListWithString?.map((content, index) => (
<SuggestionItem
key={index}
className={classNames({
'!mb-0': suggestionsWithStringWrap,
})}
content={content}
readonly={readonly}
onSuggestionClick={({ text }) => onSuggestionClick?.(text)}
showBackground={showBackground}
color={suggestionItemColor}
></SuggestionItem>
))}
</div>
)}
</div>
</div>
</OnboardingContext.Provider>
);
},
);
const CozeImageWithSizeProps: FC<CozeImageProps> = props => {
const { imageAutoSizeContainerWidth } = useContext(OnboardingContext);
return (
<CozeImageWithPreview
{...props}
imageAutoSizeContainerWidth={imageAutoSizeContainerWidth}
/>
);
};
CozeImageWithSizeProps.displayName = 'CozeImageWithSizeProps';

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cva, type VariantProps } from 'class-variance-authority';
export const botInfoNameVariants = cva(
['leading-[28px]', 'font-medium', 'text-20px'],
{
variants: {
showBackground: {
true: ['coz-fg-images-user-name'],
false: ['coz-fg-plus'],
},
},
},
);
export type BotInfoVariantProps = VariantProps<typeof botInfoNameVariants>;
export const typeSafeBotInfoNameVariants: (
props: BotInfoVariantProps,
) => string = botInfoNameVariants;

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import classNames from 'classnames';
import { IconCozStopCircle } from '@coze-arch/coze-design/icons';
import { Button } from '@coze-arch/coze-design';
interface IProps {
className?: string;
content: string;
onClick?: () => void;
}
export const StopRespondButton: FC<IProps> = props => {
const { content, onClick, className } = props;
return (
<Button
color="secondary"
onClick={onClick}
className={classNames(
'coz-stroke-primary',
'coz-fg-primary',
'border-[1px]',
'border-solid',
'coz-shadow-default',
className,
)}
icon={<IconCozStopCircle />}
>
{content}
</Button>
);
};
StopRespondButton.displayName = 'StopRespondButton';

View File

@@ -0,0 +1,62 @@
@keyframes chat-uikit-thinking-placeholder-animation {
0% {
background-color: var(--coz-fg-plus);
}
50% {
background-color: var(--coz-fg-dim);
}
100% {
background-color: var(--coz-fg-plus);
}
}
.chat-uikit-coz-thinking-placeholder {
position: relative;
overflow: visible;
width: 6px;
height: 6px;
margin: 0 10px;
@apply coz-fg-dim;
background-color: var(--coz-fg-dim);
border-radius: 100%;
animation: chat-uikit-thinking-placeholder-animation 0.8s infinite alternate;
animation-timing-function: ease;
animation-delay: -0.2s;
&::before,
&::after {
content: '';
position: absolute;
top: 0;
display: inline-block;
width: 6px;
height: 6px;
background-color: var(--coz-fg-dim);
border-radius: 100%;
animation: chat-uikit-thinking-placeholder-animation 0.8s infinite alternate;
animation-timing-function: ease;
}
&::before {
left: -10px;
animation-delay: -0.4s;
}
&::after {
left: 10px;
animation-delay: 0s;
}
}

View File

@@ -0,0 +1,59 @@
/*
* 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 ThinkingPlaceholderVariantProps,
typeSafeThinkingPlaceholderVariants,
} from './variant';
import { type IThinkingPlaceholderProps } from './type';
import './animation.less';
const getVariantByProps = ({
theme,
showBackground,
}: {
theme: IThinkingPlaceholderProps['theme'];
showBackground: boolean;
}): ThinkingPlaceholderVariantProps => {
if (showBackground) {
return { backgroundColor: 'withBackground' };
}
if (!theme) {
return { backgroundColor: null };
}
return { backgroundColor: theme };
};
export const ThinkingPlaceholder: FC<IThinkingPlaceholderProps> = props => {
const { className, theme = 'none', showBackground } = props;
return (
<div
className={classNames(
typeSafeThinkingPlaceholderVariants(
getVariantByProps({ showBackground: Boolean(showBackground), theme }),
),
className,
)}
>
<div className="chat-uikit-coz-thinking-placeholder"></div>
</div>
);
};

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface IThinkingPlaceholderProps {
className?: string;
theme?: 'primary' | 'whiteness' | 'none' | 'grey';
showBackground?: boolean;
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cva, type VariantProps } from 'class-variance-authority';
const thinkingPlaceholderVariants = cva(
[
'h-[44px]',
'w-fit',
'flex',
'justify-center',
'items-center',
'py-12px',
'px-16px',
'rounded-normal',
],
{
variants: {
backgroundColor: {
whiteness: ['bg-[var(--coz-mg-card)]'],
grey: ['bg-[var(--coz-mg-primary)]'],
primary: ['bg-[var(coz-mg-hglt-plus)]'],
withBackground: ['coz-bg-image-bots', 'coz-stroke-image-bots'],
none: ['coz-stroke-primary'],
},
},
},
);
export type ThinkingPlaceholderVariantProps = Required<
VariantProps<typeof thinkingPlaceholderVariants>
>;
export const typeSafeThinkingPlaceholderVariants: (
props: ThinkingPlaceholderVariantProps,
) => string = thinkingPlaceholderVariants;

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const MODE_CONFIG = {
pc: {
size: {
width: 486,
height: 346,
},
centerWidth: 346,
},
mobile: {
size: {
width: 248,
height: 346,
},
centerWidth: 206,
},
};

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRef } from 'react';
import { useSize } from 'ahooks';
import { getStandardRatio } from '../utils';
import { type BackgroundImageInfo } from '../types';
import { MODE_CONFIG } from '../const';
export const useGetResponsiveBackgroundInfo = ({
backgroundInfo,
}: {
backgroundInfo?: BackgroundImageInfo;
}) => {
const targetRef = useRef(null);
const size = useSize(targetRef);
const { width = 0, height = 0 } = size ?? {};
const isMobileMode = width / height <= getStandardRatio('mobile');
const mobileBackgroundInfo = backgroundInfo?.mobile_background_image;
const pcBackgroundInfo = backgroundInfo?.web_background_image;
const currentBackgroundInfo = isMobileMode
? mobileBackgroundInfo
: pcBackgroundInfo;
const { theme_color } = currentBackgroundInfo ?? {};
const { size: cropperSize } = MODE_CONFIG[isMobileMode ? 'mobile' : 'pc'];
return {
targetRef,
currentBackgroundInfo,
targetWidth: width,
targetHeight: height,
currentThemeColor: theme_color,
cropperSize,
};
};

View File

@@ -0,0 +1,187 @@
/*
* 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, useEffect } from 'react';
import { isEmpty } from 'lodash-es';
import classNames from 'classnames';
import { addAlpha, computeShowGradient } from './utils';
import {
type CanvasPosition,
type GradientPosition,
type BackgroundImageInfo,
} from './types';
import { useGetResponsiveBackgroundInfo } from './hooks/use-get-background-info';
export interface WithRuleImgBackgroundProps {
preview?: boolean;
backgroundInfo?: BackgroundImageInfo;
onError?: () => void;
}
export const Gradient: React.FC<{
position: number;
preview: boolean;
showGradient: boolean;
background: string;
direction: 'left' | 'right';
}> = ({ position, preview, showGradient, background, direction }) => (
<div
className={classNames('absolute -translate-y-1/2 top-1/2 h-full z-10', {
'transition-all duration-500': !preview,
})}
style={{
[direction]: `${(position > 0 ? position : 0) * 100 - 0.1}%`, // 0.1为阴影补偿,防止出现间隙
width: '10%',
background,
opacity: showGradient ? 1 : 0,
}}
></div>
);
const getGradient = (
gradient: GradientPosition,
canvasData: CanvasPosition,
cropperWidth = 1,
) => {
const { left: cropperImgLeft = 0, width: cropperImgWidth = 0 } = canvasData;
// 伪裁剪 兼容历史gradient不准的问题
if (!isEmpty(canvasData)) {
return {
left: cropperImgLeft / cropperWidth,
right: (cropperWidth - cropperImgWidth - cropperImgLeft) / cropperWidth,
};
} else {
return gradient;
}
};
export const WithRuleImgBackground: React.FC<WithRuleImgBackgroundProps> = ({
preview = false,
backgroundInfo,
}) => {
const {
currentBackgroundInfo,
targetHeight,
targetWidth,
targetRef,
cropperSize,
} = useGetResponsiveBackgroundInfo({
backgroundInfo,
});
const {
theme_color,
gradient_position = {},
canvas_position = {},
} = currentBackgroundInfo ?? {};
const { left: gradientLeft = 0, right: gradientRight = 0 } = getGradient(
gradient_position,
canvas_position,
cropperSize.width,
);
const { top: cropperImgTop = 0, height: cropperImgHeight = 0 } =
canvas_position;
const [themeColor, setThemeColor] = useState(theme_color ?? 'transparent');
// 与裁剪框等比例算出图片渲染区域的宽度
const imgWidth = (targetHeight * cropperSize.width) / cropperSize.height;
const mediumColor = addAlpha(themeColor, 0.95);
useEffect(() => {
if (theme_color) {
setThemeColor(theme_color);
}
}, [currentBackgroundInfo]);
return (
<div
data-testid="chat.with_rule_img_background"
ref={targetRef}
className={
'rule-img-background absolute left-1/2 -translate-x-1/2 w-full h-full overflow-hidden pointer-events-none'
}
style={{
background: preview ? 'none' : themeColor,
zIndex: preview ? 100 : 0,
}}
>
{/* 背景图上压黑阴影 */}
<div className="bg-[rgba(0,0,0,0.12)] absolute w-full h-full z-[200] rounded-t-[6px]"></div>
<div className="relative w-fit h-fit left-1/2 -translate-x-1/2">
{
<Gradient
preview={preview}
showGradient={computeShowGradient(
targetWidth,
imgWidth,
gradientLeft,
)}
position={gradientLeft}
direction="left"
background={`linear-gradient(90deg, ${themeColor} 10%, ${mediumColor} 28%, transparent 92.4%)`}
/>
}
{preview ? (
<div
style={{
height: targetHeight,
width: targetWidth,
}}
></div>
) : (
<div
style={{
width: imgWidth,
height: targetHeight,
position: 'relative',
overflow: 'hidden',
}}
>
<img
src={currentBackgroundInfo?.origin_image_url}
alt=""
style={{
height: `${(cropperImgHeight / cropperSize.height) * 100}%`,
position: 'absolute',
left: `${
gradientLeft ? gradientLeft * 100 : -gradientRight * 2 * 100
}%`,
top: `${(cropperImgTop / cropperSize.height) * 100}%`,
}}
/>
</div>
)}
{
<Gradient
preview={preview}
showGradient={computeShowGradient(
targetWidth,
imgWidth,
gradientRight,
)}
position={gradientRight}
direction="right"
background={`linear-gradient(90deg, transparent 10% , ${mediumColor} 72%, ${themeColor} 92%)`}
/>
}
</div>
</div>
);
};

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface GradientPosition {
left?: number;
right?: number;
}
export interface CanvasPosition {
width?: number;
height?: number;
left?: number;
top?: number;
}
export interface BackgroundImageDetail {
/** 原始图片 */
origin_image_uri?: string;
origin_image_url?: string;
/** 实际使用图片 */
image_uri?: string;
image_url?: string;
theme_color?: string;
/** 渐变位置 */
gradient_position?: GradientPosition;
/** 裁剪画布位置 */
canvas_position?: CanvasPosition;
}
export interface BackgroundImageInfo {
/** web端背景图 */
web_background_image?: BackgroundImageDetail;
/** 移动端背景图 */
mobile_background_image?: BackgroundImageDetail;
}

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 { MODE_CONFIG } from './const';
// 输入透明度系数 和color 返回新的颜色
export function addAlpha(color: string, alpha: number): string {
const regex = /^rgba\((\d{1,3}),(\d{1,3}),(\d{1,3})\)$/;
if (!regex.test(color)) {
return color;
}
const values: string[] = color.slice(5, -1).split(',');
values.push(alpha.toString());
const newColor = `rgba(${values.join(',')})`;
return newColor;
}
// 图片的宽高比
export const getStandardRatio = (mode: 'pc' | 'mobile'): number =>
MODE_CONFIG[mode].size.width / MODE_CONFIG[mode].size.height;
// 计算是否展示渐变阴影 = 屏幕宽度 > 图片宽度 * 1- 2 * 左/右阴影位置)
export const computeShowGradient = (
width: number,
imgWidth: number,
percent: number,
): boolean => width > imgWidth * (1 - (percent > 0 ? percent : 0) * 2);

View File

@@ -0,0 +1,86 @@
.button {
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 12px;
color: #4D53E8;
background: #FFF;
border: 1px solid rgba(10, 17, 61, 6%);
border-radius: 16px;
}
.button:hover {
background: linear-gradient(0deg, rgba(0, 0, 0, 4%) 0%, rgba(0, 0, 0, 4%) 100%), #FFF;
}
.button:active {
background: linear-gradient(0deg, rgba(0, 0, 0, 8%) 0%, rgba(0, 0, 0, 8%) 100%), #FFF;
}
.button-text {
margin-left: 8px;
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: #4D53E8;
}
.outlined-icon-button {
@apply !coz-bg-max;
&:hover {
@apply !coz-mg-secondary-hovered;
}
&:active {
@apply !coz-mg-secondary-pressed;
}
&:disabled {
@apply !coz-fg-dim bg-transparent;
&:hover,
&:active {
@apply !coz-fg-dim bg-transparent;
}
}
}
.outlined-icon-button-background {
&:hover {
/* stylelint-disable-next-line declaration-no-important */
background: rgba(249, 249, 249, 90%) !important;
}
&:active {
/* stylelint-disable-next-line declaration-no-important */
background: rgba(249, 249, 249, 85%) !important;
}
&:disabled {
@apply !coz-fg-dim bg-transparent;
&:hover,
&:active {
@apply !coz-fg-dim bg-transparent;
}
}
}
.base-outlined-icon-button {
@apply !coz-fg-primary !coz-stroke-plus;
width: 32px;
// 和 ui 确认后的业务定制样式
/* stylelint-disable-next-line declaration-no-important */
border-style: solid !important;
/* stylelint-disable-next-line declaration-no-important */
border-width: 1px !important;
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef } from 'react';
import classNames from 'classnames';
import { IconButton, type ButtonProps } from '@coze-arch/coze-design';
import { type Button as SemiButton } from '@douyinfe/semi-ui';
import styles from './index.module.less';
export const OutlinedIconButton = forwardRef<
SemiButton,
ButtonProps & { showBackground: boolean }
>(({ className, showBackground, ...restProps }, ref) => (
<IconButton
ref={ref}
className={classNames(
className,
showBackground
? ['!coz-bg-image-bots', styles['outlined-icon-button-background']]
: styles['outlined-icon-button'],
styles['base-outlined-icon-button'],
)}
{...restProps}
/>
));

View File

@@ -0,0 +1,274 @@
/*
* 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 IEventCallbacks,
ContentBoxType,
type MdBoxProps,
type GetBotInfo,
type Layout,
type IContentConfigs,
type IMessage,
} from '@coze-common/chat-uikit-shared';
import { ContentType } from '@coze-common/chat-core';
import { TextContent } from '../../contents/text-content';
import { SingleImageContentWithAutoSize } from '../../contents/single-image-content/auto-size';
import { SingleImageContent } from '../../contents/single-image-content';
import { SimpleFunctionContent } from '../../contents/simple-function-content';
import { PlainTextContent } from '../../contents/plain-text-content';
import { MultimodalContent } from '../../contents/multimodal-content';
import { ImageContent } from '../../contents/image-content';
import { FileContent } from '../../contents/file-content';
import { isImage } from '../../../utils/is-image';
import { defaultEnable } from '../../../utils/default-enable';
import { MESSAGE_TYPE_VALID_IN_TEXT_LIST } from '../../../constants/content-box';
export interface EnhancedContentConfig {
rule: (params: {
message: IMessage;
contentType: ContentType;
contentConfigs: IContentConfigs | undefined;
}) => boolean;
render: (params: {
message: IMessage;
eventCallbacks: IEventCallbacks | undefined;
contentConfigs: IContentConfigs | undefined;
options: {
isCardDisabled?: boolean;
isContentLoading?: boolean;
showBackground: boolean;
readonly?: boolean;
};
}) => ReactNode;
}
export interface IContentBoxProps {
/**
* Core SDK的消息体内容
*/
message: IMessage;
/**
* 事件回调对象
*/
eventCallbacks?: IEventCallbacks;
/**
* content卡片配置的内容
*/
contentConfigs?: IContentConfigs;
/**
* 是否只读
*/
readonly?: boolean;
getBotInfo: GetBotInfo;
layout: Layout;
/**
* 在 mix 模式下,给 text 格式卡片加插槽
*/
multimodalTextContentAddonTop?: ReactNode;
showBackground: boolean;
/**
* 启用自动适应图片能力
*/
enableAutoSizeImage?: boolean;
/**
* mdBox的配置
*/
mdBoxProps?: MdBoxProps;
/**
* 卡片状态是否为disabled
*/
isCardDisabled?: boolean;
isContentLoading?: boolean;
enhancedContentConfigList?: EnhancedContentConfig[];
}
// eslint-disable-next-line complexity, @coze-arch/max-line-per-function, @coze-arch/max-line-per-function
export const ContentBox: FC<IContentBoxProps> = props => {
const {
message,
contentConfigs,
readonly,
getBotInfo,
layout,
showBackground,
enableAutoSizeImage,
isCardDisabled,
isContentLoading,
enhancedContentConfigList,
} = props;
/**
* Content内容启用配置 Start
*/
const isTextEnable = defaultEnable(
contentConfigs?.[ContentBoxType.TEXT]?.enable,
);
const isImageEnable = defaultEnable(
contentConfigs?.[ContentBoxType.IMAGE]?.enable,
);
const isFileEnable = contentConfigs?.[ContentBoxType.FILE]?.enable;
const isSimpleFunctionEnable =
contentConfigs?.[ContentBoxType.SIMPLE_FUNCTION]?.enable;
/**
* Content内容启用配置 End
*/
const enhancedContentConfig = enhancedContentConfigList?.find(config =>
config.rule({ contentType: message.content_type, contentConfigs, message }),
);
if (enhancedContentConfig) {
return enhancedContentConfig.render({
message,
eventCallbacks: props.eventCallbacks,
contentConfigs,
options: { isCardDisabled, isContentLoading, showBackground, readonly },
});
}
/**
* 文本类型的处理
* 这里目前有两种情况第一种message.type = 'follow_up' 代表是suggestion 第二种反之是普通文本消息
*/
if (message.content_type === ContentType.Text) {
const { eventCallbacks, mdBoxProps } = props;
const { onImageClick, onLinkClick } = eventCallbacks ?? {};
if (
MESSAGE_TYPE_VALID_IN_TEXT_LIST.includes(message.type) &&
isTextEnable
) {
return message.role === 'user' ? (
<PlainTextContent
isContentLoading={isContentLoading}
content={message.content}
getBotInfo={getBotInfo}
mentioned={message.mention_list.at(0)}
/>
) : (
<TextContent
message={message}
readonly={readonly}
onImageClick={onImageClick}
onLinkClick={onLinkClick}
enableAutoSizeImage={enableAutoSizeImage}
mdBoxProps={mdBoxProps}
/>
);
}
}
/**
* FIle类型的内容
*/
if (message.content_type === ContentType.File && isFileEnable) {
const { copywriting, fileAttributeKeys } =
contentConfigs[ContentBoxType.FILE] ?? {};
const { eventCallbacks } = props;
const { onCancelUpload, onCopyUpload, onRetryUpload } =
eventCallbacks ?? {};
return (
<FileContent
message={message}
copywriting={copywriting}
fileAttributeKeys={fileAttributeKeys}
readonly={readonly}
onCancel={onCancelUpload}
onCopy={onCopyUpload}
onRetry={onRetryUpload}
layout={layout}
showBackground={showBackground}
/>
);
}
/**
* 图片类型的内容
*/
if (message.content_type === ContentType.Image && isImageEnable) {
const { eventCallbacks } = props;
const { onImageClick } = eventCallbacks ?? {};
if (!isImage(message.content_obj)) {
return null;
}
const UsedSingleImageContent = enableAutoSizeImage
? SingleImageContentWithAutoSize
: SingleImageContent;
const isMultipleImage = message.content_obj.image_list.length > 1;
if (isMultipleImage) {
return <ImageContent message={message} onImageClick={onImageClick} />;
}
return (
<UsedSingleImageContent message={message} onImageClick={onImageClick} />
);
}
/**
* function类型
*/
if (message.type === 'function_call' && isSimpleFunctionEnable) {
const { copywriting } =
contentConfigs[ContentBoxType.SIMPLE_FUNCTION] ?? {};
return (
<SimpleFunctionContent message={message} copywriting={copywriting} />
);
}
/**
* 文件文字同时发送的多模态消息
*/
if (
message.content_type === ContentType.Mix &&
isFileEnable &&
isImageEnable &&
isTextEnable
) {
const { copywriting, fileAttributeKeys } =
contentConfigs[ContentBoxType.FILE] ?? {};
const { eventCallbacks } = props;
const { onCancelUpload, onCopyUpload, onRetryUpload, onImageClick } =
eventCallbacks ?? {};
return (
<MultimodalContent
isContentLoading={isContentLoading}
renderTextContentAddonTop={props.multimodalTextContentAddonTop}
message={message}
getBotInfo={getBotInfo}
fileAttributeKeys={fileAttributeKeys}
copywriting={copywriting}
readonly={readonly}
onCancel={onCancelUpload}
onCopy={onCopyUpload}
onRetry={onRetryUpload}
onImageClick={onImageClick}
layout={layout}
showBackground={showBackground}
/>
);
}
return <span>Not Support {message.content_type} Content</span>;
};
ContentBox.displayName = 'UIKitContentBox';

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ComponentPropsWithRef, type FC } from 'react';
import { MdBoxLazy } from '@coze-arch/bot-md-box-adapter/lazy';
import { CozeLink } from '../../md-box-slots/link';
import { CozeImage } from '../../md-box-slots/coze-image';
export const LazyCozeMdBox: FC<
ComponentPropsWithRef<typeof MdBoxLazy>
> = props => (
<MdBoxLazy
slots={{
Image: CozeImage,
Link: CozeLink,
}}
{...props}
/>
);

View File

@@ -0,0 +1,7 @@
.full-width-aligner {
pointer-events: none;
}
.full-width-aligner-inner-wrap {
pointer-events: auto;
}

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 type { PropsWithChildren } from 'react';
import classNames from 'classnames';
import './index.less';
/**
* 套壳组件,默认宽度通栏,用于帮助孤立组件与 message box 保持宽度对齐
*/
export const FullWidthAligner = (
props: PropsWithChildren<{
alignWidth?: string;
className?: string;
innerWrapClassName?: string;
}>,
) => {
const { alignWidth, children, className, innerWrapClassName } = props;
return (
<div
className={classNames('full-width-aligner', className)}
style={{
width: alignWidth || '100%',
}}
>
<span
className={classNames(
'full-width-aligner-inner-wrap',
innerWrapClassName,
)}
>
{children}
</span>
</div>
);
};
FullWidthAligner.displayName = 'UIKitFullWidthAligner';

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.
*/
export * from './content-box';
export * from './tooltips';
export * from './message-box';
export * from './button';

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 { type PropsWithChildren, type FC } from 'react';
export const DefaultAvatarWrap: FC<PropsWithChildren> = ({ children }) => (
<>{children}</>
);

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRef, useEffect, type ComponentType } from 'react';
import { type FallbackProps } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import { useUiKitMessageBoxContext } from '../../../context/message-box';
export const FallbackComponent: ComponentType<FallbackProps> = ({ error }) => {
const { onError } = useUiKitMessageBoxContext();
const reported = useRef(false);
useEffect(() => {
if (!onError || !error) {
return;
}
if (reported.current) {
return;
}
onError(error);
reported.current = true;
}, [onError, error]);
return (
<div className="p-[12px]">
<span className="text-[14px] font-medium text-[#222222]">
{I18n.t('message_content_error')}
</span>
</div>
);
};

View File

@@ -0,0 +1,129 @@
/*
* 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 { Layout } from '@coze-common/chat-uikit-shared';
import { ContentBox } from '../content-box';
import {
type MessageBoxProps,
type MessageBoxShellProps,
type NormalMessageBoxProps,
} from './type';
import { MessageBoxWrap } from './message-box-wrap';
export const MessageBox: FC<
MessageBoxShellProps | NormalMessageBoxProps
> = props => {
const {
theme = 'none',
renderFooter,
hoverContent,
senderInfo,
showUserInfo,
right,
classname,
messageBubbleClassname,
messageBubbleWrapperClassname,
messageBoxWraperClassname,
messageErrorWrapperClassname,
isHoverShowUserInfo,
layout = Layout.PC,
showBackground = false,
topRightSlot,
imageAutoSizeContainerWidth,
enableImageAutoSize,
messageId,
eventCallbacks,
onError,
} = props ?? {};
const { url, nickname, id, userLabel, userUniqueName } = senderInfo ?? {};
return (
<MessageBoxWrap
messageId={messageId}
theme={theme}
avatar={url}
nickname={nickname}
showUserInfo={showUserInfo}
renderFooter={renderFooter}
hoverContent={hoverContent}
right={right}
senderId={id || ''}
classname={classname}
messageBubbleWrapperClassname={messageBubbleWrapperClassname}
messageBubbleClassname={messageBubbleClassname}
messageBoxWraperClassname={messageBoxWraperClassname}
messageErrorWrapperClassname={messageErrorWrapperClassname}
isHoverShowUserInfo={isHoverShowUserInfo}
layout={layout}
contentTime={getMessageContentTime(props)}
showBackground={showBackground}
extendedUserInfo={{
userLabel,
userUniqueName,
}}
topRightSlot={topRightSlot}
imageAutoSizeContainerWidth={imageAutoSizeContainerWidth}
enableImageAutoSize={enableImageAutoSize}
eventCallbacks={eventCallbacks}
onError={onError}
>
{getMessageBoxContent(props)}
</MessageBoxWrap>
);
};
const getMessageContentTime = (props: MessageBoxProps): number | undefined => {
if ('message' in props) {
return Number(props.message.content_time);
}
};
const getMessageBoxContent = (props: MessageBoxProps) => {
if ('children' in props) {
return props.children;
}
const {
message,
contentConfigs,
eventCallbacks,
getBotInfo,
layout = Layout.PC,
showBackground = false,
isContentLoading,
isCardDisabled,
} = props;
return (
<ContentBox
message={message}
contentConfigs={contentConfigs}
eventCallbacks={eventCallbacks}
getBotInfo={getBotInfo}
layout={layout}
showBackground={showBackground}
isContentLoading={isContentLoading}
isCardDisabled={isCardDisabled}
/>
);
};
MessageBox.displayName = 'UIKitMessageBox';

View File

@@ -0,0 +1,320 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type PropsWithChildren,
type FC,
useRef,
useState,
useEffect,
} from 'react';
import classnames from 'classnames';
import { useClickAway, useHover, useUpdateEffect } from 'ahooks';
import { ErrorBoundary } from '@coze-arch/logger';
import {
Layout,
UIKitEvents,
useUiKitEventCenter,
} from '@coze-common/chat-uikit-shared';
import { useEventCallback } from '@coze-common/chat-hooks';
import { Avatar, Typography } from '@coze-arch/coze-design';
import { UserLabel, UserName } from '../user-label';
import { MessageContentTime } from '../message-content-time';
import { typeSafeMessageBoxInnerVariants } from '../../../variants/message-box-inner-variants';
import { useObserveCardContainer } from '../../../hooks/use-observe-card-container';
import { UIKitMessageBoxProvider } from '../../../context/message-box';
import { useUIKitCustomComponent } from '../../../context/custom-components';
import defaultAvatar from '../../../assets/default-avatar.png';
import {
typeSafeBotNicknameVariants,
messageBoxContainerVariants,
} from './variants';
import { getMessageBoxInnerVariantsByTheme } from './utils';
import { type MessageBoxWrapProps } from './type';
import { FallbackComponent } from './fallback';
import { DefaultAvatarWrap } from './default-avatar-wrap';
import './message-box.less';
export const MessageBoxWrap: FC<
PropsWithChildren<MessageBoxWrapProps>
// eslint-disable-next-line @coze-arch/max-line-per-function
> = props => {
const {
children,
theme,
nickname,
avatar,
showUserInfo,
renderFooter,
hoverContent,
right,
senderId,
classname,
messageBubbleClassname,
messageBubbleWrapperClassname,
messageBoxWraperClassname,
messageErrorWrapperClassname,
isHoverShowUserInfo = true,
layout,
contentTime,
showBackground,
extendedUserInfo,
topRightSlot,
imageAutoSizeContainerWidth,
enableImageAutoSize,
messageId,
eventCallbacks,
onError,
} = props;
const { userLabel, userUniqueName } = extendedUserInfo ?? {};
const [botAvatar, setBotAvatar] = useState(avatar || defaultAvatar);
const { MentionOperateTool = () => null, AvatarWrap = DefaultAvatarWrap } =
useUIKitCustomComponent();
const wrapRef = useRef<HTMLDivElement>(null);
const messageContainerRef = useRef<HTMLDivElement>(null);
const messageFooterRef = useRef<HTMLDivElement>(null);
const eventCenter = useUiKitEventCenter();
const isMobileLayout = layout === Layout.MOBILE;
const refreshContainerWidthConditionally = useEventCallback(() => {
if (!messageContainerRef.current || !messageFooterRef.current) {
return;
}
const currentMessageWidth = `${messageContainerRef.current.offsetWidth}px`;
const currentFooterWidth = messageFooterRef.current.style.width;
if (currentFooterWidth === currentMessageWidth) {
return;
}
messageFooterRef.current.style.width = currentMessageWidth;
});
useUpdateEffect(() => {
setBotAvatar(avatar || defaultAvatar);
}, [avatar]);
useObserveCardContainer({
messageId,
cardContainerRef: messageContainerRef,
onResize: refreshContainerWidthConditionally,
});
useEffect(() => {
if (!eventCenter) {
return;
}
eventCenter.on(
UIKitEvents.WINDOW_RESIZE,
refreshContainerWidthConditionally,
);
return () => {
eventCenter.off(
UIKitEvents.WINDOW_RESIZE,
refreshContainerWidthConditionally,
);
};
}, []);
const isHovering = useHover(() => wrapRef.current);
// 适配移动端 移动端没有hover效果使用点击交互
const [hoverContentVisible, setHoverContentVisible] =
useState<boolean>(false);
useClickAway(() => {
setHoverContentVisible(false);
}, wrapRef);
return (
<UIKitMessageBoxProvider
value={{
layout,
imageAutoSizeContainerWidth,
enableImageAutoSize,
eventCallbacks,
onError,
}}
>
<div
// chat-uikit-message-box
className={classnames('max-w-full', classname)}
ref={wrapRef}
onClick={() => {
if (isMobileLayout) {
setHoverContentVisible(true);
}
}}
>
<div
// chat-uikit-message-box-container chat-uikit-message-box-container-pc
className={classnames(
messageBoxContainerVariants({ isMobileLayout }),
messageBoxWraperClassname,
)}
>
<div
// chat-uikit-message-box-container__avatar-wrap
className="mr-[12px] w-32px h-32px"
>
{showUserInfo ? (
<AvatarWrap>
<Avatar
// chat-uikit-message-box-container__avatar-wrap__avatar
size="small"
src={botAvatar}
onError={() => setBotAvatar(defaultAvatar)}
></Avatar>
</AvatarWrap>
) : null}
</div>
<div
// chat-uikit-message-box-container__message
className="flex-1 max-w-[calc(100%-44px)]"
>
{/* TODO: 不支持一条消息内渲染多个 content */}
<div
// chat-uikit-message-box-container__message__message-box
className="relative flex flex-col w-fit max-w-full"
>
{showUserInfo && nickname ? (
<div
// chat-uikit-message-box-container__message__nickname
className="flex"
>
<Typography.Text
ellipsis={{
showTooltip: {
opts: {
content: nickname,
},
},
}}
// chat-uikit-message-box-container__message__nickname-text
className={typeSafeBotNicknameVariants({
showBackground: Boolean(showBackground),
})}
>
{nickname}
</Typography.Text>
<UserLabel userLabel={userLabel} />
<div
// chat-uikit-message-box-container__message__nickname-partner
className="flex shrink w-full h-[20px]"
>
{isHovering && isHoverShowUserInfo ? (
<>
<UserName
userUniqueName={userUniqueName}
showBackground={showBackground}
/>
<MentionOperateTool senderId={senderId} />
<MessageContentTime
contentTime={contentTime}
showBackground={Boolean(showBackground)}
className="flex-shrink-0"
/>
</>
) : null}
<div className="flex gap-x-[8px] ml-auto">
{topRightSlot}
</div>
</div>
</div>
) : null}
<div
ref={messageContainerRef}
// chat-uikit-message-box-container__message__message-box__content
className={classnames(
messageBubbleWrapperClassname,
'select-text relative flex flex-row w-fit max-w-full',
)}
>
<div
// className={classnames('chat-uikit-message-box-inner', {
// 'chat-uikit-message-box-inner--primary':
// theme === 'primary',
// 'chat-uikit-message-box-inner--whiteness':
// theme === 'whiteness',
// 'chat-uikit-message-box-inner--colorful':
// theme === 'colorful',
// 'chat-uikit-message-box-inner--border': theme === 'border',
// 'chat-uikit-message-box-inner--none': theme === 'none',
// '!coz-bg-image-user !coz-stroke-image-user':
// showBackground && theme === 'primary',
// '!coz-stroke-image-user !coz-bg-image-bots':
// showBackground && theme === 'border',
// '!coz-bg-image-bots !coz-stroke-image-bots':
// showBackground && theme === 'whiteness',
// 'chat-uikit-message-box-inner--color-border':
// theme === 'color-border',
// 'chat-uikit-message-box-inner--color-border-card':
// theme === 'color-border-card',
// })}
className={classnames(
messageBubbleClassname,
typeSafeMessageBoxInnerVariants({
showBackground: Boolean(showBackground),
...getMessageBoxInnerVariantsByTheme({ theme }),
}),
layout === Layout.MOBILE ? '!text-[16px]' : '',
)}
>
<ErrorBoundary
errorBoundaryName="chat-message-box-children"
FallbackComponent={FallbackComponent}
>
{children}
</ErrorBoundary>
</div>
<div
// chat-uikit-message-box-container__message__message-box__content__right
className={classnames(
'absolute right-0 bottom-[1px]',
messageErrorWrapperClassname,
)}
>
{right}
</div>
</div>
{/* 对这个 dom 的样式改动前请先阅读上方的 refreshContainerWidthConditionally 方法 */}
<div
ref={messageFooterRef}
// chat-uikit-message-box-container__message__message-box__footer
className="overflow-visible"
>
{renderFooter?.(refreshContainerWidthConditionally)}
</div>
{isHovering || hoverContentVisible ? (
<div
// chat-uikit-message-box-container__message__message-box__hover-container
className="absolute right-[-12px] bottom-[-20px]"
>
{hoverContent}
</div>
) : null}
</div>
</div>
</div>
</div>
</UIKitMessageBoxProvider>
);
};
MessageBoxWrap.displayName = 'UIKitMessageBoxWrap';

View File

@@ -0,0 +1,3 @@
.chat-uikit-message-box-bg-primary {
background: var(--coz-mg-hglt-plus-dim);
}

View File

@@ -0,0 +1,187 @@
/*
* 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 IEventCallbacks,
type Layout,
type IContentConfigs,
type IMessage,
type GetBotInfo,
} from '@coze-common/chat-uikit-shared';
import { type UserLabelInfo } from '../user-label';
/**
* 样式主题
* @deprecated 考虑替换实现方式,目前使用不够灵活
*/
export type MessageBoxTheme =
/** 主题色 */
| 'primary'
/** 白色背景 */
| 'whiteness'
/**
* 灰色背景
*/
| 'grey'
/** home用的炫彩底色 */
| 'colorful'
/** 官方通知,有彩色边框 */
| 'color-border'
/** 官方通知,有彩色边框,但是不留 padding */
| 'color-border-card'
| 'border'
| 'none';
interface MessageBoxBasicProps {
/**
* 用户信息
*/
senderInfo: {
userUniqueName?: string;
nickname?: string;
url?: string;
id: string;
userLabel?: UserLabelInfo | null;
};
/**
* 消息 id
*/
messageId: string | null;
showUserInfo?: boolean;
/**
* 主题
*/
theme?: MessageBoxTheme;
/**
* 插入消息的footer
*/
renderFooter?: (refreshContainerWidth: () => void) => React.ReactNode;
/** 鼠标悬浮时展示的组件 */
hoverContent?: ReactNode;
/**
* 左侧插槽
*/
right?: React.ReactNode;
/**
* 右上角插槽
*/
topRightSlot?: React.ReactNode;
getBotInfo: GetBotInfo;
/**
* 是否是移动端
*/
layout?: Layout;
classname?: string;
messageBubbleWrapperClassname?: string;
messageBoxWraperClassname?: string; // message box的直接父亲样式
messageBubbleClassname?: string; // message消息气泡的样式
messageErrorWrapperClassname?: string; // message错误的父亲样式
isHoverShowUserInfo?: boolean; // hover的时候是否显示用户详细信息
showBackground?: boolean;
/**
* 容器动态宽度,用于动态计算图片尺寸
*/
imageAutoSizeContainerWidth?: number;
/**
* 是否启用图片自适应模式
*/
enableImageAutoSize?: boolean;
/**
* 事件回调
*/
eventCallbacks?: IEventCallbacks;
/**
* 针对 JS Error 的响应
*/
onError?: (error: unknown) => void;
}
/** 只是套壳,内容由 children 呈现 */
export interface MessageBoxShellProps extends MessageBoxBasicProps {
children: React.ReactNode;
}
/** 含有完整内置实现的 MessageBox */
export interface NormalMessageBoxProps extends MessageBoxBasicProps {
/**
* 消息体
*/
message: IMessage;
/**
* 文件需要用到的必备参数
*/
contentConfigs?: IContentConfigs;
/** 样式主题 */
theme?: MessageBoxTheme;
footer?: ReactNode;
readonly?: boolean;
isContentLoading?: boolean;
isCardDisabled?: boolean;
}
export type MessageBoxProps = MessageBoxShellProps | NormalMessageBoxProps;
export interface MessageBoxWrapProps {
nickname?: string;
avatar?: string;
theme: MessageBoxTheme;
showUserInfo?: boolean;
renderFooter?: (refreshContainerWidth: () => void) => React.ReactNode;
/** 鼠标悬浮时展示的组件 */
hoverContent?: React.ReactNode;
right?: React.ReactNode;
/**
* 右上角插槽
*/
topRightSlot?: React.ReactNode;
messageId: string | null;
senderId: string;
layout: Layout;
contentTime: number | undefined;
classname?: string;
messageBoxWraperClassname?: string; // message box的直接父亲样式
messageBubbleClassname?: string; // message消息气泡的样式
messageBubbleWrapperClassname?: string; // message消息气泡的父亲样式
messageErrorWrapperClassname?: string; // message错误的父亲样式
isHoverShowUserInfo?: boolean; // hover的时候是否显示用户详细信息
showBackground?: boolean;
extendedUserInfo?: {
userLabel?: UserLabelInfo | null;
userUniqueName?: string;
};
/**
* 容器动态宽度,用于动态计算图片尺寸
*/
imageAutoSizeContainerWidth?: number;
/**
* 是否启用图片自适应模式
*/
enableImageAutoSize?: boolean;
eventCallbacks?: IEventCallbacks;
/**
* 针对 JS Error 的响应
*/
onError?: (error: unknown) => void;
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { exhaustiveCheckSimple } from '@coze-common/chat-area-utils';
import { type MessageBoxInnerVariantProps } from '../../../variants/message-box-inner-variants';
import { type MessageBoxTheme } from './type';
export const getMessageBoxInnerVariantsByTheme: (props: {
theme: MessageBoxTheme;
}) => Pick<MessageBoxInnerVariantProps, 'color' | 'border' | 'tight'> = ({
theme,
}) => {
if (theme === 'primary' || theme === 'whiteness' || theme === 'grey') {
return { color: theme, border: null, tight: false };
}
if (theme === 'colorful') {
return { color: 'primary', border: null, tight: false };
}
if (theme === 'border') {
return { color: 'whiteness', border: 'primary', tight: true };
}
if (theme === 'color-border') {
return { color: 'whiteness', border: 'highlight', tight: false };
}
if (theme === 'color-border-card') {
return { color: 'whiteness', border: 'highlight', tight: true };
}
if (theme === 'none') {
return { tight: true, color: null, border: null };
}
exhaustiveCheckSimple(theme);
return { tight: false, color: null, border: null };
};

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.
*/
import { cva, type VariantProps } from 'class-variance-authority';
export const messageBoxContainerVariants = cva(['flex', 'flex-row', 'my-0'], {
variants: {
isMobileLayout: {
true: ['mx-[12px]'],
false: ['mx-[24px]'],
},
},
});
export const botNicknameVariants = cva(
[
'text-base',
'font-normal',
'leading-[16px]',
'break-words',
'flex-shrink-0',
'!max-w-[400px]',
],
{
variants: {
showBackground: {
true: ['coz-fg-images-user-name'],
false: ['coz-fg-secondary'],
},
},
},
);
export type BotNicknameVariantsProps = Required<
VariantProps<typeof botNicknameVariants>
>;
export const typeSafeBotNicknameVariants: (
props: BotNicknameVariantsProps,
) => string = botNicknameVariants;

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { formatMessageBoxContentTime } from '../../../utils/date-time';
export const MessageContentTime = ({
contentTime,
className,
showBackground,
}: {
contentTime?: number;
className?: string;
showBackground: boolean;
}) => {
if (!contentTime) {
return null;
}
return (
<span
className={classNames(
'text-[12px] leading-[16px] ml-[8px] font-normal',
'chat-uikit-message-box-container__message-content-time',
{
'coz-fg-images-secondary': showBackground,
'coz-fg-secondary': !showBackground,
},
className,
)}
>
{formatMessageBoxContentTime(contentTime)}
</span>
);
};
MessageContentTime.displayName = 'MessageContentTime';

View File

@@ -0,0 +1,3 @@
.to-newest-tip-ui-animation {
transition: opacity 0.2s;
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { IconCozLongArrowUp } from '@coze-arch/coze-design/icons';
import { OutlinedIconButton } from '../button';
import { type ToNewestTipProps } from './type';
import './animation.less';
export const ToNewestTipUI = (props: ToNewestTipProps) => {
const { onClick, style, className, show, showBackground } = props;
return (
<OutlinedIconButton
className={classNames(
[
'shadow-normal',
'coz-fg-hglt',
'to-newest-tip-ui-animation',
'!rounded-full',
],
!show && ['pointer-events-none', 'opacity-0'],
className,
)}
size="large"
onClick={onClick}
style={style}
icon={<IconCozLongArrowUp className="rotate-180" />}
showBackground={showBackground}
/>
);
};
ToNewestTipUI.displayName = 'UIKitToNewestTip';

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 { type CSSProperties } from 'react';
export interface ToNewestTipProps {
onClick: () => void;
style?: CSSProperties;
className?: string;
show?: boolean;
showBackground: boolean;
}

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.
*/
import { type FC } from 'react';
import { Tooltip, type TooltipProps } from '@coze-arch/coze-design';
// import { type TooltipProps } from '@douyinfe/semi-ui/lib/es/tooltip';
type IProps = {
children: JSX.Element;
hideToolTip?: boolean;
} & TooltipProps;
export const UIKitTooltip: FC<IProps> = props => {
const {
content,
children,
hideToolTip,
theme = 'dark',
...restProps
} = props;
return content ? (
<Tooltip
trigger={hideToolTip ? 'custom' : 'hover'}
visible={hideToolTip ? false : undefined}
content={content}
theme={theme}
{...restProps}
style={{ marginBottom: '8px' }}
>
{children}
</Tooltip>
) : (
<>{children}</>
);
};
UIKitTooltip.displayName = 'UIKitTooltip';

View File

@@ -0,0 +1,78 @@
/*
* 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 cs from 'classnames';
import { UIKitTooltip } from '../tooltips';
export interface UserLabelInfo {
label_name?: string;
icon_url?: string;
jump_link?: string;
}
export const UserLabel: FC<{
userLabel?: UserLabelInfo | null;
}> = ({ userLabel }) => {
if (!userLabel?.icon_url || !userLabel?.label_name) {
return null;
}
return (
<UIKitTooltip content={userLabel.label_name} theme="light">
<div
className={cs(
'flex-[0_0_auto] flex items-center h-[20px] ml-[4px]',
userLabel?.jump_link && 'cursor-pointer',
)}
onClick={event => {
if (userLabel?.jump_link) {
event?.preventDefault();
event?.stopPropagation();
window.open(userLabel?.jump_link, '_blank');
}
}}
>
<img src={userLabel.icon_url} width={14} height={14} />
</div>
</UIKitTooltip>
);
};
// TODO: 增加 show background 变体
export const UserName: FC<{
userUniqueName?: string;
className?: string;
showBackground: boolean | undefined;
}> = ({ userUniqueName, className, showBackground }) => {
if (!userUniqueName) {
return null;
}
return (
<div
className={cs(
'coz-fg-secondary text-[12px] leading-[16px] font-normal ml-[4px]',
showBackground && '!coz-fg-images-secondary',
className,
)}
>
@{userUniqueName}
</div>
);
};

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

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './chat';
export * from './common';
export * from './contents';

View File

@@ -0,0 +1,272 @@
/*
* 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 react-hooks/rules-of-hooks */
import {
type CSSProperties,
useEffect,
useState,
type FC,
useRef,
memo,
} from 'react';
import { isEqual } from 'lodash-es';
import classNames from 'classnames';
import { ImagePreview, Skeleton } from '@coze-arch/coze-design';
import { ImageStatus } from '@coze-arch/bot-md-box-adapter/lazy';
import { type MdBoxImageProps } from '@coze-arch/bot-md-box-adapter';
import { getImageDisplayAttribute } from '../../utils/image/get-image-display-attribute';
import { useOnboardingContext } from '../../context/onboarding';
import { useUiKitMessageBoxContext } from '../../context/message-box';
import DefaultImage from '../../assets/image-default.png';
interface OriginImageInfo {
width?: number;
height?: number;
}
export type CozeImageProps = MdBoxImageProps & {
imageAutoSizeContainerWidth?: number;
enablePreview?: boolean;
onImageElementEnter?: (params: {
element: HTMLElement;
link: string;
}) => void;
onImageElementLeave?: (params: {
element: HTMLElement;
link: string;
}) => void;
};
const TIME_OUT = 10000;
// TODO: @liushuoyan 看看这里能不能搞一个插槽之类的东西
// eslint-disable-next-line @typescript-eslint/naming-convention, @coze-arch/max-line-per-function
export const _CozeImage: FC<CozeImageProps> = props => {
const [showPreview, setShowPreview] = useState(false);
const {
className,
src,
onImageClick,
imageAutoSizeContainerWidth: imageAutoSizeContainerWidthFromProps,
enablePreview,
style,
} = props;
const containerRef = useRef<HTMLDivElement | null>(null);
const {
imageAutoSizeContainerWidth: imageAutoSizeContainerWidthFromContext,
eventCallbacks,
onError,
} = useUiKitMessageBoxContext();
const {
onMdBoxImageElementEnter: onImageElementEnterFromEventCallback,
onMdBoxImageElementLeave: onImageElementLeaveFromEventCallback,
} = eventCallbacks ?? {};
const {
imageAutoSizeContainerWidth:
imageAutoSizeContainerWidthFromOnboardingContext,
eventCallbacks: eventCallbacksFromOnboarding,
} = useOnboardingContext();
const {
onMdBoxImageElementEnter: onMdBoxImageElementEnterFromOnboarding,
onMdBoxImageElementLeave: onMdBoxImageElementLeaveFromOnboarding,
} = eventCallbacksFromOnboarding ?? {};
const onImageElementEnter =
props.onImageElementEnter ??
onImageElementEnterFromEventCallback ??
onMdBoxImageElementEnterFromOnboarding;
const onImageElementLeave =
props.onImageElementLeave ??
onImageElementLeaveFromEventCallback ??
onMdBoxImageElementLeaveFromOnboarding;
const imageAutoSizeContainerWidth =
imageAutoSizeContainerWidthFromProps ??
imageAutoSizeContainerWidthFromContext ??
imageAutoSizeContainerWidthFromOnboardingContext;
const originImageInfoRef = useRef<OriginImageInfo>({});
const [imageStyles, setImageStyles] = useState<CSSProperties>({});
// 加载状态
const [loading, setLoading] = useState(true);
const [imageSrc, setImageSrc] = useState(DefaultImage);
const timeout = useRef<NodeJS.Timeout | null>(null);
const resetImageSize = (width: number, height: number) => {
if (!imageAutoSizeContainerWidth) {
return;
}
const { displayHeight, displayWidth, isCover } = getImageDisplayAttribute(
width,
height,
imageAutoSizeContainerWidth,
);
setImageStyles({
display: 'block',
width: displayWidth,
height: displayHeight,
objectFit: isCover ? 'cover' : undefined,
objectPosition: 'left top',
});
};
const clearImageErrorTimeout = () => {
if (!timeout.current) {
return;
}
clearTimeout(timeout.current);
};
const builtinLoadImage = ({ loadImageSrc }: { loadImageSrc: string }) => {
const image = new Image();
image.src = loadImageSrc;
clearImageErrorTimeout();
image.onload = () => {
clearImageErrorTimeout();
originImageInfoRef.current = {
width: image.width,
height: image.height,
};
resetImageSize(image.width, image.height);
setImageSrc(loadImageSrc);
setLoading(false);
};
image.onerror = () => {
clearImageErrorTimeout();
timeout.current = setTimeout(() => {
setImageSrc(DefaultImage);
setLoading(false);
onError?.(new Error('coze image load error: time out'));
}, TIME_OUT);
};
};
useEffect(() => {
setLoading(true);
builtinLoadImage({
loadImageSrc: src ?? '',
});
}, [src]);
useEffect(() => {
if (!imageAutoSizeContainerWidth || !originImageInfoRef.current) {
return;
}
if (
!originImageInfoRef.current.width ||
!originImageInfoRef.current.height
) {
return;
}
resetImageSize(
originImageInfoRef.current.width,
originImageInfoRef.current.height,
);
}, [imageAutoSizeContainerWidth, originImageInfoRef.current]);
const handleMouseEnter = () => {
if (!containerRef.current) {
return;
}
onImageElementEnter?.({
element: containerRef.current,
link: src ?? '',
});
};
const handleMouseLeave = () => {
if (!containerRef.current) {
return;
}
onImageElementLeave?.({
element: containerRef.current,
link: src ?? '',
});
};
return (
<div
ref={containerRef}
className={classNames('w-fit', className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={style}
>
{enablePreview && src ? (
<ImagePreview
src={src ?? ''}
visible={showPreview}
onVisibleChange={() => setShowPreview(false)}
/>
) : null}
<Skeleton
style={{ width: 240, height: 120 }}
className="coz-mg-secondary"
loading={loading}
active
>
<img
src={imageSrc ?? ''}
style={imageStyles}
className={classNames('rounded-[8px]', className, {
'cursor-zoom-in': Boolean(onImageClick) || enablePreview,
})}
onClick={e => {
onImageClick?.(e, {
src: imageSrc ?? '',
status: ImageStatus.Success,
});
if (enablePreview) {
setShowPreview(true);
}
}}
/>
</Skeleton>
</div>
);
};
export const CozeImage = memo(_CozeImage, (prevProps, nextProps) =>
isEqual(prevProps, nextProps),
);
export const CozeImageWithPreview: FC<CozeImageProps> = props => (
<CozeImage {...props} enablePreview={true} />
);
CozeImageWithPreview.displayName = 'CozeImageWithPreview';

View File

@@ -0,0 +1,229 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC, useEffect, useRef } from 'react';
import { isObject, isString, omit } from 'lodash-es';
import cs from 'classnames';
import { typeSafeJsonParse } from '@coze-common/chat-area-utils';
import {
LinkType,
type MdBoxLinkProps,
} from '@coze-arch/bot-md-box-adapter/lazy';
import { useOnboardingContext } from '../../../context/onboarding';
import { useUiKitMessageBoxContext } from '../../../context/message-box';
import { safeParseUrl } from './utils';
const isHttpLink = (link: string) => {
const parsedLink = safeParseUrl(link);
if (!parsedLink) {
return false;
}
return parsedLink.protocol === 'http:' || parsedLink.protocol === 'https:';
};
const isCocoLink = (link: string) => {
const parsedLink = safeParseUrl(link);
if (!parsedLink) {
return false;
}
return parsedLink.protocol === 'coco:';
};
/** 被链接元素替换成的组件 */
export const CozeLink: FC<
MdBoxLinkProps & {
onLinkElementEnter?: (params: {
element: HTMLElement;
link: string;
}) => void;
onLinkElementLeave?: (params: {
element: HTMLElement;
link: string;
}) => void;
}
// eslint-disable-next-line @coze-arch/max-line-per-function
> = ({
className,
style,
href,
children,
onSendMessage,
onLinkClick,
onLinkRender,
onOpenLink,
type: _type,
onLinkElementEnter: onLinkElementEnterFromProps,
onLinkElementLeave: onLinkElementLeaveFromProps,
...restProps
}) => {
const handleOpenLink = (url?: string) => {
if (onOpenLink) {
onOpenLink?.(url);
return;
}
window.open(url);
};
const parsedUrl = href ? safeParseUrl(href) : null;
const containerRef = useRef<HTMLAnchorElement | null>(null);
const { eventCallbacks } = useUiKitMessageBoxContext();
const { eventCallbacks: eventCallbacksFromOnboarding } =
useOnboardingContext();
const {
onMdBoxLinkElementEnter: onLinkElementEnterFromEventCallback,
onMdBoxLinkElementLeave: onLinkElementLeaveFromEventCallback,
} = eventCallbacks ?? {};
const {
onMdBoxLinkElementEnter: onMdBoxLinkElementEnterFromOnboarding,
onMdBoxLinkElementLeave: onMdBoxLinkElementLeaveFromOnboarding,
} = eventCallbacksFromOnboarding ?? {};
const onLinkElementEnter =
onLinkElementEnterFromProps ??
onLinkElementEnterFromEventCallback ??
onMdBoxLinkElementEnterFromOnboarding;
const onLinkElementLeave =
onLinkElementLeaveFromProps ??
onLinkElementLeaveFromEventCallback ??
onMdBoxLinkElementLeaveFromOnboarding;
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
/* istanbul ignore if */
if (!href || !parsedUrl) {
event.preventDefault();
return;
}
if (isCocoLink(href)) {
event.preventDefault();
const message = parsedUrl.searchParams.get('msg');
const ext = parsedUrl.searchParams.get('ext');
const extObj = ext
? typeSafeJsonParse(ext, error => {
reportError(error);
})
: undefined;
const wikiLink =
isObject(extObj) &&
's$wiki_link' in extObj &&
isString(extObj?.s$wiki_link)
? extObj?.s$wiki_link
: '';
/* istanbul ignore if */
if (wikiLink) {
if (isHttpLink(wikiLink)) {
onLinkClick?.(event, {
url: href,
parsedUrl,
exts: { wiki_link: wikiLink, type: LinkType.wiki },
openLink: handleOpenLink,
});
}
return;
}
if (message) {
onSendMessage?.(message);
return;
}
onLinkClick?.(event, {
url: href,
parsedUrl,
exts: { type: LinkType.coco },
openLink: handleOpenLink,
});
}
if (!isHttpLink(href)) {
return;
}
/* istanbul ignore else */
if (onLinkClick) {
onLinkClick(event, {
url: href,
parsedUrl,
exts: {
type: LinkType.normal,
},
openLink: handleOpenLink,
});
} else {
event.preventDefault();
event.stopPropagation();
window.open(href);
}
};
useEffect(() => {
if (href && parsedUrl) {
onLinkRender?.({
url: href,
parsedUrl,
});
}
}, [href]);
const handleMouseEnter = () => {
if (!containerRef.current) {
return;
}
onLinkElementEnter?.({
element: containerRef.current,
link: href ?? '',
});
};
const handleMouseLeave = () => {
if (!containerRef.current) {
return;
}
onLinkElementLeave?.({
element: containerRef.current,
link: href ?? '',
});
};
return (
<a
{...omit(restProps, 'href')}
className={cs(['!coz-fg-hglt'], className)}
style={style}
onClick={onClick}
href={parsedUrl ? href : undefined}
target="_blank"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={containerRef}
>
{children}
</a>
);
};

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 safeParseUrl = (url: string) => {
try {
return new URL(url);
} catch (err) {
return null;
}
};