💄 style: add emoji reaction feature for messages (#12004)

*  feat: add emoji reaction feature for messages

- Add EmojiReaction type definition in metadata
- Add addReaction/removeReaction store actions in Conversation Store
- Create ReactionDisplay component for showing reactions
- Create ReactionPicker component with quick emoji selection
- Add ReactionFeedbackProcessor for context-engine
- Integrate reaction UI into Assistant message component
- Add i18n keys for reaction feature

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* update ui

* refactor reaction implement

* add reaction processor

* add reaction processor

* push

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-02-11 12:32:48 +08:00 committed by GitHub
parent e0e158c586
commit a83dc4d4ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 785 additions and 12 deletions

View file

@ -165,6 +165,7 @@
"messageAction.delAndRegenerate": "Delete and Regenerate",
"messageAction.deleteDisabledByThreads": "This message has a subtopic and cant be deleted",
"messageAction.expand": "Expand Message",
"messageAction.reaction": "Add Reaction",
"messageAction.regenerate": "Regenerate",
"messages.dm.sentTo": "Visible only to {{name}}",
"messages.dm.title": "DM",

View file

@ -165,6 +165,7 @@
"messageAction.delAndRegenerate": "删除并重新生成",
"messageAction.deleteDisabledByThreads": "该消息有子话题,无法删除",
"messageAction.expand": "展开消息",
"messageAction.reaction": "添加表情",
"messageAction.regenerate": "重新生成",
"messages.dm.sentTo": "仅对 {{name}} 可见",
"messages.dm.title": "私信",

View file

@ -158,6 +158,8 @@
"@codesandbox/sandpack-react": "^2.20.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@emotion/react": "^11.14.0",
"@fal-ai/client": "^1.8.4",
"@formkit/auto-animate": "^0.9.0",

View file

@ -14,6 +14,7 @@ import {
MessageCleanupProcessor,
MessageContentProcessor,
PlaceholderVariablesProcessor,
ReactionFeedbackProcessor,
SupervisorRoleRestoreProcessor,
TaskMessageProcessor,
TasksFlattenProcessor,
@ -299,6 +300,8 @@ export class MessagesEngine {
// =============================================
// Phase 5: Content Processing
// =============================================
// 22. Reaction feedback injection (append user reaction feedback to assistant messages)
new ReactionFeedbackProcessor({ enabled: true }),
// 22. Message content processing (image encoding, etc.)
new MessageContentProcessor({

View file

@ -218,6 +218,10 @@ export interface MessagesEngineParams {
groupAgentBuilderContext?: GroupAgentBuilderContext;
/** GTD (Getting Things Done) configuration */
gtd?: GTDConfig;
/** Reaction feedback configuration */
reactionFeedback?: {
enabled?: boolean;
};
/** User memory configuration */
userMemory?: UserMemoryConfig;

View file

@ -0,0 +1,77 @@
import type { EmojiReaction } from '@lobechat/types';
import debug from 'debug';
import { BaseProcessor } from '../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:processor:ReactionFeedbackProcessor');
export interface ReactionFeedbackConfig {
/** Whether to enable reaction feedback injection */
enabled?: boolean;
}
/**
* Reaction Feedback Processor
* Converts emoji reactions on assistant messages to feedback text
* and injects into the next user message, where the model will actually attend to it.
*/
export class ReactionFeedbackProcessor extends BaseProcessor {
readonly name = 'ReactionFeedbackProcessor';
constructor(
private config: ReactionFeedbackConfig = {},
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
if (!this.config.enabled) {
return this.markAsExecuted(context);
}
const clonedContext = this.cloneContext(context);
let processedCount = 0;
// Collect emojis from assistant messages, then inject into the next user message
let pendingEmojis: string[] = [];
for (let i = 0; i < clonedContext.messages.length; i++) {
const message = clonedContext.messages[i];
// Collect reactions from assistant messages
if (message.role === 'assistant' && message.metadata?.reactions) {
const reactions = message.metadata.reactions as EmojiReaction[];
const emojis = reactions.map((r) => r.emoji).filter(Boolean);
if (emojis.length > 0) {
pendingEmojis.push(...emojis);
log(`Collected reaction emojis from message ${message.id}: ${emojis.join(' ')}`);
}
}
// Inject accumulated feedback into the next user message
if (message.role === 'user' && pendingEmojis.length > 0) {
const originalContent = message.content;
if (typeof originalContent === 'string') {
const feedbackTag = `[User Feedback Emoji: ${pendingEmojis.join(' ')}]`;
clonedContext.messages[i] = {
...message,
content: `${feedbackTag}\n\n${originalContent}`,
};
processedCount += pendingEmojis.length;
}
pendingEmojis = [];
}
}
clonedContext.metadata.reactionFeedbackProcessed = processedCount;
log(`Reaction feedback processing completed, processed ${processedCount} messages`);
return this.markAsExecuted(clonedContext);
}
}

View file

@ -0,0 +1,210 @@
import { describe, expect, it } from 'vitest';
import { ReactionFeedbackProcessor } from '../ReactionFeedback';
const createContext = (messages: any[]) => ({
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
isAborted: false,
messages,
metadata: {
maxTokens: 4096,
model: 'gpt-4',
},
});
const createMessage = (
id: string,
role: string,
content: string | any[],
reactions?: { count: number; emoji: string; users: string[] }[],
) => ({
content,
createdAt: Date.now(),
id,
role,
updatedAt: Date.now(),
...(reactions ? { metadata: { reactions } } : {}),
});
describe('ReactionFeedbackProcessor', () => {
it('should skip processing when disabled', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: false });
const context = createContext([
createMessage('1', 'assistant', 'Hello', [{ count: 1, emoji: '👍', users: ['user1'] }]),
createMessage('2', 'user', 'Thanks'),
]);
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Hello');
expect(result.messages[1].content).toBe('Thanks');
expect(result.metadata.reactionFeedbackProcessed).toBeUndefined();
});
it('should inject feedback emoji into the next user message', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'user', 'Hi'),
createMessage('2', 'assistant', 'Hello!', [{ count: 1, emoji: '👍', users: ['user1'] }]),
createMessage('3', 'user', 'Tell me more'),
]);
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Hi');
expect(result.messages[1].content).toBe('Hello!');
expect(result.messages[2].content).toBe('[User Feedback Emoji: 👍]\n\nTell me more');
expect(result.metadata.reactionFeedbackProcessed).toBe(1);
});
it('should handle multiple reactions on one assistant message', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'assistant', 'Great answer', [
{ count: 1, emoji: '👍', users: ['user1'] },
{ count: 1, emoji: '🚀', users: ['user1'] },
]),
createMessage('2', 'user', 'Continue'),
]);
const result = await processor.process(context);
expect(result.messages[1].content).toBe('[User Feedback Emoji: 👍 🚀]\n\nContinue');
expect(result.metadata.reactionFeedbackProcessed).toBe(2);
});
it('should accumulate emojis from multiple assistant messages into one user message', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'assistant', 'First response', [
{ count: 1, emoji: '👍', users: ['user1'] },
]),
createMessage('2', 'assistant', 'Second response', [
{ count: 1, emoji: '👎', users: ['user1'] },
]),
createMessage('3', 'user', 'Next question'),
]);
const result = await processor.process(context);
expect(result.messages[2].content).toBe('[User Feedback Emoji: 👍 👎]\n\nNext question');
expect(result.metadata.reactionFeedbackProcessed).toBe(2);
});
it('should not modify assistant messages', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'assistant', 'Response with reaction', [
{ count: 1, emoji: '❤️', users: ['user1'] },
]),
createMessage('2', 'user', 'Follow up'),
]);
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Response with reaction');
});
it('should skip assistant messages without reactions', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'assistant', 'No reactions here'),
createMessage('2', 'user', 'Reply'),
]);
const result = await processor.process(context);
expect(result.messages[1].content).toBe('Reply');
expect(result.metadata.reactionFeedbackProcessed).toBe(0);
});
it('should discard pending feedback when no following user message exists', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'user', 'Question'),
createMessage('2', 'assistant', 'Answer', [{ count: 1, emoji: '👍', users: ['user1'] }]),
]);
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Question');
expect(result.messages[1].content).toBe('Answer');
expect(result.metadata.reactionFeedbackProcessed).toBe(0);
});
it('should skip non-string user message content', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'assistant', 'Response', [{ count: 1, emoji: '👍', users: ['user1'] }]),
createMessage('2', 'user', [{ text: 'array content', type: 'text' }]),
]);
const result = await processor.process(context);
expect(result.messages[1].content).toEqual([{ text: 'array content', type: 'text' }]);
expect(result.metadata.reactionFeedbackProcessed).toBe(0);
});
it('should reset pending emojis after injection', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'assistant', 'First', [{ count: 1, emoji: '👍', users: ['user1'] }]),
createMessage('2', 'user', 'Second question'),
createMessage('3', 'assistant', 'Third'),
createMessage('4', 'user', 'Fourth question'),
]);
const result = await processor.process(context);
expect(result.messages[1].content).toBe('[User Feedback Emoji: 👍]\n\nSecond question');
expect(result.messages[3].content).toBe('Fourth question');
});
it('should handle mixed conversation with multiple feedback injection points', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const context = createContext([
createMessage('1', 'user', 'Q1'),
createMessage('2', 'assistant', 'A1', [{ count: 1, emoji: '👍', users: ['user1'] }]),
createMessage('3', 'user', 'Q2'),
createMessage('4', 'assistant', 'A2', [{ count: 1, emoji: '👎', users: ['user1'] }]),
createMessage('5', 'user', 'Q3'),
]);
const result = await processor.process(context);
expect(result.messages[0].content).toBe('Q1');
expect(result.messages[2].content).toBe('[User Feedback Emoji: 👍]\n\nQ2');
expect(result.messages[4].content).toBe('[User Feedback Emoji: 👎]\n\nQ3');
expect(result.metadata.reactionFeedbackProcessed).toBe(2);
});
it('should not mutate the original context', async () => {
const processor = new ReactionFeedbackProcessor({ enabled: true });
const originalMessages = [
createMessage('1', 'assistant', 'Response', [{ count: 1, emoji: '👍', users: ['user1'] }]),
createMessage('2', 'user', 'Follow up'),
];
const context = createContext(originalMessages);
await processor.process(context);
expect(context.messages[1].content).toBe('Follow up');
});
});

