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,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-common/chat-answer-action
Chat 消息底部的操作按钮功能
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,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 { getIsLastGroup } from '../src/utils/get-is-last-group';
it('getIsLastGroupCorrectly', () => {
const res1 = getIsLastGroup({
meta: { isFromLatestGroup: false, sectionId: '123' },
latestSectionId: '123',
});
const res2 = getIsLastGroup({
meta: { isFromLatestGroup: true, sectionId: '123' },
latestSectionId: '123',
});
const res3 = getIsLastGroup({
meta: { isFromLatestGroup: true, sectionId: '321' },
latestSectionId: '123',
});
const res4 = getIsLastGroup({
meta: { isFromLatestGroup: false, sectionId: '321' },
latestSectionId: '123',
});
expect(res1).toBeFalsy();
expect(res2).toBeTruthy();
expect(res3).toBeFalsy();
expect(res4).toBeFalsy();
});

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 { getIsPushedMessage } from '../src/utils/get-is-pushed-message';
vi.mock('@coze-common/chat-area', () => ({
getIsTriggerMessage: (param: any) =>
param.type === 'task_manual_trigger' || param.source === 1,
getIsNotificationMessage: (param: any) => param.source === 2,
getIsAsyncResultMessage: (param: any) => param.source === 3,
}));
it('getIsPushedMessageCorrectly', () => {
const res1 = getIsPushedMessage({ type: 'answer', source: 0 });
const res2 = getIsPushedMessage({ type: 'answer', source: 3 });
const res3 = getIsPushedMessage({ type: 'answer', source: 1 });
const res4 = getIsPushedMessage({ type: 'task_manual_trigger', source: 0 });
const res5 = getIsPushedMessage({ type: 'answer', source: 2 });
expect(res1).toBeFalsy();
expect(res2).toBeTruthy();
expect(res3).toBeTruthy();
expect(res4).toBeTruthy();
expect(res5).toBeTruthy();
});

View File

@@ -0,0 +1,87 @@
/*
* 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 { getShowFeedback } from '../src/utils/get-show-feedback';
vi.mock('@coze-common/chat-area', () => ({
getIsTriggerMessage: (param: any) =>
param.type === 'task_manual_trigger' || param.source === 1,
getIsNotificationMessage: (param: any) => param.source === 2,
getIsAsyncResultMessage: (param: any) => param.source === 3,
}));
it('getShowFeedbackCorrectly', () => {
const res1 = getShowFeedback({
message: { type: 'answer', source: 0 },
meta: {
isFromLatestGroup: true,
sectionId: '123',
isGroupLastAnswerMessage: true,
},
latestSectionId: '123',
});
const res2 = getShowFeedback({
message: { type: 'ack', source: 0 },
meta: {
isFromLatestGroup: true,
sectionId: '123',
isGroupLastAnswerMessage: false,
},
latestSectionId: '123',
});
const res3 = getShowFeedback({
message: { type: 'answer', source: 0 },
meta: {
isFromLatestGroup: true,
sectionId: '123',
isGroupLastAnswerMessage: true,
},
latestSectionId: '321',
});
const res4 = getShowFeedback({
message: { type: 'task_manual_trigger', source: 0 },
meta: {
isFromLatestGroup: true,
sectionId: '321',
isGroupLastAnswerMessage: true,
},
latestSectionId: '321',
});
const res5 = getShowFeedback({
message: { type: 'answer', source: 2 },
meta: {
isFromLatestGroup: false,
sectionId: '321',
isGroupLastAnswerMessage: true,
},
latestSectionId: '321',
});
const res6 = getShowFeedback({
message: { type: 'answer', source: 0 },
meta: {
isFromLatestGroup: true,
sectionId: '123',
isGroupLastAnswerMessage: false,
},
latestSectionId: '123',
});
expect(res1).toBeTruthy();
expect(res2).toBeFalsy();
expect(res3).toBeFalsy();
expect(res4).toBeFalsy();
expect(res5).toBeFalsy();
expect(res6).toBeFalsy();
});

View File

@@ -0,0 +1,57 @@
/*
* 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 { getShowRegenerate } from '../src/utils/get-show-regenerate';
vi.mock('@coze-common/chat-area', () => ({
getIsTriggerMessage: (param: any) =>
param.type === 'task_manual_trigger' || param.source === 1,
getIsNotificationMessage: (param: any) => param.source === 2,
getIsAsyncResultMessage: (param: any) => param.source === 3,
}));
it('getShowRegenerateCorrectly', () => {
const res1 = getShowRegenerate({
message: { type: 'answer', source: 0 },
meta: { isFromLatestGroup: true, sectionId: '123' },
latestSectionId: '123',
});
const res2 = getShowRegenerate({
message: { type: 'ack', source: 0 },
meta: { isFromLatestGroup: true, sectionId: '123' },
latestSectionId: '123',
});
const res3 = getShowRegenerate({
message: { type: 'answer', source: 0 },
meta: { isFromLatestGroup: true, sectionId: '123' },
latestSectionId: '321',
});
const res4 = getShowRegenerate({
message: { type: 'task_manual_trigger', source: 0 },
meta: { isFromLatestGroup: true, sectionId: '321' },
latestSectionId: '321',
});
const res5 = getShowRegenerate({
message: { type: 'answer', source: 2 },
meta: { isFromLatestGroup: false, sectionId: '321' },
latestSectionId: '321',
});
expect(res1).toBeTruthy();
expect(res2).toBeTruthy();
expect(res3).toBeFalsy();
expect(res4).toBeFalsy();
expect(res5).toBeFalsy();
});

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
{
"name": "@coze-common/chat-answer-action",
"version": "0.0.1",
"description": "Chat Answer Action",
"license": "Apache-2.0",
"author": "gaoyuanhan.duty@bytedance.com",
"maintainers": [],
"exports": {
".": "./src/index.ts",
"./hooks/*": "./src/hooks/*"
},
"main": "src/index.ts",
"typesVersions": {
"*": {
"hooks/*": [
"./src/hooks/*"
]
}
},
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-flags": "workspace:*",
"@coze-common/chat-area-plugin-message-grab": "workspace:*",
"@coze-common/chat-uikit": "workspace:*",
"@coze-common/chat-uikit-shared": "workspace:*",
"@coze-common/text-grab": "workspace:*",
"classnames": "^2.3.2",
"immer": "^10.0.3"
},
"devDependencies": {
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/bot-icons": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/bot-utils": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@coze-common/chat-area": "workspace:*",
"@coze-common/chat-area-utils": "workspace:*",
"@coze-common/chat-core": "workspace:*",
"@douyinfe/semi-icons": "^2.36.0",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/lodash-es": "^4.17.10",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"ahooks": "^3.7.8",
"copy-to-clipboard": "^3.3.3",
"lodash-es": "^4.17.21",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite": "^4.3.9",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5",
"zustand": "^4.4.7"
},
"peerDependencies": {
"@coze-arch/bot-icons": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/bot-utils": "workspace:*",
"@coze-arch/i18n": "workspace:*",
"@coze-common/chat-area": "workspace:*",
"@coze-common/chat-area-utils": "workspace:*",
"@coze-common/chat-core": "workspace:*",
"@douyinfe/semi-icons": "^2.36.0",
"ahooks": "^3.7.8",
"copy-to-clipboard": "^3.3.3",
"react": ">=18.2.0",
"react-dom": ">=18.2.0",
"zustand": "^4.4.7"
}
}

View File

@@ -0,0 +1,29 @@
.container {
display: flex;
flex-direction: column;
.icon-container {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px;
.left-content {
display: flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
}
.right-content {
display: flex;
gap: 4px;
align-items: center;
margin-left: 8px;
}
}
}

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 { type PropsWithChildren } from 'react';
import s from './index.module.less';
interface ActionBarContainerProps {
leftContent?: React.ReactNode;
rightContent?: React.ReactNode;
}
export const ActionBarContainer: React.FC<
PropsWithChildren<ActionBarContainerProps>
> = ({ leftContent, rightContent, children }) => (
<div className={s.container}>
<div className={s['icon-container']}>
<div
data-testid="chat-area.answer-action.left-content"
className={s['left-content']}
>
{leftContent}
</div>
<div
data-testid="chat-area.answer-action.right-content"
className={s['right-content']}
>
{rightContent}
</div>
</div>
{children}
</div>
);
ActionBarContainer.displayName = 'ActionBarContainer';

View File

@@ -0,0 +1,14 @@
.container {
display: flex;
gap: 4px;
align-items: center;
width: 100%;
height: 32px;
padding: 4px;
border-style: solid;
border-width: 1px;
border-radius: 8px;
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 10%);
}

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 { type PropsWithChildren } from 'react';
import classNames from 'classnames';
import s from './index.module.less';
// TODO 后续迭代扩展时props可细化
interface ActionBarHoverContainerProps {
style?: React.CSSProperties;
}
export const ActionBarHoverContainer: React.FC<
PropsWithChildren<ActionBarHoverContainerProps>
> = ({ children, style }) => (
<div
data-testid="chat-area.answer-action.hover-action-bar"
className={classNames(s.container, ['coz-stroke-primary', 'coz-bg-max'])}
style={style}
>
{children}
</div>
);

View File

@@ -0,0 +1,15 @@
@keyframes trigger-config-button-slider-in {
from {
transform: translateX(30px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.slide-in {
animation: trigger-config-button-slider-in 0.5s linear;
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ReactNode } from 'react';
import classNames from 'classnames';
import {
useLatestSectionId,
useMessageBoxContext,
} from '@coze-common/chat-area';
import { I18n } from '@coze-arch/i18n';
import { Button } from '@coze-arch/coze-design';
import { TriggerEnabled } from '@coze-arch/bot-api/developer_api';
import { getShowBotTriggerButton } from '../../utils/get-show-bot-trigger-button';
import { useUpdateHomeTriggerConfig } from '../../hooks/use-update-home-trigger-config';
import { useGetBotParticipantInfo } from '../../hooks/use-get-bot-participant-info';
import { useAnswerActionStore } from '../../context/store';
import { useAnswerActionPreference } from '../../context/preference';
import styles from './index.module.less';
export interface BotTriggerConfigButtonGroupProps {
addonBefore?: ReactNode;
}
export const BotTriggerConfigButtonGroup: React.FC<
BotTriggerConfigButtonGroupProps
> = ({ addonBefore }) => {
const { message, meta } = useMessageBoxContext();
const { sender_id } = message;
const { useFavoriteBotTriggerConfigStore } = useAnswerActionStore();
const botParticipantInfo = useFavoriteBotTriggerConfigStore(
state => state.favoriteBotTriggerConfigMap[sender_id ?? ''],
);
const latestSectionId = useLatestSectionId();
const isShowTriggerButton = getShowBotTriggerButton({
message,
meta,
latestSectionId,
});
const { enableBotTriggerControl } = useAnswerActionPreference();
useGetBotParticipantInfo({
botId: sender_id,
isEnabled:
isShowTriggerButton && !botParticipantInfo && enableBotTriggerControl,
});
const { keepReceiveHomeTrigger, stopReceiveHomeTrigger, loading } =
useUpdateHomeTriggerConfig({
botId: sender_id,
onSuccess: isKeepReceiveTrigger => {
if (!sender_id) {
return;
}
const { updateFavoriteBotTriggerConfigMapByImmer } =
useFavoriteBotTriggerConfigStore.getState();
updateFavoriteBotTriggerConfigMapByImmer(draft => {
const targetConfig = draft[sender_id];
if (!targetConfig) {
return;
}
targetConfig.trigger_enabled = isKeepReceiveTrigger
? TriggerEnabled.Open
: TriggerEnabled.Close;
});
},
});
if (!isShowTriggerButton) {
return null;
}
if (!botParticipantInfo) {
return null;
}
const { is_store_favorite, trigger_enabled } = botParticipantInfo;
if (!enableBotTriggerControl) {
return null;
}
if (!is_store_favorite || trigger_enabled !== TriggerEnabled.Init) {
return null;
}
return (
<div
className={classNames(
styles['slide-in'],
'flex gap-x-[8px] items-center',
)}
>
{addonBefore}
<div className="flex gap-x-[6px] items-center">
<Button
color="highlight"
onClick={stopReceiveHomeTrigger}
loading={loading}
size="small"
>
{I18n.t('stop_receiving')}
</Button>
<Button
color="brand"
onClick={keepReceiveHomeTrigger}
loading={loading}
size="small"
>
{I18n.t('keep')}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ComponentProps, useState, type PropsWithChildren } from 'react';
import copy from 'copy-to-clipboard';
import classNames from 'classnames';
import { getReportError } from '@coze-common/chat-area-utils';
import {
getIsTextMessage,
useChatArea,
useMessageBoxContext,
} from '@coze-common/chat-area';
import { I18n } from '@coze-arch/i18n';
import { IconCozCheckMark, IconCozCopy } from '@coze-arch/coze-design/icons';
import { IconButton, Toast, Tooltip } from '@coze-arch/coze-design';
import { ReportEventNames } from '../../report-events';
import { useTooltipTrigger } from '../../hooks/use-tooltip-trigger';
type CopyTextMessageProps = Omit<
ComponentProps<typeof IconButton>,
'icon' | 'iconSize' | 'onClick'
>;
export const CopyTextMessage: React.FC<
PropsWithChildren<CopyTextMessageProps>
> = ({ className, ...props }) => {
const { reporter } = useChatArea();
const { message, meta } = useMessageBoxContext();
const { content } = message;
const [isCopySuccessful, setIsCopySuccessful] = useState<boolean>(false);
const trigger = useTooltipTrigger('hover');
// 单位s
const COUNT_DOWN_TIME = 3;
// 单位s转化为ms的倍数
const TIMES = 1000;
const handleCopy = () => {
const resp = copy(content);
if (resp) {
// 复制成功
setIsCopySuccessful(true);
setTimeout(() => setIsCopySuccessful(false), COUNT_DOWN_TIME * TIMES);
Toast.success({
content: I18n.t('copy_success'),
showClose: false,
duration: COUNT_DOWN_TIME,
});
reporter.successEvent({
eventName: ReportEventNames.CopyTextMessage,
});
} else {
// 复制失败
Toast.warning({
content: I18n.t('copy_failed'),
showClose: false,
duration: COUNT_DOWN_TIME,
});
reporter.errorEvent({
eventName: ReportEventNames.CopyTextMessage,
...getReportError('copy_text_message_error', 'copy_text_message_error'),
});
}
};
const isTextMessage = getIsTextMessage(message);
if (!isTextMessage) {
return null;
}
if (!meta.isGroupLastAnswerMessage) {
return null;
}
const iconClassNames = classNames(className, 'w-[14px] h-[14px]');
return (
<Tooltip
content={isCopySuccessful ? I18n.t('copied') : I18n.t('copy')}
trigger={trigger}
>
<IconButton
data-testid="chat-area.answer-action.copy-button"
size="small"
icon={
isCopySuccessful ? (
<IconCozCheckMark className={iconClassNames} />
) : (
<IconCozCopy className={iconClassNames} />
)
}
color="secondary"
onClick={handleCopy}
{...props}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ComponentProps, type PropsWithChildren } from 'react';
import classNames from 'classnames';
import {
useDeleteMessageGroup,
useIsDeleteMessageLock,
useMessageBoxContext,
} from '@coze-common/chat-area';
import { I18n } from '@coze-arch/i18n';
import { IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { useTooltipTrigger } from '../../hooks/use-tooltip-trigger';
type DeleteMessageProps = Omit<
ComponentProps<typeof IconButton>,
'icon' | 'iconSize' | 'onClick'
>;
export const DeleteMessage: React.FC<PropsWithChildren<DeleteMessageProps>> = ({
className,
...props
}) => {
const { groupId } = useMessageBoxContext();
const trigger = useTooltipTrigger('hover');
const isDeleteMessageLock = useIsDeleteMessageLock(groupId);
const deleteMessageGroup = useDeleteMessageGroup();
return (
<Tooltip trigger={trigger} content={I18n.t('Delete')}>
<IconButton
data-testid="chat-area.answer-action.delete-message-button"
disabled={isDeleteMessageLock}
size="small"
icon={
<IconCozTrashCan
className={classNames(
'coz-fg-hglt-red',
className,
'w-[14px] h-[14px]',
)}
/>
}
onClick={() => {
// 通过 groupId 索引即可
deleteMessageGroup(groupId);
}}
color="secondary"
{...props}
/>
</Tooltip>
);
};

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.
*/
import classNames from 'classnames';
export interface AnswerActionDividerProps {
className?: string;
}
export const AnswerActionDivider: React.FC<AnswerActionDividerProps> = ({
className,
}) => (
<div
className={classNames(
'h-[12px] border-solid border-0 border-l-[1px] coz-stroke-primary mx-[8px]',
className,
)}
/>
);

