feat(ai): add view management tools for AI chat (#16495)

## Summary

Adds a new **VIEW** tool category for the AI chat, enabling it to work
with views:

- **get-views**: List views in the workspace, optionally filtered by
object metadata ID
- **get-view-query-parameters**: Convert a view's filters and sorts into
GraphQL query parameters that can be passed to existing `find_*` data
tools
- **create-view**, **update-view**, **delete-view**: CRUD operations for
view management

### Key design decisions

1. **No pagination duplication**: Instead of creating a
`find-records-from-view` tool that would duplicate pagination logic,
`get-view-query-parameters` returns filter/sort parameters that the AI
can pass to existing record-fetching tools.

2. **Permission model**:
- Read tools (get-views, get-view-query-parameters) are available to all
users
   - Write tools require the `VIEW` permission
   - UNLISTED views can only be modified by their creator

3. **Leverages existing utilities**: Uses
`computeRecordGqlOperationFilter` from `twenty-shared` for filter
conversion.

### Files changed

- Added `ViewToolProvider`, `ViewToolsFactory`, and
`ViewQueryParamsService`
- Added `VIEW` to `ToolCategory` enum and tool registry
- Updated `chat-execution.service.ts` to include view tools in the
catalog and pass viewId in browsing context
- Extracted shared `formatValidationErrors` utility to reduce
duplication

## Test plan

- [x] Unit tests for `ViewToolsFactory` 
- [x] Unit tests for `ViewQueryParamsService`
- [x] Lint and typecheck pass
This commit is contained in:
Félix Malfait
2025-12-11 15:05:42 +01:00
committed by GitHub
parent 2943125410
commit 3cea19baf4
15 changed files with 1384 additions and 140 deletions
@@ -6,10 +6,62 @@ import {
} from '@/ai/components/RecordLink';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Fragment, lazy, Suspense, useMemo } from 'react';
import { lazy, Suspense } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { isDefined } from 'twenty-shared/utils';
const TextWithRecordLinks = ({ text }: { text: string }) => {
const parts: React.ReactNode[] = [];
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(
<RecordLink
key={match.index}
objectNameSingular={parsed.objectNameSingular}
recordId={parsed.recordId}
displayName={parsed.displayName}
/>,
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return <>{parts}</>;
};
const processChildrenForRecordLinks = (
children: React.ReactNode,
): React.ReactNode => {
if (typeof children === 'string') {
return <TextWithRecordLinks text={children} />;
}
if (Array.isArray(children)) {
return children.map((child, index) => (
<span key={index}>{processChildrenForRecordLinks(child)}</span>
));
}
return children;
};
const MarkdownRenderer = lazy(async () => {
const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([
import('react-markdown'),
@@ -32,6 +84,10 @@ const MarkdownRenderer = lazy(async () => {
<table>{children}</table>
</TableScrollContainer>
),
p: ({ children }) => <p>{processChildrenForRecordLinks(children)}</p>,
li: ({ children }) => (
<li>{processChildrenForRecordLinks(children)}</li>
),
}}
>
{children}
@@ -102,87 +158,12 @@ 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 />}>
{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;
})}
<MarkdownRenderer TableScrollContainer={StyledTableScrollContainer}>
{text}
</MarkdownRenderer>
</Suspense>
);
};