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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

View File

@@ -0,0 +1,95 @@
/*
* 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 { nanoid } from 'nanoid';
import mitt from 'mitt';
import { type PluginRegistryEntry } from '@coze-common/chat-area';
import {
type EventCenter,
type GrabPluginBizContext,
type PublicEventCenter,
type EventCallbacks,
} from './types/plugin-biz-context';
import { createSelectionStore } from './stores/selection';
import { createQuoteStore, subscribeQuoteUpdate } from './stores/quote';
import { createPreferenceStore } from './stores/preference';
import { ChatAreaGrabPlugin } from './plugin';
interface Preference {
enableGrab: boolean;
}
export type Scene = 'store' | 'other';
type CreateGrabPluginParams = {
preference: Preference;
scene?: Scene;
} & EventCallbacks;
export const publicEventCenter = mitt<PublicEventCenter>();
export const createGrabPlugin = (params: CreateGrabPluginParams) => {
const { preference, onQuote, onQuoteChange, scene } = params;
const grabPluginId = nanoid();
const grabPlugin: PluginRegistryEntry<GrabPluginBizContext> = {
createPluginBizContext: () => {
const eventCallbacks = {
onQuote,
onQuoteChange,
};
const storeSet = {
useSelectionStore: createSelectionStore('plugin'),
useQuoteStore: createQuoteStore('plugin'),
usePreferenceStore: createPreferenceStore('plugin'),
};
const eventCenter = mitt<EventCenter>();
// 默认注入preference
storeSet.usePreferenceStore
.getState()
.updateEnableGrab(preference.enableGrab);
const unsubscribeQuoteStore = subscribeQuoteUpdate(
{
useQuoteStore: storeSet.useQuoteStore,
},
eventCallbacks,
);
const ctx = {
grabPluginId,
storeSet,
eventCallbacks,
unsubscribe: () => {
unsubscribeQuoteStore();
},
eventCenter,
publicEventCenter,
scene,
};
return ctx;
},
Plugin: ChatAreaGrabPlugin,
};
return { grabPlugin, grabPluginId };
};

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;

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type MutableRefObject,
useEffect,
useState,
type RefObject,
} from 'react';
import { defer } from 'lodash-es';
import { type GrabPosition } from '@coze-common/text-grab';
import { type MenuListRef } from '../custom-components/menu-list';
export const useAutoGetMaxPosition = ({
position,
messageRef,
floatMenuRef,
}: {
position: GrabPosition | null;
messageRef: MutableRefObject<Element | null>;
floatMenuRef: RefObject<MenuListRef>;
}) => {
const [maxPositionX, setMaxPositionX] = useState(0);
useEffect(() => {
const maxX = messageRef.current?.getBoundingClientRect().right ?? 0;
setMaxPositionX(maxX);
defer(() => floatMenuRef.current?.refreshOpacity());
}, [position, messageRef.current, floatMenuRef.current]);
return { maxPositionX };
};

View File

@@ -0,0 +1,240 @@
/*
* 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, useState } from 'react';
import { defer, delay } from 'lodash-es';
import {
CONTENT_ATTRIBUTE_NAME,
getAncestorAttributeNode,
type GrabPosition,
} from '@coze-common/text-grab';
import { NO_MESSAGE_ID_MARK } from '@coze-common/chat-uikit';
import { useEventCallback } from '@coze-common/chat-hooks';
import {
type OnLinkElementContext,
type useWriteablePlugin,
} from '@coze-common/chat-area';
import { getMouseNearbyRect } from '../utils/get-mouse-nearby-rect';
import {
EventNames,
type GrabPluginBizContext,
} from '../types/plugin-biz-context';
import { type MenuListRef } from '../custom-components/menu-list';
const DELAY_DISAPPEAR_TIME = 100;
const TIMEOUT = 100;
// eslint-disable-next-line max-lines-per-function, @coze-arch/max-line-per-function
export const useFloatMenuListener = ({
plugin,
}: {
plugin: ReturnType<typeof useWriteablePlugin<GrabPluginBizContext>>;
}) => {
const floatMenuRef = useRef<MenuListRef>(null);
const [visible, setVisible] = useState(false);
const targetElement = useRef<HTMLElement | null>(null);
const targetInfo = useRef<{
source: string;
type: 'image' | 'link';
text: string;
} | null>(null);
/**
* 是否在 Scrolling 中
*/
const [isScrolling, setIsScrolling] = useState(false);
/**
* Scrolling 计时器
*/
const scrollingTimer = useRef<NodeJS.Timeout | null>(null);
const { pluginBizContext } = plugin;
const { eventCenter } = pluginBizContext;
const { on, off } = eventCenter;
const isMouseInMenu = useRef(false);
const [position, setPosition] = useState<GrabPosition | null>(null);
const timer = useRef<number | null>(null);
const mouseInfo = useRef<GrabPosition>({ x: 0, y: 0 });
const handleMenuMouseEnter = useEventCallback(() => {
isMouseInMenu.current = true;
if (timer.current) {
clearTimeout(timer.current);
}
});
const handleMenuMouseLeave = useEventCallback(() => {
isMouseInMenu.current = false;
handleCardLinkElementMouseLeave();
});
const isMessageFinished = () => {
const target = getAncestorAttributeNode(
targetElement.current,
CONTENT_ATTRIBUTE_NAME,
);
const messageId = target?.attributes.getNamedItem(
CONTENT_ATTRIBUTE_NAME,
)?.value;
const isSpecialMessage = messageId === NO_MESSAGE_ID_MARK;
if (isSpecialMessage) {
return true;
}
if (!messageId) {
return false;
}
const { is_finish } =
plugin.chatAreaPluginContext.readonlyAPI.message.findMessage(messageId) ??
{};
return is_finish;
};
const handleCardLinkElementMouseEnter = useEventCallback(
(ctx: OnLinkElementContext & { type: 'link' | 'image' }) => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
targetElement.current = ctx.element;
targetInfo.current = {
source: ctx.link,
type: ctx.type,
text: ctx.element.textContent ?? '',
};
const isFinished = isMessageFinished();
if (!isFinished) {
return;
}
setVisible(true);
handleViewScroll();
defer(() => {
floatMenuRef.current?.refreshOpacity();
});
},
);
const handleCardLinkElementMouseLeave = useEventCallback(() => {
timer.current = delay(() => {
if (isMouseInMenu.current) {
return;
}
targetElement.current = null;
setVisible(false);
}, DELAY_DISAPPEAR_TIME);
});
const handleViewScroll = useEventCallback(() => {
const menuRef = floatMenuRef.current?.getRef();
if (
!targetElement.current ||
!menuRef ||
!menuRef.current ||
!targetInfo.current
) {
return;
}
const target = targetElement.current;
const targetRect = target.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
let x = 0;
let y = 0;
if (targetInfo.current.type === 'image') {
x = targetRect.right;
y = targetRect.bottom - targetRect.height / 2 - menuRect.height / 2;
} else {
const targetRects = target.getClientRects();
const nearbyRect =
getMouseNearbyRect(Array.from(targetRects), mouseInfo.current) ??
targetRect;
x = mouseInfo.current.x;
y = nearbyRect.bottom;
}
setPosition({ x, y });
});
const handleMouseMove = useEventCallback((event: MouseEvent) => {
const [x, y] = [event.clientX, event.clientY];
mouseInfo.current = { x, y };
});
const handleSmartScreenChange = useEventCallback(() => {
if (scrollingTimer.current) {
clearTimeout(scrollingTimer.current);
}
setIsScrolling(true);
scrollingTimer.current = setTimeout(() => {
handleViewScroll();
setIsScrolling(false);
}, TIMEOUT);
});
useEffect(() => {
on(EventNames.OnLinkElementMouseEnter, handleCardLinkElementMouseEnter);
on(EventNames.OnLinkElementMouseLeave, handleCardLinkElementMouseLeave);
on(EventNames.OnViewScroll, handleSmartScreenChange);
on(EventNames.OnMessageUpdate, handleSmartScreenChange);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('resize', handleSmartScreenChange);
window.addEventListener('wheel', handleSmartScreenChange);
window.addEventListener('scroll', handleSmartScreenChange);
return () => {
off(EventNames.OnLinkElementMouseEnter, handleCardLinkElementMouseEnter);
off(EventNames.OnLinkElementMouseLeave, handleCardLinkElementMouseLeave);
off(EventNames.OnViewScroll, handleSmartScreenChange);
off(EventNames.OnMessageUpdate, handleSmartScreenChange);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('resize', handleSmartScreenChange);
window.removeEventListener('wheel', handleSmartScreenChange);
window.removeEventListener('scroll', handleSmartScreenChange);
};
}, []);
return {
handleMenuMouseEnter,
handleMenuMouseLeave,
targetElement,
targetInfo,
position,
floatMenuRef,
visible,
setVisible,
computePosition: handleSmartScreenChange,
isScrolling,
};
};

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo, useState } from 'react';
import { type MessageSource } from '@coze-common/chat-area';
import { type Scene, createGrabPlugin } from '../create';
interface Params {
onQuote?: ({
botId,
source,
}: {
botId: string;
source: MessageSource | undefined;
}) => void;
// 目前只需要区分出 store 的场景
scene?: Scene;
}
export const useCreateGrabPlugin = (params?: Params) => {
const { onQuote, scene = 'other' } = params ?? {};
const [grabEnableUpload, setGrabEnableUpload] = useState(true);
// eslint-disable-next-line @typescript-eslint/naming-convention -- 符合预期的命名
const { grabPlugin: GrabPlugin, grabPluginId } = useMemo(
() =>
createGrabPlugin({
preference: {
enableGrab: true,
},
onQuote,
onQuoteChange: ({ isEmpty }) => {
setGrabEnableUpload(isEmpty);
},
scene,
}),
[],
);
return { grabEnableUpload, GrabPlugin, grabPluginId };
};

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 { type MutableRefObject, useEffect, useRef, useState } from 'react';
import {
CONTENT_ATTRIBUTE_NAME,
MESSAGE_SOURCE_ATTRIBUTE_NAME,
getAncestorAttributeNode,
} from '@coze-common/text-grab';
import { FILTER_MESSAGE_SOURCE } from '../constants/filter-message';
interface HideQuoteProps<T> {
containerRef?: MutableRefObject<T | null>;
}
export const useHideQuote = <T extends Element>(props?: HideQuoteProps<T>) => {
const containerRef = useRef<T | null>(null);
const targetRef = useRef<Element | null>(null);
const usedContainerRef = props?.containerRef?.current
? props.containerRef
: containerRef;
const [forceHidden, setForceHidden] = useState(false);
useEffect(() => {
const target = getAncestorAttributeNode(
usedContainerRef.current,
CONTENT_ATTRIBUTE_NAME,
);
const messageSource = target?.attributes.getNamedItem(
MESSAGE_SOURCE_ATTRIBUTE_NAME,
)?.value;
if (FILTER_MESSAGE_SOURCE.includes(Number(messageSource))) {
setForceHidden(true);
}
targetRef.current = target;
return () => {
setForceHidden(false);
targetRef.current = null;
};
}, [usedContainerRef.current]);
return { targetRef, containerRef: usedContainerRef, forceHidden };
};

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 ComponentType } from 'react';
import {
type GrabPluginBizContext,
type PublicEventCenter,
PublicEventNames,
} from './types/plugin-biz-context';
export {
GrabNode,
GrabElement,
GrabElementType,
GrabImageElement,
GrabLinkElement,
GrabPosition,
GrabText,
isGrabImage,
isGrabLink,
isGrabTextNode,
} from '@coze-common/text-grab';
export type CustomFloatMenu = ComponentType<{
grabBizContext: GrabPluginBizContext;
}>;
export { GrabPluginBizContext };
export { GrabPublicMethod } from './types/public-methods';
export { PublicEventNames, PublicEventCenter };
export { useCreateGrabPlugin } from './hooks/use-grab-plugin';
export { publicEventCenter } from './create';

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 {
PluginMode,
PluginName,
WriteableChatAreaPlugin,
createCustomComponents,
} from '@coze-common/chat-area';
import { type GrabPublicMethod } from './types/public-methods';
import { type GrabPluginBizContext } from './types/plugin-biz-context';
import { GrabMessageLifeCycleService } from './services/life-cycle/message';
import { GrabCommandLifeCycleService } from './services/life-cycle/command';
import { GrabAppLifeCycleService } from './services/life-cycle/app';
import { MessageListFloat } from './custom-components/message-list-float-slot';
import { QuoteMessageInnerTopSlot } from './custom-components/message-inner-top-slot';
import { QuoteInputAddonTop } from './custom-components/input-addon-top';
export class ChatAreaGrabPlugin extends WriteableChatAreaPlugin<
GrabPluginBizContext,
GrabPublicMethod
> {
public pluginMode = PluginMode.Writeable;
public pluginName = PluginName.MessageGrab;
public customComponents = createCustomComponents({
MessageListFloatSlot: MessageListFloat,
TextMessageInnerTopSlot: QuoteMessageInnerTopSlot,
InputAddonTop: QuoteInputAddonTop,
});
public lifeCycleServices = {
appLifeCycleService: new GrabAppLifeCycleService(this),
messageLifeCycleService: new GrabMessageLifeCycleService(this),
commandLifeCycleService: new GrabCommandLifeCycleService(this),
};
public publicMethods: GrabPublicMethod = {
updateEnableGrab: (enable: boolean) => {
if (!this.pluginBizContext) {
return;
}
const { updateEnableGrab } =
this.pluginBizContext.storeSet.usePreferenceStore.getState();
updateEnableGrab(enable);
},
};
}