View File

@@ -0,0 +1,63 @@
.frown-upon-container {
display: flex;
flex-direction: column;
width: 100%;
margin-top: 12px;
padding: 12px;
border: 1px solid rgba(10, 17, 61, 6%);
.header {
display: flex;
justify-content: space-between;
width: 100%;
margin-bottom: 12px;
line-height: 20px;
.title {
font-size: 14px;
font-weight: 600;
font-style: normal;
color: rgba(6, 7, 9, 96%);
}
}
.reasons {
display: flex;
gap: 8px;
margin-bottom: 8px;
.item {
cursor: pointer;
padding: 8px 16px;
font-size: 14px;
font-weight: 400;
font-style: normal;
background-color: rgba(6, 7, 9, 5%);
border-radius: 6px;
}
.item:hover {
background-color: rgba(128, 138, 255, 20%);
}
.item.selected {
background-color: rgba(128, 138, 255, 40%);
}
}
.textarea {
margin-bottom: 12px;
}
.footer {
text-align: right;
}
}

View File

@@ -0,0 +1,283 @@
/*
* 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 CSSProperties,
useRef,
useState,
type PropsWithChildren,
} from 'react';
import classNames from 'classnames';
import { useHover } from 'ahooks';
import {
MessageFeedbackDetailType,
MessageFeedbackType,
} from '@coze-common/chat-core';
import {
useChatAreaLayout,
useLatestSectionId,
useMessageBoxContext,
} from '@coze-common/chat-area';
import { I18n } from '@coze-arch/i18n';
import {
IconCozThumbdown,
IconCozThumbdownFill,
IconCozCross,
} from '@coze-arch/coze-design/icons';
import { TextArea, Tooltip, Button, IconButton } from '@coze-arch/coze-design';
import { Layout } from '@coze-common/chat-uikit-shared';
import { getShowFeedback } from '../../utils/get-show-feedback';
import { useReportMessageFeedback } from '../../hooks/use-report-message-feedback';
import { useDispatchMouseLeave } from '../../hooks/use-dispatch-mouse-leave';
import s from './index.module.less';
export interface FrownUponProps {
onClick?: () => void;
isFrownUponPanelVisible: boolean;
isFrownUponSuccessful: boolean;
}
export interface FrownUponUIProps extends FrownUponProps {
isMobile: boolean;
}
// 点踩按钮
export const FrownUpon: React.FC<PropsWithChildren<FrownUponProps>> = ({
onClick,
isFrownUponPanelVisible,
isFrownUponSuccessful,
}) => {
const layout = useChatAreaLayout();
const isMobileLayout = layout === Layout.MOBILE;
const reportMessageFeedback = useReportMessageFeedback();
const { message, meta } = useMessageBoxContext();
const latestSectionId = useLatestSectionId();
const handleClick = () => {
reportMessageFeedback({
message_feedback: {
feedback_type: isFrownUponSuccessful
? MessageFeedbackType.Default
: MessageFeedbackType.Unlike,
detail_types: isFrownUponSuccessful
? []
: [MessageFeedbackDetailType.UnlikeDefault],
},
}).then(() => {
// 接口调用后再切换展示状态
onClick?.();
});
};
if (!getShowFeedback({ message, meta, latestSectionId })) {
return null;
}
return (
<FrownUponUI
onClick={handleClick}
isMobile={isMobileLayout}
isFrownUponPanelVisible={isFrownUponPanelVisible}
isFrownUponSuccessful={isFrownUponSuccessful}
/>
);
};
export const FrownUponUI: React.FC<FrownUponUIProps> = ({
onClick,
isFrownUponPanelVisible,
isFrownUponSuccessful,
isMobile,
}) => {
const toolTipWrapperRef = useRef<HTMLDivElement>(null);
const isHovering = useHover(toolTipWrapperRef);
// 解决点踩填写原因面板展开收起过程中点踩按钮的tooltip展示错乱问题
useDispatchMouseLeave(toolTipWrapperRef, isFrownUponPanelVisible);
return (
<div style={{ position: 'relative' }} ref={toolTipWrapperRef}>
<Tooltip
content={I18n.t('dislike')}
getPopupContainer={() => toolTipWrapperRef.current ?? document.body}
trigger="custom"
visible={!isMobile && isHovering}
>
<IconButton
data-testid="chat-area.answer-action.frown-upon-buton"
size="small"
icon={
isFrownUponSuccessful ? (
<IconCozThumbdownFill className="w-[14px] h-[14px]" />
) : (
<IconCozThumbdown className="w-[14px] h-[14px]" />
)
}
color="secondary"
onClick={onClick}
/>
</Tooltip>
</div>
);
};
const getFrownUponPanelReasons = () => [
{
label: I18n.t('dislike_feedback_tag_harm'),
value: MessageFeedbackDetailType.UnlikeHarmful,
},
{
label: I18n.t('dislike_feedback_tag_mislead'),
value: MessageFeedbackDetailType.UnlikeIncorrect,
},
{
label: I18n.t('dislike_feedback_tag_unfollow_instruction'),
value: MessageFeedbackDetailType.UnlikeNotFollowInstructions,
},
{
label: I18n.t('dislike_feedback_tag_unfollow_others'),
value: MessageFeedbackDetailType.UnlikeOthers,
},
];
export interface FrownUponPanelProps {
containerStyle?: CSSProperties;
onCancel?: () => void;
onSubmit?: () => void;
wrapReasons?: boolean;
}
export interface OnFrownUponSubmitParam {
reasons: Array<MessageFeedbackDetailType>;
detailContent: string;
}
export interface FrownUponPanelUIProps {
onSubmit: (param: OnFrownUponSubmitParam) => void;
onCancel: (() => void) | undefined;
wrapReasons: boolean | undefined;
style?: CSSProperties;
}
// 点踩填写原因面板
export const FrownUponPanel: React.FC<
PropsWithChildren<FrownUponPanelProps>
> = ({ containerStyle, onCancel, onSubmit, wrapReasons }) => {
const reportMessageFeedback = useReportMessageFeedback();
const handleSubmit = ({ reasons, detailContent }: OnFrownUponSubmitParam) => {
reportMessageFeedback({
message_feedback: {
feedback_type: MessageFeedbackType.Unlike,
detail_types:
reasons.length > 0
? reasons
: [MessageFeedbackDetailType.UnlikeDefault],
detail_content: reasons.includes(MessageFeedbackDetailType.UnlikeOthers)
? detailContent
: undefined,
},
}).then(() => {
// 接口调用后再切换展示状态
onSubmit?.();
});
};
return (
<FrownUponPanelUI
wrapReasons={wrapReasons}
onSubmit={handleSubmit}
onCancel={onCancel}
style={containerStyle}
/>
);
};
export const FrownUponPanelUI: React.FC<FrownUponPanelUIProps> = ({
onSubmit,
onCancel,
style,
wrapReasons,
}) => {
const [reasons, setReasons] = useState<Array<MessageFeedbackDetailType>>([]);
const [textAreaValue, setTextAreaValue] = useState<string>('');
const handleItemClick = (reason: MessageFeedbackDetailType) => {
setReasons(prev => {
if (prev.includes(reason)) {
return prev.filter(item => item !== reason);
}
return [...prev, reason];
});
};
return (
<div
className={classNames(s['frown-upon-container'], [
'bg-[var(--coz-mg-card)]',
'rounded-normal',
])}
style={style}
>
<div className={s.header}>
<div className={s.title}>{I18n.t('dislike_feedback_title')}</div>
<IconButton
size="small"
color="secondary"
icon={<IconCozCross />}
onClick={onCancel}
/>
</div>
<div className={classNames(s.reasons, wrapReasons && 'flex-wrap')}>
{getFrownUponPanelReasons().map(item => (
<span
className={classNames(s.item, {
[`${s.selected}`]: reasons.includes(item.value),
})}
onClick={() => {
handleItemClick(item.value);
}}
>
{item.label}
</span>
))}
</div>
<div className={s.textarea}>
{reasons.includes(MessageFeedbackDetailType.UnlikeOthers) && (
<TextArea
placeholder={I18n.t('dislike_feedback_placeholder')}
maxCount={500}
showClear
rows={2}
value={textAreaValue}
onChange={v => {
setTextAreaValue(v);
}}
/>
)}
</div>
<div className={s.footer}>
<Button
theme="solid"
onClick={() => onSubmit({ reasons, detailContent: textAreaValue })}
>
{I18n.t('feedback_submit')}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import {
useDeleteMessageGroup,
useIsDeleteMessageLock,
useMessageBoxContext,
} from '@coze-common/chat-area';
import { I18n } from '@coze-arch/i18n';
import { IconCozMore, IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { IconButton, Dropdown } from '@coze-arch/coze-design';
interface MoreOperationsProps {
className?: string;
}
export const MoreOperations: React.FC<MoreOperationsProps> = ({
className,
}) => {
const { groupId } = useMessageBoxContext();
const isDeleteMessageLock = useIsDeleteMessageLock(groupId);
const deleteMessageGroup = useDeleteMessageGroup();
return (
<Dropdown
render={
<Dropdown.Menu mode="menu">
<Dropdown.Item
disabled={isDeleteMessageLock}
icon={<IconCozTrashCan className="coz-fg-hglt-red" />}
onClick={() => {
// 通过 groupId 索引即可
deleteMessageGroup(groupId);
}}
type="danger"
>
{I18n.t('Delete')}
</Dropdown.Item>
</Dropdown.Menu>
}
>
<IconButton
data-testid="chat-area.answer-action.more-operation-button"
size="small"
color="secondary"
icon={
<IconCozMore className={classNames(className, 'w-[14px] h-[14px]')} />
}
/>
</Dropdown>
);
};

View File

@@ -0,0 +1,173 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ComponentProps, type PropsWithChildren } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import {
parseMarkdownToGrabNode,
GrabElementType,
} from '@coze-common/text-grab';
import { messageSource, type MessageContent } from '@coze-common/chat-core';
import {
safeAsyncThrow,
typeSafeJsonParseEnhanced,
} from '@coze-common/chat-area-utils';
import { type GrabPluginBizContext } from '@coze-common/chat-area-plugin-message-grab';
import {
getIsImageMessage,
ContentType,
getIsTextMessage,
useMessageBoxContext,
type WriteableChatAreaPlugin,
} from '@coze-common/chat-area';
import { IconCozQuotation } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { useTooltipTrigger } from '../../hooks/use-tooltip-trigger';
import { useQuotePlugin } from '../../hooks/use-quote-plugin';
type QuoteMessageProps = Omit<
ComponentProps<typeof IconButton>,
'icon' | 'iconSize' | 'onClick'
>;
export const QuoteMessage: React.FC<
PropsWithChildren<QuoteMessageProps>
> = props => {
const plugin = useQuotePlugin();
const { message } = useMessageBoxContext();
if (!plugin || message.source === messageSource.Notice) {
return null;
}
return <QuoteMessageImpl {...props} />;
};
/**
* 哥哥们改动这里要小心一点喔QuoteMessageImpl的前置依赖项是 message-grab
*/
export const QuoteMessageImpl: React.FC<
PropsWithChildren<QuoteMessageProps>
> = ({ className, ...props }) => {
// INFO: 这里使用 as 是因为明确的知道 父组件提前尝试取 plugin 并且提前拦截的情况
// 后续如果有改动,请务必注意这里
const plugin = useQuotePlugin() as WriteableChatAreaPlugin<
GrabPluginBizContext,
unknown
>;
const { chatAreaPluginContext, pluginBizContext } = plugin;
const { useDeleteFile } = plugin.chatAreaPluginContext.writeableHook.file;
const { getFileStoreInstantValues } =
chatAreaPluginContext.readonlyAPI.batchFile;
const { useQuoteStore } = pluginBizContext.storeSet;
const { onQuote } = pluginBizContext.eventCallbacks;
const { updateQuoteContent, updateQuoteVisible } = useQuoteStore.getState();
const { message, meta } = useMessageBoxContext();
const { content, content_type } = message;
const trigger = useTooltipTrigger('hover');
const deleteFile = useDeleteFile();
const deleteAllFile = () => {
const { fileIdList } = getFileStoreInstantValues();
fileIdList.forEach(id => deleteFile(id));
};
const handleQuote = () => {
deleteAllFile();
if (content_type === ContentType.Image) {
const contentObj = typeSafeJsonParseEnhanced<
MessageContent<ContentType.Image>
>({
str: content,
verifyStruct: (
_content: unknown,
): _content is MessageContent<ContentType.Image> =>
_content instanceof Object && 'image_list' in _content,
onParseError: error => {
safeAsyncThrow(error.message);
},
onVerifyError: error => {
safeAsyncThrow(error.message);
},
});
updateQuoteContent(
contentObj?.image_list.map(img => ({
type: GrabElementType.IMAGE,
src: img.image_ori.url,
children: [],
})) ?? [],
);
} else {
const grabNodeList = parseMarkdownToGrabNode(content);
updateQuoteContent(grabNodeList);
}
onQuote?.({ botId: message.sender_id ?? '', source: message.source });
updateQuoteVisible(true);
};
const isTextMessage = getIsTextMessage(message);
const isImageMessage = getIsImageMessage(message);
if (!isTextMessage && !isImageMessage) {
return null;
}
if (!meta.isGroupLastAnswerMessage) {
return null;
}
if (!plugin) {
return null;
}
return (
<Tooltip content={I18n.t('quote_ask_in_chat')} trigger={trigger}>
<IconButton
data-testid="chat-area.answer-action.quote-message"
size="small"
icon={
<IconCozQuotation
className={classNames(className, 'w-[14px] h-[14px]')}
/>
}
onClick={handleQuote}
color="secondary"
{...props}
/>
</Tooltip>
);
};
QuoteMessage.displayName = 'QuoteMessage';

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ComponentProps, type PropsWithChildren } from 'react';
import classNames from 'classnames';
import {
useMessageBoxContext,
useLatestSectionId,
} from '@coze-common/chat-area';
import { I18n } from '@coze-arch/i18n';
import { IconCozRefresh } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { getShowRegenerate } from '../../utils/get-show-regenerate';
import { useTooltipTrigger } from '../../hooks/use-tooltip-trigger';
type RegenerateMessageProps = Omit<
ComponentProps<typeof IconButton>,
'icon' | 'iconSize' | 'onClick'
>;
export const RegenerateMessage: React.FC<
PropsWithChildren<RegenerateMessageProps>
> = ({ className, ...props }) => {
const { message, meta, regenerateMessage } = useMessageBoxContext();
const latestSectionId = useLatestSectionId();
const trigger = useTooltipTrigger('hover');
const showRegenerate = getShowRegenerate({ message, meta, latestSectionId });
if (!showRegenerate) {
return null;
}
return (
<Tooltip trigger={trigger} content={I18n.t('message_tool_regenerate')}>
<IconButton
data-testid="chat-area.answer-action.regenerate-message-button"
size="small"
color="secondary"
icon={
<IconCozRefresh
className={classNames(className, 'w-[14px] h-[14px]')}
/>
}
onClick={() => {
regenerateMessage();
}}
{...props}
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRef } from 'react';
import { useHover } from 'ahooks';
import { MessageFeedbackType } from '@coze-common/chat-core';
import {
useChatAreaLayout,
useLatestSectionId,
useMessageBoxContext,
} from '@coze-common/chat-area';
import { I18n } from '@coze-arch/i18n';
import {
IconCozThumbsup,
IconCozThumbsupFill,
} from '@coze-arch/coze-design/icons';
import { Tooltip, IconButton } from '@coze-arch/coze-design';
import { Layout } from '@coze-common/chat-uikit-shared';
import { getShowFeedback } from '../../utils/get-show-feedback';
import { useReportMessageFeedback } from '../../hooks/use-report-message-feedback';
import { useDispatchMouseLeave } from '../../hooks/use-dispatch-mouse-leave';
export interface ThumbsUpProps {
isThumbsUpSuccessful?: boolean;
onClick?: () => void;
isFrownUponPanelVisible: boolean;
}
export interface ThumbsUpUIProps extends ThumbsUpProps {
isMobile: boolean;
}
export const ThumbsUp: React.FC<ThumbsUpProps> = ({
isThumbsUpSuccessful = false,
onClick,
isFrownUponPanelVisible,
}) => {
const layout = useChatAreaLayout();
const isMobileLayout = layout === Layout.MOBILE;
const reportMessageFeedback = useReportMessageFeedback();
const { message, meta } = useMessageBoxContext();
const latestSectionId = useLatestSectionId();
const handleClick = () => {
reportMessageFeedback({
message_feedback: {
feedback_type: isThumbsUpSuccessful
? MessageFeedbackType.Default
: MessageFeedbackType.Like,
},
}).then(() => {
// 接口调用后再切换展示状态
onClick?.();
});
};
if (!getShowFeedback({ message, meta, latestSectionId })) {
return null;
}
return (
<ThumbsUpUI
isMobile={isMobileLayout}
onClick={handleClick}
isThumbsUpSuccessful={isThumbsUpSuccessful}
isFrownUponPanelVisible={isFrownUponPanelVisible}
/>
);
};
export const ThumbsUpUI: React.FC<ThumbsUpUIProps> = ({
onClick,
isFrownUponPanelVisible,
isMobile,
isThumbsUpSuccessful,
}) => {
const toolTipWrapperRef = useRef<HTMLDivElement>(null);
const isHovering = useHover(toolTipWrapperRef);
useDispatchMouseLeave(toolTipWrapperRef, isFrownUponPanelVisible);
return (
<div ref={toolTipWrapperRef}>
<Tooltip
trigger="custom"
visible={!isMobile && isHovering}
content={I18n.t('like')}
>
<IconButton
data-testid="chat-area.answer-action.thumb-up-button"
size="small"
icon={
isThumbsUpSuccessful ? (
<IconCozThumbsupFill className="w-[14px] h-[14px] coz-fg-color-brand" />
) : (
<IconCozThumbsup className="w-[14px] h-[14px]" />
)
}
color="secondary"
onClick={onClick}
/>
</Tooltip>
</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 {
getIsNotificationMessage,
useLatestSectionId,
useMessageBoxContext,
} from '@coze-common/chat-area';
export const useIsFinalAnswerMessageInLastGroup = () => {
const { meta } = useMessageBoxContext();
const latestSectionId = useLatestSectionId();
return (
meta.isGroupLastAnswerMessage &&
meta.isFromLatestGroup &&
meta.sectionId === latestSectionId
);
};
export const useIsNotificationMessage = () => {
const { message } = useMessageBoxContext();
return getIsNotificationMessage(message);
};

View File

@@ -0,0 +1,39 @@
/*
* 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 { AnswerActionStoreContext } from '../store/context';
import { AnswerActionPreferenceContext } from '../preference/context';
import { useInitStoreSet } from '../../hooks/use-init-store-set';
export const AnswerActionProvider: React.FC<
PropsWithChildren<{
enableBotTriggerControl: boolean;
}>
> = ({ children, enableBotTriggerControl }) => {
const storeSet = useInitStoreSet();
return (
<AnswerActionStoreContext.Provider value={storeSet}>
<AnswerActionPreferenceContext.Provider
value={{ enableBotTriggerControl }}
>
{children}
</AnswerActionPreferenceContext.Provider>
</AnswerActionStoreContext.Provider>
);
};

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 { createContext } from 'react';
import { type ChatAnswerActionPreference } from './type';
export const AnswerActionPreferenceContext =
createContext<ChatAnswerActionPreference>({
enableBotTriggerControl: false,
});

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.
*/
import { useContext } from 'react';
import { AnswerActionPreferenceContext } from './context';
export const useAnswerActionPreference = () =>
useContext(AnswerActionPreferenceContext);

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 interface ChatAnswerActionPreference {
enableBotTriggerControl: boolean;
}

View File

@@ -0,0 +1,41 @@
/*
* 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 { createContext, type PropsWithChildren } from 'react';
import { useRequest } from 'ahooks';
import {
type ReportMessageFeedbackFn,
type ReportMessageFeedbackFnProviderProps,
} from './type';
export const ReportMessageFeedbackFnContext =
createContext<ReportMessageFeedbackFn | null>(null);
export const ReportMessageFeedbackFnProvider: React.FC<
PropsWithChildren<ReportMessageFeedbackFnProviderProps>
> = ({ children, reportMessageFeedback }) => {
const { runAsync: asyncReportMessage } = useRequest(reportMessageFeedback, {
manual: true,
});
return (
<ReportMessageFeedbackFnContext.Provider value={asyncReportMessage}>
{children}
</ReportMessageFeedbackFnContext.Provider>
);
};

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useContext } from 'react';
import { ReportMessageFeedbackFnContext } from './context';
export { ReportMessageFeedbackFnProvider } from './context';
export const useReportMessageFeedbackFn = () => {
const reportMessageFeedbackFn = useContext(ReportMessageFeedbackFnContext);
if (!reportMessageFeedbackFn) {
throw new Error('reportMessageFeedbackFn not provided');
}
return reportMessageFeedbackFn;
};

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.
*/
import { type ChatCore } from '@coze-common/chat-core';
export type ReportMessageFeedbackFn = ChatCore['reportMessage'];
export interface ReportMessageFeedbackFnProviderProps {
reportMessageFeedback: ReportMessageFeedbackFn;
}

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.
*/
import { createContext } from 'react';
import { type StoreSet } from './type';
export const AnswerActionStoreContext = createContext<StoreSet | null>(null);

View File

@@ -0,0 +1,27 @@
/*
* 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 { useContext } from 'react';
import { AnswerActionStoreContext } from './context';
export const useAnswerActionStore = () => {
const storeSet = useContext(AnswerActionStoreContext);
if (!storeSet) {
throw new Error('answer action store not provided');
}
return storeSet;
};

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.
*/
import { type FavoriteBotTriggerConfigStore } from '../../store/favorite-bot-trigger-config';
export interface StoreSet {
useFavoriteBotTriggerConfigStore: FavoriteBotTriggerConfigStore;
}

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 { type RefObject, useEffect } from 'react';
/**
* 点击赞、踩按钮,可以关闭打开原因填写面板
* 填写面板关闭的时候, 会造成一次 Reflow。此时赞、踩按钮的位置会发生变化 鼠标已经不在按钮上,但是对应按钮元素不会处罚 mouseleave 事件
* 由于不触发 mouseleave 造成按钮上的 tooltip 不消失、错位等问题
* 所以需要在面板 visible 变化时 patch 一个 mouseleave 事件
*/
export const useDispatchMouseLeave = (
ref: RefObject<HTMLDivElement>,
isFrownUponPanelVisible: boolean,
) => {
useEffect(() => {
ref.current?.dispatchEvent(
new MouseEvent('mouseleave', {
view: window,
bubbles: true,
cancelable: true,
}),
);
}, [isFrownUponPanelVisible, ref.current]);
};

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRequest } from 'ahooks';
import { type GetBotParticipantInfoByBotIdsResponse } from '@coze-arch/bot-api/developer_api';
import { DeveloperApi } from '@coze-arch/bot-api';
import { type BotParticipantInfoWithId } from '../store/favorite-bot-trigger-config';
import { useAnswerActionStore } from '../context/store';
export const useGetBotParticipantInfo = ({
botId,
isEnabled,
}: {
botId: string | undefined;
isEnabled: boolean;
}) => {
const { useFavoriteBotTriggerConfigStore } = useAnswerActionStore();
useRequest(
(): Promise<GetBotParticipantInfoByBotIdsResponse | undefined> => {
if (!botId) {
return Promise.resolve(undefined);
}
return DeveloperApi.GetBotParticipantInfoByBotIds({
bot_ids: [botId],
});
},
{
ready: isEnabled && Boolean(botId),
refreshDeps: [isEnabled, botId],
onSuccess: res => {
if (!res) {
return;
}
const { updateMapByConfigList } =
useFavoriteBotTriggerConfigStore.getState();
const participantInfoList: BotParticipantInfoWithId[] = Object.entries(
res.participant_info_map ?? {},
).map(([participantBotId, item]) => ({
...item,
botId: participantBotId,
}));
updateMapByConfigList(participantInfoList);
},
},
);
};

View File

@@ -0,0 +1,28 @@
/*
* 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 { useCreation } from 'ahooks';
import { createFavoriteBotTriggerConfigStore } from '../store/favorite-bot-trigger-config';
import { type StoreSet } from '../context/store/type';
export const useInitStoreSet = (): StoreSet => {
const useFavoriteBotTriggerConfigStore = useCreation(
() => createFavoriteBotTriggerConfigStore(),
[],
);
return { useFavoriteBotTriggerConfigStore };
};

View File

@@ -0,0 +1,30 @@
/*
* 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 GrabPluginBizContext } from '@coze-common/chat-area-plugin-message-grab';
import { PluginName, useWriteablePlugin } from '@coze-common/chat-area';
export const useQuotePlugin = () => {
try {
const plugin = useWriteablePlugin<GrabPluginBizContext>(
PluginName.MessageGrab,
);
return plugin;
} catch (e) {
return null;
}
};

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useToggle } from 'ahooks';
import {
ReportMessageAction,
type ReportMessageProps,
} from '@coze-common/chat-core';
import { getReportError } from '@coze-common/chat-area-utils';
import {
isFallbackErrorMessage,
useChatArea,
useMessageBoxContext,
} from '@coze-common/chat-area';
import { ReportEventNames } from '../report-events';
import { useReportMessageFeedbackFn } from '../context/report-message-feedback';
/**
* @description 消息点赞/点踩
*/
export const useReportMessageFeedback = () => {
const { reporter } = useChatArea();
const asyncReportMessage = useReportMessageFeedbackFn();
const { message } = useMessageBoxContext();
const { message_id } = message;
const reportMessageFeedback = async (
params: Pick<ReportMessageProps, 'message_feedback'>,
) => {
if (isFallbackErrorMessage(message)) {
return;
}
try {
await asyncReportMessage({
message_id,
action: ReportMessageAction.Feedback,
...params,
});
reporter.successEvent({ eventName: ReportEventNames.ReportMessage });
} catch (e) {
reporter.errorEvent({
eventName: ReportEventNames.ReportMessage,
...getReportError(e),
});
}
};
return reportMessageFeedback;
};
/**
* @description 获取 点赞按钮组件/点踩按钮组件/点踩原因填写面板组件 props
*/
export const useReportMessageFeedbackHelpers = () => {
// 点赞成功标识
const [isThumbsUpSuccessful, { toggle: toogleIsThumbsUpSuccessful }] =
useToggle<boolean>(false);
// 点踩成功标识
const [isFrownUponSuccessful, { toggle: toogleIsFrownUponSuccessful }] =
useToggle<boolean>(false);
// 点踩原因填写面板展示
const [
isFrownUponPanelVisible,
{
setLeft: setIsFrownUponPanelVisibleFalse,
setRight: setIsFrownUponPanelVisibleTrue,
},
] = useToggle<boolean>(false);
// 点赞按钮组件onClick事件
const thumbsUpOnClick = () => {
toogleIsThumbsUpSuccessful();
// 点赞/点踩互斥
if (!isThumbsUpSuccessful && isFrownUponSuccessful) {
toogleIsFrownUponSuccessful();
setIsFrownUponPanelVisibleFalse();
}
};
// 点踩按钮组件onClick事件
const frownUponOnClick = () => {
toogleIsFrownUponSuccessful();
// 点赞/点踩互斥
if (!isFrownUponSuccessful && isThumbsUpSuccessful) {
toogleIsThumbsUpSuccessful();
}
if (!isFrownUponSuccessful) {
setIsFrownUponPanelVisibleTrue();
} else {
setIsFrownUponPanelVisibleFalse();
}
};
// 点踩原因填写面板组件onCancel事件
const frownUponPanelonCancel = () => {
setIsFrownUponPanelVisibleFalse();
};
// 点踩原因填写面板组件onSubmit事件
const frownUponPanelonSubmit = () => {
setIsFrownUponPanelVisibleFalse();
// 点赞/点踩互斥
if (isThumbsUpSuccessful) {
toogleIsThumbsUpSuccessful();
}
if (!isFrownUponSuccessful) {
toogleIsFrownUponSuccessful();
}
};
return {
isThumbsUpSuccessful,
isFrownUponSuccessful,
isFrownUponPanelVisible,
thumbsUpOnClick,
frownUponOnClick,
frownUponPanelonCancel,
frownUponPanelonSubmit,
};
};

View File

@@ -0,0 +1,28 @@
/*
* 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 { useChatAreaLayout } from '@coze-common/chat-area/hooks/public/common';
import { Layout } from '@coze-common/chat-uikit-shared';
export const useTooltipTrigger = (
defaultTrigger: 'hover' | 'click' | 'focus' | 'contextMenu',
) => {
const layout = useChatAreaLayout();
if (layout === Layout.MOBILE) {
return 'custom';
}
return defaultTrigger;
};

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 { useRequest } from 'ahooks';
import { TriggerEnabled } from '@coze-arch/bot-api/developer_api';
import { DeveloperApi } from '@coze-arch/bot-api';
export const useUpdateHomeTriggerConfig = ({
botId,
onSuccess,
}: {
botId: string | undefined;
onSuccess?: (isKeepReceiveTrigger: boolean) => void;
}) => {
const { run, loading } = useRequest(
async ({ isKeepReceiveTrigger }: { isKeepReceiveTrigger: boolean }) => {
if (!botId) {
throw new Error('try to request home trigger but no bot id');
}
await DeveloperApi.UpdateHomeTriggerUserConfig({
bot_id: botId,
action: isKeepReceiveTrigger
? TriggerEnabled.Open
: TriggerEnabled.Close,
});
return isKeepReceiveTrigger;
},
{
manual: true,
onSuccess,
},
);
return {
keepReceiveHomeTrigger: () => run({ isKeepReceiveTrigger: true }),
stopReceiveHomeTrigger: () => run({ isKeepReceiveTrigger: false }),
loading,
};
};

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.
*/
export { ActionBarContainer } from './components/action-bar-container';
export { ActionBarHoverContainer } from './components/action-bar-hover-container';
export {
ThumbsUp,
ThumbsUpUI,
ThumbsUpProps,
ThumbsUpUIProps,
} from './components/thumbs-up';
export { RegenerateMessage } from './components/regenerate-message';
export { MoreOperations } from './components/more-operations';
export {
FrownUpon,
FrownUponUI,
FrownUponProps,
FrownUponUIProps,
FrownUponPanel,
FrownUponPanelUI,
FrownUponPanelProps,
FrownUponPanelUIProps,
OnFrownUponSubmitParam,
} from './components/frown-upon';
export { DeleteMessage } from './components/delete-message';
export { CopyTextMessage } from './components/copy-text-message';
export { QuoteMessage } from './components/quote-message';
export {
useReportMessageFeedback,
useReportMessageFeedbackHelpers,
} from './hooks/use-report-message-feedback';
export { useTooltipTrigger } from './hooks/use-tooltip-trigger';
export { AnswerActionProvider } from './context/main';
export {
AnswerActionDivider,
type AnswerActionDividerProps,
} from './components/divider';
export {
BotTriggerConfigButtonGroup,
type BotTriggerConfigButtonGroupProps,
} from './components/bot-trigger-config-button-group';
export { useAnswerActionStore } from './context/store';
export {
ReportMessageFeedbackFnProvider,
useReportMessageFeedbackFn,
} from './context/report-message-feedback';
export { BotParticipantInfoWithId } from './store/favorite-bot-trigger-config';
export { useUpdateHomeTriggerConfig } from './hooks/use-update-home-trigger-config';
export { useDispatchMouseLeave } from './hooks/use-dispatch-mouse-leave';
export { ReportEventNames } from './report-events';

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 enum ReportEventNames {
/** 原名: chat_area_tts_voice_ws */
TtsVoiceWs = 'chat_answer_action_start_TTS',
/** 原名: chat_area_report_message */
ReportMessage = 'chat_answer_action_message_feedback',
/** 原名: chat_area_copy_text_message */
CopyTextMessage = 'chat_answer_action_copy_text_message',
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { devtools } from 'zustand/middleware';
import { create } from 'zustand';
import { produce } from 'immer';
import { type BotParticipantInfo } from '@coze-arch/bot-api/developer_api';
type BotId = string;
export type BotParticipantInfoWithId = BotParticipantInfo & { botId: string };
export interface FavoriteBotTriggerConfigState {
favoriteBotTriggerConfigMap: Record<BotId, BotParticipantInfo>;
}
export interface FavoriteBotTriggerConfigAction {
updateFavoriteBotTriggerConfigMap: (
map: Record<BotId, BotParticipantInfo>,
) => void;
updateFavoriteBotTriggerConfigMapByImmer: (
updateFn: (map: Record<BotId, BotParticipantInfo>) => void,
) => void;
updateMapByConfigList: (list: BotParticipantInfoWithId[]) => void;
getFavoriteBotConfigIdList: () => BotId[];
deleteConfigById: (id: BotId) => void;
}
export const createFavoriteBotTriggerConfigStore = () =>
create<FavoriteBotTriggerConfigState & FavoriteBotTriggerConfigAction>()(
devtools(
(set, get) => ({
favoriteBotTriggerConfigMap: {},
updateFavoriteBotTriggerConfigMap: map => {
set(
{
favoriteBotTriggerConfigMap: Object.assign(
{},
get().favoriteBotTriggerConfigMap,
map,
),
},
false,
'updateFavoriteBotTriggerConfigMap',
);
},
updateMapByConfigList: list => {
const map = Object.fromEntries(list.map(item => [item.botId, item]));
set(
{
favoriteBotTriggerConfigMap: Object.assign(
{},
get().favoriteBotTriggerConfigMap,
map,
),
},
false,
'updateMapByConfigList',
);
},
updateFavoriteBotTriggerConfigMapByImmer: updateFn => {
set(
{
favoriteBotTriggerConfigMap: produce<
FavoriteBotTriggerConfigState['favoriteBotTriggerConfigMap']
>(get().favoriteBotTriggerConfigMap, updateFn),
},
false,
'updateFavoriteBotTriggerConfigMapByImmer',
);
},
deleteConfigById: id => {
set(
{
favoriteBotTriggerConfigMap: produce(
get().favoriteBotTriggerConfigMap,
map => {
delete map[id];
},
),
},
false,
'deleteConfigById',
);
},
getFavoriteBotConfigIdList: () =>
Object.entries(get().favoriteBotTriggerConfigMap).map(([id]) => id),
}),
{
enabled: IS_DEV_MODE,
name: 'botStudio.ChatAnswerActionBotTrigger',
},
),
);
export type FavoriteBotTriggerConfigStore = ReturnType<
typeof createFavoriteBotTriggerConfigStore
>;

View File

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

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 MessageMeta } from '@coze-common/chat-area';
export const getIsLastGroup = ({
meta: { isFromLatestGroup, sectionId },
latestSectionId,
}: {
meta: Pick<MessageMeta, 'isFromLatestGroup' | 'sectionId'>;
latestSectionId: string;
}) => isFromLatestGroup && sectionId === latestSectionId;

View File

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

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 { messageSource, taskType } from '@coze-common/chat-core';
import { type Message, type MessageMeta } from '@coze-common/chat-area';
import { getIsLastGroup } from './get-is-last-group';
export const getShowBotTriggerButton = ({
// eslint-disable-next-line @typescript-eslint/naming-convention -- .
message: { source, extra_info },
meta,
latestSectionId,
}: {
message: Pick<Message, 'source' | 'extra_info'>;
meta: Pick<MessageMeta, 'isFromLatestGroup' | 'sectionId'>;
latestSectionId: string;
}) =>
source === messageSource.TaskManualTrigger &&
extra_info.task_type === taskType.PresetTask &&
getIsLastGroup({ meta, latestSectionId });

View File

@@ -0,0 +1,45 @@
/*
* 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 Message, type MessageMeta } from '@coze-common/chat-area';
import { getIsPushedMessage } from './get-is-pushed-message';
export const getShowFeedback = ({
message,
meta,
latestSectionId,
}: {
message: Pick<Message, 'type' | 'source'>;
meta: Pick<
MessageMeta,
'isFromLatestGroup' | 'sectionId' | 'isGroupLastAnswerMessage'
>;
latestSectionId: string;
}): boolean => {
// 是否是推送的消息
const isPushedMessage = getIsPushedMessage(message);
if (isPushedMessage) {
return false;
}
// 来自最后一个消息组的 final answer
return (
meta.isGroupLastAnswerMessage &&
meta.isFromLatestGroup &&
meta.sectionId === latestSectionId
);
};

View File

@@ -0,0 +1,39 @@
/*
* 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 Message, type MessageMeta } from '@coze-common/chat-area';
import { getIsPushedMessage } from './get-is-pushed-message';
import { getIsLastGroup } from './get-is-last-group';
export const getShowRegenerate = ({
message,
meta,
latestSectionId,
}: {
message: Pick<Message, 'type' | 'source'>;
meta: Pick<MessageMeta, 'isFromLatestGroup' | 'sectionId'>;
latestSectionId: string;
}): boolean => {
// 是否是推送的消息
const isPushedMessage = getIsPushedMessage(message);
if (isPushedMessage) {
return false;
}
// 来自最后一个消息组
return getIsLastGroup({ meta, latestSectionId });
};

View File

@@ -0,0 +1,70 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"compilerOptions": {
"types": [],
"strictNullChecks": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"references": [
{
"path": "../../../arch/bot-api/tsconfig.build.json"
},
{
"path": "../../../arch/bot-flags/tsconfig.build.json"
},
{
"path": "../../../arch/bot-typings/tsconfig.build.json"
},
{
"path": "../../../arch/bot-utils/tsconfig.build.json"
},
{
"path": "../../../arch/i18n/tsconfig.build.json"
},
{
"path": "../chat-area/tsconfig.build.json"
},
{
"path": "../chat-core/tsconfig.build.json"
},
{
"path": "../chat-uikit-shared/tsconfig.build.json"
},
{
"path": "../chat-uikit/tsconfig.build.json"
},
{
"path": "../../../components/bot-icons/tsconfig.build.json"
},
{
"path": "../../../components/bot-semi/tsconfig.build.json"
},
{
"path": "../../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../../config/vitest-config/tsconfig.build.json"
},
{
"path": "../plugin-message-grab/tsconfig.build.json"
},
{
"path": "../text-grab/tsconfig.build.json"
},
{
"path": "../utils/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
],
"exclude": ["**/*"]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["__tests__", "stories", "vitest.config.ts", "tailwind.config.ts"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
],
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"types": ["vitest/globals"],
"strictNullChecks": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true
}
}