View file

@ -20,6 +20,7 @@ export {
} from './PlaceholderVariables';
export { SupervisorRoleRestoreProcessor } from './SupervisorRoleRestore';
export { TaskMessageProcessor } from './TaskMessage';
export { ReactionFeedbackProcessor } from './ReactionFeedback';
export { TasksFlattenProcessor } from './TasksFlatten';
export { ToolCallProcessor } from './ToolCall';
export { ToolMessageReorder } from './ToolMessageReorder';
@ -34,5 +35,6 @@ export type {
PlaceholderValueMap,
PlaceholderVariablesConfig,
} from './PlaceholderVariables';
export type { ReactionFeedbackConfig } from './ReactionFeedback';
export type { TaskMessageConfig } from './TaskMessage';
export type { ToolCallConfig } from './ToolCall';

View file

@ -77,12 +77,27 @@ export const ModelPerformanceSchema = z.object({
latency: z.number().optional(),
});
// ============ Emoji Reaction ============ //
export interface EmojiReaction {
emoji: string;
count: number;
users: string[];
}
export const EmojiReactionSchema = z.object({
emoji: z.string(),
count: z.number(),
users: z.array(z.string()),
});
export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSchema).extend({
collapsed: z.boolean().optional(),
inspectExpanded: z.boolean().optional(),
isMultimodal: z.boolean().optional(),
isSupervisor: z.boolean().optional(),
pageSelections: z.array(PageSelectionSchema).optional(),
reactions: z.array(EmojiReactionSchema).optional(),
});
export interface ModelUsage extends ModelTokensUsage {
@ -155,4 +170,8 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
* Used for Ask AI functionality to persist selection context
*/
pageSelections?: PageSelection[];
/**
* Emoji reactions on this message
*/
reactions?: EmojiReaction[];
}

