💄 style(hetero-agent): add hetero-mode actions bar (#13963)

*  feat(hetero-agent): add hetero-mode actions bar with copy/delete only

Hide edit, regenerate, branching, translate, tts, share and delAndRegenerate
for heterogeneous-agent sessions where these actions don't apply. Introduce
`mode: 'hetero'` on MessageActionsConfig and dispatch to dedicated Hetero
action bars for user, assistant, and assistant-group messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(conversation): replace per-role action hooks with declarative action registry

Replace the 4 duplicate per-role action hooks (useUserActions / useAssistantActions
/ useGroupActions / Task.useAssistantActions) and the 4 copies of
stripHandleClick / buildActionsMap / dispatch logic with a single registry +
universal MessageActionBar renderer.

Each action (copy / del / edit / regenerate / delAndRegenerate /
continueGeneration / translate / tts / share / collapse / branching) is now a
standalone module under components/MessageActionBar/actions/. Config is
declarative — string slot keys (e.g. ['copy', 'divider', 'del']) resolved
against the registry at render time.

Hetero-agent sessions drop the special mode flag; they just declare copy-only
slot lists via config. Dev-mode branching becomes a registry key instead of a
factory.

Deletes ErrorActionsBar (handled in-place via slot lists), the dead
Supervisor/Actions folder, and the HeteroActionsBar scaffold introduced in
the previous commit.

Net: -1900 lines, one place to add a new action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-19 00:16:48 +08:00 committed by GitHub
parent 7fe751eaec
commit b909e4ae20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 771 additions and 1949 deletions

View file

@ -1,24 +0,0 @@
import { type ActionIconGroupEvent } from '@lobehub/ui';
import { ActionIconGroup } from '@lobehub/ui';
import { memo } from 'react';
import { type AssistantActions } from './useAssistantActions';
interface ErrorActionsBarProps {
actions: AssistantActions;
onActionClick: (event: ActionIconGroupEvent) => void;
}
export const ErrorActionsBar = memo<ErrorActionsBarProps>(({ actions, onActionClick }) => {
const { regenerate, copy, edit, del, divider } = actions;
return (
<ActionIconGroup
items={[regenerate, del]}
menu={[edit, copy, divider, del]}
onActionClick={onActionClick}
/>
);
});
ErrorActionsBar.displayName = 'ErrorActionsBar';

View file

@ -1,222 +1,61 @@
import { type UIChatMessage } from '@lobechat/types';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { ActionIconGroup, createRawModal, Flexbox } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { Flexbox } from '@lobehub/ui';
import { memo, useMemo } from 'react';
import { ReactionPicker } from '../../../components/Reaction';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import type { MessageActionsConfig } from '../../../types';
import {
createStore,
messageStateSelectors,
Provider,
useConversationStore,
useConversationStoreApi,
} from '../../../store';
import type {
MessageActionItem,
MessageActionItemOrDivider,
MessageActionsConfig,
} from '../../../types';
import { ErrorActionsBar } from './Error';
import { useAssistantActions } from './useAssistantActions';
MessageActionBar,
type MessageActionContext,
type MessageActionSlot,
} from '../../components/MessageActionBar';
// Helper to strip handleClick from action items before passing to ActionIconGroup
const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => {
if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType;
const { children, ...rest } = item as MessageActionItem;
const baseItem = { ...rest } as MessageActionItem;
delete (baseItem as { handleClick?: unknown }).handleClick;
if (children) {
return {
...baseItem,
children: children.map((child) => {
const nextChild = { ...child } as MessageActionItem;
delete (nextChild as { handleClick?: unknown }).handleClick;
return nextChild;
}),
} as ActionIconGroupItemType;
}
return baseItem as ActionIconGroupItemType;
};
// Build action items map for handleAction lookup
const buildActionsMap = (items: MessageActionItemOrDivider[]): Map<string, MessageActionItem> => {
const map = new Map<string, MessageActionItem>();
for (const item of items) {
if ('key' in item && item.key) {
map.set(String(item.key), item as MessageActionItem);
// Also index children for submenu items
if ('children' in item && item.children) {
for (const child of item.children) {
if (child.key) {
map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem);
}
}
}
}
}
return map;
};
const DEFAULT_BAR_WITH_TOOLS: MessageActionSlot[] = ['delAndRegenerate', 'copy'];
const DEFAULT_BAR: MessageActionSlot[] = ['edit', 'copy'];
const DEFAULT_MENU: MessageActionSlot[] = [
'edit',
'copy',
'collapse',
'divider',
'tts',
'translate',
'divider',
'share',
'divider',
'regenerate',
'delAndRegenerate',
'del',
];
const ERROR_BAR: MessageActionSlot[] = ['regenerate', 'del'];
const ERROR_MENU: MessageActionSlot[] = ['edit', 'copy', 'divider', 'del'];
interface AssistantActionsBarProps {
actionsConfig?: MessageActionsConfig;
data: UIChatMessage;
id: string;
index: number;
}
export const AssistantActionsBar = memo<AssistantActionsBarProps>(
({ actionsConfig, id, data, index }) => {
const { error, tools } = data;
const store = useConversationStoreApi();
export const AssistantActionsBar = memo<AssistantActionsBarProps>(({ actionsConfig, id, data }) => {
const ctx = useMemo<MessageActionContext>(() => ({ data, id, role: 'assistant' }), [data, id]);
const handleOpenShareModal = useCallback(() => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = store.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{
message: data,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
}, [data, store]);
const { error, tools } = data;
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
if (error) {
return <MessageActionBar bar={ERROR_BAR} ctx={ctx} menu={ERROR_MENU} />;
}
const defaultActions = useAssistantActions({
data,
id,
index,
onOpenShareModal: handleOpenShareModal,
});
const defaultBar = tools ? DEFAULT_BAR_WITH_TOOLS : DEFAULT_BAR;
const hasTools = !!tools;
// Get collapse/expand action based on current state
const collapseAction = isCollapsed ? defaultActions.expand : defaultActions.collapse;
// Create extra actions from factory functions
const extraBarItems = useMemo(() => {
if (!actionsConfig?.extraBarActions) return [];
return actionsConfig.extraBarActions
.map((factory) => factory(id))
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [actionsConfig?.extraBarActions, id]);
const extraMenuItems = useMemo(() => {
if (!actionsConfig?.extraMenuActions) return [];
return actionsConfig.extraMenuActions
.map((factory) => factory(id))
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [actionsConfig?.extraMenuActions, id]);
// Use external config if provided, otherwise use defaults
// Append extra actions from factories
const barItems = useMemo(() => {
const base =
actionsConfig?.bar ??
(hasTools
? [defaultActions.delAndRegenerate, defaultActions.copy]
: [defaultActions.edit, defaultActions.copy]);
return [...base, ...extraBarItems];
}, [
actionsConfig?.bar,
hasTools,
defaultActions.delAndRegenerate,
defaultActions.copy,
defaultActions.edit,
extraBarItems,
]);
const menuItems = useMemo(() => {
const base = actionsConfig?.menu ?? [
defaultActions.edit,
defaultActions.copy,
collapseAction,
defaultActions.divider,
defaultActions.tts,
defaultActions.translate,
defaultActions.divider,
defaultActions.share,
defaultActions.divider,
defaultActions.regenerate,
defaultActions.delAndRegenerate,
defaultActions.del,
];
return [...base, ...extraMenuItems];
}, [
actionsConfig?.menu,
defaultActions.edit,
defaultActions.copy,
collapseAction,
defaultActions.divider,
defaultActions.tts,
defaultActions.translate,
defaultActions.share,
defaultActions.regenerate,
defaultActions.delAndRegenerate,
defaultActions.del,
extraMenuItems,
]);
// Strip handleClick for DOM safety
const items = useMemo(
() =>
barItems
.filter((item) => item && !('disabled' in item && item.disabled))
.map(stripHandleClick),
[barItems],
);
const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]);
// Build actions map for click handling
const allActions = useMemo(
() => buildActionsMap([...barItems, ...menuItems]),
[barItems, menuItems],
);
const handleAction = useCallback(
(event: ActionIconGroupEvent) => {
// Handle submenu items (e.g., translate -> zh-CN)
if (event.keyPath && event.keyPath.length > 1) {
const parentKey = event.keyPath.at(-1);
const childKey = event.keyPath[0];
const parent = allActions.get(parentKey!);
if (parent && 'children' in parent && parent.children) {
const child = parent.children.find((c) => c.key === childKey);
child?.handleClick?.();
return;
}
}
// Handle regular actions
const action = allActions.get(event.key);
action?.handleClick?.();
},
[allActions],
);
if (error) return <ErrorActionsBar actions={defaultActions} onActionClick={handleAction} />;
return (
<Flexbox horizontal align={'center'} gap={8}>
<ReactionPicker messageId={id} />
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
</Flexbox>
);
},
);
return (
<Flexbox horizontal align={'center'} gap={8}>
<ReactionPicker messageId={id} />
<MessageActionBar
bar={actionsConfig?.bar ?? defaultBar}
ctx={ctx}
menu={actionsConfig?.menu ?? DEFAULT_MENU}
/>
</Flexbox>
);
});
AssistantActionsBar.displayName = 'AssistantActionsBar';

View file