View File

@@ -0,0 +1,28 @@
/*
* 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 { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
test: {
coverage: {
all: true,
exclude: ['index.ts'],
},
},
});

View File

@@ -0,0 +1,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
{
"name": "@coze-common/chat-area-plugin-reasoning",
"version": "0.0.1",
"description": "chat area plugin reasoning",
"license": "Apache-2.0",
"author": "fanchen@bytedance.com",
"maintainers": [],
"main": "src/index.ts",
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-md-box-adapter": "workspace:*",
"@coze-common/chat-area": "workspace:*",
"@coze-common/chat-core": "workspace:*",
"classnames": "^2.3.2"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite": "^4.3.9",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

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 { memo, useEffect, useRef, useState, type FC } from 'react';
import { type Message, type ContentType } from '@coze-common/chat-core';
import { MdBoxLazy } from '@coze-arch/bot-md-box-adapter/lazy';
type IProps = Record<'message', Message<ContentType>>;
// export const BizMessageInnerAddonBottom: FC<IProps> = p =>
// p.message.role === 'assistant' && p.message.reasoning_content ? (
// <div className="my-[8px] px-[14px] border-solid border-[0] border-l-[0.25em] border-l-[var(--color-border-default)] text-[var(--color-fg-muted)]">
// {p.message.reasoning_content}
// </div>
// ) : null;
export const BizMessageInnerAddonBottom: FC<IProps> = memo(
p => {
const [reasoningFinished, setReasoningFinished] = useState(false);
const ref = useRef(p.message.reasoning_content);
useEffect(() => {
setReasoningFinished(ref.current === p.message.reasoning_content);
return () => {
ref.current = p.message.reasoning_content;
};
// content 用来触发 reasoning 的 rerender
}, [p.message.reasoning_content, p.message.content]);
return p.message.role === 'assistant' && p.message.reasoning_content ? (
<div className="my-[8px]">
<MdBoxLazy
markDown={`${p.message.reasoning_content.replace(/^/gm, '> ')}`}
showIndicator={!p.message.is_finish && !reasoningFinished}
></MdBoxLazy>
</div>
) : null;
},
(prev, next) =>
prev.message.role === next.message.role &&
prev.message.is_finish === next.message.is_finish &&
prev.message.reasoning_content === next.message.reasoning_content &&
prev.message.content === next.message.content,
);

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 PluginRegistryEntry } from '@coze-common/chat-area';
import { type PluginBizContext } from './types/biz-context';
import { BizPlugin } from './plugin';
// eslint-disable-next-line @typescript-eslint/naming-convention -- 插件命名大写开头符合预期
export const ReasoningPluginRegistry: PluginRegistryEntry<PluginBizContext> = {
/**
* 贯穿插件生命周期、组件的上下文
*/
createPluginBizContext() {
return {};
},
/**
* 插件本体
*/
Plugin: BizPlugin,
};