View File

@@ -0,0 +1,68 @@
/*
* 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 GrabNode } from '@coze-common/text-grab';
import { WriteableAppLifeCycleService } from '@coze-common/chat-area';
import {
PublicEventNames,
type GrabPluginBizContext,
} from '../../types/plugin-biz-context';
export class GrabAppLifeCycleService extends WriteableAppLifeCycleService<GrabPluginBizContext> {
onBeforeDestroy(): void {
const { unsubscribe, eventCenter, publicEventCenter, scene } =
this.pluginInstance.pluginBizContext;
// Store 历史逻辑有一些问题,导致调用了 多次 destroy 但未初始化的情况,就不走下面的强制清理流程,而是走组件生命周期销毁
if (scene === 'store') {
return;
}
unsubscribe();
eventCenter.all.clear();
publicEventCenter.all.clear();
}
onBeforeInitial(): void {
const {
publicEventCenter,
grabPluginId: currentGrabPluginId,
storeSet,
} = this.pluginInstance.pluginBizContext;
const { useQuoteStore } = storeSet;
const { updateQuoteContent, updateQuoteVisible } = useQuoteStore.getState();
publicEventCenter.on(
PublicEventNames.UpdateQuote,
({
grabPluginId,
quote,
}: {
grabPluginId: string;
quote: GrabNode[] | null;
}) => {
if (currentGrabPluginId !== grabPluginId) {
return;
}
updateQuoteContent(quote);
updateQuoteVisible(true);
},
);
}
}

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 {
type OnImageElementContext,
WriteableCommandLifeCycleService,
type OnLinkElementContext,
} from '@coze-common/chat-area';
import {
EventNames,
type GrabPluginBizContext,
} from '../../types/plugin-biz-context';
export class GrabCommandLifeCycleService extends WriteableCommandLifeCycleService<GrabPluginBizContext> {
onViewScroll(): void {
const { emit } = this.pluginInstance.pluginBizContext.eventCenter;
emit(EventNames.OnViewScroll);
}
onCardLinkElementMouseEnter(ctx: OnLinkElementContext) {
const { emit } = this.pluginInstance.pluginBizContext.eventCenter;
emit(EventNames.OnLinkElementMouseEnter, {
...ctx,
type: 'image',
});
}
onCardLinkElementMouseLeave(ctx: OnLinkElementContext) {
const { emit } = this.pluginInstance.pluginBizContext.eventCenter;
emit(EventNames.OnLinkElementMouseLeave, {
...ctx,
type: 'image',
});
}
onMdBoxImageElementMouseEnter(ctx: OnImageElementContext): void {
const { emit } = this.pluginInstance.pluginBizContext.eventCenter;
emit(EventNames.OnLinkElementMouseEnter, {
...ctx,
type: 'image',
});
}
onMdBoxImageElementMouseLeave(ctx: OnImageElementContext): void {
const { emit } = this.pluginInstance.pluginBizContext.eventCenter;
emit(EventNames.OnLinkElementMouseLeave, {
...ctx,
type: 'image',
});
}
onMdBoxLinkElementMouseEnter(ctx: OnLinkElementContext): void {
const { emit } = this.pluginInstance.pluginBizContext.eventCenter;
emit(EventNames.OnLinkElementMouseEnter, {
...ctx,
type: 'link',
});
}
onMdBoxLinkElementMouseLeave(ctx: OnLinkElementContext): void {
const { emit } = this.pluginInstance.pluginBizContext.eventCenter;
emit(EventNames.OnLinkElementMouseLeave, {
...ctx,
type: 'link',
});
}
}

View File

@@ -0,0 +1,98 @@
/*
* 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 { getOriginContentText } from '@coze-common/text-grab';
import {
ContentType,
type OnBeforeAppendSenderMessageIntoStore,
WriteableMessageLifeCycleService,
type OnAfterAppendSenderMessageIntoStore,
} from '@coze-common/chat-area';
import {
EventNames,
type GrabPluginBizContext,
} from '../../types/plugin-biz-context';
export class GrabMessageLifeCycleService extends WriteableMessageLifeCycleService<GrabPluginBizContext> {
onBeforeAppendSenderMessageIntoStore(
ctx: OnBeforeAppendSenderMessageIntoStore,
) {
const { quoteContent, updateQuoteContentMapByImmer } =
this.pluginInstance.pluginBizContext.storeSet.useQuoteStore.getState();
if (!quoteContent || ctx.from === 'shortcut') {
return ctx;
}
const originMessage = ctx.message;
const newContent = {
item_list: [
{
type: 'text',
text: originMessage.content,
},
],
refer_items: [] as unknown[],
};
newContent.refer_items.push({
type: ContentType.Text,
text: getOriginContentText(quoteContent),
});
const newMessage = {
...originMessage,
content_type: ContentType.Mix,
content: JSON.stringify(newContent),
content_obj: newContent,
};
updateQuoteContentMapByImmer(quoteContentMap => {
const localMessageId = newMessage.extra_info.local_message_id;
if (!quoteContentMap[localMessageId]) {
quoteContentMap[localMessageId] = quoteContent;
}
});
return {
...ctx,
message: newMessage,
};
}
onAfterAppendSenderMessageIntoStore(
ctx: OnAfterAppendSenderMessageIntoStore,
) {
if (ctx.from === 'shortcut') {
return;
}
const { storeSet } = this.pluginInstance.pluginBizContext;
const { updateQuoteVisible, updateQuoteContent } =
storeSet.useQuoteStore.getState();
updateQuoteVisible(false);
updateQuoteContent(null);
return;
}
onAfterProcessReceiveMessage() {
const { emit } = this.pluginInstance.pluginBizContext.eventCenter;
emit(EventNames.OnMessageUpdate);
}
}

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 { devtools, subscribeWithSelector } from 'zustand/middleware';
import { create } from 'zustand';
export interface PreferenceState {
enableGrab: boolean;
}
export interface PreferenceAction {
updateEnableGrab: (enable: boolean) => void;
}
export const createPreferenceStore = (mark: string) => {
const usePreferenceStore = create<PreferenceState & PreferenceAction>()(
devtools(
subscribeWithSelector(set => ({
enableGrab: false,
updateEnableGrab: enable => {
set({
enableGrab: enable,
});
},
})),
{
name: `botStudio.ChatAreaGrabPlugin.Preference.${mark}`,
enabled: IS_DEV_MODE,
},
),
);
return usePreferenceStore;
};
export type PreferenceStore = ReturnType<typeof createPreferenceStore>;

View File

@@ -0,0 +1,93 @@
/*
* 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, subscribeWithSelector } from 'zustand/middleware';
import { create } from 'zustand';
import { produce } from 'immer';
import { type GrabNode } from '@coze-common/text-grab';
import { type EventCallbacks } from '../types/plugin-biz-context';
export interface QuoteState {
quoteContent: GrabNode[] | null;
quoteVisible: boolean;
quoteContentMap: Record<string, GrabNode[]>;
}
export interface QuoteAction {
updateQuoteContent: (quote: GrabNode[] | null) => void;
updateQuoteContentMapByImmer: (
updater: (quoteContentMap: Record<string, GrabNode[]>) => void,
) => void;
updateQuoteVisible: (visible: boolean) => void;
clearStore: () => void;
}
export const createQuoteStore = (mark: string) => {
const useQuoteStore = create<QuoteState & QuoteAction>()(
devtools(
subscribeWithSelector(set => ({
quoteContent: null,
quoteVisible: false,
quoteContentMap: {},
updateQuoteContent: quote => {
set({
quoteContent: quote,
});
},
updateQuoteContentMapByImmer: updater => {
set(produce<QuoteState>(state => updater(state.quoteContentMap)));
},
updateQuoteVisible: visible => {
set({
quoteVisible: visible,
});
},
clearStore: () => {
set({
quoteContent: null,
quoteVisible: false,
});
},
})),
{
name: `botStudio.ChatAreaGrabPlugin.Quote.${mark}`,
enabled: IS_DEV_MODE,
},
),
);
return useQuoteStore;
};
export type QuoteStore = ReturnType<typeof createQuoteStore>;
export const subscribeQuoteUpdate = (
store: {
useQuoteStore: QuoteStore;
},
eventCallbacks: EventCallbacks,
) => {
const { useQuoteStore } = store;
return useQuoteStore.subscribe(
state => state.quoteContent,
quoteContent => {
const { onQuoteChange } = eventCallbacks;
onQuoteChange?.({ isEmpty: !quoteContent });
},
);
};

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { devtools } from 'zustand/middleware';
import { create } from 'zustand';
import {
type SelectionData,
type GrabNode,
type GrabPosition,
} from '@coze-common/text-grab';
export interface SelectionState {
humanizedContentText: string;
originContentText: string;
normalizeSelectionNodeList: GrabNode[];
selectionData: SelectionData | null;
isFloatMenuVisible: boolean;
floatMenuPosition: GrabPosition | null;
}
export interface SelectionAction {
updateHumanizedContentText: (text: string) => void;
updateOriginContentText: (text: string) => void;
updateNormalizeSelectionNodeList: (nodeList: GrabNode[]) => void;
updateSelectionData: (selectionData: SelectionData | null) => void;
updateIsFloatMenuVisible: (visible: boolean) => void;
updateFloatMenuPosition: (position: GrabPosition | null) => void;
clearStore: () => void;
}
export const createSelectionStore = (mark: string) => {
const useSelectionStore = create<SelectionState & SelectionAction>()(
devtools(
(set, get) => ({
humanizedContentText: '',
originContentText: '',
normalizeSelectionNodeList: [],
selectionData: null,
isFloatMenuVisible: false,
floatMenuPosition: null,
updateHumanizedContentText: text => {
set({
humanizedContentText: text,
});
},
updateOriginContentText: text => {
set({
originContentText: text,
});
},
updateNormalizeSelectionNodeList: nodeList => {
set({
normalizeSelectionNodeList: nodeList,
});
},
updateIsFloatMenuVisible: visible => {
set({
isFloatMenuVisible: visible,
});
},
updateSelectionData: selectionData => {
set({
selectionData,
});
},
updateFloatMenuPosition: position => {
set({
floatMenuPosition: position,
});
},
clearStore: () => {
set({
humanizedContentText: '',
originContentText: '',
normalizeSelectionNodeList: [],
selectionData: null,
});
},
}),
{
name: `botStudio.ChatAreaGrabPlugin.SelectionStore.${mark}`,
enabled: IS_DEV_MODE,
},
),
);
return useSelectionStore;
};
export type SelectionStore = ReturnType<typeof createSelectionStore>;

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type Emitter } from 'mitt';
import { type GrabNode } from '@coze-common/text-grab';
import {
type OnLinkElementContext,
type MessageSource,
} from '@coze-common/chat-area';
import { type SelectionStore } from '../stores/selection';
import { type QuoteStore } from '../stores/quote';
import { type PreferenceStore } from '../stores/preference';
import { type Scene } from '../create';
export const enum EventNames {
OnViewScroll = 'onViewScroll',
OnMessageUpdate = 'onMessageUpdate',
OnLinkElementMouseEnter = 'onCardLinkElementMouseEnter',
OnLinkElementMouseLeave = 'onCardLinkElementMouseLeave',
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- 符合预期
export type EventCenter = {
[EventNames.OnMessageUpdate]: unknown;
[EventNames.OnViewScroll]: unknown;
[EventNames.OnLinkElementMouseEnter]: OnLinkElementContext & {
type: 'link' | 'image';
};
[EventNames.OnLinkElementMouseLeave]: OnLinkElementContext & {
type: 'link' | 'image';
};
};
export const enum PublicEventNames {
UpdateQuote = 'updateQuote',
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type PublicEventCenter = {
[PublicEventNames.UpdateQuote]: {
grabPluginId: string;
quote: GrabNode[] | null;
};
};
export interface GrabPluginBizContext {
grabPluginId: string;
storeSet: {
useSelectionStore: SelectionStore;
useQuoteStore: QuoteStore;
usePreferenceStore: PreferenceStore;
};
eventCallbacks: EventCallbacks;
eventCenter: Emitter<EventCenter>;
publicEventCenter: Emitter<PublicEventCenter>;
unsubscribe: () => void;
scene?: Scene;
}
export interface EventCallbacks {
onQuote?: ({
botId,
source,
}: {
botId: string;
source: MessageSource | undefined;
}) => void;
onQuoteChange?: ({ isEmpty }: { isEmpty: boolean }) => void;
}

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 GrabPublicMethod {
updateEnableGrab: (enable: boolean) => void;
}

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,56 @@
/*
* 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 {
Children,
type ReactElement,
isValidElement,
type ReactNode,
} from 'react';
import { isObject } from 'lodash-es';
const isReactElementWithChildren = (
node: unknown,
): node is ReactElement<{ children: ReactNode }> =>
isValidElement(node) &&
'props' in node &&
isObject(node.props) &&
'children' in node.props;
/**
* 从 ReactNode 中提取纯文本(不包括各种特殊转换逻辑)
*/
export const extractTextFromReactNode = (children: ReactNode): string => {
let text = '';
Children.forEach(children, child => {
if (typeof child === 'string' || typeof child === 'number') {
// 如果 child 是字符串或数字,直接加到 text 上
text += child.toString();
} else if (
isValidElement(child) &&
isReactElementWithChildren(child) &&
child.props.children
) {
// 如果 child 是 React 元素且有 children 属性,递归提取
text += extractTextFromReactNode(child.props.children);
}
// 如果 child 是 null 或 boolean不需要做任何操作
});
return text;
};

