mirror of
https://github.com/novuhq/novu.git
synced 2026-03-13 10:41:26 +08:00
chore(dashboard): novu copilot chat ui improvements, filter dangling tool calls fixes NV-7227 (#10270)
This commit is contained in:
2
.source
2
.source
Submodule .source updated: 0ec418c0cd...fb927690ee
@@ -83,7 +83,7 @@ export type ChainOfThoughtStepProps = ComponentProps<'div'> & {
|
||||
icon?: IconType | LucideIcon;
|
||||
label?: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: 'complete' | 'active' | 'pending';
|
||||
status?: 'complete' | 'active' | 'pending' | 'error';
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
autoCollapse?: boolean;
|
||||
@@ -105,7 +105,7 @@ export const ChainOfThoughtStep = memo(
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoCollapse && status === 'complete') {
|
||||
if (autoCollapse && (status === 'complete' || status === 'error')) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [autoCollapse, status]);
|
||||
@@ -114,6 +114,7 @@ export const ChainOfThoughtStep = memo(
|
||||
complete: 'text-muted-foreground',
|
||||
active: 'text-foreground',
|
||||
pending: 'text-muted-foreground/50',
|
||||
error: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AiAgentTypeEnum, AiMessageRoleEnum, AiResourceTypeEnum } from '@novu/shared';
|
||||
import { ChatStatus, DataUIPart, generateId, UIMessage } from 'ai';
|
||||
import { ChatStatus, DataUIPart, DynamicToolUIPart, generateId, UIMessage } from 'ai';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { cancelStream } from '@/api/ai';
|
||||
@@ -12,6 +12,7 @@ import { useFetchLatestAiChat } from '@/hooks/use-fetch-latest-ai-chat';
|
||||
import { useKeepAiChanges } from '@/hooks/use-keep-ai-changes';
|
||||
import { useRevertMessage } from '@/hooks/use-revert-message';
|
||||
import { showErrorToast } from '../primitives/sonner-helpers';
|
||||
import { isCancelledToolCall } from './message-utils';
|
||||
|
||||
export type ReasoningDataPart = DataUIPart<{ reasoning: { toolCallId: string; text: string } }>;
|
||||
|
||||
@@ -58,6 +59,57 @@ export type AiChatResourceConfig = {
|
||||
|
||||
const AiChatContext = createContext<AiChatContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Strip incomplete tool-call parts and step-start markers from all assistant messages.
|
||||
* Dangling parts are kept in the DB (so toUIMessageStream can match them to the correct
|
||||
* assistant message via the values stream), but hidden from the user in the UI.
|
||||
*/
|
||||
const cleanupIncompleteToolCalls = <T extends UIMessage>(currentMessages: T[]): T[] => {
|
||||
let changed = false;
|
||||
|
||||
const result = currentMessages.reduce<T[]>((acc, msg) => {
|
||||
if (msg.role !== 'assistant') {
|
||||
acc.push(msg);
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const cleanedParts = msg.parts.filter((part) => {
|
||||
if (part.type === 'step-start') return false;
|
||||
if (part.type.startsWith('dynamic-tool')) {
|
||||
const tool = part as DynamicToolUIPart;
|
||||
if (isCancelledToolCall(tool)) return false;
|
||||
|
||||
return tool.state === 'output-available' || tool.state === 'output-error';
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (cleanedParts.length !== msg.parts.length) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const hasContent = cleanedParts.some(
|
||||
(p) =>
|
||||
p.type === 'text' ||
|
||||
(p.type.startsWith('dynamic-tool') &&
|
||||
!isCancelledToolCall(p as DynamicToolUIPart) &&
|
||||
((p as DynamicToolUIPart).state === 'output-available' || (p as DynamicToolUIPart).state === 'output-error'))
|
||||
);
|
||||
|
||||
if (hasContent) {
|
||||
acc.push(changed ? ({ ...msg, parts: cleanedParts } as T) : msg);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return changed ? result : currentMessages;
|
||||
};
|
||||
|
||||
export function AiChatProvider({ children, config }: { children: React.ReactNode; config: AiChatResourceConfig }) {
|
||||
const {
|
||||
resourceType,
|
||||
@@ -79,6 +131,8 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode
|
||||
} | null>(null);
|
||||
const isMountedRef = useRef(false);
|
||||
const hasHandledInitialResumeRef = useRef(false);
|
||||
const isStoppingRef = useRef(false);
|
||||
const skipMessageSyncRef = useRef(false);
|
||||
const location = useLocation();
|
||||
const { areEnvironmentsInitialLoading, currentEnvironment } = useEnvironment();
|
||||
|
||||
@@ -113,11 +167,14 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode
|
||||
onData({ type: dataType });
|
||||
}
|
||||
},
|
||||
onFinish: ({ isAbort, isDisconnect, isError }) => {
|
||||
onFinish: ({ isAbort, isDisconnect, isError, messages }) => {
|
||||
setMessages(cleanupIncompleteToolCalls(messages));
|
||||
|
||||
if (isAbort || isDisconnect || isError) {
|
||||
return;
|
||||
}
|
||||
|
||||
skipMessageSyncRef.current = true;
|
||||
refetchLatestChat();
|
||||
},
|
||||
});
|
||||
@@ -129,11 +186,18 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode
|
||||
const isActionPending = isKeepPending || isRevertPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestChat || isGenerating) {
|
||||
if (!latestChat || isGenerating || isStoppingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages(latestChat.messages as typeof messages);
|
||||
if (skipMessageSyncRef.current) {
|
||||
skipMessageSyncRef.current = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const latestChatMessages = latestChat.messages as typeof messages;
|
||||
setMessages(cleanupIncompleteToolCalls(latestChatMessages));
|
||||
}, [latestChat, isGenerating, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -175,7 +239,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (message: string) => {
|
||||
const { resourceType, resourceId, isAborted, latestChat, messages } = dataRef.current;
|
||||
const { resourceType, resourceId, latestChat, messages } = dataRef.current;
|
||||
const isLastUserMessage = messages.length > 0 && messages[messages.length - 1].role === AiMessageRoleEnum.USER;
|
||||
|
||||
const messageToSend = message.trim();
|
||||
@@ -184,7 +248,7 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode
|
||||
if (!latestChat) {
|
||||
const newChat = await createAiChat({ resourceType, resourceId });
|
||||
sendPrompt({ chatId: newChat._id, prompt: messageToSend });
|
||||
} else if (isLastUserMessage || isAborted) {
|
||||
} else if (isLastUserMessage) {
|
||||
const lastUserMessage = messages.filter((m) => m.role === AiMessageRoleEnum.USER).pop();
|
||||
sendPrompt({ messageId: lastUserMessage?.id, chatId: latestChat._id, prompt: messageToSend });
|
||||
} else if (messageToSend) {
|
||||
@@ -342,16 +406,15 @@ export function AiChatProvider({ children, config }: { children: React.ReactNode
|
||||
}, [firstMessageRevert]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
isStoppingRef.current = true;
|
||||
await stop();
|
||||
if (latestChat && currentEnvironment && isGenerating) {
|
||||
cancelStream({ environment: currentEnvironment, chatId: latestChat._id });
|
||||
await cancelStream({ environment: currentEnvironment, chatId: latestChat._id });
|
||||
}
|
||||
stop();
|
||||
refetchLatestChat();
|
||||
const lastUserMessage = messages.filter((m) => m.role === AiMessageRoleEnum.USER).pop();
|
||||
if (lastUserMessage) {
|
||||
setInputText(lastUserMessage.parts.find((p) => p.type === 'text')?.text ?? '');
|
||||
}
|
||||
}, [latestChat, currentEnvironment, isGenerating, stop, messages, refetchLatestChat]);
|
||||
|
||||
await refetchLatestChat();
|
||||
isStoppingRef.current = false;
|
||||
}, [latestChat, currentEnvironment, isGenerating, stop, refetchLatestChat]);
|
||||
|
||||
const isLoading = isResourceLoading || isFetchingAiChat || areEnvironmentsInitialLoading;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { UIMessage } from 'ai';
|
||||
import { DynamicToolUIPart, UIMessage } from 'ai';
|
||||
import { useMemo } from 'react';
|
||||
import { Message } from '../ai-elements/message';
|
||||
import { ChatChainOfThought } from './chat-chain-of-thought';
|
||||
import { ChatMessageActions } from './chat-message-actions';
|
||||
import { StyledMessageResponse } from './chat-message-response';
|
||||
import { hasKnownMessageParts } from './message-utils';
|
||||
import { hasKnownMessageParts, isCancelledToolCall } from './message-utils';
|
||||
|
||||
export const AssistantMessage = ({
|
||||
message,
|
||||
@@ -29,7 +29,7 @@ export const AssistantMessage = ({
|
||||
}) => {
|
||||
const isAssistantMessageWithKnownParts = useMemo(() => hasKnownMessageParts(message), [message]);
|
||||
const hasDynamicToolParts = useMemo(
|
||||
() => message.parts.filter((p) => p.type.startsWith('dynamic-tool')).length > 0,
|
||||
() => message.parts.some((p) => p.type.startsWith('dynamic-tool') && !isCancelledToolCall(p as DynamicToolUIPart)),
|
||||
[message]
|
||||
);
|
||||
const textParts = useMemo(() => {
|
||||
@@ -48,7 +48,7 @@ export const AssistantMessage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<Message from={message.role}>
|
||||
{hasDynamicToolParts && <ChatChainOfThought message={message} />}
|
||||
{textParts.map((text, i) => (
|
||||
<StyledMessageResponse key={`text-${message.id}-${i}`}>{text}</StyledMessageResponse>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AiWorkflowToolsEnum } from '@novu/shared';
|
||||
import { DynamicToolUIPart, UIMessage } from 'ai';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
RiAddBoxLine,
|
||||
RiArrowRightSLine,
|
||||
RiCheckLine,
|
||||
RiCloseCircleLine,
|
||||
RiDeleteBin2Line,
|
||||
RiEdit2Line,
|
||||
RiLoader3Line,
|
||||
@@ -22,7 +23,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../primitiv
|
||||
import { Skeleton } from '../primitives/skeleton';
|
||||
import { Tag } from '../primitives/tag';
|
||||
import { StyledMessageResponse } from './chat-message-response';
|
||||
import { unwrapToolResult } from './message-utils';
|
||||
import { isCancelledToolCall, unwrapToolResult } from './message-utils';
|
||||
|
||||
const toolNameToAction: Record<string, 'add' | 'edit' | 'remove'> = {
|
||||
[AiWorkflowToolsEnum.ADD_STEP]: 'add',
|
||||
@@ -48,6 +49,10 @@ const BroomIcon = (props: React.ComponentPropsWithoutRef<typeof Broom>) => {
|
||||
return <Broom {...props} className={cn('p-0.5', props.className)} />;
|
||||
};
|
||||
|
||||
const ErrorCircleIcon = (props: React.ComponentPropsWithoutRef<typeof RiCloseCircleLine>) => {
|
||||
return <RiCloseCircleLine {...props} className={cn('p-0.5 rounded-full text-destructive', props.className)} />;
|
||||
};
|
||||
|
||||
type WorkflowMetadataOutput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -231,36 +236,53 @@ function WorkflowStepItem({
|
||||
|
||||
function StepTool({
|
||||
stepOutput,
|
||||
error,
|
||||
isStreaming,
|
||||
labelStreaming,
|
||||
labelComplete,
|
||||
labelError,
|
||||
action,
|
||||
}: {
|
||||
stepOutput?: { stepId: string; name: string; type: string };
|
||||
error?: string | null;
|
||||
isStreaming: boolean;
|
||||
labelStreaming: string;
|
||||
labelComplete: string;
|
||||
labelError: string;
|
||||
action: 'add' | 'edit' | 'remove';
|
||||
}) {
|
||||
const hasError = !!error;
|
||||
const status = hasError ? 'error' : isStreaming ? 'active' : 'complete';
|
||||
const icon = hasError ? ErrorCircleIcon : isStreaming ? BroomIcon : CheckCircleIcon;
|
||||
|
||||
const label = isStreaming ? (
|
||||
<Shimmer className={cn('text-label-xs font-medium')}>{labelStreaming}</Shimmer>
|
||||
) : hasError ? (
|
||||
<span className="text-label-xs font-medium">{labelError}</span>
|
||||
) : (
|
||||
<span className={cn('flex items-center justify-between gap-1')}>
|
||||
<span className="text-label-xs font-medium text-text-soft">{labelComplete}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
label={
|
||||
isStreaming ? (
|
||||
<Shimmer className={cn('text-label-xs font-medium')}>{labelStreaming}</Shimmer>
|
||||
) : (
|
||||
<span className={cn('flex items-center justify-between gap-1')}>
|
||||
<span className="text-label-xs font-medium text-text-soft">{labelComplete}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
status={isStreaming ? 'active' : 'complete'}
|
||||
icon={isStreaming ? BroomIcon : CheckCircleIcon}
|
||||
label={label}
|
||||
status={status}
|
||||
icon={icon}
|
||||
collapsible
|
||||
defaultOpen={true}
|
||||
defaultOpen={!hasError}
|
||||
autoCollapse={hasError}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-2 pl-0 pr-0">
|
||||
<WorkflowStepItem output={stepOutput} isStreaming={isStreaming} action={action} />
|
||||
</div>
|
||||
{hasError ? (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 my-2 px-2 py-1">
|
||||
<span className="text-label-xs text-destructive">{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-2 pl-0 pr-0">
|
||||
<WorkflowStepItem output={stepOutput} isStreaming={isStreaming} action={action} />
|
||||
</div>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
@@ -283,6 +305,15 @@ const toolNameToCompleteLabel = {
|
||||
[AiWorkflowToolsEnum.MOVE_STEP]: 'Moved Workflow Step',
|
||||
};
|
||||
|
||||
const toolNameToErrorLabel = {
|
||||
[AiWorkflowToolsEnum.ADD_STEP]: 'Failed to Add Workflow Step',
|
||||
[AiWorkflowToolsEnum.ADD_STEP_IN_BETWEEN]: 'Failed to Add Workflow Step In Between',
|
||||
[AiWorkflowToolsEnum.EDIT_STEP_CONTENT]: 'Failed to Update Workflow Step Content',
|
||||
[AiWorkflowToolsEnum.UPDATE_STEP_CONDITIONS]: 'Failed to Update Workflow Step Conditions',
|
||||
[AiWorkflowToolsEnum.REMOVE_STEP]: 'Failed to Remove Workflow Step',
|
||||
[AiWorkflowToolsEnum.MOVE_STEP]: 'Failed to Move Workflow Step',
|
||||
};
|
||||
|
||||
const STREAMING_MAX_LINES = 4;
|
||||
const STREAMING_LINE_HEIGHT_REM = 1.25;
|
||||
const STREAMING_MAX_HEIGHT = `${STREAMING_MAX_LINES * STREAMING_LINE_HEIGHT_REM}rem`;
|
||||
@@ -331,74 +362,81 @@ type ChatChainOfThoughtReasoningProps = {
|
||||
};
|
||||
|
||||
export function ChatChainOfThought({ message }: ChatChainOfThoughtReasoningProps) {
|
||||
const parts = message.parts ?? [];
|
||||
const toolParts = useMemo(
|
||||
() =>
|
||||
(message.parts ?? []).filter(
|
||||
(p) => p.type.startsWith('dynamic-tool') && !isCancelledToolCall(p as DynamicToolUIPart)
|
||||
) as DynamicToolUIPart[],
|
||||
[message.parts]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThought open className="text-text-soft">
|
||||
<ChainOfThoughtContent className="mb-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
{parts.map((item) => {
|
||||
if (item.type.startsWith('dynamic-tool')) {
|
||||
const tool = item as DynamicToolUIPart;
|
||||
{toolParts.map((tool) => {
|
||||
if (tool.toolName === AiWorkflowToolsEnum.REASONING) {
|
||||
const input = tool.input as { label?: string; thought?: string } | undefined;
|
||||
const label = input?.label ?? 'Reasoning...';
|
||||
const body = input?.thought ?? '';
|
||||
const isStreaming = tool.state !== 'output-available';
|
||||
|
||||
if (tool.toolName === AiWorkflowToolsEnum.REASONING) {
|
||||
const input = tool.input as { label?: string; thought?: string } | undefined;
|
||||
const label = input?.label ?? 'Reasoning...';
|
||||
const body = input?.thought ?? '';
|
||||
const isStreaming = tool.state !== 'output-available';
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={`${tool.toolCallId}-${tool.toolName}`}
|
||||
icon={isStreaming ? BroomIcon : CheckCircleIcon}
|
||||
label={
|
||||
isStreaming ? (
|
||||
<Shimmer className={cn('text-label-xs font-medium')}>{label}</Shimmer>
|
||||
) : (
|
||||
<span className="text-label-xs font-medium text-text-soft">{label}</span>
|
||||
)
|
||||
}
|
||||
collapsible
|
||||
autoCollapse
|
||||
status={isStreaming ? 'active' : 'complete'}
|
||||
defaultOpen={isStreaming}
|
||||
>
|
||||
<ScrollableReasoningBody body={body} isStreaming={isStreaming} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={`${tool.toolCallId}-${tool.toolName}`}
|
||||
icon={isStreaming ? BroomIcon : CheckCircleIcon}
|
||||
label={
|
||||
isStreaming ? (
|
||||
<Shimmer className={cn('text-label-xs font-medium')}>{label}</Shimmer>
|
||||
) : (
|
||||
<span className="text-label-xs font-medium text-text-soft">{label}</span>
|
||||
)
|
||||
}
|
||||
collapsible
|
||||
autoCollapse
|
||||
status={isStreaming ? 'active' : 'complete'}
|
||||
defaultOpen={isStreaming}
|
||||
>
|
||||
<ScrollableReasoningBody body={body} isStreaming={isStreaming} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
if (tool.toolName === AiWorkflowToolsEnum.SET_WORKFLOW_METADATA) {
|
||||
return (
|
||||
<WorkflowInitializedSection
|
||||
key={`${tool.toolCallId}-${tool.toolName}`}
|
||||
output={unwrapToolResult<WorkflowMetadataOutput>(tool.output)}
|
||||
isStreaming={tool.state !== 'output-available'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (tool.toolName === AiWorkflowToolsEnum.SET_WORKFLOW_METADATA) {
|
||||
return (
|
||||
<WorkflowInitializedSection
|
||||
key={`${tool.toolCallId}-${tool.toolName}`}
|
||||
output={unwrapToolResult<WorkflowMetadataOutput>(tool.output)}
|
||||
isStreaming={tool.state !== 'output-available'}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
tool.toolName === AiWorkflowToolsEnum.ADD_STEP ||
|
||||
tool.toolName === AiWorkflowToolsEnum.ADD_STEP_IN_BETWEEN ||
|
||||
tool.toolName === AiWorkflowToolsEnum.EDIT_STEP_CONTENT ||
|
||||
tool.toolName === AiWorkflowToolsEnum.UPDATE_STEP_CONDITIONS ||
|
||||
tool.toolName === AiWorkflowToolsEnum.REMOVE_STEP ||
|
||||
tool.toolName === AiWorkflowToolsEnum.MOVE_STEP
|
||||
) {
|
||||
const streamingLabel = toolNameToStreamingLabel[tool.toolName];
|
||||
const completeLabel = toolNameToCompleteLabel[tool.toolName];
|
||||
const action = toolNameToAction[tool.toolName];
|
||||
if (
|
||||
tool.toolName === AiWorkflowToolsEnum.ADD_STEP ||
|
||||
tool.toolName === AiWorkflowToolsEnum.ADD_STEP_IN_BETWEEN ||
|
||||
tool.toolName === AiWorkflowToolsEnum.EDIT_STEP_CONTENT ||
|
||||
tool.toolName === AiWorkflowToolsEnum.UPDATE_STEP_CONDITIONS ||
|
||||
tool.toolName === AiWorkflowToolsEnum.REMOVE_STEP ||
|
||||
tool.toolName === AiWorkflowToolsEnum.MOVE_STEP
|
||||
) {
|
||||
const streamingLabel = toolNameToStreamingLabel[tool.toolName];
|
||||
const completeLabel = toolNameToCompleteLabel[tool.toolName];
|
||||
const errorLabel = toolNameToErrorLabel[tool.toolName];
|
||||
const action = toolNameToAction[tool.toolName];
|
||||
|
||||
return (
|
||||
<StepTool
|
||||
key={`${tool.toolCallId}-${tool.toolName}`}
|
||||
stepOutput={unwrapToolResult<{ stepId: string; name: string; type: string }>(tool.output)}
|
||||
isStreaming={tool.state !== 'output-available'}
|
||||
labelStreaming={streamingLabel}
|
||||
labelComplete={completeLabel}
|
||||
action={action}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StepTool
|
||||
key={`${tool.toolCallId}-${tool.toolName}`}
|
||||
stepOutput={unwrapToolResult<{ stepId: string; name: string; type: string }>(tool.output)}
|
||||
isStreaming={tool.state !== 'output-available' && tool.state !== 'output-error'}
|
||||
labelStreaming={streamingLabel}
|
||||
labelComplete={completeLabel}
|
||||
labelError={errorLabel}
|
||||
action={action}
|
||||
error={tool.state === 'output-error' ? tool.errorText : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -6,13 +6,24 @@ export const hasKnownMessageParts = (message: UIMessage): boolean => {
|
||||
|
||||
return (message.parts ?? []).some(
|
||||
(p) =>
|
||||
p.type?.startsWith?.('text') ||
|
||||
(p.type?.startsWith?.('text') &&
|
||||
typeof (p as { text?: string }).text === 'string' &&
|
||||
!(p as { text: string }).text.startsWith('{')) ||
|
||||
(p.type?.startsWith?.('dynamic-tool') &&
|
||||
'toolName' in p &&
|
||||
knownToolNames.includes((p as DynamicToolUIPart).toolName))
|
||||
);
|
||||
};
|
||||
|
||||
export function isCancelledToolCall(tool: DynamicToolUIPart): boolean {
|
||||
return (
|
||||
tool.state === 'output-available' &&
|
||||
tool.output != null &&
|
||||
typeof tool.output === 'object' &&
|
||||
'__cancelled' in (tool.output as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
export function unwrapToolResult<T>(output: unknown): T | undefined {
|
||||
if (output && typeof output === 'object' && 'result' in output) {
|
||||
return (output as { result: T }).result;
|
||||
|
||||
@@ -109,9 +109,9 @@ export function useAiChatStream<D extends UIDataTypes = UIDataTypes, T extends U
|
||||
.flatMap((m) => m.parts.filter((p) => p.type.startsWith('data-'))) as DataUIPart<D>[];
|
||||
}, [messages]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
const handleStop = useCallback(async () => {
|
||||
setIsAborted(true);
|
||||
stop();
|
||||
await stop();
|
||||
}, [stop]);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user