View File

@@ -0,0 +1,57 @@
/*
* 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 {
PluginMode,
PluginName,
ReadonlyChatAreaPlugin,
createReadonlyLifeCycleServices,
createCustomComponents,
} from '@coze-common/chat-area';
import { type PluginBizContext } from './types/biz-context';
import { bizLifeCycleServiceGenerator } from './services/life-cycle';
import { BizMessageInnerAddonBottom } from './custom-components/message-inner-addon-bottom';
export class BizPlugin extends ReadonlyChatAreaPlugin<PluginBizContext> {
/**
* 插件类型
* PluginMode.Readonly = 只读模式
* PluginMode.Writeable = 可写模式
*/
public pluginMode = PluginMode.Readonly;
/**
* 插件名称
* 请点 PluginName 里面去定义
*/
public pluginName = PluginName.Demo;
/**
* 生命周期服务
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public lifeCycleServices: any = createReadonlyLifeCycleServices(
this,
bizLifeCycleServiceGenerator,
);
/**
* 自定义组件
*/
public customComponents = createCustomComponents({
TextMessageInnerTopSlot: BizMessageInnerAddonBottom,
});
}

View File

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

View File

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

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.
*/
import { type ReadonlyLifeCycleServiceGenerator } from '@coze-common/chat-area';
import { type PluginBizContext } from '../../types/biz-context';
import { renderLifeCycleServiceGenerator } from './render';
import { messageLifeCycleServiceGenerator } from './message';
import { commandLifeCycleServiceGenerator } from './command';
import { appLifeCycleServiceGenerator } from './app';
export const bizLifeCycleServiceGenerator: ReadonlyLifeCycleServiceGenerator<
PluginBizContext
> = plugin => ({
appLifeCycleService: appLifeCycleServiceGenerator(plugin),
messageLifeCycleService: messageLifeCycleServiceGenerator(plugin),
commandLifeCycleService: commandLifeCycleServiceGenerator(plugin),
renderLifeCycleService: renderLifeCycleServiceGenerator(plugin),
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler",
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"exclude": ["node_modules", "dist"],
"references": [
{
"path": "../../../arch/bot-md-box-adapter/tsconfig.build.json"
},
{
"path": "../../../arch/bot-typings/tsconfig.build.json"
},
{
"path": "../chat-area/tsconfig.build.json"
},
{
"path": "../chat-core/tsconfig.build.json"
},
{
"path": "../../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../../config/vitest-config/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"exclude": ["**/*"],
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
]
}

View File

@@ -0,0 +1,20 @@
{
"extends": "@coze-arch/ts-config/tsconfig.base.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler"
},
"include": ["__tests__", "vitest.config.ts", "stories"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
]
}

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.
*/
import { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
});

View File

@@ -0,0 +1,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-common/chat-area
Chat area 核心逻辑
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,264 @@
/*
* 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 { getReceiveMessageBoxTheme } from '../../src/utils/components/get-receive-message-box-theme';
// eslint-disable-next-line @typescript-eslint/naming-convention
const MockContentType = {
Text: 'text',
Link: 'link',
Music: 'music',
Video: 'video',
Card: 'card',
Image: 'image',
File: 'file',
Tako: 'tako',
Custom: 'custom',
Mix: 'mix',
};
vi.mock('@coze-common/chat-core', () => ({
ContentType: {
Text: 'text',
Link: 'link',
Music: 'music',
Video: 'video',
Card: 'card',
Image: 'image',
File: 'file',
Tako: 'tako',
Custom: 'custom',
Mix: 'mix',
},
}));
const cardMessage: any = {
role: 'assistant',
type: 'answer',
content: 'Hello',
content_obj: 'Hello',
content_type: MockContentType.Card,
message_id: '7392514629399953443',
reply_id: '7392514612706705443',
section_id: '7380292213265317928',
extra_info: {
local_message_id: '',
input_tokens: '1055',
output_tokens: '0',
token: '1055',
plugin_status: '',
time_cost: '',
workflow_tokens: '',
bot_state:
'{"bot_id":"7326859717089804315","agent_name":"意图识别","agent_id":"7386916906693410825","awaiting":"7386916906693410825"}',
plugin_request: '',
tool_name: '',
plugin: '',
mock_hit_info: '',
log_id: '2024071716121849EA05C1D7C3036CEE60',
message_title: '',
stream_plugin_running: '',
new_section_id: '',
remove_query_id: '',
execute_display_name: '',
task_type: '',
call_id: '',
},
mention_list: [],
sender_id: '7326859717089804315',
content_time: 1721203942447,
message_index: '384',
source: 0,
is_finish: false,
index: 1,
};
const followUpMessage: any = {
role: 'assistant',
type: 'answer',
content: 'Hello',
content_obj: 'Hello',
content_type: MockContentType.Card,
message_id: '7392514629399953443',
reply_id: '7392514612706705443',
section_id: '7380292213265317928',
extra_info: {
local_message_id: '',
input_tokens: '1055',
output_tokens: '0',
token: '1055',
plugin_status: '',
time_cost: '',
workflow_tokens: '',
bot_state:
'{"bot_id":"7326859717089804315","agent_name":"意图识别","agent_id":"7386916906693410825","awaiting":"7386916906693410825"}',
plugin_request: '',
tool_name: '',
plugin: '',
mock_hit_info: '',
log_id: '2024071716121849EA05C1D7C3036CEE60',
message_title: '',
stream_plugin_running: '',
new_section_id: '',
remove_query_id: '',
execute_display_name: '',
task_type: '',
call_id: '',
},
mention_list: [],
sender_id: '7326859717089804315',
content_time: 1721203942447,
message_index: '384',
source: 0,
is_finish: false,
index: 1,
};
const imageMessage: any = {
role: 'assistant',
type: 'answer',
content: 'Hello',
content_obj: 'Hello',
content_type: MockContentType.Image,
message_id: '7392514629399953443',
reply_id: '7392514612706705443',
section_id: '7380292213265317928',
extra_info: {
local_message_id: '',
input_tokens: '1055',
output_tokens: '0',
token: '1055',
plugin_status: '',
time_cost: '',
workflow_tokens: '',
bot_state:
'{"bot_id":"7326859717089804315","agent_name":"意图识别","agent_id":"7386916906693410825","awaiting":"7386916906693410825"}',
plugin_request: '',
tool_name: '',
plugin: '',
mock_hit_info: '',
log_id: '2024071716121849EA05C1D7C3036CEE60',
message_title: '',
stream_plugin_running: '',
new_section_id: '',
remove_query_id: '',
execute_display_name: '',
task_type: '',
call_id: '',
},
mention_list: [],
sender_id: '7326859717089804315',
content_time: 1721203942447,
message_index: '384',
source: 0,
is_finish: false,
index: 1,
};
const answerMessage: any = {
role: 'assistant',
type: 'answer',
content: 'Hello',
content_obj: 'Hello',
content_type: MockContentType.Text,
message_id: '7392514629399953443',
reply_id: '7392514612706705443',
section_id: '7380292213265317928',
extra_info: {
local_message_id: '',
input_tokens: '1055',
output_tokens: '0',
token: '1055',
plugin_status: '',
time_cost: '',
workflow_tokens: '',
bot_state:
'{"bot_id":"7326859717089804315","agent_name":"意图识别","agent_id":"7386916906693410825","awaiting":"7386916906693410825"}',
plugin_request: '',
tool_name: '',
plugin: '',
mock_hit_info: '',
log_id: '2024071716121849EA05C1D7C3036CEE60',
message_title: '',
stream_plugin_running: '',
new_section_id: '',
remove_query_id: '',
execute_display_name: '',
task_type: '',
call_id: '',
},
mention_list: [],
sender_id: '7326859717089804315',
content_time: 1721203942447,
message_index: '384',
source: 0,
is_finish: false,
index: 1,
};
describe('get receive message box theme should be correct', () => {
it('get border theme', () => {
const theme = getReceiveMessageBoxTheme({
message: imageMessage,
bizTheme: 'home',
onParseReceiveMessageBoxTheme: undefined,
});
expect(theme).toBe('border');
});
it('get none theme', () => {
const noneTheme1 = getReceiveMessageBoxTheme({
message: cardMessage,
bizTheme: 'home',
onParseReceiveMessageBoxTheme: undefined,
});
const noneTheme2 = getReceiveMessageBoxTheme({
message: followUpMessage,
bizTheme: 'home',
onParseReceiveMessageBoxTheme: undefined,
});
expect(noneTheme1).toBe('none');
expect(noneTheme2).toBe('none');
});
it('enable uikit coze design', () => {
const whiteTheme2 = getReceiveMessageBoxTheme({
message: answerMessage,
bizTheme: 'home',
onParseReceiveMessageBoxTheme: undefined,
});
expect(whiteTheme2).toBe('whiteness');
const greyTheme = getReceiveMessageBoxTheme({
message: answerMessage,
bizTheme: 'debug',
onParseReceiveMessageBoxTheme: undefined,
});
expect(greyTheme).toBe('grey');
});
it('custom', () => {
const custom = getReceiveMessageBoxTheme({
message: answerMessage,
bizTheme: 'home',
onParseReceiveMessageBoxTheme: () => 'color-border-card',
});
expect(custom).toBe('color-border-card');
});
});

View File

@@ -0,0 +1,41 @@
/*
* 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 { getThinkingPlaceholderTheme } from '../../src/utils/components/get-thinking-placeholder-theme';
describe('test get thinking placeholder theme', () => {
it('not enable UIKit Coze Design', () => {
const theme = getThinkingPlaceholderTheme({
bizTheme: 'home',
});
expect(theme).toBe('whiteness');
});
it('enable UIKit Coze Design', () => {
const theme1 = getThinkingPlaceholderTheme({
bizTheme: 'home',
});
expect(theme1).toBe('whiteness');
const theme2 = getThinkingPlaceholderTheme({
bizTheme: 'debug',
});
const theme3 = getThinkingPlaceholderTheme({
bizTheme: 'store',
});
expect(theme2).toBe('grey');
expect(theme3).toBe('grey');
});
});

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 React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useBackgroundScroll } from '../../src/hooks/uikit/use-background-scroll';
vi.mock('../../src/hooks/public/use-show-bgackground', () => ({
useShowBackGround: () => true,
}));
describe('useBackgroundScroll', () => {
it('should update showGradient state correctly', () => {
const maskNode = React.createElement('div');
const { result } = renderHook(() =>
useBackgroundScroll({
hasHeaderNode: true,
styles: { a: 'ss' },
maskNode,
}),
);
expect(result.current.showGradient).toBe(true);
act(() => {
result.current.onReachTop();
});
expect(result.current.showGradient).toBe(false);
expect(result.current.beforeNode).toBe(null);
act(() => {
result.current.onLeaveTop();
});
expect(result.current.showGradient).toBe(true);
expect(result.current.beforeNode).toBe(maskNode);
});
it('should update beforeClassName correctly', () => {
const maskNode = React.createElement('div');
const { result } = renderHook(() =>
useBackgroundScroll({
hasHeaderNode: true,
maskNode,
styles: { 'scroll-mask': 'mask-class' },
}),
);
expect(result.current.beforeClassName).toBe('absolute left-0');
});
it('should update maskClass correctly', () => {
const maskNode = React.createElement('div');
const { result } = renderHook(() =>
useBackgroundScroll({
hasHeaderNode: true,
maskNode,
styles: { 'scroll-mask': 'mask-class' },
}),
);
expect(result.current.maskClassName).toBe('mask-class');
});
});

View File

@@ -0,0 +1,486 @@
/*
* 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 -- test */
vi.mock(
'../../chat-area-utils/src/parse-markdown/parse-markdown-to-text.ts',
() => ({
parseMarkdown: vi.fn(),
}),
);
import Mock from 'mockjs';
import { ContentType, Scene, type Message } from '@coze-common/chat-core';
import { describe, expect, it, vi } from 'vitest';
import { type MessagesStore, createMessagesStore } from '../src/store/messages';
import {
type MessageGroup,
type TextMessage,
type MessageIdStruct,
} from '../src/store/types';
import { createSectionIdStore } from '../src/store/section-id';
import { subscribeMessageToUpdateMessageGroup } from '../src/store/messages';
import { SystemLifeCycleService } from '../src/plugin/life-cycle';
import { renderHook } from '@testing-library/react-hooks';
import { useCreatePluginStoreSet } from '../src/hooks/context/use-create-plugin-store';
import { createPluginStore } from '../src/store/plugins';
vi.mock('@coze-common/chat-core', () => ({
ContentType: vi.fn(),
VerboseMsgType: vi.fn(),
Scene: {
CozeHome: 3,
},
messageSource: vi.fn(),
}));
vi.mock('@coze-arch/coze-design', () => ({
UIToast: {
error: vi.fn(),
},
}));
vi.mock('@coze-arch/bot-api/memory', () => ({
BotTableRWMode: {
LimitedReadWrite: 1,
ReadOnly: 2,
CozeHome: 3,
},
messageSource: vi.fn(),
}));
vi.mock('../src/utils/message', () => ({
getMessageUniqueKey: vi.fn(
(message: Message<ContentType>) =>
message?.message_id || message?.extra_info?.local_message_id,
),
findMessageById: vi.fn((messages: Message<ContentType>[], id: string) =>
messages.find(
m => m.message_id === id || m.extra_info?.local_message_id === id,
),
),
findMessageByIdStruct: vi.fn(
(messages: Message<ContentType>[], idStruct: MessageIdStruct) =>
messages.find(
m =>
m.message_id === idStruct.message_id ||
m.extra_info?.local_message_id ===
idStruct.extra_info?.local_message_id,
),
),
findMessageIndexById: vi.fn((messages: Message<ContentType>[], id: string) =>
messages.findIndex(
m => m.message_id === id || m.extra_info?.local_message_id === id,
),
),
findMessageIndexByIdStruct: vi.fn(
(messages: Message<ContentType>[], idStruct: MessageIdStruct) =>
messages.findIndex(
m =>
m.message_id === idStruct.message_id ||
m.extra_info?.local_message_id ===
idStruct.extra_info?.local_message_id,
),
),
getIsValidMessage: vi.fn(() => true),
getIsTriggerMessage: vi.fn(() => false),
}));
vi.stubGlobal('IS_DEV_MODE', false);
let useMessagesStore: MessagesStore;
const testMessageId = '321321';
const testMessageLocalId = '123123';
const testUserMessageLocalId = '6345634523';
const testReplyId = '666666666';
const testSectionId = '9999';
const testMessage: Message<ContentType.Text> = {
message_id: testMessageId,
extra_info: {
local_message_id: testMessageLocalId,
bot_state: '',
execute_display_name: '',
input_tokens: '',
output_tokens: '',
plugin: '',
plugin_request: '',
plugin_status: '',
time_cost: '',
token: '',
tool_name: '',
workflow_tokens: '',
},
role: 'assistant',
content: '',
content_obj: '',
type: 'answer',
is_finish: true,
broken_pos: 9999999,
reply_id: testReplyId,
section_id: '',
sender_id: '',
mention_list: [],
content_type: ContentType.Text,
};
const testUserMessage: TextMessage = {
role: 'user',
type: 'ack',
content: '',
content_type: ContentType.Text,
message_id: testReplyId,
reply_id: testReplyId,
section_id: '',
extra_info: {
local_message_id: testUserMessageLocalId,
input_tokens: '',
output_tokens: '',
token: '',
plugin_status: '',
time_cost: '',
workflow_tokens: '',
bot_state: '',
plugin_request: '',
tool_name: '',
plugin: '',
execute_display_name: '',
},
/** 正常、打断状态 拉消息列表时使用chat运行时没有这个字段 */
/** 打断位置 */
broken_pos: 9999999,
sender_id: '',
mention_list: [],
content_obj: '',
is_finish: true,
};
Mock.Random.extend({
random_type() {
const type = [
'answer',
'function_call',
'tool_response',
'follow_up',
'ack',
'question',
];
return this.pick?.(type);
},
random_content_type() {
const contentType = [
'text',
'link',
'music',
'video',
'card',
'image',
'file',
'tako',
'custom',
];
return this.pick?.(contentType);
},
random_message_status() {
const status = ['available', 'broken'];
return this.pick?.(status);
},
});
const randomTestMessageList: Message<ContentType>[] = Mock.mock({
'array|20-60': [
{
role: 'assistant',
type: '@RANDOM_TYPE',
content: '@string',
content_type: '@RANDOM_CONTENT_TYPE',
message_id: '@string',
reply_id: '9999999',
section_id: '8888888',
extra_info: {
local_message_id: '@string',
input_tokens: '@string',
output_tokens: '@string',
token: '@string',
plugin_status: '@string',
time_cost: '@string',
workflow_tokens: '@string',
bot_state: '@string',
plugin_request: '@string',
tool_name: '@string',
plugin: '@string',
},
/** 正常、打断状态 拉消息列表时使用chat运行时没有这个字段 */
/** 打断位置 */
broken_pos: 9999999,
sender_id: '77777',
mention_list: [],
content_obj: '',
is_finish: true,
},
],
}).array;
beforeEach(() => {
vi.useFakeTimers();
const sectionIdStore = createSectionIdStore('unit-test');
const newUseMessagesStore = createMessagesStore('unit-test');
const { result } = renderHook(() =>
useCreatePluginStoreSet({
mark: 'test',
scene: Scene.CozeHome,
}),
);
const pluginStore = createPluginStore('unit-test');
const lifeCycleService = new SystemLifeCycleService({
usePluginStore: pluginStore,
});
useMessagesStore = newUseMessagesStore;
sectionIdStore.getState().setLatestSectionId(testSectionId);
subscribeMessageToUpdateMessageGroup(
{
useMessagesStore: newUseMessagesStore,
useSectionIdStore: sectionIdStore,
},
{},
lifeCycleService,
);
});
describe('useMessagesStore', () => {
beforeEach(() => {
useMessagesStore = createMessagesStore('unit-test');
});
it('findMessage', () => {
const { findMessage } = useMessagesStore.getState();
const undefinedResult = findMessage('not-exist');
expect(undefinedResult).toBeUndefined();
useMessagesStore.getState().addMessage(testMessage);
const messageByLocalId = findMessage(testMessageLocalId);
expect(messageByLocalId).toStrictEqual(testMessage);
const messageById = findMessage(testMessageId);
expect(messageById).toStrictEqual(testMessage);
});
it('hasMessage', () => {
const { hasMessage } = useMessagesStore.getState();
const falsy = hasMessage('not-exist');
expect(falsy).toBeFalsy();
useMessagesStore.getState().addMessage(testMessage);
const localIdTruthy = hasMessage(testMessageLocalId);
expect(localIdTruthy).toBeTruthy();
const idTruthy = hasMessage(testMessageId);
expect(idTruthy).toBeTruthy();
});
it('updateMessage', () => {
const { updateMessage } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
const newMessage: Message<ContentType.Text> = {
...testMessage,
content: 'new content',
};
updateMessage(testMessageId, newMessage);
const message = useMessagesStore.getState().findMessage(testMessageId);
expect(message?.content).toBe('new content');
});
it('addMessage', () => {
const { addMessage } = useMessagesStore.getState();
addMessage(testMessage);
const message = useMessagesStore.getState().findMessage(testMessageId);
expect(message).toStrictEqual(testMessage);
});
it('addMessages', () => {
const { addMessages } = useMessagesStore.getState();
addMessages([testMessage]);
const message = useMessagesStore.getState().findMessage(testMessageId);
expect(message).toStrictEqual(testMessage);
});
it('deleteMessageByIdStruct', () => {
const { deleteMessageByIdStruct } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
const errorIdStruct: MessageIdStruct = {
message_id: 'not-exist',
extra_info: { local_message_id: 'not-exist' },
};
const correctIdStruct: MessageIdStruct = {
message_id: testMessageId,
extra_info: { local_message_id: testMessageLocalId },
};
deleteMessageByIdStruct(errorIdStruct);
expect(useMessagesStore.getState().messages).toStrictEqual([testMessage]);
deleteMessageByIdStruct(correctIdStruct);
expect(useMessagesStore.getState().messages).toStrictEqual([]);
});
it('deleteMessageById', () => {
const { deleteMessageById } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
const errorId = 'not-exist';
deleteMessageById(errorId);
expect(useMessagesStore.getState().messages).toStrictEqual([testMessage]);
deleteMessageById(testMessageId);
expect(useMessagesStore.getState().messages).toStrictEqual([]);
});
it('deleteMessageByIdList', () => {
const { deleteMessageByIdList } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
const errorIdList = ['not-exist'];
deleteMessageByIdList(errorIdList);
expect(useMessagesStore.getState().messages).toStrictEqual([testMessage]);
deleteMessageByIdList([testMessageId]);
expect(useMessagesStore.getState().messages).toStrictEqual([]);
});
it('setGroupMessageList', () => {
const { setGroupMessageList } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
const messageGroup = {
groupId: testReplyId,
memberSet: {
userMessageId: '',
llmAnswerMessageIdList: [testMessageId],
functionCallMessageIdList: [],
followUpMessageIdList: [],
},
sectionId: '',
showContextDivider: 'with-onboarding' as const,
showSuggestions: false,
isLatest: true,
};
setGroupMessageList([messageGroup]);
expect(useMessagesStore.getState().messageGroupList).toStrictEqual([
messageGroup,
]);
});
it('getMessageGroupById', () => {
const { getMessageGroupById } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
const messageGroup = {
groupId: testReplyId,
memberSet: {
userMessageId: '',
llmAnswerMessageIdList: [testMessageId],
functionCallMessageIdList: [],
followUpMessageIdList: [],
},
sectionId: '',
showContextDivider: 'with-onboarding' as const,
showSuggestions: false,
isLatest: true,
};
useMessagesStore.getState().setGroupMessageList([messageGroup]);
expect(getMessageGroupById(testReplyId)).toStrictEqual(messageGroup);
});
it('getMessageGroupByUserMessageId', () => {
const { getMessageGroupByUserMessageId } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
const messageGroup = {
groupId: testReplyId,
memberSet: {
userMessageId: testReplyId,
llmAnswerMessageIdList: [testMessageId],
functionCallMessageIdList: [],
followUpMessageIdList: [],
},
sectionId: '',
showContextDivider: 'with-onboarding' as const,
showSuggestions: false,
isLatest: true,
};
useMessagesStore.getState().setGroupMessageList([messageGroup]);
expect(getMessageGroupByUserMessageId(testReplyId)).toStrictEqual(
messageGroup,
);
});
it('isLastMessageGroup', () => {
const { isLastMessageGroup } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
const messageGroup = {
groupId: testReplyId,
memberSet: {
userMessageId: '',
llmAnswerMessageIdList: [testMessageId],
functionCallMessageIdList: [],
followUpMessageIdList: [],
},
sectionId: '',
showContextDivider: 'with-onboarding' as const,
showSuggestions: false,
isLatest: true,
};
useMessagesStore.getState().setGroupMessageList([messageGroup]);
expect(isLastMessageGroup(testReplyId)).toBeTruthy();
});
it('clearMessage', () => {
const { clearMessage } = useMessagesStore.getState();
useMessagesStore.getState().addMessage(testMessage);
clearMessage();
expect(useMessagesStore.getState().messages).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,153 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { LoadDirection } from '@coze-common/chat-core';
import {
type LoadMoreEnvConstructParams,
LoadMoreEnvTools,
type LoadMoreEnvValues,
} from '../../../src/service/load-more/load-more-env-tools';
import {
type AbortMessageInfo,
type HasMoreInfo,
MessageIndexHelper,
} from '../../../src/service/load-more/helper/message-index-helper';
vi.mock(
'../../../../chat-area-utils/src/parse-markdown/parse-markdown-to-text.ts',
() => ({
parseMarkdown: vi.fn(),
}),
);
vi.mock('@coze-common/chat-core', () => ({
LoadDirection: {
Unknown: 0,
Prev: 1,
Next: 2,
},
}));
// 固定一下参数
vi.mock('../../../src/constants/message', () => ({
MIN_MESSAGE_INDEX_DIFF_TO_ABORT_CURRENT: 10,
}));
describe('test getShouldAbortLoadedMessage', () => {
it('mock constant success', async () => {
expect(
await import('../../../src/constants/message').then(
x => x.MIN_MESSAGE_INDEX_DIFF_TO_ABORT_CURRENT,
),
).toBe(10);
});
const envValues = { maxLoadIndex: '0' } as LoadMoreEnvValues;
const envTools = new LoadMoreEnvTools({
readEnvValues: () => envValues,
} as LoadMoreEnvConstructParams);
const helper = new MessageIndexHelper(envTools);
it('run with nothing', () => {
expect(helper.getShouldAbortLoadedMessage([{}])).toMatchObject({
maxLoadIndex: '0',
abort: true,
} satisfies Partial<AbortMessageInfo>);
});
it('run with nothing while max loaded is not 0', () => {
envValues.maxLoadIndex = '5';
expect(helper.getShouldAbortLoadedMessage([{}])).toMatchObject({
maxLoadIndex: '5',
abort: true,
} satisfies Partial<AbortMessageInfo>);
});
it('should not abort', () => {
envValues.maxLoadIndex = '10';
expect(
helper.getShouldAbortLoadedMessage(
['11', '12', '20'].map(n => ({ message_index: n })),
),
).toMatchObject({
maxLoadIndex: '10',
abort: false,
} satisfies Partial<AbortMessageInfo>);
});
it('should abort due to big diff', () => {
envValues.maxLoadIndex = '10';
expect(
helper.getShouldAbortLoadedMessage(
['35', '30'].map(n => ({ message_index: n })),
),
).toMatchObject({
maxLoadIndex: '10',
abort: true,
indexInfo: 'start 30, end 35',
} satisfies Partial<AbortMessageInfo>);
});
it('perform well on intersection index', () => {
envValues.maxLoadIndex = '20';
expect(
helper.getShouldAbortLoadedMessage(
['5', '30'].map(n => ({ message_index: n })),
),
).toMatchObject({
maxLoadIndex: '20',
abort: false,
indexInfo: 'start 5, end 30',
} satisfies Partial<AbortMessageInfo>);
});
});
describe('test getHasMoreByDirection', () => {
const fakeEnvTools = {} as LoadMoreEnvTools;
const helper = new MessageIndexHelper(fakeEnvTools);
it('accept only safe direction hasMore', () => {
const prevCase = helper.getHasMoreByDirection(
{ hasmore: true, next_has_more: true },
LoadDirection.Prev as any,
);
expect(prevCase).toMatchObject({ prevHasMore: true } satisfies HasMoreInfo);
expect(prevCase).not.haveOwnProperty(
'nextHasMore' satisfies keyof HasMoreInfo,
);
const nextCase = helper.getHasMoreByDirection(
{ hasmore: true, next_has_more: true },
LoadDirection.Next as any,
);
expect(nextCase).toMatchObject({ nextHasMore: true } satisfies HasMoreInfo);
expect(nextCase).not.haveOwnProperty(
'prevHasMore' satisfies keyof HasMoreInfo,
);
});
it('not adjust both false value', () => {
expect(
helper.getHasMoreByDirection(
{ hasmore: false, next_has_more: false },
LoadDirection.Next as any,
),
).toMatchObject({
prevHasMore: false,
nextHasMore: false,
} satisfies HasMoreInfo);
});
});

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { type MessageIndexRange } from '../../../src/store/messages';
import { getMessageIndexRange } from '../../../src/store/action-implement/messages/get-message-index-range';
vi.mock(
'../../../../chat-area-utils/src/parse-markdown/parse-markdown-to-text.ts',
() => ({
parseMarkdown: vi.fn(),
}),
);
const getMsg = (index?: string) => ({ message_index: index });
describe('getMessageIndexRange', () => {
it('getMessageIndexRange of empty ', () => {
expect(getMessageIndexRange([])).toMatchObject({
withNoIndexed: false,
min: undefined,
max: undefined,
} satisfies MessageIndexRange);
});
it('getMessageIndexRange normally', () => {
expect(
getMessageIndexRange([getMsg(), getMsg('1'), getMsg('2')]),
).toMatchObject({
withNoIndexed: true,
min: '1',
max: '2',
});
});
it('getMessageIndexRange single', () => {
expect(getMessageIndexRange([getMsg('1')])).toMatchObject({
withNoIndexed: false,
min: '1',
max: '1',
});
});
it('reject index "0"', () => {
expect(getMessageIndexRange([getMsg('0')])).toMatchObject({
withNoIndexed: true,
min: undefined,
max: undefined,
});
});
it('handle index "0" "200" "1"', () => {
expect(
getMessageIndexRange([getMsg('0'), getMsg('200'), getMsg('1')]),
).toMatchObject({
withNoIndexed: true,
min: '1',
max: '200',
});
});
});

View File

@@ -0,0 +1,27 @@
/*
* 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 { computedStyleToNumber } from '../../../src/utils/dom/computed-style-to-number';
describe('computedStyleToNumber', () => {
it('correctly number', () => {
const res1 = computedStyleToNumber('123123124px');
const res2 = computedStyleToNumber('1231.4124123px');
expect(res1).toBe(123123124);
expect(res2).toBe(1231.4124123);
});
});

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 { findRespondRecord, type Responding } from '../../src/store/waiting';
vi.mock('@coze-common/chat-core', () => ({
ContentType: vi.fn(),
VerboseMsgType: {
/** 跳转节点 */
JUMP_TO: 'multi_agents_jump_to_agent',
/** 回溯节点 */
BACK_WORD: 'multi_agents_backwards',
/** 长期记忆节点 */
LONG_TERM_MEMORY: 'time_capsule_recall',
/** finish answer*/
GENERATE_ANSWER_FINISH: 'generate_answer_finish',
/** 流式插件调用状态 */
STREAM_PLUGIN_FINISH: 'stream_plugin_finish',
/** 知识库召回 */
KNOWLEDGE_RECALL: 'knowledge_recall',
/** 中断消息:目前只用于地理位置授权 */
INTERRUPT: 'interrupt',
/** hooks调用 */
HOOK_CALL: 'hook_call',
},
Scene: {
CozeHome: 3,
},
messageSource: vi.fn(),
}));
vi.mock('@coze-common/chat-uikit', () => ({
MentionList: vi.fn(),
}));
vi.mock('@coze-arch/bot-md-box-adapter', () => ({
MdBoxLazy: vi.fn(),
}));
vi.stubGlobal('IS_DEV_MODE', false);
describe('findRespondRecord', () => {
it('find', () => {
const response: Responding['response'] = [
{
id: '1',
type: 'ack',
index: 1,
streamPlugin: {
streamUuid: '1',
},
},
];
// @ts-expect-error -- aa
const r = findRespondRecord({ message_id: '1' }, response);
expect(r).toStrictEqual(response[0]);
});
});

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 { getIsGroupChatActive } from '../../src/utils/message-group/get-is-group-chat-active';
import { WaitingPhase } from '../../src/store/waiting';
vi.mock('@coze-common/chat-core', () => ({
ContentType: vi.fn(),
VerboseMsgType: vi.fn(),
}));
it('get active correct', () => {
const res1 = getIsGroupChatActive({
waiting: null,
sending: {
message_id: '123',
extra_info: {
local_message_id: '',
},
},
groupId: '123',
});
expect(res1).toBeTruthy();
const res2 = getIsGroupChatActive({
waiting: null,
sending: {
message_id: '',
extra_info: {
local_message_id: '123',
},
},
groupId: '321',
});
expect(res2).toBeFalsy();
const res3 = getIsGroupChatActive({
waiting: {
replyId: '999',
phase: WaitingPhase.Suggestion,
},
sending: null,
groupId: '999',
});
expect(res3).toBeFalsy();
const res4 = getIsGroupChatActive({
waiting: {
replyId: '123123',
phase: WaitingPhase.Formal,
},
sending: null,
groupId: '999',
});
expect(res4).toBeFalsy();
const res5 = getIsGroupChatActive({
waiting: {
replyId: '999',
phase: WaitingPhase.Formal,
},
sending: null,
groupId: '999',
});
expect(res5).toBeTruthy();
});

View File

@@ -0,0 +1,97 @@
/*
* 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 { ContentType, type Message } from '@coze-common/chat-core';
import { getResponse } from '../../src/store/waiting';
vi.mock('@coze-common/chat-core', () => ({
ContentType: vi.fn(),
VerboseMsgType: vi.fn(),
Scene: {
CozeHome: 3,
},
messageSource: vi.fn(),
}));
const llmMessage: Message<ContentType> = {
role: 'assistant',
type: 'answer',
content: 'Hello',
content_obj: 'Hello',
content_type: ContentType.Text,
message_id: '7392514629399953443',
reply_id: '7392514612706705443',
section_id: '7380292213265317928',
extra_info: {
local_message_id: '',
input_tokens: '1055',
output_tokens: '0',
token: '1055',
plugin_status: '',
time_cost: '',
workflow_tokens: '',
bot_state:
'{"bot_id":"7326859717089804315","agent_name":"意图识别","agent_id":"7386916906693410825","awaiting":"7386916906693410825"}',
plugin_request: '',
tool_name: '',
plugin: '',
mock_hit_info: '',
log_id: '2024071716121849EA05C1D7C3036CEE60',
message_title: '',
stream_plugin_running: '',
new_section_id: '',
remove_query_id: '',
execute_display_name: '',
task_type: '',
call_id: '',
},
mention_list: [],
sender_id: '7326859717089804315',
content_time: 1721203942447,
message_index: '384',
source: 0,
is_finish: false,
index: 1,
};
describe('get responding should be correct', () => {
it('get response', () => {
const expectedResponse = {
index: llmMessage.index,
type: llmMessage.type,
id: llmMessage.message_id,
streamPlugin: llmMessage.extra_info.stream_plugin_running
? {
streamUuid: llmMessage.extra_info.stream_plugin_running,
}
: null,
};
expect(getResponse(llmMessage)).toStrictEqual(expectedResponse);
});
it('get response null', () => {
const expectedResponse = {
index: llmMessage.index,
type: llmMessage.type,
id: llmMessage.message_id,
streamPlugin: null,
};
expect(getResponse(llmMessage)).toStrictEqual(expectedResponse);
});
});

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isFileCountExceedsLimit } from '../../src/utils/is-file-count-exceeds-limit';
describe('is-file-count-exceeds-limit', () => {
test('expect to be true', () => {
const res = isFileCountExceedsLimit({
fileCount: 5,
fileLimit: 6,
existingFileCount: 3,
});
expect(res).toBeTruthy();
});
test('expect to be false', () => {
const res = isFileCountExceedsLimit({
fileCount: 5,
fileLimit: 6,
existingFileCount: 1,
});
expect(res).toBeFalsy();
});
});

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 { isFallbackErrorMessage } from '../../src/utils/message';
vi.mock('@coze-common/chat-core', () => ({
ContentType: vi.fn(),
VerboseMsgType: vi.fn(),
Scene: {
CozeHome: 3,
},
messageSource: vi.fn(),
}));
vi.mock('@coze-arch/coze-design', () => ({
UIToast: {
error: vi.fn(),
},
Avatar: vi.fn(),
}));
describe('isFallbackErrorMessage', () => {
it('should return true for fallback error messages', () => {
const message = {
message_id: '7486354676263567404_error',
};
expect(isFallbackErrorMessage(message)).toBe(true);
});
it('should return false for fallback error messages', () => {
const message = {
message_id: '74863546762635asdasv',
};
expect(isFallbackErrorMessage(message)).toBe(false);
});
});

View File

@@ -0,0 +1,70 @@
/*
* 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 { proxyFreeze } from '../../src/utils/proxy-freeze';
vi.stubGlobal('IS_DEV_MODE', true);
describe('proxyFreeze', () => {
it('should return the same object for non-objects', () => {
const nonObject = 42;
// @ts-expect-error -- 测试使用
expect(proxyFreeze(nonObject)).toBe(nonObject);
});
it('should return a proxy for an object', () => {
const obj = { a: 1 };
const proxyObj = proxyFreeze(obj);
expect(proxyObj).not.toBe(obj);
expect(typeof proxyObj).toBe('object');
});
it('should prevent modifications to the proxied object', () => {
const obj = { a: 1 };
const proxyObj = proxyFreeze(obj) as { a: number };
expect(() => {
proxyObj.a = 2;
}).toThrow();
});
it('should allow reading properties from the proxied object', () => {
const obj = { a: 1 };
const proxyObj = proxyFreeze(obj) as { a: number };
expect(proxyObj.a).toBe(1);
});
it('should cache and return the same proxy for the same object', () => {
const obj = { a: 1 };
const proxyObj1 = proxyFreeze(obj);
const proxyObj2 = proxyFreeze(obj);
expect(proxyObj1).toBe(proxyObj2);
});
it('should not re-freeze already frozen objects', () => {
const obj = { a: 1 };
const proxyObj = proxyFreeze(obj);
const proxyObj2 = proxyFreeze(proxyObj);
expect(proxyObj).toBe(proxyObj2);
});
it('should recursively freeze nested objects', () => {
const obj = { a: { b: 1 } };
const proxyObj = proxyFreeze(obj) as { a: { b: number } };
expect(() => {
proxyObj.a.b = 2;
}).toThrow();
});
});

View File

@@ -0,0 +1,499 @@
/*
* 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 { ContentType, type Message } from '@coze-common/chat-core';
import {
type WaitingStore,
createWaitingStore,
type Responding,
getResponse,
type Waiting,
WaitingPhase,
} from '../src/store/waiting';
vi.mock('@coze-common/chat-core', () => ({
ContentType: vi.fn(),
VerboseMsgType: {
/** 跳转节点 */
JUMP_TO: 'multi_agents_jump_to_agent',
/** 回溯节点 */
BACK_WORD: 'multi_agents_backwards',
/** 长期记忆节点 */
LONG_TERM_MEMORY: 'time_capsule_recall',
/** finish answer*/
GENERATE_ANSWER_FINISH: 'generate_answer_finish',
/** 流式插件调用状态 */
STREAM_PLUGIN_FINISH: 'stream_plugin_finish',
/** 知识库召回 */
KNOWLEDGE_RECALL: 'knowledge_recall',
/** 中断消息:目前只用于地理位置授权 */
INTERRUPT: 'interrupt',
/** hooks调用 */
HOOK_CALL: 'hook_call',
},
Scene: {
CozeHome: 3,
},
messageSource: vi.fn(),
}));
vi.mock('@coze-common/chat-uikit', () => ({
MentionList: vi.fn(),
}));
vi.mock('@coze-arch/bot-md-box-adapter', () => ({
MdBoxLazy: vi.fn(),
}));
vi.stubGlobal('IS_DEV_MODE', false);
let useWaitingStore: WaitingStore;
const testSectionId = '7380292213265317928';
const sentMessage: Message<ContentType> = {
role: 'user',
type: 'ack',
content: 'hello',
content_obj: 'hello',
content_type: ContentType.Text,
message_id: '7392514612706705443',
reply_id: '7392514612706705443',
section_id: testSectionId,
extra_info: {
local_message_id: 'X_HfUyEeTE_sjbiyk2W8v',
input_tokens: '',
output_tokens: '',
token: '',
plugin_status: '',
time_cost: '',
workflow_tokens: '',
bot_state: '',
plugin_request: '',
tool_name: '',
plugin: '',
mock_hit_info: '',
log_id: '2024071716121849EA05C1D7C3036CEE60',
message_title: '',
stream_plugin_running: '',
new_section_id: '',
remove_query_id: '',
execute_display_name: '',
task_type: '',
call_id: '',
},
mention_list: [],
sender_id: '7326859717089804315',
content_time: 1721203939098,
message_index: '383',
source: 0,
is_finish: false,
index: 0,
};
const llmMessage: Message<ContentType> = {
role: 'assistant',
type: 'answer',
content: 'Hello',
content_obj: 'Hello',
content_type: ContentType.Text,
message_id: '7392514629399953443',
reply_id: '7392514612706705443',
section_id: '7380292213265317928',
extra_info: {
local_message_id: '',
input_tokens: '1055',
output_tokens: '0',
token: '1055',
plugin_status: '',
time_cost: '',
workflow_tokens: '',
bot_state:
'{"bot_id":"7326859717089804315","agent_name":"意图识别","agent_id":"7386916906693410825","awaiting":"7386916906693410825"}',
plugin_request: '',
tool_name: '',
plugin: '',
mock_hit_info: '',
log_id: '2024071716121849EA05C1D7C3036CEE60',
message_title: '',
stream_plugin_running: '',
new_section_id: '',
remove_query_id: '',
execute_display_name: '',
task_type: '',
call_id: '',
},
mention_list: [],
sender_id: '7326859717089804315',
content_time: 1721203942447,
message_index: '384',
source: 0,
is_finish: false,
index: 1,
};
beforeEach(() => {
vi.useFakeTimers();
const newWaitingStore = createWaitingStore('unit-test');
useWaitingStore = newWaitingStore;
});
describe('normal text message', () => {
it('sending should be append into waiting store', () => {
const { startSending } = useWaitingStore.getState();
startSending(sentMessage);
// 检测 Sending 是否存在
const { sending } = useWaitingStore.getState();
expect(sending).toStrictEqual(sentMessage);
});
it('sending should be clear after stop sending', () => {
const { startSending, clearSending } = useWaitingStore.getState();
startSending(sentMessage);
// 检测 Sending 是否存在
const { sending } = useWaitingStore.getState();
expect(sending).toStrictEqual(sentMessage);
// 清除 Sending
clearSending();
const { sending: afterClearSending } = useWaitingStore.getState();
expect(afterClearSending).toBeNull();
});
it('waiting should be append into waiting store', () => {
const { startWaiting } = useWaitingStore.getState();
startWaiting(sentMessage);
const { waiting } = useWaitingStore.getState();
const expectedWaiting: Waiting = {
replyId: sentMessage.reply_id,
questionLocalMessageId: sentMessage.extra_info.local_message_id,
phase: WaitingPhase.Formal,
};
expect(waiting).toStrictEqual(expectedWaiting);
});
it('update responding is correct', () => {
// 检测 responding 是否存在
const { updateResponding } = useWaitingStore.getState();
updateResponding(llmMessage);
const expectedResponding: Responding = {
replyId: llmMessage.reply_id,
response: [getResponse(llmMessage)],
};
const { responding } = useWaitingStore.getState();
expect(responding).toStrictEqual(expectedResponding);
});
it('no responding , at verbose message is all finished', () => {
const { updateResponding } = useWaitingStore.getState();
const allFinishedMessage = {
...llmMessage,
type: 'verbose',
content: JSON.stringify({
msg_type: 'generate_answer_finish',
}),
is_finish: true,
};
// @ts-expect-error -- 单测
updateResponding(allFinishedMessage);
const { responding } = useWaitingStore.getState();
expect(responding).toBeNull();
});
it('not responding, only has tool_response', () => {
const { updateResponding } = useWaitingStore.getState();
const toolResponseMessage = {
...llmMessage,
type: 'tool_response',
};
// @ts-expect-error -- 单测
updateResponding(toolResponseMessage);
const { responding } = useWaitingStore.getState();
expect(responding).toBeNull();
});
it('has responding, but not match reply_id', () => {
const { updateResponding } = useWaitingStore.getState();
updateResponding(llmMessage);
// 修改 reply_id 造成冲突假象
const modifiedMessage = {
...llmMessage,
reply_id: '嘤嘤嘤',
};
updateResponding(modifiedMessage);
const { responding } = useWaitingStore.getState();
expect(responding).toStrictEqual({
replyId: llmMessage.reply_id,
response: [getResponse(llmMessage)],
});
});
it('has responding, is all finish', () => {
const { updateResponding } = useWaitingStore.getState();
const verboseMessage = {
...llmMessage,
type: 'verbose',
content: JSON.stringify({
msg_type: 'aaa',
}),
};
// @ts-expect-error -- 测试
updateResponding(verboseMessage);
const finishedMessage = {
...llmMessage,
type: 'verbose',
content: JSON.stringify({
msg_type: 'generate_answer_finish',
}),
};
// @ts-expect-error -- 测试
updateResponding(finishedMessage);
const { responding } = useWaitingStore.getState();
expect(responding).toBeNull();
});
it('has responding, normal message finished', () => {
const { updateResponding } = useWaitingStore.getState();
updateResponding(llmMessage);
const modifiedMessage = {
...llmMessage,
is_finish: true,
};
updateResponding(modifiedMessage);
const { responding } = useWaitingStore.getState();
expect(responding).toStrictEqual({
replyId: llmMessage.reply_id,
response: [],
});
});
it('function call', () => {
const { updateResponding } = useWaitingStore.getState();
const functionCallMessage = {
...llmMessage,
type: 'function_call',
};
// @ts-expect-error -- 测试
updateResponding(functionCallMessage);
const respondingMessage = {
...llmMessage,
type: 'tool_response',
index: 2,
};
// @ts-expect-error -- 测试
updateResponding(respondingMessage);
const { responding } = useWaitingStore.getState();
expect(responding).toStrictEqual({
replyId: llmMessage.reply_id,
response: [],
});
});
it('clearAllUnsettledUnconditionally', () => {
const {
clearAllUnsettledUnconditionally,
updateResponding,
updateWaiting,
startSending,
} = useWaitingStore.getState();
updateResponding(llmMessage);
updateWaiting(sentMessage);
startSending(sentMessage);
clearAllUnsettledUnconditionally();
const { waiting, sending, responding } = useWaitingStore.getState();
expect(waiting).toBeNull();
expect(responding).toBeNull();
expect(sending).toBeNull();
});
it('clearUnsettledByReplyId', () => {
const { clearUnsettledByReplyId, updateResponding, updateWaiting } =
useWaitingStore.getState();
updateResponding(llmMessage);
updateWaiting(sentMessage);
clearUnsettledByReplyId(llmMessage.reply_id);
const { waiting, responding } = useWaitingStore.getState();
expect(waiting).toBeNull();
expect(responding).toBeNull();
});
it('clearWaitingStore', () => {
const { clearWaitingStore, updateResponding, updateWaiting, startSending } =
useWaitingStore.getState();
updateResponding(llmMessage);
updateWaiting(sentMessage);
startSending(sentMessage);
clearWaitingStore();
const { waiting, sending, responding } = useWaitingStore.getState();
expect(waiting).toBeNull();
expect(responding).toBeNull();
expect(sending).toBeNull();
});
it('function call index not correct', () => {
const { updateResponding } = useWaitingStore.getState();
const functionCallMessage = {
...llmMessage,
type: 'function_call',
};
// @ts-expect-error -- 测试
updateResponding(functionCallMessage);
const respondingMessage = {
...llmMessage,
type: 'tool_response',
index: -1,
};
// @ts-expect-error -- 测试
updateResponding(respondingMessage);
const { responding } = useWaitingStore.getState();
expect(responding).toStrictEqual({
replyId: llmMessage.reply_id,
// @ts-expect-error -- 测试
response: [getResponse(functionCallMessage)],
});
});
it('function call index not a number', () => {
const { updateResponding } = useWaitingStore.getState();
const functionCallMessage = {
...llmMessage,
type: 'function_call',
};
// @ts-expect-error -- 测试
updateResponding(functionCallMessage);
const respondingMessage = {
...llmMessage,
type: 'tool_response',
index: 'hhhh',
};
// @ts-expect-error -- 测试
updateResponding(respondingMessage);
const { responding } = useWaitingStore.getState();
expect(responding).toStrictEqual({
replyId: llmMessage.reply_id,
// @ts-expect-error -- 测试
response: [getResponse(functionCallMessage)],
});
});
it('function call double', () => {
const { updateResponding } = useWaitingStore.getState();
const functionCallMessage = {
...llmMessage,
type: 'function_call',
};
// @ts-expect-error -- 测试
updateResponding(functionCallMessage);
const functionCallMessage2 = {
...llmMessage,
message_id: '1234',
extra_info: {
...llmMessage.extra_info,
local_message_id: '9999',
},
type: 'function_call',
};
// @ts-expect-error -- 测试
updateResponding(functionCallMessage2);
const { responding } = useWaitingStore.getState();
expect(responding).toStrictEqual({
replyId: llmMessage.reply_id,
response: [
// @ts-expect-error -- 测试
getResponse(functionCallMessage),
// @ts-expect-error -- 测试
getResponse(functionCallMessage2),
],
});
});
});

View File

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

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