feat: simplify AI chat architecture and add record links (#16463)
## Summary This PR significantly simplifies the AI chat architecture by removing complex routing/planning mechanisms and introduces clickable record links in AI responses. ## Changes ### AI Chat Architecture Simplification - **Removed** the entire `ai-chat-router` module (~850 lines) including: - Strategy decider service - Plan generator service - Complex routing logic - **Removed** agent execution planning services (~700 lines): - `agent-execution.service.ts` - `agent-plan-executor.service.ts` - `agent-tool-generator.service.ts` - **Added** centralized `ToolRegistryService` for tool management: - Builds searchable tool index (database, action, workflow tools) - Provides tool lookup by name - Supports agent search for loading expertise - **Added** `ChatExecutionService` as simple replacement: - Includes full tool catalog in system prompt - Pre-loads common tools (find/create/update for company, person, opportunity, task, note) - Uses `load_tools` mechanism for dynamic tool activation - Enables native web search by default ### Record References in AI Responses - Added `recordReferences` field to tool outputs for create, find, and update operations - Implemented `[[record:objectName:recordId:displayName]]` syntax for AI to reference records - Created `RecordLink` component that renders clickable chips with object icons - Integrated record link parsing into the markdown renderer - Users can now click directly on created/found records in AI responses ### Workflow Agent Fixes - Fixed cache invalidation issue when creating agents in workflows - Added default prompt for workflow-created agents to prevent validation errors - Relaxed agent validation to only check properties being updated (not all required properties) ### Code Quality Improvements - Extracted `getRecordDisplayName` utility that mirrors frontend's `getLabelIdentifierFieldValue` logic - Uses object metadata to determine the correct label identifier field - Handles `FULL_NAME` composite type for person/workspaceMember objects - Shared across create, find, and update record services ## Net Impact - **~1,200 lines deleted** (complex routing/planning code) - **~500 lines added** (simpler tool registry + record links) - Significantly reduced code complexity - Better tool discovery through full catalog in system prompt - Improved UX with clickable record references ## Testing - Typecheck passes - Lint passes - Manual testing of AI chat with record creation and linking
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
|
||||
import {
|
||||
parseRecordReference,
|
||||
RECORD_REFERENCE_REGEX,
|
||||
RecordLink,
|
||||
} from '@/ai/components/RecordLink';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Fragment, lazy, Suspense, useMemo } from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const MarkdownRenderer = lazy(async () => {
|
||||
const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([
|
||||
@@ -96,12 +102,87 @@ const LoadingSkeleton = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const useTextWithRecordLinks = (text: string) => {
|
||||
return useMemo(() => {
|
||||
const parts: Array<
|
||||
| string
|
||||
| { type: 'record'; props: ReturnType<typeof parseRecordReference> }
|
||||
> = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
RECORD_REFERENCE_REGEX.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
|
||||
while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const parsed = parseRecordReference(match[0]);
|
||||
|
||||
if (isDefined(parsed)) {
|
||||
parts.push({ type: 'record', props: parsed });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}, [text]);
|
||||
};
|
||||
|
||||
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
|
||||
const parts = useTextWithRecordLinks(text);
|
||||
|
||||
// If there are no record references, render normally
|
||||
const hasRecordReferences = parts.some(
|
||||
(part) => typeof part === 'object' && part.type === 'record',
|
||||
);
|
||||
|
||||
if (!hasRecordReferences) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<MarkdownRenderer TableScrollContainer={StyledTableScrollContainer}>
|
||||
{text}
|
||||
</MarkdownRenderer>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Render with record links inline
|
||||
return (
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<MarkdownRenderer TableScrollContainer={StyledTableScrollContainer}>
|
||||
{text}
|
||||
</MarkdownRenderer>
|
||||
{parts.map((part, index) => {
|
||||
if (typeof part === 'string') {
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
key={index}
|
||||
TableScrollContainer={StyledTableScrollContainer}
|
||||
>
|
||||
{part}
|
||||
</MarkdownRenderer>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === 'record' && isDefined(part.props)) {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<RecordLink
|
||||
objectNameSingular={part.props.objectNameSingular}
|
||||
recordId={part.props.recordId}
|
||||
displayName={part.props.displayName}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { AvatarChip, ChipVariant, LinkChip } from 'twenty-ui/components';
|
||||
|
||||
type RecordLinkProps = {
|
||||
objectNameSingular: string;
|
||||
recordId: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export const RecordLink = ({
|
||||
objectNameSingular,
|
||||
recordId,
|
||||
displayName,
|
||||
}: RecordLinkProps) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
if (!objectMetadataItem || !isNonEmptyString(recordId)) {
|
||||
return <span>{displayName}</span>;
|
||||
}
|
||||
|
||||
const linkToShowPage = getLinkToShowPage(objectNameSingular, {
|
||||
id: recordId,
|
||||
});
|
||||
|
||||
return (
|
||||
<LinkChip
|
||||
label={displayName}
|
||||
to={linkToShowPage}
|
||||
variant={ChipVariant.Highlighted}
|
||||
leftComponent={
|
||||
<AvatarChip
|
||||
placeholder={displayName}
|
||||
placeholderColorSeed={recordId}
|
||||
avatarType="rounded"
|
||||
avatarUrl=""
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const RECORD_REFERENCE_REGEX =
|
||||
/\[\[record:([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/g;
|
||||
|
||||
export const parseRecordReference = (match: string) => {
|
||||
const regex = /\[\[record:([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/;
|
||||
const result = regex.exec(match);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
objectNameSingular: result[1],
|
||||
recordId: result[2],
|
||||
displayName: result[3],
|
||||
};
|
||||
};
|
||||
@@ -53,12 +53,36 @@ const StyledToggleButton = styled.div<{ isExpandable: boolean }>`
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: ${({ theme }) => theme.spacing(1)} 0;
|
||||
transition: color ${({ theme }) => theme.animation.duration.normal}s;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledToolName = styled.span`
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
border-radius: ${({ theme }) => theme.border.radius.xs};
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
padding: ${({ theme }) => theme.spacing(0.5)}
|
||||
${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledLeftContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledRightContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledDisplayMessage = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
@@ -120,13 +144,20 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
||||
if (!output && !hasError) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLoadingContainer>
|
||||
<ShimmeringText>
|
||||
<StyledDisplayMessage>
|
||||
{getToolDisplayMessage(input, toolName, false)}
|
||||
</StyledDisplayMessage>
|
||||
</ShimmeringText>
|
||||
</StyledLoadingContainer>
|
||||
<StyledToggleButton isExpandable={false}>
|
||||
<StyledLeftContent>
|
||||
<StyledLoadingContainer>
|
||||
<ShimmeringText>
|
||||
<StyledDisplayMessage>
|
||||
{getToolDisplayMessage(input, toolName, false)}
|
||||
</StyledDisplayMessage>
|
||||
</ShimmeringText>
|
||||
</StyledLoadingContainer>
|
||||
</StyledLeftContent>
|
||||
<StyledRightContent>
|
||||
<StyledToolName>{toolName}</StyledToolName>
|
||||
</StyledRightContent>
|
||||
</StyledToggleButton>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@@ -153,16 +184,21 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
||||
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} />
|
||||
))}
|
||||
<StyledLeftContent>
|
||||
<StyledIconTextContainer>
|
||||
<ToolIcon size={theme.icon.size.sm} />
|
||||
<StyledDisplayMessage>{displayMessage}</StyledDisplayMessage>
|
||||
</StyledIconTextContainer>
|
||||
</StyledLeftContent>
|
||||
<StyledRightContent>
|
||||
<StyledToolName>{toolName}</StyledToolName>
|
||||
{isExpandable &&
|
||||
(isExpanded ? (
|
||||
<IconChevronUp size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
))}
|
||||
</StyledRightContent>
|
||||
</StyledToggleButton>
|
||||
|
||||
{isExpandable && (
|
||||
|
||||
+1
-6
@@ -1,4 +1,3 @@
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -7,17 +6,13 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
export const getLabelIdentifierFieldValue = (
|
||||
record: ObjectRecord,
|
||||
labelIdentifierFieldMetadataItem: FieldMetadataItem | undefined,
|
||||
objectNameSingular: string,
|
||||
): string => {
|
||||
if (!isDefined(labelIdentifierFieldMetadataItem)) {
|
||||
return record.id;
|
||||
}
|
||||
|
||||
const recordIdentifierValue = record[labelIdentifierFieldMetadataItem.name];
|
||||
if (
|
||||
objectNameSingular === CoreObjectNameSingular.WorkspaceMember ||
|
||||
labelIdentifierFieldMetadataItem.type === FieldMetadataType.FULL_NAME
|
||||
) {
|
||||
if (labelIdentifierFieldMetadataItem.type === FieldMetadataType.FULL_NAME) {
|
||||
return `${recordIdentifierValue?.firstName ?? ''} ${recordIdentifierValue?.lastName ?? ''}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export const getObjectRecordIdentifier = ({
|
||||
const labelIdentifierFieldValue = getLabelIdentifierFieldValue(
|
||||
record,
|
||||
labelIdentifierFieldMetadataItem,
|
||||
objectMetadataItem.nameSingular,
|
||||
);
|
||||
|
||||
const imageIdentifierFieldMetadata = objectMetadataItem.fields.find(
|
||||
|
||||
+2
-7
@@ -59,17 +59,12 @@ export const RecordTableRowVirtualizedDebugRowHelper = ({
|
||||
(RECORD_TABLE_ROW_HEIGHT + 1);
|
||||
|
||||
const record = useRecoilValue(recordStoreFamilyState(recordId ?? ''));
|
||||
const { objectMetadataItem, objectNameSingular } =
|
||||
useRecordTableContextOrThrow();
|
||||
const { objectMetadataItem } = useRecordTableContextOrThrow();
|
||||
const labelIdentifierFieldMetadataItem =
|
||||
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
|
||||
|
||||
const labelIdentifier = isDefined(record)
|
||||
? getLabelIdentifierFieldValue(
|
||||
record,
|
||||
labelIdentifierFieldMetadataItem,
|
||||
objectNameSingular,
|
||||
)
|
||||
? getLabelIdentifierFieldValue(record, labelIdentifierFieldMetadataItem)
|
||||
: '-';
|
||||
|
||||
const position = record?.position;
|
||||
|
||||
@@ -87,7 +87,6 @@ export const getRecordChipGenerators = (
|
||||
name: getLabelIdentifierFieldValue(
|
||||
record,
|
||||
labelIdentifierFieldMetadataItemToUse,
|
||||
objectMetadataItemToUse.nameSingular,
|
||||
),
|
||||
avatarUrl: getAvatarUrl(
|
||||
objectMetadataItemToUse.nameSingular,
|
||||
|
||||
Reference in New Issue
Block a user