View File

@@ -0,0 +1,37 @@
/*
* 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 ChatAreaPluginContext,
type PluginMode,
} from '@coze-common/chat-area';
export const getMessage = ({
messageId,
chatAreaPluginContext,
}: {
messageId: string;
chatAreaPluginContext: ChatAreaPluginContext<PluginMode.Writeable>;
}) => {
if (!messageId) {
return;
}
const message =
chatAreaPluginContext.readonlyAPI.message.findMessage(messageId);
return message;
};

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 GrabPosition } from '@coze-common/text-grab';
const BUFFER_SIZE = 5;
export const getMouseNearbyRect = (
rects: DOMRect[],
mouseInfo: GrabPosition,
) => {
let nearbyRect = rects.at(0);
for (const rect of rects) {
if (
mouseInfo.x >= rect.left - BUFFER_SIZE &&
mouseInfo.x <= rect.right + BUFFER_SIZE &&
mouseInfo.y >= rect.top - BUFFER_SIZE &&
mouseInfo.y <= rect.bottom + BUFFER_SIZE
) {
nearbyRect = rect;
}
}
return nearbyRect;
};

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 { ContentType, type Message } from '@coze-common/chat-area';
export const getReferFromMessage = (message: Message<ContentType>) => {
if (message.content_type === ContentType.Mix) {
const { refer_items } = JSON.parse(message.content) ?? {};
if (refer_items) {
const firstItem = refer_items?.[0];
if (firstItem?.type === 'text') {
return {
type: ContentType.Text,
text: firstItem?.text,
};
} else if (firstItem?.type === 'image') {
return {
type: ContentType.Image,
uri: firstItem?.image?.key,
url: firstItem?.image?.image_thumb?.url,
};
}
}
}
return undefined;
};

View File

@@ -0,0 +1,37 @@
/*
* 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 ChatAreaPluginContext,
type PluginMode,
} from '@coze-common/chat-area';
export const getSenderId = ({
messageId,
chatAreaPluginContext,
}: {
messageId: string;
chatAreaPluginContext: ChatAreaPluginContext<PluginMode.Writeable>;
}) => {
if (!messageId) {
return;
}
const senderId =
chatAreaPluginContext.readonlyAPI.message.findMessage(messageId)?.sender_id;
return senderId;
};