@ -1,190 +0,0 @@
import { type ActionIconGroupItemType } from '@lobehub/ui';
import { copyToClipboard } from '@lobehub/ui';
import { App } from 'antd';
import { css, cx } from 'antd-style';
import {
Copy,
Edit,
LanguagesIcon,
ListChevronsDownUp,
ListChevronsUpDown,
ListRestart,
Play,
RotateCcw,
Share2,
Trash,
} from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { localeOptions } from '@/locales/resources';
import { type UIChatMessage } from '@/types/index';
import { messageStateSelectors, useConversationStore } from '../../../store';
const translateStyle = css`
.ant-dropdown-menu-sub {
overflow-y: scroll;
max-height: 400px;
}
`;
export interface ActionItem extends ActionIconGroupItemType {
children?: Array<{ handleClick?: () => void; key: string; label: string }>;
handleClick?: () => void | Promise<void>;
}
export interface AssistantActions {
collapse: ActionItem;
copy: ActionItem;
del: ActionItem;
delAndRegenerate: ActionItem;
divider: { type: 'divider' };
edit: ActionItem;
expand: ActionItem;
regenerate: ActionItem;
share: ActionItem;
translate: ActionItem;
tts: ActionItem;
}
interface UseAssistantActionsParams {
data: UIChatMessage;
id: string;
index: number;
onOpenShareModal?: () => void;
}
export const useAssistantActions = ({
id,
data,
onOpenShareModal,
}: UseAssistantActionsParams): AssistantActions => {
const { t } = useTranslation(['common', 'chat']);
const { message } = App.useApp();
// Get state from ConversationStore
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id));
// Get actions from ConversationStore
const [
toggleMessageEditing,
toggleMessageCollapsed,
deleteMessage,
regenerateAssistantMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
] = useConversationStore((s) => [
s.toggleMessageEditing,
s.toggleMessageCollapsed,
s.deleteMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.ttsMessage,
s.delAndRegenerateMessage,
]);
return useMemo<AssistantActions>(
() => ({
collapse: {
handleClick: () => toggleMessageCollapsed(id),
icon: ListChevronsDownUp,
key: 'collapse',
label: t('messageAction.collapse', { ns: 'chat' }),
},
copy: {
handleClick: async () => {
await copyToClipboard(data.content);
message.success(t('copySuccess'));
},
icon: Copy,
key: 'copy',
label: t('copy'),
},
del: {
danger: true,
handleClick: () => deleteMessage(id),
icon: Trash,
key: 'del',
label: t('delete'),
},
delAndRegenerate: {
disabled: isRegenerating,
handleClick: () => delAndRegenerateMessage(id),
icon: ListRestart,
key: 'delAndRegenerate',
label: t('messageAction.delAndRegenerate', { ns: 'chat' }),
},
divider: {
type: 'divider',
},
edit: {
handleClick: () => {
toggleMessageEditing(id, true);
},
icon: Edit,
key: 'edit',
label: t('edit'),
},
expand: {
handleClick: () => toggleMessageCollapsed(id),
icon: ListChevronsUpDown,
key: 'expand',
label: t('messageAction.expand', { ns: 'chat' }),
},
regenerate: {
disabled: isRegenerating,
handleClick: () => {
regenerateAssistantMessage(id);
if (data.error) deleteMessage(id);
},
icon: RotateCcw,
key: 'regenerate',
label: t('regenerate'),
spin: isRegenerating || undefined,
},
share: {
handleClick: onOpenShareModal,
icon: Share2,
key: 'share',
label: t('share'),
},
translate: {
children: localeOptions.map((i) => ({
key: i.value,
label: t(`lang.${i.value}`),
onClick: () => translateMessage(id, i.value),
})),
icon: LanguagesIcon,
key: 'translate',
label: t('translate.action', { ns: 'chat' }),
popupClassName: cx(translateStyle),
},
tts: {
handleClick: () => ttsMessage(id),
icon: Play,
key: 'tts',
label: t('tts.action', { ns: 'chat' }),
},
}),
[
t,
id,
data.content,
data.error,
isRegenerating,
isCollapsed,
toggleMessageEditing,
deleteMessage,
regenerateAssistantMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
toggleMessageCollapsed,
onOpenShareModal,
message,
],
);
};

View file

@ -1,61 +1,28 @@
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { ActionIconGroup, createRawModal, Flexbox } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { Flexbox } from '@lobehub/ui';
import { memo, useMemo } from 'react';
import { ReactionPicker } from '../../../components/Reaction';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import type { MessageActionsConfig } from '../../../types';
import {
createStore,
messageStateSelectors,
Provider,
useConversationStore,
useConversationStoreApi,
} from '../../../store';
import type {
MessageActionItem,
MessageActionItemOrDivider,
MessageActionsConfig,
} from '../../../types';
import { useGroupActions } from './useGroupActions';
MessageActionBar,
type MessageActionContext,
type MessageActionSlot,
} from '../../components/MessageActionBar';
// Helper to strip handleClick from action items before passing to ActionIconGroup
const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => {
if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType;
const { children, ...rest } = item as MessageActionItem;
const baseItem = { ...rest } as MessageActionItem;
delete (baseItem as { handleClick?: unknown }).handleClick;
if (children) {
return {
...baseItem,
children: children.map((child) => {
const nextChild = { ...child } as MessageActionItem;
delete (nextChild as { handleClick?: unknown }).handleClick;
return nextChild;
}),
} as ActionIconGroupItemType;
}
return baseItem as ActionIconGroupItemType;
};
// Build action items map for handleAction lookup
const buildActionsMap = (items: MessageActionItemOrDivider[]): Map<string, MessageActionItem> => {
const map = new Map<string, MessageActionItem>();
for (const item of items) {
if ('key' in item && item.key) {
map.set(String(item.key), item as MessageActionItem);
// Also index children for submenu items
if ('children' in item && item.children) {
for (const child of item.children) {
if (child.key) {
map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem);
}
}
}
}
}
return map;
};
const DEFAULT_BAR_WITH_TOOLS: MessageActionSlot[] = ['delAndRegenerate', 'copy'];
const DEFAULT_BAR: MessageActionSlot[] = ['edit', 'copy'];
const DEFAULT_MENU: MessageActionSlot[] = [
'edit',
'copy',
'collapse',
'divider',
'share',
'divider',
'regenerate',
'del',
];
const EMPTY_GROUP_BAR: MessageActionSlot[] = ['continueGeneration', 'delAndRegenerate', 'del'];
interface GroupActionsProps {
actionsConfig?: MessageActionsConfig;
@ -65,165 +32,29 @@ interface GroupActionsProps {
id: string;
}
/**
* Actions bar for group messages with content (has assistant message content)
*/
const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, contentBlock }) => {
const { tools } = data;
const store = useConversationStoreApi();
const handleOpenShareModal = useCallback(() => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = store.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{
message: data,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
}, [data, store]);
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
const defaultActions = useGroupActions({
contentBlock,
data,
id,
onOpenShareModal: handleOpenShareModal,
});
const hasTools = !!tools;
// Get collapse/expand action based on current state
const collapseAction = isCollapsed ? defaultActions.expand : defaultActions.collapse;
// Use external config if provided, otherwise use defaults
const barItems =
actionsConfig?.bar ??
(hasTools
? [defaultActions.delAndRegenerate, defaultActions.copy]
: [defaultActions.edit, defaultActions.copy]);
const menuItems = actionsConfig?.menu ?? [
defaultActions.edit,
defaultActions.copy,
collapseAction,
defaultActions.divider,
defaultActions.share,
defaultActions.divider,
defaultActions.regenerate,
defaultActions.del,
];
// Strip handleClick for DOM safety
const items = useMemo(
() =>
barItems
.filter((item) => item && !('disabled' in item && item.disabled))
.map(stripHandleClick),
[barItems],
);
const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]);
// Build actions map for click handling
const allActions = useMemo(
() => buildActionsMap([...barItems, ...menuItems]),
[barItems, menuItems],
);
const handleAction = useCallback(
(event: ActionIconGroupEvent) => {
// Handle submenu items (e.g., translate -> zh-CN)
if (event.keyPath && event.keyPath.length > 1) {
const parentKey = event.keyPath.at(-1);
const childKey = event.keyPath[0];
const parent = allActions.get(parentKey!);
if (parent && 'children' in parent && parent.children) {
const child = parent.children.find((c) => c.key === childKey);
child?.handleClick?.();
return;
}
}
// Handle regular actions
const action = allActions.get(event.key);
action?.handleClick?.();
},
[allActions],
);
return (
<Flexbox horizontal align={'center'} gap={8}>
<ReactionPicker messageId={id} />
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
</Flexbox>
);
});
WithContentId.displayName = 'GroupActionsWithContentId';
/**
* Actions bar for group messages without content (empty assistant response)
*/
const WithoutContentId = memo<Omit<GroupActionsProps, 'contentBlock' | 'contentId'>>(
({ actionsConfig, id, data }) => {
const defaultActions = useGroupActions({
data,
id,
});
// Use external config if provided, otherwise use defaults
const barItems = actionsConfig?.bar ?? [
defaultActions.continueGeneration,
defaultActions.delAndRegenerate,
defaultActions.del,
];
// Strip handleClick for DOM safety
const items = useMemo(() => barItems.map(stripHandleClick), [barItems]);
// Build actions map for click handling
const allActions = useMemo(() => buildActionsMap(barItems), [barItems]);
const handleAction = useCallback(
(event: ActionIconGroupEvent) => {
const action = allActions.get(event.key);
action?.handleClick?.();
},
[allActions],
);
return <ActionIconGroup items={items} onActionClick={handleAction} />;
},
);
WithoutContentId.displayName = 'GroupActionsWithoutContentId';
/**
* Main GroupActionsBar component that renders appropriate variant
*/
export const GroupActionsBar = memo<GroupActionsProps>(
({ actionsConfig, id, data, contentBlock, contentId }) => {
if (!contentId) return <WithoutContentId actionsConfig={actionsConfig} data={data} id={id} />;
const ctx = useMemo<MessageActionContext>(
() => ({ contentBlock, data, id, role: 'group' }),
[contentBlock, data, id],
);
// Empty group (no assistant content) — only allows continuing / reset / delete
if (!contentId) {
return <MessageActionBar bar={actionsConfig?.bar ?? EMPTY_GROUP_BAR} ctx={ctx} />;
}
const defaultBar = data.tools ? DEFAULT_BAR_WITH_TOOLS : DEFAULT_BAR;
return (
<WithContentId
actionsConfig={actionsConfig}
contentBlock={contentBlock}
data={data}
id={id}
/>
<Flexbox horizontal align={'center'} gap={8}>
<ReactionPicker messageId={id} />
<MessageActionBar
bar={actionsConfig?.bar ?? defaultBar}
ctx={ctx}
menu={actionsConfig?.menu ?? DEFAULT_MENU}
/>
</Flexbox>
);
},
);

