mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 20:16:02 +00:00
🐛 fix: support retry error message and fix continueGenerationMessage
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { type BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -18,6 +19,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
|
||||
statusIcon: css`
|
||||
margin-block-end: -2px;
|
||||
margin-inline-start: 4px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ExecuteCodeParams {
|
||||
@@ -28,23 +34,38 @@ interface ExecuteCodeParams {
|
||||
|
||||
export const ExecuteCodeInspector = memo<
|
||||
BuiltinInspectorProps<ExecuteCodeParams, ExecuteCodeState>
|
||||
>(({ args, partialArgs, isArgumentsStreaming }) => {
|
||||
>(({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const description = args?.description || partialArgs?.description;
|
||||
|
||||
if (isArgumentsStreaming && !description) {
|
||||
if (isArgumentsStreaming) {
|
||||
if (!description)
|
||||
return (
|
||||
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-cloud-code-interpreter.apiName.executeCode')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-cloud-code-interpreter.apiName.executeCode')}</span>
|
||||
<span>{t('builtins.lobe-cloud-code-interpreter.apiName.executeCode')}: </span>
|
||||
<span className={highlightTextStyles.gold}>{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root, isArgumentsStreaming && shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-cloud-code-interpreter.apiName.executeCode')}: </span>
|
||||
{description && <span className={highlightTextStyles.gold}>{description}</span>}
|
||||
<div className={cx(styles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span style={{ marginInlineStart: 2 }}>
|
||||
<span>{t('builtins.lobe-cloud-code-interpreter.apiName.executeCode')}: </span>
|
||||
{description && <span className={highlightTextStyles.primary}>{description}</span>}
|
||||
{isLoading ? null : pluginState?.success ? (
|
||||
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
|
||||
) : (
|
||||
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ export const RunCommandInspector = memo<BuiltinInspectorProps<RunCommandParams,
|
||||
return (
|
||||
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-cloud-code-interpreter.apiName.runCommand')}: </span>
|
||||
<span>{description}</span>
|
||||
<span className={highlightTextStyles.primary}>{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Alert, Skeleton } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { Suspense, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConversationStore } from '@/features/Conversation';
|
||||
|
||||
@@ -9,9 +12,11 @@ export interface ErrorContentProps {
|
||||
customErrorRender?: ChatItemProps['customErrorRender'];
|
||||
error: ChatItemProps['error'];
|
||||
id?: string;
|
||||
onRegenerate?: () => void;
|
||||
}
|
||||
|
||||
const ErrorContent = memo<ErrorContentProps>(({ customErrorRender, error, id }) => {
|
||||
const ErrorContent = memo<ErrorContentProps>(({ customErrorRender, error, id, onRegenerate }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [deleteMessage] = useConversationStore((s) => [s.deleteMessage]);
|
||||
|
||||
if (!error) return;
|
||||
@@ -24,7 +29,21 @@ const ErrorContent = memo<ErrorContentProps>(({ customErrorRender, error, id })
|
||||
|
||||
return (
|
||||
<Alert
|
||||
action={
|
||||
onRegenerate && (
|
||||
<Button
|
||||
color="default"
|
||||
icon={<RotateCcw size={14} />}
|
||||
onClick={onRegenerate}
|
||||
size="small"
|
||||
variant="filled"
|
||||
>
|
||||
{t('regenerate')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
closable
|
||||
extraDefaultExpand
|
||||
extraIsolate={false}
|
||||
showIcon
|
||||
type={'secondary'}
|
||||
|
||||
@@ -95,7 +95,7 @@ export const useGroupActions = ({
|
||||
disabled: isContinuing,
|
||||
handleClick: () => {
|
||||
if (!lastBlockId) return;
|
||||
continueGenerationMessage(lastBlockId, id);
|
||||
continueGenerationMessage(id, lastBlockId);
|
||||
},
|
||||
icon: StepForward,
|
||||
key: 'continueGeneration',
|
||||
@@ -172,7 +172,6 @@ export const useGroupActions = ({
|
||||
},
|
||||
}),
|
||||
[
|
||||
t,
|
||||
id,
|
||||
contentBlock,
|
||||
data.error,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { LOADING_FLAT } from '@/const/message';
|
||||
import { useErrorContent } from '@/features/Conversation/Error';
|
||||
@@ -12,15 +12,27 @@ import Reasoning from '../../components/Reasoning';
|
||||
import { Tools } from '../Tools';
|
||||
import MessageContent from './MessageContent';
|
||||
|
||||
const ContentBlock = memo<AssistantContentBlock>(
|
||||
({ id, tools, content, imageList, reasoning, error }) => {
|
||||
interface ContentBlockProps extends AssistantContentBlock {
|
||||
assistantId: string;
|
||||
}
|
||||
const ContentBlock = memo<ContentBlockProps>(
|
||||
({ id, tools, content, imageList, reasoning, error, assistantId }) => {
|
||||
const errorContent = useErrorContent(error);
|
||||
const showImageItems = !!imageList && imageList.length > 0;
|
||||
const isReasoning = useConversationStore(messageStateSelectors.isMessageInReasoning(id));
|
||||
const [isReasoning, deleteMessage, continueGeneration] = useConversationStore((s) => [
|
||||
messageStateSelectors.isMessageInReasoning(id)(s),
|
||||
s.deleteDBMessage,
|
||||
s.continueGeneration,
|
||||
]);
|
||||
const hasTools = tools && tools.length > 0;
|
||||
const showReasoning =
|
||||
(!!reasoning && reasoning.content?.trim() !== '') || (!reasoning && isReasoning);
|
||||
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
await deleteMessage(id);
|
||||
continueGeneration(assistantId);
|
||||
}, [id]);
|
||||
|
||||
if (error && (content === LOADING_FLAT || !content))
|
||||
return (
|
||||
<ErrorContent
|
||||
@@ -30,6 +42,7 @@ const ContentBlock = memo<AssistantContentBlock>(
|
||||
: undefined
|
||||
}
|
||||
id={id}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ const Group = memo<GroupChildrenProps>(
|
||||
return (
|
||||
<GroupItem
|
||||
{...item}
|
||||
assistantId={id}
|
||||
contentId={contentId}
|
||||
disableEditing={disableEditing}
|
||||
key={id + '.' + item.id}
|
||||
|
||||
@@ -8,26 +8,30 @@ import { useConversationStore } from '../../../store';
|
||||
import ContentBlock from './ContentBlock';
|
||||
|
||||
interface GroupItemProps extends AssistantContentBlock {
|
||||
assistantId: string;
|
||||
contentId?: string;
|
||||
disableEditing?: boolean;
|
||||
messageIndex: number;
|
||||
}
|
||||
|
||||
const GroupItem = memo<GroupItemProps>(({ contentId, disableEditing, error, ...item }) => {
|
||||
const toggleMessageEditing = useConversationStore((s) => s.toggleMessageEditing);
|
||||
const GroupItem = memo<GroupItemProps>(
|
||||
({ contentId, disableEditing, error, assistantId, ...item }) => {
|
||||
const toggleMessageEditing = useConversationStore((s) => s.toggleMessageEditing);
|
||||
|
||||
return item.id === contentId ? (
|
||||
<Flexbox
|
||||
onDoubleClick={(e) => {
|
||||
if (disableEditing || error || !e.altKey) return;
|
||||
toggleMessageEditing(item.id, true);
|
||||
}}
|
||||
>
|
||||
<ContentBlock {...item} error={error} />
|
||||
</Flexbox>
|
||||
) : (
|
||||
<ContentBlock {...item} error={error} />
|
||||
);
|
||||
}, isEqual);
|
||||
return item.id === contentId ? (
|
||||
<Flexbox
|
||||
onDoubleClick={(e) => {
|
||||
if (disableEditing || error || !e.altKey) return;
|
||||
toggleMessageEditing(item.id, true);
|
||||
}}
|
||||
>
|
||||
<ContentBlock {...item} assistantId={assistantId} error={error} />
|
||||
</Flexbox>
|
||||
) : (
|
||||
<ContentBlock {...item} assistantId={assistantId} error={error} />
|
||||
);
|
||||
},
|
||||
isEqual,
|
||||
);
|
||||
|
||||
export default GroupItem;
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('Generation Actions', () => {
|
||||
});
|
||||
|
||||
describe('continueGeneration', () => {
|
||||
it('should continue generation from message with context including groupId', async () => {
|
||||
it('should continue generation from assistantGroup message with last child as blockId', async () => {
|
||||
// Reset mock to ensure all required functions are available
|
||||
vi.mocked(await import('@/store/chat').then((m) => m.useChatStore.getState)).mockReturnValue({
|
||||
messagesMap: {},
|
||||
@@ -175,7 +175,64 @@ describe('Generation Actions', () => {
|
||||
|
||||
const store = createStore({ context });
|
||||
|
||||
// Set displayMessages after store creation
|
||||
// Set displayMessages with assistantGroup message containing children
|
||||
act(() => {
|
||||
store.setState({
|
||||
displayMessages: [
|
||||
{
|
||||
id: 'group-msg-1',
|
||||
role: 'assistantGroup',
|
||||
content: '',
|
||||
children: [
|
||||
{ id: 'child-1', content: 'First response' },
|
||||
{ id: 'child-2', content: 'Second response' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await store.getState().continueGeneration('group-msg-1');
|
||||
});
|
||||
|
||||
// Should create operation with groupMessageId
|
||||
expect(mockStartOperation).toHaveBeenCalledWith({
|
||||
context: { ...context, messageId: 'group-msg-1' },
|
||||
type: 'continue',
|
||||
});
|
||||
|
||||
// Should call internal_execAgentRuntime with last child id as parentMessageId
|
||||
expect(mockInternalExecAgentRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context,
|
||||
parentMessageId: 'child-2', // last child's id
|
||||
parentMessageType: 'assistantGroup',
|
||||
parentOperationId: 'test-op-id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not continue if message is not assistantGroup', async () => {
|
||||
// Reset mock to ensure all required functions are available
|
||||
vi.mocked(await import('@/store/chat').then((m) => m.useChatStore.getState)).mockReturnValue({
|
||||
messagesMap: {},
|
||||
operations: {},
|
||||
startOperation: mockStartOperation,
|
||||
completeOperation: mockCompleteOperation,
|
||||
failOperation: mockFailOperation,
|
||||
internal_execAgentRuntime: mockInternalExecAgentRuntime,
|
||||
} as any);
|
||||
|
||||
const context: ConversationContext = {
|
||||
agentId: 'session-1',
|
||||
topicId: null,
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
const store = createStore({ context });
|
||||
|
||||
// Set displayMessages with regular assistant message (not assistantGroup)
|
||||
act(() => {
|
||||
store.setState({
|
||||
displayMessages: [{ id: 'msg-1', role: 'assistant', content: 'Hello' }],
|
||||
@@ -186,21 +243,46 @@ describe('Generation Actions', () => {
|
||||
await store.getState().continueGeneration('msg-1');
|
||||
});
|
||||
|
||||
// Should create operation with context including groupId
|
||||
expect(mockStartOperation).toHaveBeenCalledWith({
|
||||
context: { ...context, messageId: 'msg-1' },
|
||||
type: 'continue',
|
||||
// Should not create operation if message is not assistantGroup
|
||||
expect(mockStartOperation).not.toHaveBeenCalled();
|
||||
expect(mockInternalExecAgentRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not continue if assistantGroup has no children', async () => {
|
||||
// Reset mock to ensure all required functions are available
|
||||
vi.mocked(await import('@/store/chat').then((m) => m.useChatStore.getState)).mockReturnValue({
|
||||
messagesMap: {},
|
||||
operations: {},
|
||||
startOperation: mockStartOperation,
|
||||
completeOperation: mockCompleteOperation,
|
||||
failOperation: mockFailOperation,
|
||||
internal_execAgentRuntime: mockInternalExecAgentRuntime,
|
||||
} as any);
|
||||
|
||||
const context: ConversationContext = {
|
||||
agentId: 'session-1',
|
||||
topicId: null,
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
const store = createStore({ context });
|
||||
|
||||
// Set displayMessages with assistantGroup but no children
|
||||
act(() => {
|
||||
store.setState({
|
||||
displayMessages: [
|
||||
{ id: 'group-msg-1', role: 'assistantGroup', content: '', children: [] },
|
||||
],
|
||||
} as any);
|
||||
});
|
||||
|
||||
// Should call internal_execAgentRuntime with context including groupId
|
||||
expect(mockInternalExecAgentRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context,
|
||||
parentMessageId: 'msg-1',
|
||||
parentMessageType: 'assistant',
|
||||
parentOperationId: 'test-op-id',
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
await store.getState().continueGeneration('group-msg-1');
|
||||
});
|
||||
|
||||
// Should not create operation if no children
|
||||
expect(mockStartOperation).not.toHaveBeenCalled();
|
||||
expect(mockInternalExecAgentRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onBeforeContinue hook and respect false return', async () => {
|
||||
@@ -225,18 +307,25 @@ describe('Generation Actions', () => {
|
||||
|
||||
const store = createStore({ context, hooks });
|
||||
|
||||
// Set displayMessages after store creation
|
||||
// Set displayMessages with assistantGroup
|
||||
act(() => {
|
||||
store.setState({
|
||||
displayMessages: [{ id: 'msg-1', role: 'assistant', content: 'Hello' }],
|
||||
displayMessages: [
|
||||
{
|
||||
id: 'group-msg-1',
|
||||
role: 'assistantGroup',
|
||||
content: '',
|
||||
children: [{ id: 'child-1', content: 'Response' }],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await store.getState().continueGeneration('msg-1');
|
||||
await store.getState().continueGeneration('group-msg-1');
|
||||
});
|
||||
|
||||
expect(onBeforeContinue).toHaveBeenCalledWith('msg-1');
|
||||
expect(onBeforeContinue).toHaveBeenCalledWith('group-msg-1');
|
||||
// Should not call internal_execAgentRuntime if hook returns false
|
||||
expect(mockInternalExecAgentRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -263,18 +352,25 @@ describe('Generation Actions', () => {
|
||||
|
||||
const store = createStore({ context, hooks });
|
||||
|
||||
// Set displayMessages after store creation
|
||||
// Set displayMessages with assistantGroup
|
||||
act(() => {
|
||||
store.setState({
|
||||
displayMessages: [{ id: 'msg-1', role: 'assistant', content: 'Hello' }],
|
||||
displayMessages: [
|
||||
{
|
||||
id: 'group-msg-1',
|
||||
role: 'assistantGroup',
|
||||
content: '',
|
||||
children: [{ id: 'child-1', content: 'Response' }],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await store.getState().continueGeneration('msg-1');
|
||||
await store.getState().continueGeneration('group-msg-1');
|
||||
});
|
||||
|
||||
expect(onContinueComplete).toHaveBeenCalledWith('msg-1');
|
||||
expect(onContinueComplete).toHaveBeenCalledWith('group-msg-1');
|
||||
});
|
||||
|
||||
it('should not continue if message is not found', async () => {
|
||||
|
||||
@@ -37,12 +37,12 @@ export interface GenerationAction {
|
||||
/**
|
||||
* Continue generation from a message
|
||||
*/
|
||||
continueGeneration: (messageId: string) => Promise<void>;
|
||||
continueGeneration: (displayMessageId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Continue generation from a specific block
|
||||
*/
|
||||
continueGenerationMessage: (blockId: string, messageId: string) => Promise<void>;
|
||||
continueGenerationMessage: (displayMessageId: string, messageId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete and regenerate a message
|
||||
@@ -135,30 +135,48 @@ export const generationSlice: StateCreator<
|
||||
await chatStore.clearTranslate(messageId);
|
||||
},
|
||||
|
||||
continueGeneration: async (messageId: string) => {
|
||||
// Note: continueGenerationMessage takes (blockId, messageId)
|
||||
// For now, we use messageId for both since we don't have block ID
|
||||
// Hooks are handled in continueGenerationMessage
|
||||
await get().continueGenerationMessage(messageId, messageId);
|
||||
continueGeneration: async (groupMessageId: string) => {
|
||||
const { displayMessages } = get();
|
||||
|
||||
// Find the message
|
||||
const message = displayMessages.find((m) => m.id === groupMessageId);
|
||||
if (!message) return;
|
||||
|
||||
// If it's an assistantGroup, find the last child's ID as blockId
|
||||
let lastBlockId: string | undefined;
|
||||
|
||||
if (message.role !== 'assistantGroup') return;
|
||||
|
||||
if (message.children && message.children.length > 0) {
|
||||
const lastChild = message.children.at(-1);
|
||||
|
||||
if (lastChild) {
|
||||
lastBlockId = lastChild.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastBlockId) return;
|
||||
|
||||
await get().continueGenerationMessage(groupMessageId, lastBlockId);
|
||||
},
|
||||
|
||||
continueGenerationMessage: async (blockId: string, messageId: string) => {
|
||||
continueGenerationMessage: async (displayMessageId: string, dbMessageId: string) => {
|
||||
const { context, displayMessages, hooks } = get();
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
// Find the message (blockId refers to the assistant message to continue from)
|
||||
const message = displayMessages.find((m) => m.id === blockId);
|
||||
const message = displayMessages.find((m) => m.id === displayMessageId);
|
||||
if (!message) return;
|
||||
|
||||
// ===== Hook: onBeforeContinue =====
|
||||
if (hooks.onBeforeContinue) {
|
||||
const shouldProceed = await hooks.onBeforeContinue(messageId);
|
||||
const shouldProceed = await hooks.onBeforeContinue(displayMessageId);
|
||||
if (shouldProceed === false) return;
|
||||
}
|
||||
|
||||
// Create continue operation with ConversationStore context (includes groupId)
|
||||
const { operationId } = chatStore.startOperation({
|
||||
context: { ...context, messageId },
|
||||
context: { ...context, messageId: displayMessageId },
|
||||
type: 'continue',
|
||||
});
|
||||
|
||||
@@ -167,7 +185,7 @@ export const generationSlice: StateCreator<
|
||||
await chatStore.internal_execAgentRuntime({
|
||||
context,
|
||||
messages: displayMessages,
|
||||
parentMessageId: blockId,
|
||||
parentMessageId: dbMessageId,
|
||||
parentMessageType: message.role as 'assistant' | 'tool' | 'user',
|
||||
parentOperationId: operationId,
|
||||
});
|
||||
@@ -176,7 +194,7 @@ export const generationSlice: StateCreator<
|
||||
|
||||
// ===== Hook: onContinueComplete =====
|
||||
if (hooks.onContinueComplete) {
|
||||
hooks.onContinueComplete(messageId);
|
||||
hooks.onContinueComplete(displayMessageId);
|
||||
}
|
||||
} catch (error) {
|
||||
chatStore.failOperation(operationId, {
|
||||
|
||||
@@ -273,6 +273,100 @@ describe('Message CRUD Actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDBMessage', () => {
|
||||
it('should delete a single DB message directly', async () => {
|
||||
const removeMessageSpy = vi
|
||||
.spyOn(messageServiceModule.messageService, 'removeMessage')
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
const store = createTestStore();
|
||||
|
||||
const testMessage = {
|
||||
id: 'msg-1',
|
||||
content: 'Hello',
|
||||
role: 'user' as const,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
meta: {},
|
||||
};
|
||||
|
||||
act(() => {
|
||||
store.getState().replaceMessages([testMessage]);
|
||||
});
|
||||
|
||||
expect(store.getState().dbMessages.length).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
await store.getState().deleteDBMessage('msg-1');
|
||||
});
|
||||
|
||||
expect(removeMessageSpy).toHaveBeenCalledWith('msg-1', {
|
||||
agentId: 'test-session',
|
||||
threadId: null,
|
||||
topicId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should do nothing if message not found in dbMessages', async () => {
|
||||
const removeMessageSpy = vi.spyOn(messageServiceModule.messageService, 'removeMessage');
|
||||
|
||||
const store = createTestStore();
|
||||
|
||||
await act(async () => {
|
||||
await store.getState().deleteDBMessage('nonexistent');
|
||||
});
|
||||
|
||||
expect(removeMessageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not handle assistantGroup aggregation like deleteMessage does', async () => {
|
||||
const removeMessageSpy = vi
|
||||
.spyOn(messageServiceModule.messageService, 'removeMessage')
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
const store = createTestStore();
|
||||
|
||||
// Create raw DB messages (not aggregated)
|
||||
const messages = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
content: 'Response 1',
|
||||
role: 'assistant' as const,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
content: 'Response 2',
|
||||
role: 'assistant' as const,
|
||||
createdAt: Date.now() + 1,
|
||||
updatedAt: Date.now() + 1,
|
||||
meta: {},
|
||||
},
|
||||
];
|
||||
|
||||
act(() => {
|
||||
store.getState().replaceMessages(messages);
|
||||
});
|
||||
|
||||
// Delete only assistant-1, should NOT delete assistant-2
|
||||
await act(async () => {
|
||||
await store.getState().deleteDBMessage('assistant-1');
|
||||
});
|
||||
|
||||
// Should call removeMessage with only the single ID
|
||||
expect(removeMessageSpy).toHaveBeenCalledWith('assistant-1', expect.any(Object));
|
||||
expect(removeMessageSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMessages', () => {
|
||||
it('should delete multiple messages', async () => {
|
||||
const removeMessagesSpy = vi
|
||||
|
||||
@@ -66,7 +66,13 @@ export interface MessageCRUDAction {
|
||||
|
||||
// ===== Delete ===== //
|
||||
/**
|
||||
* Delete a single message
|
||||
* Delete a single DB message directly without handling display message aggregation logic
|
||||
* Use this when you need to delete a single raw database message
|
||||
*/
|
||||
deleteDBMessage: (id: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a single message (handles display message aggregation like assistantGroup)
|
||||
*/
|
||||
deleteMessage: (id: string) => Promise<void>;
|
||||
|
||||
@@ -266,6 +272,23 @@ export const messageCRUDSlice: StateCreator<
|
||||
},
|
||||
|
||||
// ===== Delete ===== //
|
||||
deleteDBMessage: async (id) => {
|
||||
const { internal_dispatchMessage, replaceMessages, context } = get();
|
||||
|
||||
const message = dataSelectors.getDbMessageById(id)(get());
|
||||
if (!message) return;
|
||||
|
||||
// Optimistic update
|
||||
internal_dispatchMessage({ id, type: 'deleteMessage' });
|
||||
|
||||
// Persist to database
|
||||
const result = await messageService.removeMessage(id, context);
|
||||
|
||||
if (result?.success && result.messages) {
|
||||
replaceMessages(result.messages);
|
||||
}
|
||||
},
|
||||
|
||||
deleteMessage: async (id) => {
|
||||
const state = get();
|
||||
const { internal_dispatchMessage, replaceMessages, context } = state;
|
||||
|
||||
Reference in New Issue
Block a user