🐛 fix: support retry error message and fix continueGenerationMessage

This commit is contained in:
arvinxx
2025-12-31 18:22:52 +08:00
parent 7a532eee92
commit 8bf85fb251
11 changed files with 355 additions and 67 deletions
@@ -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;