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:
Félix Malfait
2025-12-10 15:14:12 +01:00
committed by GitHub
parent 59b54f254e
commit 5df8fd90c3
49 changed files with 1708 additions and 2700 deletions
@@ -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,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(
@@ -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,