e518f03031
### Problems Fixed
1. **Tool execution errors broke conversations**
- Failed tool executions showed "Processing..." indefinitely instead of
error messages
- Tool errors with `input: null` caused subsequent messages to fail with
`Missing required parameter: 'input[X].arguments'`
2. **Relation fields not saved in AI Agent**
- AI Agent couldn't save relation fields (e.g., `companyId`) when
creating/upserting records
- Join column names weren't recognized during field validation
### Solutions
**Tool Error Handling:**
- Display error messages in UI with expandable error details
- Ensure tool parts always have valid `input` field (`input:
part.toolInput ?? {}`)
- Refactored `ToolStepRenderer` to accept complete `toolPart` object
**Relation Field Support:**
- Updated field validation in `create-record.service.ts` and
`upsert-record.service.ts`
- Check both `fieldIdByName` and `fieldIdByJoinColumnName` mappings
### Changes
- `packages/twenty-front/src/modules/ai/`
- `ToolStepRenderer.tsx` - Error state handling
- `AIChatAssistantMessageRenderer.tsx` - Pass complete toolPart
- `mapDBPartToUIMessagePart.ts` - Prevent null tool input
- `packages/twenty-server/src/engine/core-modules/record-crud/services/`
- `create-record.service.ts` - Add join column validation
- `upsert-record.service.ts` - Add join column validation
205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
import { useTheme } from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import { useState } from 'react';
|
|
|
|
import { IconChevronDown, IconChevronUp } from 'twenty-ui/display';
|
|
import { JsonTree } from 'twenty-ui/json-visualizer';
|
|
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
|
|
|
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
|
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
|
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
|
|
import { useLingui } from '@lingui/react/macro';
|
|
import { type ToolUIPart } from 'ai';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { type JsonValue } from 'type-fest';
|
|
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
|
|
|
const StyledContainer = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: ${({ theme }) => theme.spacing(2)};
|
|
`;
|
|
|
|
const StyledLoadingContainer = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
gap: ${({ theme }) => theme.spacing(2)};
|
|
`;
|
|
|
|
const StyledContentContainer = styled.div`
|
|
background: ${({ theme }) => theme.background.transparent.lighter};
|
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
|
padding: ${({ theme }) => theme.spacing(3)};
|
|
border: 1px solid ${({ theme }) => theme.border.color.light};
|
|
`;
|
|
|
|
const StyledToggleButton = styled.div<{ isExpandable: boolean }>`
|
|
align-items: center;
|
|
background: none;
|
|
border: none;
|
|
cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'auto')};
|
|
display: flex;
|
|
color: ${({ theme }) => theme.font.color.tertiary};
|
|
gap: ${({ theme }) => theme.spacing(1)};
|
|
padding: ${({ theme }) => theme.spacing(1)} 0;
|
|
transition: color ${({ theme }) => theme.animation.duration.normal}s;
|
|
|
|
&:hover {
|
|
color: ${({ theme }) => theme.font.color.secondary};
|
|
}
|
|
`;
|
|
|
|
const StyledDisplayMessage = styled.span`
|
|
color: ${({ theme }) => theme.font.color.tertiary};
|
|
font-size: ${({ theme }) => theme.font.size.md};
|
|
font-weight: ${({ theme }) => theme.font.weight.medium};
|
|
`;
|
|
|
|
const StyledIconTextContainer = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${({ theme }) => theme.spacing(1)};
|
|
|
|
svg {
|
|
min-width: ${({ theme }) => theme.icon.size.sm}px;
|
|
}
|
|
`;
|
|
|
|
const StyledTabContainer = styled.div`
|
|
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
|
display: flex;
|
|
gap: ${({ theme }) => theme.spacing(3)};
|
|
margin-bottom: ${({ theme }) => theme.spacing(3)};
|
|
`;
|
|
|
|
const StyledTab = styled.div<{ isActive: boolean }>`
|
|
color: ${({ theme, isActive }) =>
|
|
isActive ? theme.font.color.primary : theme.font.color.tertiary};
|
|
font-size: ${({ theme }) => theme.font.size.sm};
|
|
font-weight: ${({ theme, isActive }) =>
|
|
isActive ? theme.font.weight.medium : theme.font.weight.regular};
|
|
cursor: pointer;
|
|
transition: color ${({ theme }) => theme.animation.duration.normal}s;
|
|
padding-bottom: ${({ theme }) => theme.spacing(2)};
|
|
|
|
&:hover {
|
|
color: ${({ theme }) => theme.font.color.secondary};
|
|
}
|
|
`;
|
|
|
|
const StyledJsonContainer = styled.div`
|
|
max-height: 400px;
|
|
overflow: auto;
|
|
`;
|
|
|
|
type TabType = 'output' | 'input';
|
|
|
|
export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
|
const { t } = useLingui();
|
|
const theme = useTheme();
|
|
const { copyToClipboard } = useCopyToClipboard();
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<TabType>('output');
|
|
|
|
const { input, output, type, errorText } = toolPart;
|
|
const toolName = type.split('-')[1];
|
|
|
|
const hasError = isDefined(errorText);
|
|
const isExpandable = isDefined(output) || hasError;
|
|
|
|
const isTwoFirstDepths = ({ depth }: { depth: number }) => depth < 2;
|
|
|
|
if (!output && !hasError) {
|
|
return (
|
|
<StyledContainer>
|
|
<StyledLoadingContainer>
|
|
<ShimmeringText>
|
|
<StyledDisplayMessage>
|
|
{getToolDisplayMessage(input, toolName, false)}
|
|
</StyledDisplayMessage>
|
|
</ShimmeringText>
|
|
</StyledLoadingContainer>
|
|
</StyledContainer>
|
|
);
|
|
}
|
|
|
|
const displayMessage = hasError
|
|
? 'Tool execution failed'
|
|
: output &&
|
|
typeof output === 'object' &&
|
|
'message' in output &&
|
|
typeof output.message === 'string'
|
|
? output.message
|
|
: getToolDisplayMessage(input, toolName, true);
|
|
|
|
const result =
|
|
output && typeof output === 'object' && 'result' in output
|
|
? (output as { result: string }).result
|
|
: output;
|
|
|
|
const ToolIcon = getToolIcon(toolName);
|
|
|
|
return (
|
|
<StyledContainer>
|
|
<StyledToggleButton
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
isExpandable={isExpandable}
|
|
>
|
|
<StyledIconTextContainer>
|
|
<ToolIcon size={theme.icon.size.sm} />
|
|
<StyledDisplayMessage>{displayMessage}</StyledDisplayMessage>
|
|
</StyledIconTextContainer>
|
|
{isExpandable &&
|
|
(isExpanded ? (
|
|
<IconChevronUp size={theme.icon.size.sm} />
|
|
) : (
|
|
<IconChevronDown size={theme.icon.size.sm} />
|
|
))}
|
|
</StyledToggleButton>
|
|
|
|
{isExpandable && (
|
|
<AnimatedExpandableContainer isExpanded={isExpanded}>
|
|
<StyledContentContainer>
|
|
{hasError ? (
|
|
<StyledJsonContainer>{errorText}</StyledJsonContainer>
|
|
) : (
|
|
<>
|
|
<StyledTabContainer>
|
|
<StyledTab
|
|
isActive={activeTab === 'output'}
|
|
onClick={() => setActiveTab('output')}
|
|
>
|
|
Output
|
|
</StyledTab>
|
|
<StyledTab
|
|
isActive={activeTab === 'input'}
|
|
onClick={() => setActiveTab('input')}
|
|
>
|
|
Input
|
|
</StyledTab>
|
|
</StyledTabContainer>
|
|
|
|
<StyledJsonContainer>
|
|
<JsonTree
|
|
value={
|
|
(activeTab === 'output' ? result : input) as JsonValue
|
|
}
|
|
shouldExpandNodeInitially={isTwoFirstDepths}
|
|
emptyArrayLabel={t`Empty Array`}
|
|
emptyObjectLabel={t`Empty Object`}
|
|
emptyStringLabel={t`[empty string]`}
|
|
arrowButtonCollapsedLabel={t`Expand`}
|
|
arrowButtonExpandedLabel={t`Collapse`}
|
|
onNodeValueClick={copyToClipboard}
|
|
/>
|
|
</StyledJsonContainer>
|
|
</>
|
|
)}
|
|
</StyledContentContainer>
|
|
</AnimatedExpandableContainer>
|
|
)}
|
|
</StyledContainer>
|
|
);
|
|
};
|