mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
💄 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:
parent
7fe751eaec
commit
b909e4ae20
36 changed files with 771 additions and 1949 deletions
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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]);
|
||||
},
|
||||
});
|
||||
|
|
@ -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]);
|
||||
},
|
||||
});
|
||||
|
|
@ -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],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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]);
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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]);
|
||||
},
|
||||
});
|
||||
|
|
@ -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],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>(() => ({}), []);
|
||||
|
|
|
|||
Loading…
Reference in a new issue