View file

@ -1,195 +0,0 @@
import { type ActionIconGroupItemType } from '@lobehub/ui';
import { copyToClipboard } from '@lobehub/ui';
import { App } from 'antd';
import {
Copy,
Edit,
LanguagesIcon,
ListChevronsDownUp,
ListChevronsUpDown,
ListRestart,
RotateCcw,
Share2,
StepForward,
Trash,
} from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { localeOptions } from '@/locales/resources';
import { type AssistantContentBlock, type UIChatMessage } from '@/types/index';
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../../store';
export interface ActionItem extends ActionIconGroupItemType {
children?: Array<{ handleClick?: () => void; key: string; label: string }>;
handleClick?: () => void | Promise<void>;
}
export interface GroupActions {
collapse: ActionItem;
continueGeneration: ActionItem;
copy: ActionItem;
del: ActionItem;
delAndRegenerate: ActionItem;
divider: { type: 'divider' };
edit: ActionItem;
expand: ActionItem;
regenerate: ActionItem;
share: ActionItem;
translate: ActionItem;
}
interface UseGroupActionsParams {
contentBlock?: AssistantContentBlock;
data: UIChatMessage;
id: string;
onOpenShareModal?: () => void;
}
export const useGroupActions = ({
id,
data,
contentBlock,
onOpenShareModal,
}: UseGroupActionsParams): GroupActions => {
const { t } = useTranslation(['common', 'chat']);
const { message } = App.useApp();
// Get state from ConversationStore
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id));
const lastBlockId = useConversationStore(dataSelectors.findLastMessageId(id));
const isContinuing = useConversationStore((s) =>
lastBlockId ? messageStateSelectors.isMessageContinuing(lastBlockId)(s) : false,
);
// Get actions from ConversationStore
const [
toggleMessageEditing,
toggleMessageCollapsed,
deleteMessage,
regenerateAssistantMessage,
translateMessage,
delAndRegenerateMessage,
continueGenerationMessage,
] = useConversationStore((s) => [
s.toggleMessageEditing,
s.toggleMessageCollapsed,
s.deleteMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.delAndRegenerateMessage,
s.continueGenerationMessage,
]);
return useMemo<GroupActions>(
() => ({
collapse: {
handleClick: () => toggleMessageCollapsed(id),
icon: ListChevronsDownUp,
key: 'collapse',
label: t('messageAction.collapse', { ns: 'chat' }),
},
continueGeneration: {
disabled: isContinuing,
handleClick: () => {
if (!lastBlockId) return;
continueGenerationMessage(id, lastBlockId);
},
icon: StepForward,
key: 'continueGeneration',
label: t('messageAction.continueGeneration', { ns: 'chat' }),
spin: isContinuing || undefined,
},
copy: {
handleClick: async () => {
if (!contentBlock) return;
await copyToClipboard(contentBlock.content);
message.success(t('copySuccess'));
},
icon: Copy,
key: 'copy',
label: t('copy'),
},
del: {
danger: true,
handleClick: () => deleteMessage(id),
icon: Trash,
key: 'del',
label: t('delete'),
},
delAndRegenerate: {
disabled: isRegenerating,
handleClick: () => delAndRegenerateMessage(id),
icon: ListRestart,
key: 'delAndRegenerate',
label: t('messageAction.delAndRegenerate', { ns: 'chat' }),
},
divider: {
type: 'divider',
},
edit: {
handleClick: () => {
if (!contentBlock) return;
toggleMessageEditing(contentBlock.id, true);
},
icon: Edit,
key: 'edit',
label: t('edit'),
},
expand: {
handleClick: () => toggleMessageCollapsed(id),
icon: ListChevronsUpDown,
key: 'expand',
label: t('messageAction.expand', { ns: 'chat' }),
},
regenerate: {
disabled: isRegenerating,
handleClick: () => {
regenerateAssistantMessage(id);
if (data.error) deleteMessage(id);
},
icon: RotateCcw,
key: 'regenerate',
label: t('regenerate'),
spin: isRegenerating || undefined,
},
share: {
handleClick: onOpenShareModal,
icon: Share2,
key: 'share',
label: t('share'),
},
translate: {
children: localeOptions.map((i) => ({
handleClick: () => translateMessage(id, i.value),
key: i.value,
label: t(`lang.${i.value}`),
})),
icon: LanguagesIcon,
key: 'translate',
label: t('translate.action', { ns: 'chat' }),
},
}),
[
id,
contentBlock,
data.error,
isRegenerating,
isContinuing,
isCollapsed,
lastBlockId,
toggleMessageEditing,
deleteMessage,
regenerateAssistantMessage,
translateMessage,
delAndRegenerateMessage,
toggleMessageCollapsed,
continueGenerationMessage,
onOpenShareModal,
message,
],
);
};

View file

@ -24,13 +24,13 @@ interface SingletonPortalProps {
index: number;
}
const AssistantActionsRenderer: FC<SingletonPortalProps> = ({ id, index }) => {
const AssistantActionsRenderer: FC<SingletonPortalProps> = ({ id }) => {
const actionsConfig = useConversationStore((s) => s.actionsBar?.assistant);
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual);
if (!item) return null;
return <AssistantActionsBar actionsConfig={actionsConfig} data={item} id={id} index={index} />;
return <AssistantActionsBar actionsConfig={actionsConfig} data={item} id={id} />;
};
const UserActionsRenderer: FC<SingletonPortalProps> = ({ id }) => {

View file

@ -19,7 +19,6 @@ import TaskItem from './TaskItem';
interface GroupTasksMessageProps {
id: string;
index: number;
}
/**
@ -62,7 +61,7 @@ const GroupTasksAvatar = memo<{ avatars: { avatar?: string; background?: string
GroupTasksAvatar.displayName = 'GroupTasksAvatar';
const GroupTasksMessage = memo<GroupTasksMessageProps>(({ id, index }) => {
const GroupTasksMessage = memo<GroupTasksMessageProps>(({ id }) => {
const { t } = useTranslation('chat');
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
const actionsConfig = useConversationStore((s) => s.actionsBar?.assistant);
@ -126,6 +125,7 @@ const GroupTasksMessage = memo<GroupTasksMessageProps>(({ id, index }) => {
<ChatItem
showTitle
aboveMessage={null}
actions={<AssistantActionsBar actionsConfig={actionsConfig} data={item} id={id} />}
avatar={{ title }}
customAvatarRender={() => <GroupTasksAvatar avatars={taskAgents} />}
id={id}
@ -133,9 +133,6 @@ const GroupTasksMessage = memo<GroupTasksMessageProps>(({ id, index }) => {
placement="left"
time={createdAt}
titleAddon={<Tag>{t('task.groupTasks', { count: tasks.length })}</Tag>}
actions={
<AssistantActionsBar actionsConfig={actionsConfig} data={item} id={id} index={index} />
}
>
<Flexbox gap={8} width={'100%'}>
{tasks.map((task) => (

View file

@ -1,184 +0,0 @@
import type { AssistantContentBlock, UIChatMessage } from '@lobechat/types';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { ActionIconGroup, createRawModal, Flexbox } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { ReactionPicker } from '../../../components/Reaction';
import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal';
import {
createStore,
messageStateSelectors,
Provider,
useConversationStore,
useConversationStoreApi,
} from '../../../store';
import type {
MessageActionItem,
MessageActionItemOrDivider,
MessageActionsConfig,
} from '../../../types';
import { useGroupActions } from './useGroupActions';
// Helper to strip handleClick from action items before passing to ActionIconGroup
const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => {
if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType;
const { children, ...rest } = item as MessageActionItem;
const baseItem = { ...rest } as MessageActionItem;
delete (baseItem as { handleClick?: unknown }).handleClick;
if (children) {
return {
...baseItem,
children: children.map((child) => {
const nextChild = { ...child } as MessageActionItem;
delete (nextChild as { handleClick?: unknown }).handleClick;
return nextChild;
}),
} as ActionIconGroupItemType;
}
return baseItem as ActionIconGroupItemType;
};
// Build action items map for handleAction lookup
const buildActionsMap = (items: MessageActionItemOrDivider[]): Map<string, MessageActionItem> => {
const map = new Map<string, MessageActionItem>();
for (const item of items) {
if ('key' in item && item.key) {
map.set(String(item.key), item as MessageActionItem);
// Also index children for submenu items
if ('children' in item && item.children) {
for (const child of item.children) {
if (child.key) {
map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem);
}
}
}
}
}
return map;
};
interface GroupActionsProps {
actionsConfig?: MessageActionsConfig;
contentBlock?: AssistantContentBlock;
contentId?: string;
data: UIChatMessage;
id: string;
}
/**
* Actions bar for group messages with content (has assistant message content)
*/
const WithContentId = memo<GroupActionsProps>(({ actionsConfig, id, data, contentBlock }) => {
const store = useConversationStoreApi();
const handleOpenShareModal = useCallback(() => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = store.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{
message: data,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
}, [data, store]);
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
const defaultActions = useGroupActions({
contentBlock,
data,
id,
onOpenShareModal: handleOpenShareModal,
});
// Get collapse/expand action based on current state
const collapseAction = isCollapsed ? defaultActions.expand : defaultActions.collapse;
// Use external config if provided, otherwise use defaults
const barItems = actionsConfig?.bar ?? [defaultActions.copy];
const menuItems = actionsConfig?.menu ?? [
defaultActions.copy,
collapseAction,
defaultActions.divider,
defaultActions.share,
defaultActions.divider,
defaultActions.regenerate,
defaultActions.del,
];
// Strip handleClick for DOM safety
const items = useMemo(
() =>
barItems
.filter((item) => item && !('disabled' in item && item.disabled))
.map(stripHandleClick),
[barItems],
);
const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]);
// Build actions map for click handling
const allActions = useMemo(
() => buildActionsMap([...barItems, ...menuItems]),
[barItems, menuItems],
);
const handleAction = useCallback(
(event: ActionIconGroupEvent) => {
// Handle submenu items (e.g., translate -> zh-CN)
if (event.keyPath && event.keyPath.length > 1) {
const parentKey = event.keyPath.at(-1);
const childKey = event.keyPath[0];
const parent = allActions.get(parentKey!);
if (parent && 'children' in parent && parent.children) {
const child = parent.children.find((c) => c.key === childKey);
child?.handleClick?.();
return;
}
}
// Handle regular actions
const action = allActions.get(event.key);
action?.handleClick?.();
},
[allActions],
);
return (
<Flexbox horizontal align={'center'} gap={8}>
<ReactionPicker messageId={id} />
<ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />
</Flexbox>
);
});
WithContentId.displayName = 'GroupActionsWithContentId';
/**
* Main GroupActionsBar component that renders appropriate variant
*/
export const GroupActionsBar = memo<GroupActionsProps>(
({ actionsConfig, id, data, contentBlock }) => {
return (
<WithContentId
actionsConfig={actionsConfig}
contentBlock={contentBlock}
data={data}
id={id}
/>
);
},
);
GroupActionsBar.displayName = 'GroupActionsBar';

