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,19 @@
.close {
width: 20px;
height: 20px;
font-size: 12px;
:global {
.semi-button.semi-button-with-icon-only {
width: 20px;
height: 20px;
}
svg {
width: 14px;
height: 14px;
@apply coz-fg-secondary;
}
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, type FC } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { PluginName, useWriteablePlugin } from '@coze-common/chat-area';
import { IconCozCross, IconCozQuotation } from '@coze-arch/coze-design/icons';
import { IconButton } from '@coze-arch/coze-design';
import { QuoteNode } from '../quote-node';
import { type GrabPluginBizContext } from '../../types/plugin-biz-context';
type IProps = Record<string, unknown>;
export const QuoteInputAddonTop: FC<IProps> = () => {
const { pluginBizContext, chatAreaPluginContext } =
useWriteablePlugin<GrabPluginBizContext>(PluginName.MessageGrab);
const { useChatInputLayout } = chatAreaPluginContext.readonlyHook.input;
const { useQuoteStore } = pluginBizContext.storeSet;
const { quoteVisible, quoteContent, updateQuoteVisible, updateQuoteContent } =
useQuoteStore(
useShallow(state => ({
quoteVisible: state.quoteVisible,
quoteContent: state.quoteContent,
updateQuoteVisible: state.updateQuoteVisible,
updateQuoteContent: state.updateQuoteContent,
})),
);
const handleClose = () => {
updateQuoteContent(null);
updateQuoteVisible(false);
};
const { layoutContainerRef } = useChatInputLayout();
useEffect(() => {
if (!layoutContainerRef?.current) {
return;
}
const handleStopPropagation = (e: PointerEvent) => e.stopPropagation();
layoutContainerRef.current.addEventListener(
'pointerup',
handleStopPropagation,
);
return () => {
layoutContainerRef.current?.removeEventListener(
'pointerup',
handleStopPropagation,
);
};
}, [layoutContainerRef?.current]);
if (!quoteContent || !quoteVisible) {
return null;
}
return (
<div className="w-full h-[32px] flex items-center px-[16px] coz-mg-primary">
<IconCozQuotation className="coz-fg-secondary mr-[8px] w-[12px] h-[12px]" />
<div className="flex flex-row items-center flex-1">
<div className="coz-fg-secondary flex-1 min-w-0 w-0 truncate text-[12px] leading-[16px]">
<QuoteNode nodeList={quoteContent} theme="black" />
</div>
<IconButton
icon={<IconCozCross className="w-[14px] h-[14px]" />}
onClick={handleClose}
color="secondary"
size="small"
className="!rounded-[4px]"
wrapperClass="flex item-center justify-center"
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,4 @@
.container-box-shadow {
z-index: 1;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 8%), 0 4px 12px 0 rgba(0, 0, 0, 4%);
}

View File

@@ -0,0 +1,125 @@
/*
* 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,
forwardRef,
type CSSProperties,
useEffect,
useImperativeHandle,
useRef,
type MutableRefObject,
useState,
} from 'react';
import classNames from 'classnames';
import { useEventCallback } from '@coze-common/chat-hooks';
import { PluginName, useWriteablePlugin } from '@coze-common/chat-area';
import {
EventNames,
type GrabPluginBizContext,
} from '../../types/plugin-biz-context';
import s from './index.module.less';
interface MenuListProps {
style?: CSSProperties;
children: ReactNode;
className?: string;
onScroll?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
}
export interface MenuListRef {
getRef: () => MutableRefObject<HTMLDivElement | null>;
refreshOpacity: () => void;
}
const GRADIENT_RANGE = 100;
export const MenuList = forwardRef<MenuListRef, MenuListProps>((props, ref) => {
const plugin = useWriteablePlugin<GrabPluginBizContext>(
PluginName.MessageGrab,
);
const { useGetScrollView } =
plugin.chatAreaPluginContext.readonlyHook.scrollView;
const { on, off } = plugin.pluginBizContext.eventCenter;
const { style, children, onScroll, onMouseEnter, onMouseLeave, className } =
props;
const getScrollView = useGetScrollView();
const menuRef = useRef<HTMLDivElement>(null);
const [opacity, setOpacity] = useState(1);
useImperativeHandle(ref, () => ({
getRef: () => menuRef,
refreshOpacity,
}));
const refreshOpacity = useEventCallback(() => {
onScroll?.();
const localRect = menuRef.current?.getBoundingClientRect();
const { rect: scrollRect } = getScrollView().getOriginScrollInfo();
if (!scrollRect || !localRect) {
return;
}
if (localRect.y - GRADIENT_RANGE <= scrollRect.top) {
const _opacity = (localRect.y - scrollRect.top) / GRADIENT_RANGE;
setOpacity(_opacity < 0 ? 0 : _opacity);
} else if (localRect.y + GRADIENT_RANGE >= scrollRect.bottom) {
const _opacity = (scrollRect.bottom - localRect.y) / GRADIENT_RANGE;
setOpacity(_opacity < 0 ? 0 : _opacity);
} else {
setOpacity(1);
}
});
useEffect(() => {
on(EventNames.OnMessageUpdate, refreshOpacity);
return () => {
off(EventNames.OnMessageUpdate, refreshOpacity);
};
}, []);
return (
<div
className={classNames(
'fixed p-[2px] coz-bg-max coz-stroke-primary border-[0.5px] rounded-[8px] overflow-hidden grid grid-flow-col gap-[2px] h-[28px]',
s['container-box-shadow'],
className,
)}
style={{
...style,
opacity,
}}
ref={menuRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</div>
);
});

View File

@@ -0,0 +1,49 @@
/*
* 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 { useShallow } from 'zustand/react/shallow';
import {
PluginName,
useWriteablePlugin,
type CustomTextMessageInnerTopSlot,
} from '@coze-common/chat-area';
import { type GrabPluginBizContext } from '../../types/plugin-biz-context';
import { RemoteQuoteInnerTopSlot } from './remote-slot';
import { LocalQuoteInnerTopSlot } from './local-slot';
export const QuoteMessageInnerTopSlot: CustomTextMessageInnerTopSlot = ({
message,
}) => {
const localMessageId = message.extra_info.local_message_id;
const plugin = useWriteablePlugin<GrabPluginBizContext>(
PluginName.MessageGrab,
);
const { useQuoteStore } = plugin.pluginBizContext.storeSet;
// 优先用本地映射的
const hasLocal = useQuoteStore(
useShallow(state => !!state.quoteContentMap[localMessageId]),
);
if (hasLocal) {
return <LocalQuoteInnerTopSlot message={message} />;
}
return <RemoteQuoteInnerTopSlot message={message} />;
};

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useShallow } from 'zustand/react/shallow';
import {
PluginName,
useWriteablePlugin,
type CustomTextMessageInnerTopSlot,
} from '@coze-common/chat-area';
import { QuoteNode } from '../quote-node';
import { type GrabPluginBizContext } from '../../types/plugin-biz-context';
import { QuoteTopUI } from './quote-top-ui';
export const LocalQuoteInnerTopSlot: CustomTextMessageInnerTopSlot = ({
message,
}) => {
const localMessageId = message.extra_info.local_message_id;
const plugin = useWriteablePlugin<GrabPluginBizContext>(
PluginName.MessageGrab,
);
const { useQuoteStore } = plugin.pluginBizContext.storeSet;
// 优先用本地映射的
const localNodeList = useQuoteStore(
useShallow(state => state.quoteContentMap[localMessageId]),
);
if (localNodeList) {
return (
<QuoteTopUI>
<QuoteNode nodeList={localNodeList} theme="white" />
</QuoteTopUI>
);
}
return null;
};

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, type PropsWithChildren } from 'react';
import classNames from 'classnames';
import { useShowBackGround } from '@coze-common/chat-area';
import { IconCozQuotation } from '@coze-arch/coze-design/icons';
import { typeSafeQuoteNodeColorVariants } from '../variants';
export const QuoteTopUI: FC<PropsWithChildren> = ({ children }) => {
const showBackground = useShowBackGround();
return (
<div
className={classNames(
['h-auto', 'py-4px'],
'flex flex-row items-center select-none w-m-0',
)}
>
<IconCozQuotation
className={classNames(
typeSafeQuoteNodeColorVariants({ showBackground }),
'mr-[8px] shrink-0 w-[12px] h-[12px]',
)}
/>
<div
className={classNames('flex-1 min-w-0 truncate text-[12px]', [
'leading-[16px]',
typeSafeQuoteNodeColorVariants({ showBackground }),
])}
>
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { parseMarkdownToGrabNode } from '@coze-common/text-grab';
import {
ContentType,
type CustomTextMessageInnerTopSlot,
} from '@coze-common/chat-area';
import { QuoteNode } from '../quote-node';
import { getReferFromMessage } from '../../utils/get-refer-from-message';
import { QuoteTopUI } from './quote-top-ui';
export const RemoteQuoteInnerTopSlot: CustomTextMessageInnerTopSlot = ({
message,
}) => {
// 本地没有用服务端下发的
const refer = getReferFromMessage(message);
if (!refer) {
return null;
}
if (refer.type === ContentType.Image) {
return (
<QuoteTopUI>
<img className="w-[24px] h-[24px] rounded-[4px]" src={refer.url} />
</QuoteTopUI>
);
}
// 尝试解析ast
const nodeList = parseMarkdownToGrabNode(refer.text);
return (
<QuoteTopUI>
<QuoteNode nodeList={nodeList} theme="white" />
</QuoteTopUI>
);
};

View File

@@ -0,0 +1,181 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { defer } from 'lodash-es';
import classNames from 'classnames';
import { isTouchDevice } from '@coze-common/text-grab';
import {
CONTENT_ATTRIBUTE_NAME,
GrabElementType,
type GrabNode,
} from '@coze-common/text-grab';
import {
PluginName,
useWriteablePlugin,
type MessageListFloatSlot,
} from '@coze-common/chat-area';
import { QuoteButton } from '../quote-button';
import { MenuList } from '../menu-list';
import { getMessage } from '../../utils/get-message';
import {
EventNames,
type GrabPluginBizContext,
} from '../../types/plugin-biz-context';
import { useHideQuote } from '../../hooks/use-hide-quote';
import { useFloatMenuListener } from '../../hooks/use-float-menu-listener';
import { useAutoGetMaxPosition } from '../../hooks/use-auto-get-max-position';
export const FloatMenu: MessageListFloatSlot = () => {
const plugin = useWriteablePlugin<GrabPluginBizContext>(
PluginName.MessageGrab,
);
const { chatAreaPluginContext, pluginBizContext } = plugin;
const { eventCenter } = pluginBizContext;
const { usePreferenceStore } = pluginBizContext.storeSet;
const enableGrab = usePreferenceStore(state => state.enableGrab);
const { on, off } = eventCenter;
const { useQuoteStore } = pluginBizContext.storeSet;
const { updateQuoteContent, updateQuoteVisible } = useQuoteStore(
useShallow(state => ({
updateQuoteVisible: state.updateQuoteVisible,
updateQuoteContent: state.updateQuoteContent,
})),
);
const {
handleMenuMouseEnter,
handleMenuMouseLeave,
targetElement,
targetInfo,
position,
floatMenuRef,
visible,
setVisible,
computePosition,
isScrolling,
} = useFloatMenuListener({
plugin,
});
const { targetRef: messageRef, forceHidden } = useHideQuote<HTMLElement>({
containerRef: targetElement,
});
const { maxPositionX } = useAutoGetMaxPosition({
messageRef,
position,
floatMenuRef,
});
const isGrabMenuVisible = plugin.pluginBizContext.storeSet.useSelectionStore(
state => state.isFloatMenuVisible && !!state.floatMenuPosition,
);
const handleQuoteClick = () => {
const { source = '', type = 'link', text = '' } = targetInfo.current ?? {};
const target = messageRef.current;
const messageId = target?.attributes.getNamedItem(
CONTENT_ATTRIBUTE_NAME,
)?.value;
if (!messageId) {
return;
}
const message = getMessage({
messageId,
chatAreaPluginContext,
});
const node: GrabNode =
type === 'image'
? {
type: GrabElementType.IMAGE,
src: source,
children: [
{
text: source,
},
],
}
: {
type: GrabElementType.LINK,
url: source,
children: [
{
text,
},
],
};
updateQuoteContent([node]);
updateQuoteVisible(true);
pluginBizContext.eventCallbacks.onQuote?.({
botId: message?.sender_id ?? '',
source: message?.source,
});
};
useEffect(() => {
on(EventNames.OnMessageUpdate, computePosition);
on(EventNames.OnViewScroll, computePosition);
return () => {
off(EventNames.OnMessageUpdate, computePosition);
off(EventNames.OnViewScroll, computePosition);
};
}, []);
if (!enableGrab || isTouchDevice()) {
return null;
}
const top = position?.y;
const left =
(position?.x ?? 0) > (maxPositionX ?? 0) ? maxPositionX : position?.x;
return (
<>
{
<MenuList
ref={floatMenuRef}
className={classNames({
hidden: !visible || forceHidden || isGrabMenuVisible || isScrolling,
})}
style={{
top,
left,
}}
onMouseEnter={handleMenuMouseEnter}
onMouseLeave={handleMenuMouseLeave}
>
<QuoteButton
onClick={handleQuoteClick}
onClose={() => {
defer(() => setVisible(false));
}}
/>
</MenuList>
}
</>
);
};

View File

@@ -0,0 +1,180 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { defer } from 'lodash-es';
import classNames from 'classnames';
import { type GrabPosition, type SelectionData } from '@coze-common/text-grab';
import { useGrab } from '@coze-common/text-grab';
import { NO_MESSAGE_ID_MARK } from '@coze-common/chat-uikit';
import {
PluginName,
useWriteablePlugin,
type MessageListFloatSlot,
} from '@coze-common/chat-area';
import { QuoteButton } from '../quote-button';
import { MenuList, type MenuListRef } from '../menu-list';
import {
EventNames,
type GrabPluginBizContext,
} from '../../types/plugin-biz-context';
import { FILTER_MESSAGE_SOURCE } from '../../constants/filter-message';
export const GrabMenu: MessageListFloatSlot = ({ contentRef }) => {
// 获取插件实例
const plugin = useWriteablePlugin<GrabPluginBizContext>(
PluginName.MessageGrab,
);
// 获取插件的业务上下文信息
const { pluginBizContext } = plugin;
const { eventCenter, storeSet } = pluginBizContext;
const { usePreferenceStore, useSelectionStore } = storeSet;
// 事件中心监听函数
const { on, off } = eventCenter;
/**
* Float Menu 的 Ref
*/
const floatMenuRef = useRef<MenuListRef>(null);
const { isFloatMenuVisible, position, updateFloatMenuPosition } =
useSelectionStore(
useShallow(state => ({
isFloatMenuVisible: state.isFloatMenuVisible,
position: state.floatMenuPosition,
updateFloatMenuPosition: state.updateFloatMenuPosition,
})),
);
/**
* 是否启用 Grab 插件
*/
const enableGrab = usePreferenceStore(state => state.enableGrab);
/**
* 处理位置信息的回调
*/
const handlePositionChange = (_position: GrabPosition | null) => {
// 更新浮动菜单在 Store 中的数据
updateFloatMenuPosition(_position);
// 清空栈后调用刷新的逻辑 (因为 floatMenu 在 hidden 状态下 通过 ref 拿到的 rect 信息是空,所以需要延迟一会儿获取)
defer(() => floatMenuRef.current?.refreshOpacity());
};
/**
* 处理选区的变化
*/
const handleSelectChange = (selectionData: SelectionData | null) => {
const {
updateHumanizedContentText,
updateNormalizeSelectionNodeList,
updateOriginContentText,
updateSelectionData,
updateIsFloatMenuVisible,
} = plugin.pluginBizContext.storeSet.useSelectionStore.getState();
/**
* 过滤特殊类型的消息
* 目前只有通过消息来源进行判断 messageSource
* 1. 通知类型 (禁止选择)
*/
if (
selectionData &&
FILTER_MESSAGE_SOURCE.includes(selectionData.messageSource)
) {
return;
}
// 更新选区数据
updateSelectionData(selectionData);
// 更新展示浮动菜单的状态
updateIsFloatMenuVisible(!!selectionData);
// 拿取 MessageId
const messageId = selectionData?.ancestorAttributeValue;
// 判断是否是特殊消息
const isSpecialMessage = messageId === NO_MESSAGE_ID_MARK;
// 如果拿不到消息 ID就证明选区不在消息哪
if (!messageId) {
return;
}
// 特殊处理,需要判断是否是回复中的消息
const { is_finish } =
plugin.chatAreaPluginContext.readonlyAPI.message.findMessage(messageId) ??
{};
// 如果消息回复没完成 并且 不是一个特殊的消息 那么就不展示,否则展示
if (!is_finish && !isSpecialMessage) {
updateIsFloatMenuVisible(false);
return;
}
updateHumanizedContentText(selectionData?.humanizedContentText ?? '');
updateNormalizeSelectionNodeList(
selectionData?.normalizeSelectionNodeList ?? [],
);
updateOriginContentText(selectionData?.originContentText ?? '');
};
const { clearSelection, isScrolling, computePosition } = useGrab({
contentRef,
floatMenuRef: floatMenuRef.current?.getRef(),
onSelectChange: handleSelectChange,
onPositionChange: handlePositionChange,
});
const isShowFloatMenu = !!position && isFloatMenuVisible;
useEffect(() => {
on(EventNames.OnMessageUpdate, computePosition);
on(EventNames.OnViewScroll, computePosition);
return () => {
off(EventNames.OnMessageUpdate, computePosition);
off(EventNames.OnViewScroll, computePosition);
};
}, []);
// 如果没有开启 Grab 插件,那么就隐藏了当然
if (!enableGrab) {
return null;
}
return (
<MenuList
ref={floatMenuRef}
style={{
top: position?.y,
left: position?.x,
}}
className={classNames({
hidden: !isShowFloatMenu || isScrolling,
})}
>
<QuoteButton onClose={clearSelection} />
</MenuList>
);
};

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 { type MessageListFloatSlot } from '@coze-common/chat-area';
import { GrabMenu } from './grab-menu';
import { FloatMenu } from './float-menu';
export const MessageListFloat: MessageListFloatSlot = props => (
<>
<GrabMenu {...props} />
<FloatMenu {...props} />
</>
);

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { PluginName, useWriteablePlugin } from '@coze-common/chat-area';
import { I18n } from '@coze-arch/i18n';
import { IconCozQuotation } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { getMessage } from '../../utils/get-message';
import { type GrabPluginBizContext } from '../../types/plugin-biz-context';
export interface QuoteButtonProps {
onClose?: () => void;
onClick?: () => void;
}
export const QuoteButton: FC<QuoteButtonProps> = ({ onClose, onClick }) => {
const { pluginBizContext, chatAreaPluginContext } =
useWriteablePlugin<GrabPluginBizContext>(PluginName.MessageGrab);
const { onQuote } = pluginBizContext.eventCallbacks;
const { useQuoteStore, useSelectionStore, usePreferenceStore } =
pluginBizContext.storeSet;
const { updateQuoteContent, updateQuoteVisible } = useQuoteStore(
useShallow(state => ({
updateQuoteVisible: state.updateQuoteVisible,
updateQuoteContent: state.updateQuoteContent,
})),
);
const enableGrab = usePreferenceStore(state => state.enableGrab);
const { useDeleteFile } = chatAreaPluginContext.writeableHook.file;
const { getFileStoreInstantValues } =
chatAreaPluginContext.readonlyAPI.batchFile;
const deleteFile = useDeleteFile();
const deleteAllFile = () => {
const { fileIdList } = getFileStoreInstantValues();
fileIdList.forEach(id => deleteFile(id));
};
const getMessageInfo = () => {
const { selectionData } = useSelectionStore.getState();
const messageId = selectionData?.ancestorAttributeValue;
if (!messageId) {
return;
}
return getMessage({ messageId, chatAreaPluginContext });
};
const handleClick = () => {
deleteAllFile();
if (onClick) {
onClick();
onClose?.();
return;
}
const { normalizeSelectionNodeList } = useSelectionStore.getState();
updateQuoteContent(normalizeSelectionNodeList);
updateQuoteVisible(true);
const message = getMessageInfo();
onQuote?.({ botId: message?.sender_id ?? '', source: message?.source });
onClose?.();
};
if (!enableGrab) {
return null;
}
return (
<Tooltip content={I18n.t('quote_ask_in_chat')} clickToHide={true}>
<IconButton
icon={<IconCozQuotation className="text-lg coz-fg-secondary" />}
color="secondary"
onClick={handleClick}
size="small"
wrapperClass="flex justify-center items-center"
/>
</Tooltip>
);
};

View File

@@ -0,0 +1,7 @@
.image {
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, type FC } from 'react';
import classNames from 'classnames';
import {
isGrabTextNode,
isGrabLink,
isGrabImage,
} from '@coze-common/text-grab';
import { type GrabNode } from '@coze-common/text-grab';
import { useShowBackGround } from '@coze-common/chat-area';
import { Image, ImagePreview } from '@coze-arch/coze-design';
import { typeSafeQuoteNodeColorVariants } from '../variants';
import DefaultImage from '../../assets/image-default.png';
import styles from './index.module.less';
const getQuoteNode = ({
nodeList,
theme,
handleClickImage,
showBackground,
}: {
nodeList: GrabNode[];
theme: 'white' | 'black';
handleClickImage: (url: string) => void;
showBackground: boolean;
}): React.ReactNode =>
nodeList.map((node, index) => {
if (isGrabTextNode(node)) {
return (
<span
className={classNames('text-[14px] align-middle', 'leading-[16px]', {
'coz-fg-secondary': theme === 'black',
[typeSafeQuoteNodeColorVariants({ showBackground })]:
theme === 'white',
})}
key={index}
>
{node.text}
</span>
);
}
if (isGrabImage(node)) {
return (
<Image
className={classNames(
styles.image,
'w-[24px] h-[24px] leading-[24px] align-middle rounded-[4px] cursor-zoom-in',
{
'mx-[4px]': index !== 0,
'mr-[4px]': index === 0,
},
)}
src={node.src}
onClick={() => handleClickImage(node.src)}
key={index}
fallback={DefaultImage}
preview={false}
/>
);
}
if (isGrabLink(node)) {
return (
<span
className={classNames('text-[14px] align-middle', 'leading-[16px]', {
'coz-fg-secondary': theme === 'black',
[typeSafeQuoteNodeColorVariants({ showBackground })]:
theme === 'white',
})}
key={index}
>
[
{getQuoteNode({
nodeList: node.children,
theme,
handleClickImage,
showBackground,
})}
]
</span>
);
}
return null;
});
export const QuoteNode: FC<{
nodeList: GrabNode[];
theme: 'white' | 'black';
}> = ({ nodeList, theme }) => {
const [previewUrl, setPreviewUrl] = useState('');
const showBackground = useShowBackGround();
const handleClickImage = (url: string) => {
setPreviewUrl(url);
};
return (
<>
<ImagePreview
src={previewUrl}
visible={Boolean(previewUrl)}
onVisibleChange={() => setPreviewUrl('')}
/>
{getQuoteNode({
nodeList,
theme,
handleClickImage,
showBackground,
})}
</>
);
};

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 { cva, type VariantProps } from 'class-variance-authority';
const quoteNodeColorVariants = cva([], {
variants: {
showBackground: {
true: ['text-[#FFFFFF/60]'],
false: ['coz-fg-secondary'],
},
},
});
export const typeSafeQuoteNodeColorVariants: (
props: Required<VariantProps<typeof quoteNodeColorVariants>>,
) => string = quoteNodeColorVariants;