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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user