View file

@ -1,171 +0,0 @@
import { type ActionIconGroupItemType } from '@lobehub/ui';
import { copyToClipboard } from '@lobehub/ui';
import { App } from 'antd';
import {
Copy,
LanguagesIcon,
ListChevronsDownUp,
ListChevronsUpDown,
ListRestart,
RotateCcw,
Share2,
Trash,
} from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { localeOptions } from '@/locales/resources';
import { type AssistantContentBlock, type UIChatMessage } from '@/types/index';
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../../store';
export interface ActionItem extends ActionIconGroupItemType {
children?: Array<{ handleClick?: () => void; key: string; label: string }>;
handleClick?: () => void | Promise<void>;
}
export interface GroupActions {
collapse: ActionItem;
copy: ActionItem;
del: ActionItem;
delAndRegenerate: ActionItem;
divider: { type: 'divider' };
expand: ActionItem;
regenerate: ActionItem;
share: ActionItem;
translate: ActionItem;
}
interface UseGroupActionsParams {
contentBlock?: AssistantContentBlock;
data: UIChatMessage;
id: string;
onOpenShareModal?: () => void;
}
export const useGroupActions = ({
id,
data,
contentBlock,
onOpenShareModal,
}: UseGroupActionsParams): GroupActions => {
const { t } = useTranslation(['common', 'chat']);
const { message } = App.useApp();
// Get state from ConversationStore
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id));
const lastBlockId = useConversationStore(dataSelectors.findLastMessageId(id));
const isContinuing = useConversationStore((s) =>
lastBlockId ? messageStateSelectors.isMessageContinuing(lastBlockId)(s) : false,
);
// Get actions from ConversationStore
const [
toggleMessageEditing,
toggleMessageCollapsed,
deleteMessage,
regenerateAssistantMessage,
translateMessage,
delAndRegenerateMessage,
continueGenerationMessage,
] = useConversationStore((s) => [
s.toggleMessageEditing,
s.toggleMessageCollapsed,
s.deleteMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.delAndRegenerateMessage,
s.continueGenerationMessage,
]);
return useMemo<GroupActions>(
() => ({
collapse: {
handleClick: () => toggleMessageCollapsed(id),
icon: ListChevronsDownUp,
key: 'collapse',
label: t('messageAction.collapse', { ns: 'chat' }),
},
copy: {
handleClick: async () => {
if (!contentBlock) return;
await copyToClipboard(contentBlock.content);
message.success(t('copySuccess'));
},
icon: Copy,
key: 'copy',
label: t('copy'),
},
del: {
danger: true,
handleClick: () => deleteMessage(id),
icon: Trash,
key: 'del',
label: t('delete'),
},
delAndRegenerate: {
disabled: isRegenerating,
handleClick: () => delAndRegenerateMessage(id),
icon: ListRestart,
key: 'delAndRegenerate',
label: t('messageAction.delAndRegenerate', { ns: 'chat' }),
},
divider: {
type: 'divider',
},
expand: {
handleClick: () => toggleMessageCollapsed(id),
icon: ListChevronsUpDown,
key: 'expand',
label: t('messageAction.expand', { ns: 'chat' }),
},
regenerate: {
disabled: isRegenerating,
handleClick: () => {
regenerateAssistantMessage(id);
if (data.error) deleteMessage(id);
},
icon: RotateCcw,
key: 'regenerate',
label: t('regenerate'),
spin: isRegenerating || undefined,
},
share: {
handleClick: onOpenShareModal,
icon: Share2,
key: 'share',
label: t('share'),
},
translate: {
children: localeOptions.map((i) => ({
handleClick: () => translateMessage(id, i.value),
key: i.value,
label: t(`lang.${i.value}`),
})),
icon: LanguagesIcon,
key: 'translate',
label: t('translate.action', { ns: 'chat' }),
},
}),
[
t,
id,
contentBlock,
data.error,
isRegenerating,
isContinuing,
isCollapsed,
lastBlockId,
toggleMessageEditing,
deleteMessage,
regenerateAssistantMessage,
translateMessage,
delAndRegenerateMessage,
toggleMessageCollapsed,
continueGenerationMessage,
onOpenShareModal,
message,
],
);
};

View file

@ -1,24 +0,0 @@
import { type ActionIconGroupEvent } from '@lobehub/ui';
import { ActionIconGroup } from '@lobehub/ui';
import { memo } from 'react';
import { type AssistantActions } from './useAssistantActions';
interface ErrorActionsBarProps {
actions: AssistantActions;
onActionClick: (event: ActionIconGroupEvent) => void;
}
export const ErrorActionsBar = memo<ErrorActionsBarProps>(({ actions, onActionClick }) => {
const { regenerate, copy, edit, del, divider } = actions;
return (
<ActionIconGroup
items={[regenerate, del]}
menu={[edit, copy, divider, del]}
onActionClick={onActionClick}
/>
);
});
ErrorActionsBar.displayName = 'ErrorActionsBar';

View file

@ -1,194 +1,57 @@
import { type UIChatMessage } from '@lobechat/types';
import { type ActionIconGroupEvent, type ActionIconGroupItemType } from '@lobehub/ui';
import { ActionIconGroup, createRawModal } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { useEventCallback } from '@/hooks/useEventCallback';
import { type ShareModalProps } from '../../../components/ShareMessageModal';
import ShareMessageModal from '../../../components/ShareMessageModal';
import { createStore, Provider, useConversationStoreApi } from '../../../store';
import type { MessageActionsConfig } from '../../../types';
import {
type MessageActionItem,
type MessageActionItemOrDivider,
type MessageActionsConfig,
} from '../../../types';
import { ErrorActionsBar } from './Error';
import { useAssistantActions } from './useAssistantActions';
MessageActionBar,
type MessageActionContext,
type MessageActionSlot,
} from '../../components/MessageActionBar';
// Helper to strip handleClick from action items before passing to ActionIconGroup
const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => {
if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType;
const { children, ...rest } = item as MessageActionItem;
const baseItem = { ...rest } as MessageActionItem;
delete (baseItem as { handleClick?: unknown }).handleClick;
if (children) {
return {
...baseItem,
children: children.map((child) => {
const nextChild = { ...child } as MessageActionItem;
delete (nextChild as { handleClick?: unknown }).handleClick;
return nextChild;
}),
} as ActionIconGroupItemType;
}
return baseItem as ActionIconGroupItemType;
};
// Build action items map for handleAction lookup
const buildActionsMap = (items: MessageActionItemOrDivider[]): Map<string, MessageActionItem> => {
const map = new Map<string, MessageActionItem>();
for (const item of items) {
if ('key' in item && item.key) {
map.set(String(item.key), item as MessageActionItem);
// Also index children for submenu items
if ('children' in item && item.children) {
for (const child of item.children) {
if (child.key) {
map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem);
}
}
}
}
}
return map;
};
const DEFAULT_BAR_WITH_TOOLS: MessageActionSlot[] = ['copy'];
const DEFAULT_BAR: MessageActionSlot[] = ['edit', 'copy'];
const DEFAULT_MENU: MessageActionSlot[] = [
'edit',
'copy',
'collapse',
'divider',
'share',
'divider',
'regenerate',
'del',
];
const ERROR_BAR: MessageActionSlot[] = ['regenerate', 'del'];
const ERROR_MENU: MessageActionSlot[] = ['edit', 'copy', 'divider', 'del'];
interface AssistantActionsBarProps {
actionsConfig?: MessageActionsConfig;
data: UIChatMessage;
id: string;
index: number;
}
export const AssistantActionsBar = memo<AssistantActionsBarProps>(
({ actionsConfig, id, data, index }) => {
const { error, tools } = data;
const store = useConversationStoreApi();
const handleOpenShareModal = useEventCallback(() => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = store.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{
message: data,
},
{ onCloseKey: 'onCancel', openKey: 'open' },
);
});
/**
* Action bar for Task / Tasks / GroupTasks messages. Uses `assistant` role
* context but with a slimmer default menu (no tts / translate /
* delAndRegenerate).
*/
export const AssistantActionsBar = memo<AssistantActionsBarProps>(({ actionsConfig, id, data }) => {
const ctx = useMemo<MessageActionContext>(() => ({ data, id, role: 'assistant' }), [data, id]);
const defaultActions = useAssistantActions({
data,
id,
index,
onOpenShareModal: handleOpenShareModal,
});
const { error, tools } = data;
const hasTools = !!tools;
if (error) {
return <MessageActionBar bar={ERROR_BAR} ctx={ctx} menu={ERROR_MENU} />;
}
// Get collapse/expand action based on current state
const collapseAction = defaultActions.collapse;
const defaultBar = tools ? DEFAULT_BAR_WITH_TOOLS : DEFAULT_BAR;
// Create extra actions from factory functions
const extraBarItems = useMemo(() => {
if (!actionsConfig?.extraBarActions) return [];
return actionsConfig.extraBarActions
.map((factory) => factory(id))
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [actionsConfig?.extraBarActions, id]);
return (
<MessageActionBar
bar={actionsConfig?.bar ?? defaultBar}
ctx={ctx}
menu={actionsConfig?.menu ?? DEFAULT_MENU}
/>
);
});
const extraMenuItems = useMemo(() => {
if (!actionsConfig?.extraMenuActions) return [];
return actionsConfig.extraMenuActions
.map((factory) => factory(id))
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [actionsConfig?.extraMenuActions, id]);
// Use external config if provided, otherwise use defaults
// Append extra actions from factories
const barItems = useMemo(() => {
const base =
actionsConfig?.bar ??
(hasTools ? [defaultActions.copy] : [defaultActions.edit, defaultActions.copy]);
return [...base, ...extraBarItems];
}, [actionsConfig?.bar, hasTools, defaultActions.copy, defaultActions.edit, extraBarItems]);
const menuItems = useMemo(() => {
const base = actionsConfig?.menu ?? [
defaultActions.edit,
defaultActions.copy,
collapseAction,
defaultActions.divider,
defaultActions.share,
defaultActions.divider,
defaultActions.regenerate,
defaultActions.del,
];
return [...base, ...extraMenuItems];
}, [
actionsConfig?.menu,
defaultActions.edit,
defaultActions.copy,
collapseAction,
defaultActions.divider,
defaultActions.share,
defaultActions.regenerate,
defaultActions.del,
extraMenuItems,
]);
// Strip handleClick for DOM safety
const items = useMemo(
() =>
barItems
.filter((item) => item && !('disabled' in item && item.disabled))
.map(stripHandleClick),
[barItems],
);
const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]);
// Build actions map for click handling
const allActions = useMemo(
() => buildActionsMap([...barItems, ...menuItems]),
[barItems, menuItems],
);
const handleAction = useCallback(
(event: ActionIconGroupEvent) => {
// Handle submenu items (e.g., translate -> zh-CN)
if (event.keyPath && event.keyPath.length > 1) {
const parentKey = event.keyPath.at(-1);
const childKey = event.keyPath[0];
const parent = allActions.get(parentKey!);
if (parent && 'children' in parent && parent.children) {
const child = parent.children.find((c) => c.key === childKey);
child?.handleClick?.();
return;
}
}
// Handle regular actions
const action = allActions.get(event.key);
action?.handleClick?.();
},
[allActions],
);
if (error) return <ErrorActionsBar actions={defaultActions} onActionClick={handleAction} />;
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
},
);
AssistantActionsBar.displayName = 'AssistantActionsBar';
AssistantActionsBar.displayName = 'TaskAssistantActionsBar';