View file

@ -40,6 +40,7 @@ const MainChatInput = memo(() => {
}}
rightActions={rightActions}
sendMenu={{ items: sendMenuItems }}
skipScrollMarginWithList
/>
);
});

View file

@ -40,6 +40,7 @@ const MainChatInput = memo(() => {
}}
rightActions={rightActions}
sendMenu={{ items: sendMenuItems }}
skipScrollMarginWithList
/>
);
});

View file

@ -43,6 +43,10 @@ export interface ChatInputProps {
* Send menu configuration (for send options like Enter/Cmd+Enter, Add AI/User message)
*/
sendMenu?: MenuProps;
/**
* ChatList
*/
skipScrollMarginWithList?: boolean;
}
/**
@ -60,6 +64,7 @@ const ChatInput = memo<ChatInputProps>(
sendMenu,
sendButtonProps: customSendButtonProps,
onEditorReady,
skipScrollMarginWithList,
}) => {
const { t } = useTranslation('chat');
@ -132,7 +137,7 @@ const ChatInput = memo<ChatInputProps>(
};
const defaultContent = (
<WideScreenContainer>
<WideScreenContainer style={skipScrollMarginWithList ? { marginTop: -12 } : undefined}>
{sendMessageErrorMsg && (
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
<Alert

View file

@ -1,8 +1,9 @@
import { type UIChatMessage } from '@lobechat/types';
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
import { ActionIconGroup, Flexbox, createRawModal } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { ReactionPicker } from '../../../components/Reaction';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import {
Provider,
@ -209,7 +210,12 @@ export const AssistantActionsBar = memo<AssistantActionsBarProps>(
if (error) return <ErrorActionsBar actions={defaultActions} onActionClick={handleAction} />;
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
return (
<Flexbox align={'center'} gap={8} horizontal>
<ReactionPicker messageId={id} />
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
</Flexbox>
);
},
);

View file

@ -1,8 +1,12 @@
import { LOADING_FLAT } from '@lobechat/const';
import { type UIChatMessage } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { memo, useCallback } from 'react';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { ReactionDisplay } from '../../../components/Reaction';
import { messageStateSelectors, useConversationStore } from '../../../store';
import { CollapsedMessage } from '../../AssistantGroup/components/CollapsedMessage';
import DisplayContent from '../../components/DisplayContent';
@ -19,6 +23,9 @@ const MessageContent = memo<UIChatMessage>(
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
const isReasoning = useConversationStore(messageStateSelectors.isMessageInReasoning(id));
const addReaction = useConversationStore((s) => s.addReaction);
const removeReaction = useConversationStore((s) => s.removeReaction);
const userId = useUserStore(userProfileSelectors.userId)!;
const isToolCallGenerating = generating && (content === LOADING_FLAT || !content) && !!tools;
@ -33,6 +40,28 @@ const MessageContent = memo<UIChatMessage>(
const showFileChunks = !!chunksList && chunksList.length > 0;
const reactions = metadata?.reactions || [];
const handleReactionClick = useCallback(
(emoji: string) => {
const existing = reactions.find((r) => r.emoji === emoji);
if (existing && existing.users.includes(userId)) {
removeReaction(id, emoji);
} else {
addReaction(id, emoji);
}
},
[id, reactions, addReaction, removeReaction],
);
const isActive = useCallback(
(emoji: string) => {
const reaction = reactions.find((r) => r.emoji === emoji);
return !!reaction && reaction.users.includes(userId);
},
[reactions],
);
if (isCollapsed) return <CollapsedMessage content={content} id={id} />;
return (
@ -52,6 +81,14 @@ const MessageContent = memo<UIChatMessage>(
tempDisplayContent={metadata?.tempDisplayContent}
/>
{showImageItems && <ImageFileListViewer items={imageList} />}
{reactions.length > 0 && (
<ReactionDisplay
isActive={isActive}
messageId={id}
onReactionClick={handleReactionClick}
reactions={reactions}
/>
)}
</Flexbox>
);
},

View file

@ -1,8 +1,9 @@
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
import { ActionIconGroup, Flexbox, createRawModal } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { ReactionPicker } from '../../../components/Reaction';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import {
Provider,
@ -162,7 +163,12 @@ const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, conten
[allActions],
);
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
return (
<Flexbox align={'center'} gap={8} horizontal>
<ReactionPicker messageId={id} />
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
</Flexbox>
);
});
WithContentId.displayName = 'GroupActionsWithContentId';

View file

@ -1,6 +1,6 @@
'use client';
import type { AssistantContentBlock } from '@lobechat/types';
import type { AssistantContentBlock, EmojiReaction } from '@lobechat/types';
import isEqual from 'fast-deep-equal';
import { type MouseEventHandler, Suspense, memo, useCallback, useMemo } from 'react';
@ -12,7 +12,10 @@ import dynamic from '@/libs/next/dynamic';
import { useAgentStore } from '@/store/agent';
import { builtinAgentSelectors } from '@/store/agent/selectors';
import { useGlobalStore } from '@/store/global';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { ReactionDisplay } from '../../components/Reaction';
import { useAgentMeta } from '../../hooks';
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../store';
import {
@ -45,7 +48,8 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
// Get message and actionsConfig from ConversationStore
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
const { agentId, usage, createdAt, children, performance, model, provider, branch } = item;
const { agentId, usage, createdAt, children, performance, model, provider, branch, metadata } =
item;
const avatar = useAgentMeta(agentId);
// Collect fileList from all children blocks
@ -75,6 +79,31 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
messageId: id,
});
const addReaction = useConversationStore((s) => s.addReaction);
const removeReaction = useConversationStore((s) => s.removeReaction);
const userId = useUserStore(userProfileSelectors.userId)!;
const reactions: EmojiReaction[] = metadata?.reactions || [];
const handleReactionClick = useCallback(
(emoji: string) => {
const existing = reactions.find((r) => r.emoji === emoji);
if (existing && existing.users.includes(userId)) {
removeReaction(id, emoji);
} else {
addReaction(id, emoji);
}
},
[id, reactions, addReaction, removeReaction],
);
const isReactionActive = useCallback(
(emoji: string) => {
const reaction = reactions.find((r) => r.emoji === emoji);
return !!reaction && reaction.users.includes(userId);
},
[reactions],
);
const setMessageItemActionElementPortialContext = useSetMessageItemActionElementPortialContext();
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();
@ -143,6 +172,14 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
{model && (
<Usage model={model} performance={performance} provider={provider!} usage={usage} />
)}
{reactions.length > 0 && (
<ReactionDisplay
isActive={isReactionActive}
messageId={id}
onReactionClick={handleReactionClick}
reactions={reactions}
/>
)}
<Suspense fallback={null}>
{editing && contentId && <EditState content={lastAssistantMsg?.content} id={contentId} />}
</Suspense>

View file

@ -1,8 +1,9 @@
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
import { ActionIconGroup, Flexbox, createRawModal } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { ReactionPicker } from '../../../components/Reaction';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import {
Provider,
@ -154,7 +155,12 @@ const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, conten
[allActions],
);
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
return (
<Flexbox align={'center'} gap={8} horizontal>
<ReactionPicker messageId={id} />
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
</Flexbox>
);
});
WithContentId.displayName = 'GroupActionsWithContentId';

View file

@ -1,5 +1,6 @@
'use client';
import type { EmojiReaction } from '@lobechat/types';
import { Tag } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { type MouseEventHandler, memo, useCallback } from 'react';
@ -11,7 +12,10 @@ import { ChatItem } from '@/features/Conversation/ChatItem';
import { useNewScreen } from '@/features/Conversation/Messages/components/useNewScreen';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { ReactionDisplay } from '../../components/Reaction';
import { useAgentMeta } from '../../hooks';
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../store';
import {
@ -41,7 +45,8 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
// Get message and actionsConfig from ConversationStore
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
const { agentId, usage, createdAt, children, performance, model, provider, branch } = item;
const { agentId, usage, createdAt, children, performance, model, provider, branch, metadata } =
item;
const avatar = useAgentMeta(agentId);
// Get group member avatars for GroupAvatar
@ -62,6 +67,31 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
messageId: id,
});
const addReaction = useConversationStore((s) => s.addReaction);
const removeReaction = useConversationStore((s) => s.removeReaction);
const userId = useUserStore(userProfileSelectors.userId)!;
const reactions: EmojiReaction[] = metadata?.reactions || [];
const handleReactionClick = useCallback(
(emoji: string) => {
const existing = reactions.find((r) => r.emoji === emoji);
if (existing && existing.users.includes(userId)) {
removeReaction(id, emoji);
} else {
addReaction(id, emoji);
}
},
[id, reactions, addReaction, removeReaction],
);
const isReactionActive = useCallback(
(emoji: string) => {
const reaction = reactions.find((r) => r.emoji === emoji);
return !!reaction && reaction.users.includes(userId);
},
[reactions],
);
const setMessageItemActionElementPortialContext = useSetMessageItemActionElementPortialContext();
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();
@ -121,6 +151,14 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
{model && (
<Usage model={model} performance={performance} provider={provider!} usage={usage} />
)}
{reactions.length > 0 && (
<ReactionDisplay
isActive={isReactionActive}
messageId={id}
onReactionClick={handleReactionClick}
reactions={reactions}
/>
)}
</ChatItem>
);
}, isEqual);

View file

@ -0,0 +1,93 @@
'use client';
import type { EmojiReaction } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import ReactionPicker from './ReactionPicker';
const useStyles = createStyles(({ css, token }) => ({
active: css`
background: ${token.colorFillTertiary};
`,
container: css`
display: flex;
flex-wrap: wrap;
gap: 4px;
`,
count: css`
font-size: 12px;
color: ${token.colorTextSecondary};
`,
reactionTag: css`
cursor: pointer;
display: inline-flex;
gap: 4px;
align-items: center;
height: 28px;
padding-block: 0;
padding-inline: 10px;
border-radius: 14px;
font-size: 14px;
line-height: 1;
background: ${token.colorFillSecondary};
transition: all 0.2s;
&:hover {
background: ${token.colorFillTertiary};
}
`,
}));
interface ReactionDisplayProps {
/**
* Whether the current user has reacted (used for single-user mode)
*/
isActive?: (emoji: string) => boolean;
/**
* The message ID for adding reactions via the inline picker
*/
messageId?: string;
/**
* Callback when a reaction is clicked
*/
onReactionClick?: (emoji: string) => void;
/**
* The reactions to display
*/
reactions: EmojiReaction[];
}
const ReactionDisplay = memo<ReactionDisplayProps>(
({ reactions, onReactionClick, messageId, isActive }) => {
const { styles, cx } = useStyles();
if (reactions.length === 0) return null;
return (
<Flexbox align={'center'} className={styles.container} horizontal>
{reactions.map((reaction) => (
<div
className={cx(styles.reactionTag, isActive?.(reaction.emoji) && styles.active)}
key={reaction.emoji}
onClick={() => onReactionClick?.(reaction.emoji)}
>
<span>{reaction.emoji}</span>
{reaction.count > 1 && <span className={styles.count}>{reaction.count}</span>}
</div>
))}
{messageId && <ReactionPicker messageId={messageId} />}
</Flexbox>
);
},
);
ReactionDisplay.displayName = 'ReactionDisplay';
export default ReactionDisplay;