View file

@ -1,151 +0,0 @@
import { type ActionIconGroupItemType } from '@lobehub/ui';
import { copyToClipboard } from '@lobehub/ui';
import { App } from 'antd';
import {
Copy,
Edit,
ListChevronsDownUp,
ListRestart,
RotateCcw,
Share2,
Trash,
} from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { type UIChatMessage } from '@/types/index';
import { messageStateSelectors, useConversationStore } from '../../../store';
export interface ActionItem extends ActionIconGroupItemType {
children?: Array<{ handleClick?: () => void; key: string; label: string }>;
handleClick?: () => void | Promise<void>;
}
export interface AssistantActions {
collapse: ActionItem;
copy: ActionItem;
del: ActionItem;
divider: { type: 'divider' };
edit: ActionItem;
regenerate: ActionItem;
share: ActionItem;
}
interface UseAssistantActionsParams {
data: UIChatMessage;
id: string;
index: number;
onOpenShareModal?: () => void;
}
export const useAssistantActions = ({
id,
data,
onOpenShareModal,
}: UseAssistantActionsParams): AssistantActions => {
const { t } = useTranslation(['common', 'chat']);
const { message } = App.useApp();
// Get state from ConversationStore
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id));
const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id));
// Get actions from ConversationStore
const [
toggleMessageEditing,
toggleMessageCollapsed,
deleteMessage,
regenerateAssistantMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
] = useConversationStore((s) => [
s.toggleMessageEditing,
s.toggleMessageCollapsed,
s.deleteMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.ttsMessage,
s.delAndRegenerateMessage,
]);
return useMemo<AssistantActions>(
() => ({
collapse: {
handleClick: () => toggleMessageCollapsed(id),
icon: ListChevronsDownUp,
key: 'collapse',
label: t('messageAction.collapse', { ns: 'chat' }),
},
copy: {
handleClick: async () => {
await copyToClipboard(data.content);
message.success(t('copySuccess'));
},
icon: Copy,
key: 'copy',
label: t('copy'),
},
del: {
danger: true,
handleClick: () => deleteMessage(id),
icon: Trash,
key: 'del',
label: t('delete'),
},
delAndRegenerate: {
disabled: isRegenerating,
handleClick: () => delAndRegenerateMessage(id),
icon: ListRestart,
key: 'delAndRegenerate',
label: t('messageAction.delAndRegenerate', { ns: 'chat' }),
},
divider: {
type: 'divider',
},
edit: {
handleClick: () => {
toggleMessageEditing(id, true);
},
icon: Edit,
key: 'edit',
label: t('edit'),
},
regenerate: {
disabled: isRegenerating,
handleClick: () => {
regenerateAssistantMessage(id);
if (data.error) deleteMessage(id);
},
icon: RotateCcw,
key: 'regenerate',
label: t('regenerate'),
spin: isRegenerating || undefined,
},
share: {
handleClick: onOpenShareModal,
icon: Share2,
key: 'share',
label: t('share'),
},
}),
[
t,
id,
data.content,
data.error,
isRegenerating,
isCollapsed,
toggleMessageEditing,
deleteMessage,
regenerateAssistantMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
toggleMessageCollapsed,
onOpenShareModal,
message,
],
);
};

View file

@ -69,6 +69,7 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing }) => {
<ChatItem
showTitle
aboveMessage={null}
actions={<AssistantActionsBar actionsConfig={actionsConfig} data={item} id={id} />}
avatar={{ ...avatar, title }}
customAvatarRender={(_, node) => <TaskAvatar>{node}</TaskAvatar>}
customErrorRender={(error) => <ErrorMessageExtra data={item} error={error} />}
@ -79,9 +80,6 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing }) => {
placement={'left'}
time={createdAt}
titleAddon={<Tag>{t('task.subtask')}</Tag>}
actions={
<AssistantActionsBar actionsConfig={actionsConfig} data={item} id={id} index={index} />
}
error={
errorContent && error && (message === LOADING_FLAT || !message) ? errorContent : undefined
}

View file

@ -16,10 +16,9 @@ import TaskItem from './TaskItem';
interface TasksMessageProps {
id: string;
index: number;
}
const TasksMessage = memo<TasksMessageProps>(({ id, index }) => {
const TasksMessage = memo<TasksMessageProps>(({ id }) => {
const { t } = useTranslation('chat');
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
const actionsConfig = useConversationStore((s) => s.actionsBar?.assistant);
@ -39,6 +38,7 @@ const TasksMessage = memo<TasksMessageProps>(({ id, index }) => {
<ChatItem
showTitle
aboveMessage={null}
actions={<AssistantActionsBar actionsConfig={actionsConfig} data={item} id={id} />}
avatar={avatar}
customAvatarRender={(_, node) => <TaskAvatar>{node}</TaskAvatar>}
id={id}
@ -46,9 +46,6 @@ const TasksMessage = memo<TasksMessageProps>(({ id, index }) => {
placement="left"
time={createdAt}
titleAddon={<Tag>{t('task.batchTasks', { count: tasks.length })}</Tag>}
actions={
<AssistantActionsBar actionsConfig={actionsConfig} data={item} id={id} index={index} />
}
>
<Flexbox gap={8} width={'100%'}>
{tasks.map((task) => (

View file

@ -1,166 +1,60 @@
import { type UIChatMessage } from '@lobechat/types';
import { type ActionIconGroupEvent, type ActionIconGroupItemType } from '@lobehub/ui';
import { ActionIconGroup, Flexbox } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { Flexbox } from '@lobehub/ui';
import { memo, useMemo } from 'react';
import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { type MessageActionsConfig } from '../../../types';
import {
type MessageActionItem,
type MessageActionItemOrDivider,
type MessageActionsConfig,
} from '../../../types';
MessageActionBar,
type MessageActionContext,
type MessageActionSlot,
} from '../../components/MessageActionBar';
import MessageBranch from '../../components/MessageBranch';
import { useUserActions } from './useUserActions';
// Helper to strip handleClick from action items before passing to ActionIconGroup
const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => {
if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType;
const { children, ...rest } = item as MessageActionItem;
const baseItem = { ...rest } as MessageActionItem;
delete (baseItem as { handleClick?: unknown }).handleClick;
if (children) {
return {
...baseItem,
children: children.map((child) => {
const nextChild = { ...child } as MessageActionItem;
delete (nextChild as { handleClick?: unknown }).handleClick;
return nextChild;
}),
} as ActionIconGroupItemType;
}
return baseItem as ActionIconGroupItemType;
};
// Build action items map for handleAction lookup
const buildActionsMap = (items: MessageActionItemOrDivider[]): Map<string, MessageActionItem> => {
const map = new Map<string, MessageActionItem>();
for (const item of items) {
if ('key' in item && item.key) {
map.set(String(item.key), item as MessageActionItem);
// Also index children for submenu items
if ('children' in item && item.children) {
for (const child of item.children) {
if (child.key) {
map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem);
}
}
}
}
}
return map;
};
const DEFAULT_BAR: MessageActionSlot[] = ['regenerate', 'edit', 'copy'];
const DEFAULT_MENU: MessageActionSlot[] = [
'edit',
'copy',
'divider',
'tts',
'translate',
'divider',
'regenerate',
'del',
];
interface UserActionsProps {
actionsConfig?: MessageActionsConfig;
data: UIChatMessage;
disableEditing?: boolean;
id: string;
}
export const UserActionsBar = memo<UserActionsProps>(({ actionsConfig, id, data }) => {
// Get default actions from hook
const defaultActions = useUserActions({ data, id });
// Create extra actions from factory functions
const extraBarItems = useMemo(() => {
if (!actionsConfig?.extraBarActions) return [];
return actionsConfig.extraBarActions
.map((factory) => factory(id))
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [actionsConfig?.extraBarActions, id]);
const extraMenuItems = useMemo(() => {
if (!actionsConfig?.extraMenuActions) return [];
return actionsConfig.extraMenuActions
.map((factory) => factory(id))
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [actionsConfig?.extraMenuActions, id]);
// Use external config if provided, otherwise use defaults
// Append extra actions from factories
const barItems = useMemo(() => {
const base = actionsConfig?.bar ?? [
defaultActions.regenerate,
defaultActions.edit,
defaultActions.copy,
];
return [...base, ...extraBarItems];
}, [actionsConfig?.bar, defaultActions.regenerate, defaultActions.edit, extraBarItems]);
const menuItems = useMemo(() => {
const base = actionsConfig?.menu ?? [
defaultActions.edit,
defaultActions.copy,
defaultActions.divider,
defaultActions.tts,
defaultActions.translate,
defaultActions.divider,
defaultActions.regenerate,
defaultActions.del,
];
return [...base, ...extraMenuItems];
}, [
actionsConfig?.menu,
defaultActions.edit,
defaultActions.copy,
defaultActions.divider,
defaultActions.tts,
defaultActions.translate,
defaultActions.regenerate,
defaultActions.del,
extraMenuItems,
]);
// Strip handleClick for DOM safety
const items = useMemo(() => barItems.map(stripHandleClick), [barItems]);
const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]);
// Build actions map for click handling
const allActions = useMemo(
() => buildActionsMap([...barItems, ...menuItems]),
[barItems, menuItems],
const ctx = useMemo<MessageActionContext>(() => ({ data, id, role: 'user' }), [data, id]);
return (
<MessageActionBar
bar={actionsConfig?.bar ?? DEFAULT_BAR}
ctx={ctx}
menu={actionsConfig?.menu ?? DEFAULT_MENU}
/>
);
const handleAction = useCallback(
(event: ActionIconGroupEvent) => {
// Handle submenu items (e.g., translate -> zh-CN)
if (event.keyPath && event.keyPath.length > 1) {
const parentKey = event.keyPath.at(-1);
const childKey = event.keyPath[0];
const parent = allActions.get(parentKey!);
if (parent && 'children' in parent && parent.children) {
const child = parent.children.find((c) => c.key === childKey);
child?.handleClick?.();
return;
}
}
// Handle regular actions
const action = allActions.get(event.key);
action?.handleClick?.();
},
[allActions],
);
return <ActionIconGroup items={items} menu={menu} onActionClick={handleAction} />;
});
UserActionsBar.displayName = 'UserActionsBar';
interface ActionsProps {
actionsConfig?: MessageActionsConfig;
data: UIChatMessage;
disableEditing?: boolean;
id: string;
index: number;
}
const actionBarHolder = (
<div {...{ [MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES.user]: '' }} style={{ height: '28px' }} />
);
const Actions = memo<ActionsProps>(({ id, data, disableEditing }) => {
const { branch } = data;
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);

View file

@ -1,122 +0,0 @@
import { type ActionIconGroupItemType } from '@lobehub/ui';
import { copyToClipboard } from '@lobehub/ui';
import { App } from 'antd';
import { Copy, Edit, LanguagesIcon, Play, RotateCcw, Trash } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { localeOptions } from '@/locales/resources';
import { cleanSpeakerTag } from '@/store/chat/utils/cleanSpeakerTag';
import { type UIChatMessage } from '@/types/index';
import { messageStateSelectors, useConversationStore } from '../../../store';
export interface ActionItem extends ActionIconGroupItemType {
children?: Array<{ handleClick?: () => void; key: string; label: string }>;
handleClick?: () => void | Promise<void>;
}
export interface UserActions {
copy: ActionItem;
del: ActionItem;
divider: { type: 'divider' };
edit: ActionItem;
regenerate: ActionItem;
translate: ActionItem;
tts: ActionItem;
}
interface UseUserActionsParams {
data: UIChatMessage;
id: string;
}
export const useUserActions = ({ id, data }: UseUserActionsParams): UserActions => {
const { t } = useTranslation('common');
const { message } = App.useApp();
// Get state from ConversationStore
const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id));
// Get actions from ConversationStore
const [toggleMessageEditing, deleteMessage, regenerateUserMessage, translateMessage, ttsMessage] =
useConversationStore((s) => [
s.toggleMessageEditing,
s.deleteMessage,
s.regenerateUserMessage,
s.translateMessage,
s.ttsMessage,
]);
return useMemo<UserActions>(
() => ({
copy: {
handleClick: async () => {
await copyToClipboard(cleanSpeakerTag(data.content));
message.success(t('copySuccess'));
},
icon: Copy,
key: 'copy',
label: t('copy'),
},
del: {
danger: true,
handleClick: () => deleteMessage(id),
icon: Trash,
key: 'del',
label: t('delete'),
},
divider: {
type: 'divider',
},
edit: {
handleClick: () => {
toggleMessageEditing(id, true);
},
icon: Edit,
key: 'edit',
label: t('edit'),
},
regenerate: {
disabled: isRegenerating,
handleClick: () => {
regenerateUserMessage(id);
if (data.error) deleteMessage(id);
},
icon: RotateCcw,
key: 'regenerate',
label: t('regenerate'),
spin: isRegenerating || undefined,
},
translate: {
children: localeOptions.map((i) => ({
key: i.value,
label: t(`lang.${i.value}`),
onClick: () => translateMessage(id, i.value),
})),
icon: LanguagesIcon,
key: 'translate',
label: t('translate.action', { ns: 'chat' }),
},
tts: {
handleClick: () => ttsMessage(id),
icon: Play,
key: 'tts',
label: t('tts.action', { ns: 'chat' }),
},
}),
[
t,
id,
data.content,
data.error,
isRegenerating,
toggleMessageEditing,
deleteMessage,
regenerateUserMessage,
translateMessage,
ttsMessage,
message,
],
);
};

View file

@ -29,7 +29,6 @@ interface UserMessageProps {
const UserMessage = memo<UserMessageProps>(({ id, disableEditing, index }) => {
const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!;
const actionsConfig = useConversationStore((s) => s.actionsBar?.user);
const { content, createdAt, error, role, extra, targetId } = item;
const { t } = useTranslation('chat');
@ -76,6 +75,7 @@ const UserMessage = memo<UserMessageProps>(({ id, disableEditing, index }) => {
return (
<ChatItem
actions={<Actions data={item} disableEditing={disableEditing} id={id} />}
avatar={{ avatar, title }}
editing={editing}
id={id}
@ -86,15 +86,6 @@ const UserMessage = memo<UserMessageProps>(({ id, disableEditing, index }) => {
showTitle={false}
time={createdAt}
titleAddon={dmIndicator}
actions={
<Actions
actionsConfig={actionsConfig}
data={item}
disableEditing={disableEditing}
id={id}
index={index}
/>
}
onDoubleClick={onDoubleClick}
onMouseEnter={onMouseEnter}
>

View file

@ -0,0 +1,33 @@
import { App } from 'antd';
import { Split } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { defineAction } from '../defineAction';
export const branchingAction = defineAction({
key: 'branching',
useBuild: (ctx) => {
const { t } = useTranslation('common');
const { message } = App.useApp();
const [topic, openThreadCreator] = useChatStore((s) => [s.activeTopicId, s.openThreadCreator]);
return useMemo(
() => ({
handleClick: () => {
if (!topic) {
message.warning(t('branchingRequiresSavedTopic'));
return;
}
openThreadCreator(ctx.id);
},
icon: Split,
key: 'branching',
label: t('branching'),
}),
[t, ctx.id, topic, openThreadCreator, message],
);
},
});

View file

@ -0,0 +1,30 @@
import { ListChevronsDownUp, ListChevronsUpDown } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { messageStateSelectors, useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
/**
* Toggle between collapse and expand based on message state. A single key
* (`collapse`) swaps its icon/label depending on whether the message is
* currently collapsed.
*/
export const collapseAction = defineAction({
key: 'collapse',
useBuild: (ctx) => {
const { t } = useTranslation('chat');
const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(ctx.id));
const toggleMessageCollapsed = useConversationStore((s) => s.toggleMessageCollapsed);
return useMemo(
() => ({
handleClick: () => toggleMessageCollapsed(ctx.id),
icon: isCollapsed ? ListChevronsUpDown : ListChevronsDownUp,
key: 'collapse',
label: t(isCollapsed ? 'messageAction.expand' : 'messageAction.collapse'),
}),
[t, ctx.id, isCollapsed, toggleMessageCollapsed],
);
},
});

View file

@ -0,0 +1,33 @@
import { StepForward } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
export const continueGenerationAction = defineAction({
key: 'continueGeneration',
useBuild: (ctx) => {
const { t } = useTranslation('chat');
const lastBlockId = useConversationStore(dataSelectors.findLastMessageId(ctx.id));
const isContinuing = useConversationStore((s) =>
lastBlockId ? messageStateSelectors.isMessageContinuing(lastBlockId)(s) : false,
);
const continueGenerationMessage = useConversationStore((s) => s.continueGenerationMessage);
return useMemo(() => {
if (ctx.role !== 'group') return null;
return {
disabled: isContinuing,
handleClick: () => {
if (!lastBlockId) return;
continueGenerationMessage(ctx.id, lastBlockId);
},
icon: StepForward,
key: 'continueGeneration',
label: t('messageAction.continueGeneration'),
spin: isContinuing || undefined,
};
}, [t, ctx.id, ctx.role, lastBlockId, isContinuing, continueGenerationMessage]);
},
});

View file

@ -0,0 +1,33 @@
import { copyToClipboard } from '@lobehub/ui';
import { App } from 'antd';
import { Copy } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { cleanSpeakerTag } from '@/store/chat/utils/cleanSpeakerTag';
import { defineAction } from '../defineAction';
export const copyAction = defineAction({
key: 'copy',
useBuild: (ctx) => {
const { t } = useTranslation('common');
const { message } = App.useApp();
return useMemo(() => {
const raw =
ctx.role === 'group' ? (ctx.contentBlock?.content ?? ctx.data.content) : ctx.data.content;
const content = ctx.role === 'user' ? cleanSpeakerTag(raw) : raw;
return {
handleClick: async () => {
await copyToClipboard(content);
message.success(t('copySuccess'));
},
icon: Copy,
key: 'copy',
label: t('copy'),
};
}, [t, message, ctx.role, ctx.data.content, ctx.contentBlock?.content]);
},
});

View file

@ -0,0 +1,25 @@
import { Trash } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
export const delAction = defineAction({
key: 'del',
useBuild: (ctx) => {
const { t } = useTranslation('common');
const deleteMessage = useConversationStore((s) => s.deleteMessage);
return useMemo(
() => ({
danger: true,
handleClick: () => deleteMessage(ctx.id),
icon: Trash,
key: 'del',
label: t('delete'),
}),
[t, ctx.id, deleteMessage],
);
},
});

View file

@ -0,0 +1,28 @@
import { ListRestart } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { messageStateSelectors, useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
export const delAndRegenerateAction = defineAction({
key: 'delAndRegenerate',
useBuild: (ctx) => {
const { t } = useTranslation('chat');
const isRegenerating = useConversationStore(
messageStateSelectors.isMessageRegenerating(ctx.id),
);
const delAndRegenerateMessage = useConversationStore((s) => s.delAndRegenerateMessage);
return useMemo(
() => ({
disabled: isRegenerating,
handleClick: () => delAndRegenerateMessage(ctx.id),
icon: ListRestart,
key: 'delAndRegenerate',
label: t('messageAction.delAndRegenerate'),
}),
[t, ctx.id, isRegenerating, delAndRegenerateMessage],
);
},
});

View file

@ -0,0 +1,29 @@
import { Edit } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
export const editAction = defineAction({
key: 'edit',
useBuild: (ctx) => {
const { t } = useTranslation('common');
const toggleMessageEditing = useConversationStore((s) => s.toggleMessageEditing);
return useMemo(() => {
// group edits the inner content block; other roles edit the message itself
const targetId = ctx.role === 'group' ? ctx.contentBlock?.id : ctx.id;
return {
handleClick: () => {
if (!targetId) return;
toggleMessageEditing(targetId, true);
},
icon: Edit,
key: 'edit',
label: t('edit'),
};
}, [t, ctx.role, ctx.id, ctx.contentBlock?.id, toggleMessageEditing]);
},
});

View file

@ -0,0 +1,48 @@
import { RotateCcw } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { messageStateSelectors, useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
export const regenerateAction = defineAction({
key: 'regenerate',
useBuild: (ctx) => {
const { t } = useTranslation('common');
const isRegenerating = useConversationStore(
messageStateSelectors.isMessageRegenerating(ctx.id),
);
const [regenerateUserMessage, regenerateAssistantMessage, deleteMessage] = useConversationStore(
(s) => [s.regenerateUserMessage, s.regenerateAssistantMessage, s.deleteMessage],
);
return useMemo(
() => ({
disabled: isRegenerating,
handleClick: () => {
if (ctx.role === 'user') {
regenerateUserMessage(ctx.id);
if (ctx.data.error) deleteMessage(ctx.id);
} else {
regenerateAssistantMessage(ctx.id);
if (ctx.data.error) deleteMessage(ctx.id);
}
},
icon: RotateCcw,
key: 'regenerate',
label: t('regenerate'),
spin: isRegenerating || undefined,
}),
[
t,
ctx.id,
ctx.role,
ctx.data.error,
isRegenerating,
regenerateUserMessage,
regenerateAssistantMessage,
deleteMessage,
],
);
},
});

View file

@ -0,0 +1,45 @@
import { createRawModal } from '@lobehub/ui';
import { Share2 } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ShareMessageModal, { type ShareModalProps } from '../../../../components/ShareMessageModal';
import { createStore, Provider, useConversationStoreApi } from '../../../../store';
import { defineAction } from '../defineAction';
export const shareAction = defineAction({
key: 'share',
useBuild: (ctx) => {
const { t } = useTranslation('common');
const storeApi = useConversationStoreApi();
return useMemo(() => {
if (ctx.role === 'user') return null;
return {
handleClick: () => {
createRawModal(
(props: ShareModalProps) => (
<Provider
createStore={() => {
const state = storeApi.getState();
return createStore({
context: state.context,
hooks: state.hooks,
skipFetch: state.skipFetch,
});
}}
>
<ShareMessageModal {...props} />
</Provider>
),
{ message: ctx.data },
{ onCloseKey: 'onCancel', openKey: 'open' },
);
},
icon: Share2,
key: 'share',
label: t('share'),
};
}, [t, ctx.role, ctx.data, storeApi]);
},
});

View file

@ -0,0 +1,39 @@
import { css, cx } from 'antd-style';
import { LanguagesIcon } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { localeOptions } from '@/locales/resources';
import { useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
const translateStyle = css`
.ant-dropdown-menu-sub {
overflow-y: scroll;
max-height: 400px;
}
`;
export const translateAction = defineAction({
key: 'translate',
useBuild: (ctx) => {
const { t } = useTranslation(['common', 'chat']);
const translateMessage = useConversationStore((s) => s.translateMessage);
return useMemo(
() => ({
children: localeOptions.map((i) => ({
handleClick: () => translateMessage(ctx.id, i.value),
key: i.value,
label: t(`lang.${i.value}`, { ns: 'common' }),
})),
icon: LanguagesIcon,
key: 'translate',
label: t('translate.action', { ns: 'chat' }),
popupClassName: cx(translateStyle),
}),
[t, ctx.id, translateMessage],
);
},
});

View file

@ -0,0 +1,24 @@
import { Play } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useConversationStore } from '../../../../store';
import { defineAction } from '../defineAction';
export const ttsAction = defineAction({
key: 'tts',
useBuild: (ctx) => {
const { t } = useTranslation('chat');
const ttsMessage = useConversationStore((s) => s.ttsMessage);
return useMemo(
() => ({
handleClick: () => ttsMessage(ctx.id),
icon: Play,
key: 'tts',
label: t('tts.action'),
}),
[t, ctx.id, ttsMessage],
);
},
});

View file

@ -0,0 +1,6 @@
import { type MessageActionDefinition } from './types';
/**
* Identity helper that narrows an action definition's type at the call site.
*/
export const defineAction = (def: MessageActionDefinition): MessageActionDefinition => def;

View file

@ -0,0 +1,116 @@
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { ActionIconGroup } from '@lobehub/ui';
import { memo, useCallback, useMemo } from 'react';
import { type MessageActionItem, type MessageActionItemOrDivider } from '../../../types';
import { DIVIDER_KEY, type MessageActionContext, type MessageActionSlot } from './types';
import { useBuildActions } from './useBuildActions';
const DIVIDER: MessageActionItemOrDivider = { type: 'divider' };
const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => {
if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType;
const { children, ...rest } = item as MessageActionItem;
const baseItem = { ...rest } as MessageActionItem;
delete (baseItem as { handleClick?: unknown }).handleClick;
if (children) {
return {
...baseItem,
children: children.map((child) => {
const nextChild = { ...child } as MessageActionItem;
delete (nextChild as { handleClick?: unknown }).handleClick;
return nextChild;
}),
} as ActionIconGroupItemType;
}
return baseItem as ActionIconGroupItemType;
};
const buildActionsMap = (items: MessageActionItemOrDivider[]): Map<string, MessageActionItem> => {
const map = new Map<string, MessageActionItem>();
for (const item of items) {
if ('key' in item && item.key) {
map.set(String(item.key), item as MessageActionItem);
if ('children' in item && item.children) {
for (const child of item.children) {
if (child.key) {
map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem);
}
}
}
}
}
return map;
};
const resolveSlots = (
slots: MessageActionSlot[],
built: Record<string, MessageActionItem | null>,
): MessageActionItemOrDivider[] => {
const out: MessageActionItemOrDivider[] = [];
for (const slot of slots) {
if (slot === DIVIDER_KEY) {
out.push(DIVIDER);
continue;
}
const item = built[slot];
if (item) out.push(item);
}
return out;
};
interface MessageActionBarProps {
/** Bar slots (always visible as icons) */
bar: MessageActionSlot[];
/** Runtime context passed to every action's builder */
ctx: MessageActionContext;
/** Menu slots (shown in the overflow dropdown); defaults to `bar` when omitted */
menu?: MessageActionSlot[];
}
/**
* Universal action bar. Resolves declarative slot keys (`'copy'`, `'edit'`,
* `'divider'`, ...) against the registry and renders an ActionIconGroup.
*/
export const MessageActionBar = memo<MessageActionBarProps>(({ ctx, bar, menu }) => {
const built = useBuildActions(ctx);
const barItems = useMemo(() => resolveSlots(bar, built), [bar, built]);
const menuItems = useMemo(() => (menu ? resolveSlots(menu, built) : undefined), [menu, built]);
const items = useMemo(
() => barItems.filter((item) => !('disabled' in item && item.disabled)).map(stripHandleClick),
[barItems],
);
const menuStripped = useMemo(() => menuItems?.map(stripHandleClick), [menuItems]);
const allActions = useMemo(
() => buildActionsMap([...barItems, ...(menuItems ?? [])]),
[barItems, menuItems],
);
const handleAction = useCallback(
(event: ActionIconGroupEvent) => {
if (event.keyPath && event.keyPath.length > 1) {
const parentKey = event.keyPath.at(-1);
const childKey = event.keyPath[0];
const parent = allActions.get(parentKey!);
if (parent && 'children' in parent && parent.children) {
const child = parent.children.find((c) => c.key === childKey);
child?.handleClick?.();
return;
}
}
const action = allActions.get(event.key);
action?.handleClick?.();
},
[allActions],
);
return <ActionIconGroup items={items} menu={menuStripped} onActionClick={handleAction} />;
});
MessageActionBar.displayName = 'MessageActionBar';
export type { MessageActionContext, MessageActionSlot } from './types';
export { DIVIDER_KEY } from './types';

View file

@ -0,0 +1,33 @@
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
import { type MessageActionItem } from '../../../types';
export type MessageRole = 'user' | 'assistant' | 'group';
/**
* Runtime context an action builder receives. All fields except `role`/`id`
* may vary actions decide what they care about.
*/
export interface MessageActionContext {
contentBlock?: AssistantContentBlock;
data: UIChatMessage;
id: string;
role: MessageRole;
}
/**
* A registered action. `useBuild` is a hook called unconditionally for every
* message, returns `null` when the action doesn't apply to the current role.
*/
export interface MessageActionDefinition {
key: string;
useBuild: (ctx: MessageActionContext) => MessageActionItem | null;
}
/**
* Slot in a bar/menu list. A string is an action key; `'divider'` inserts a
* divider.
*/
export type MessageActionSlot = string;
export const DIVIDER_KEY = 'divider';

View file

@ -0,0 +1,38 @@
import { type MessageActionItem } from '../../../types';
import { branchingAction } from './actions/branching';
import { collapseAction } from './actions/collapse';
import { continueGenerationAction } from './actions/continueGeneration';
import { copyAction } from './actions/copy';
import { delAction } from './actions/del';
import { delAndRegenerateAction } from './actions/delAndRegenerate';
import { editAction } from './actions/edit';
import { regenerateAction } from './actions/regenerate';
import { shareAction } from './actions/share';
import { translateAction } from './actions/translate';
import { ttsAction } from './actions/tts';
import { type MessageActionContext } from './types';
/**
* Calls every registered action's `useBuild` hook for the given context.
*
* Returns a record keyed by action `key`. Hook order is fixed don't change
* this call sequence without updating React dev expectations.
*
* Actions that don't apply to the current role return `null` and are simply
* absent from the result when consumed.
*/
export const useBuildActions = (
ctx: MessageActionContext,
): Record<string, MessageActionItem | null> => ({
branching: branchingAction.useBuild(ctx),
collapse: collapseAction.useBuild(ctx),
continueGeneration: continueGenerationAction.useBuild(ctx),
copy: copyAction.useBuild(ctx),
del: delAction.useBuild(ctx),
delAndRegenerate: delAndRegenerateAction.useBuild(ctx),
edit: editAction.useBuild(ctx),
regenerate: regenerateAction.useBuild(ctx),
share: shareAction.useBuild(ctx),
translate: translateAction.useBuild(ctx),
tts: ttsAction.useBuild(ctx),
});

View file

@ -162,11 +162,11 @@ const MessageItem = memo<MessageItemProps>(
);
}
case 'tasks': {
return <TasksMessage id={id} index={index} />;
return <TasksMessage id={id} />;
}
case 'groupTasks': {
return <GroupTasksMessage id={id} index={index} />;
return <GroupTasksMessage id={id} />;
}
case 'agentCouncil': {

View file

@ -29,33 +29,24 @@ export interface MessageActionItem extends ActionIconGroupItemType {
export type MessageActionItemOrDivider = MessageActionItem | { type: 'divider' };
/**
* Factory function type for creating message-specific actions
* Receives message id and returns an action item or null
* Action slot reference. A registered action key (e.g. `'copy'`) or the
* reserved `'divider'` literal.
*
* Uses declarative keys rather than pre-built items so per-message action
* construction stays lazy and per-session/role config lives at the route
* layer (see `useActionsBarConfig`).
*/
export type MessageActionFactory = (id: string) => MessageActionItem | null;
export type MessageActionSlot = string;
/**
* Action configuration for a specific message type
* Action configuration for a specific message type. Lists of registered
* action keys resolved at render-time against the action registry.
*/
export interface MessageActionsConfig {
/**
* Actions to display in the action bar (always visible)
*/
bar?: MessageActionItemOrDivider[];
/**
* Extra actions to add to the bar, created per-message using factory functions.
* These are appended after the default/configured bar actions.
*/
extraBarActions?: MessageActionFactory[];
/**
* Extra actions to add to the menu, created per-message using factory functions.
* These are appended after the default/configured menu actions.
*/
extraMenuActions?: MessageActionFactory[];
/**
* Actions to display in the dropdown menu
*/
menu?: MessageActionItemOrDivider[];
/** Bar slots (always visible as icons) */
bar?: MessageActionSlot[];
/** Menu slots (overflow dropdown); when omitted the role's default menu is used */
menu?: MessageActionSlot[];
}
/**

View file

@ -1,77 +1,50 @@
'use client';
import { App } from 'antd';
import { Split } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import {
type ActionsBarConfig,
type MessageActionFactory,
type MessageActionItem,
} from '@/features/Conversation/types';
import { type ActionsBarConfig, type MessageActionSlot } from '@/features/Conversation/types';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
/**
* Hook to create a branching action factory function.
* The factory function takes a message id and returns the branching action
* with proper ChatStore integration.
* Hetero-agent (ACP) sessions only support copy + delete edit / regenerate /
* branching / translate / tts / share don't apply because the external
* runtime owns message lifecycle.
*/
export const useBranchingActionFactory = (): MessageActionFactory => {
const { t } = useTranslation('common');
const { message } = App.useApp();
const [topic, openThreadCreator] = useChatStore((s) => [s.activeTopicId, s.openThreadCreator]);
return useCallback(
(id: string): MessageActionItem | null => {
return {
handleClick: () => {
if (!topic) {
message.warning(t('branchingRequiresSavedTopic'));
return;
}
openThreadCreator(id);
},
icon: Split,
key: 'branching',
label: t('branching'),
};
},
[topic, openThreadCreator],
);
const HETERO_USER: { bar: MessageActionSlot[]; menu: MessageActionSlot[] } = {
bar: ['copy'],
menu: ['copy', 'divider', 'del'],
};
const HETERO_ASSISTANT: { bar: MessageActionSlot[]; menu: MessageActionSlot[] } = {
bar: ['copy'],
menu: ['copy', 'divider', 'del'],
};
/**
* Hook to generate actionsBar configuration with branching support.
* This creates the complete actionsBar config to be passed to ChatList.
*/
export const useActionsBarConfig = (): ActionsBarConfig => {
const branchingFactory = useBranchingActionFactory();
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const hasACPProvider = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
return useMemo(
() => ({
assistant: {
extraBarActions: isDevMode && !hasACPProvider ? [branchingFactory] : [],
},
// For ACP agents, only show copy + delete in the assistant group action bar
...(hasACPProvider
? {
assistantGroup: {
extraBarActions: [],
},
}
: {}),
user: {
extraBarActions: isDevMode ? [branchingFactory] : [],
},
}),
[branchingFactory, hasACPProvider, isDevMode],
);
return useMemo<ActionsBarConfig>(() => {
if (hasACPProvider) {
return {
assistant: HETERO_ASSISTANT,
assistantGroup: HETERO_ASSISTANT,
user: HETERO_USER,
};
}
// Dev mode adds `branching` to the default bars. Everything else falls
// back to each role's component-level defaults.
if (isDevMode) {
return {
assistant: { bar: ['edit', 'copy', 'branching'] },
user: { bar: ['regenerate', 'edit', 'copy', 'branching'] },
};
}
return {};
}, [hasACPProvider, isDevMode]);
};

View file

@ -1,63 +1,12 @@
'use client';
import { App } from 'antd';
import { Split } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import {
type ActionsBarConfig,
type MessageActionFactory,
type MessageActionItem,
} from '@/features/Conversation/types';
import { useChatStore } from '@/store/chat';
import { type ActionsBarConfig } from '@/features/Conversation/types';
/**
* Hook to create a branching action factory function.
* The factory function takes a message id and returns the branching action
* with proper ChatStore integration.
* Group-chat conversation action bar config. Currently relies on each role's
* component-level defaults no per-session overrides are needed yet.
*/
export const useBranchingActionFactory = (): MessageActionFactory => {
const { t } = useTranslation('common');
const { message } = App.useApp();
const [topic, openThreadCreator] = useChatStore((s) => [s.activeTopicId, s.openThreadCreator]);
return useCallback(
(id: string): MessageActionItem | null => {
return {
handleClick: () => {
if (!topic) {
message.warning(t('branchingRequiresSavedTopic'));
return;
}
openThreadCreator(id);
},
icon: Split,
key: 'branching',
label: t('branching'),
};
},
[topic, openThreadCreator],
);
};
/**
* Hook to generate actionsBar configuration with branching support.
* This creates the complete actionsBar config to be passed to ChatList.
*/
export const useActionsBarConfig = (): ActionsBarConfig => {
const branchingFactory = useBranchingActionFactory();
return useMemo(
() => ({
assistant: {
// extraBarActions: [branchingFactory],
},
user: {
// extraBarActions: [branchingFactory],
},
}),
[branchingFactory],
);
};
export const useActionsBarConfig = (): ActionsBarConfig =>
useMemo<ActionsBarConfig>(() => ({}), []);