View file

@ -0,0 +1,135 @@
'use client';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import { ActionIcon, Flexbox, Tooltip } from '@lobehub/ui';
import { Popover } from 'antd';
import { createStyles, useTheme } from 'antd-style';
import { PlusIcon, SmilePlus } from 'lucide-react';
import { type FC, type ReactNode, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import { useConversationStore } from '../../store';
const QUICK_REACTIONS = ['👍', '👎', '❤️', '😄', '😂', '😅', '🎉', '😢', '🤔', '🚀'];
const useStyles = createStyles(({ css, token }) => ({
emojiButton: css`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: ${token.borderRadius}px;
font-size: 18px;
transition: all 0.2s;
&:hover {
transform: scale(1.1);
background: ${token.colorFillSecondary};
}
`,
moreButton: css`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: ${token.borderRadius}px;
color: ${token.colorTextTertiary};
transition: all 0.2s;
&:hover {
color: ${token.colorText};
background: ${token.colorFillSecondary};
}
`,
pickerContainer: css`
padding: 4px;
`,
}));
interface ReactionPickerProps {
messageId: string;
trigger?: ReactNode;
}
const ReactionPicker: FC<ReactionPickerProps> = memo(({ messageId, trigger }) => {
const { styles } = useStyles();
const { t } = useTranslation('chat');
const theme = useTheme();
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const addReaction = useConversationStore((s) => s.addReaction);
const [open, setOpen] = useState(false);
const [showFullPicker, setShowFullPicker] = useState(false);
const handleSelect = (emoji: string) => {
addReaction(messageId, emoji);
setOpen(false);
setShowFullPicker(false);
};
const handleOpenChange = (visible: boolean) => {
setOpen(visible);
if (!visible) setShowFullPicker(false);
};
const content = showFullPicker ? (
<Picker
data={data}
locale={locale?.split('-')[0] || 'en'}
onEmojiSelect={(emoji: any) => handleSelect(emoji.native)}
previewPosition="none"
skinTonePosition="none"
theme={theme.appearance === 'dark' ? 'dark' : 'light'}
/>
) : (
<Flexbox className={styles.pickerContainer} gap={4} horizontal wrap="wrap">
{QUICK_REACTIONS.map((emoji) => (
<div className={styles.emojiButton} key={emoji} onClick={() => handleSelect(emoji)}>
{emoji}
</div>
))}
<div className={styles.moreButton} onClick={() => setShowFullPicker(true)}>
<PlusIcon size={16} />
</div>
</Flexbox>
);
return (
<Popover
arrow={false}
content={content}
onOpenChange={handleOpenChange}
open={open}
overlayInnerStyle={{ padding: 0 }}
placement="top"
trigger="click"
>
{trigger || (
<span {...(open ? { 'data-popup-open': '' } : {})}>
<Tooltip title={t('messageAction.reaction')}>
<ActionIcon icon={SmilePlus} size="small" />
</Tooltip>
</span>
)}
</Popover>
);
});
ReactionPicker.displayName = 'ReactionPicker';
export default ReactionPicker;

View file

@ -0,0 +1,2 @@
export { default as ReactionDisplay } from './ReactionDisplay';
export { default as ReactionPicker } from './ReactionPicker';

View file

@ -8,6 +8,7 @@ import {
type ChatToolPayloadWithResult,
type ChatVideoItem,
type CreateMessageParams,
type GroundingSearch,
type MessageMetadata,
type MessagePluginItem,

View file

@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand';
import type { Store as ConversationStore } from '../../../action';
import { type MessageCRUDAction, messageCRUDSlice } from './crud';
import { type MessageReactionAction, messageReactionSlice } from './reaction';
import { sendMessage } from './sendMessage';
import { type MessageStateAction, messageStateSlice } from './state';
@ -10,10 +11,11 @@ import { type MessageStateAction, messageStateSlice } from './state';
*
* Handles all message operations:
* - CRUD (create, read, update, delete)
* - Reaction (add, remove emoji reactions)
* - State management (loading, collapsed, editing)
* - Sending messages
*/
export interface MessageAction extends MessageCRUDAction, MessageStateAction {
export interface MessageAction extends MessageCRUDAction, MessageReactionAction, MessageStateAction {
/**
* Add an AI message (convenience method)
*/
@ -39,6 +41,9 @@ export const messageSlice: StateCreator<
// Spread CRUD actions
...messageCRUDSlice(set, get, ...rest),
// Spread reaction actions
...messageReactionSlice(set, get, ...rest),
// Spread state actions
...messageStateSlice(set, get, ...rest),

View file

@ -0,0 +1,80 @@
import type { EmojiReaction } from '@lobechat/types';
import type { StateCreator } from 'zustand';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import type { Store as ConversationStore } from '../../../action';
import { dataSelectors } from '../../data/selectors';
export interface MessageReactionAction {
/**
* Add an emoji reaction to a message
*/
addReaction: (messageId: string, emoji: string) => Promise<void>;
/**
* Remove an emoji reaction from a message
*/
removeReaction: (messageId: string, emoji: string) => Promise<void>;
}
export const messageReactionSlice: StateCreator<
ConversationStore,
[['zustand/devtools', never]],
[],
MessageReactionAction
> = (set, get) => ({
addReaction: async (messageId, emoji) => {
const { updateMessageMetadata } = get();
const message = dataSelectors.getDisplayMessageById(messageId)(get());
const userId = userProfileSelectors.userId(useUserStore.getState())!;
const currentReactions = message?.metadata?.reactions || [];
const existingIndex = currentReactions.findIndex((r: EmojiReaction) => r.emoji === emoji);
let newReactions: EmojiReaction[];
if (existingIndex >= 0) {
newReactions = currentReactions.map((r: EmojiReaction, i: number) =>
i === existingIndex ? { ...r, count: r.count + 1, users: [...r.users, userId] } : r,
);
} else {
newReactions = [...currentReactions, { count: 1, emoji, users: [userId] }];
}
await updateMessageMetadata(messageId, { reactions: newReactions });
},
removeReaction: async (messageId, emoji) => {
const { updateMessageMetadata } = get();
const message = dataSelectors.getDisplayMessageById(messageId)(get());
const userId = userProfileSelectors.userId(useUserStore.getState())!;
const currentReactions = message?.metadata?.reactions || [];
const existingIndex = currentReactions.findIndex((r: EmojiReaction) => r.emoji === emoji);
if (existingIndex < 0) return;
const emojiReaction = currentReactions[existingIndex];
let newReactions: EmojiReaction[];
if (emojiReaction.count <= 1) {
newReactions = currentReactions.filter((_: EmojiReaction, i: number) => i !== existingIndex);
} else {
const userIndex = emojiReaction.users.lastIndexOf(userId);
const newUsers =
userIndex >= 0
? [
...emojiReaction.users.slice(0, userIndex),
...emojiReaction.users.slice(userIndex + 1),
]
: emojiReaction.users.slice(0, -1);
newReactions = currentReactions.map((r: EmojiReaction, i: number) =>
i === existingIndex ? { ...r, count: r.count - 1, users: newUsers } : r,
);
}
await updateMessageMetadata(messageId, { reactions: newReactions });
},
});

View file

@ -190,6 +190,7 @@ export default {
'messageAction.delAndRegenerate': 'Delete and Regenerate',
'messageAction.deleteDisabledByThreads': 'This message has a subtopic and cant be deleted',
'messageAction.expand': 'Expand Message',
'messageAction.reaction': 'Add Reaction',
'messageAction.regenerate': 'Regenerate',
'messages.dm.sentTo': 'Visible only to {{name}}',
'messages.dm.title': 'DM',