Make AI chat streaming render cost independent of message length (#23831)

Follow-up to #23573: chip-heavy answers are long by design, and each
stream flush re-ran `protectChatReferencesForMarkdown` and
`marked.lexer` over the whole message, so render cost grew quadratically
with message length. This makes the per-flush cost proportional to the
appended text instead, and offsets the new code by removing dead AI chat
code.

## Streaming render

- **Incremental block splitting.** `getMarkdownBlocksIncrementally`
reuses blocks that can no longer change and re-tokenizes only the
trailing ones. Two trailing blocks stay unstable, not one: a loose list
followed by a blank line still merges with a later item (`- a\n\n` + `-
b` is one list token). Uses `Lexer.blockTokens` instead of
`marked.lexer` since only block raws are needed and the full lexer also
runs the inline tokenizer. Simulated stream over a 22 KB chip-heavy
message (120 chars/flush, matching the 100 ms flush throttle): 191 ms →
3.7 ms cumulative. The test suite pins char-by-char equivalence against
full `marked.lexer` output across loose lists, unclosed fences, setext
headings, tables, CRLF and chip markers.
- **Per-block reference protection.** `protectChatReferencesForMarkdown`
moved behind the existing block memo, so settled blocks never re-run
reference parsing during a stream.
- **Anchored open pattern.** `(?<!\[)\[\[+` anchors marker matching to
the start of a bracket run. The greedy `+` from #23798 backtracked at
every position inside a run, once per alternative: 429 ms → ~1 ms on a
10 KB bracket-run input. A run start always yields the same match, so no
valid marker is lost. Also an `includes('[[')` bail-out in
`findChatReferences`, which runs on every text node of the streaming
block.

## Chip lookups

`fieldMetadataItemByIdSelector` did `objectMetadataItems.find(obj =>
obj.fields.some(...))` per chip — O(workspace fields) each time the
agent's tool calls trigger a metadata refetch mid-chat. The by-id and
by-name map selectors mostly already existed with almost no consumers;
this wires `fieldMetadataItemByIdSelector`,
`objectMetadataItemFamilySelector` and `viewFromViewIdFamilySelector` to
them (adding the missing `objectMetadataItemsByIdMapSelector` and
`viewsByIdMapSelector`) and adds `areEqual` so unchanged lookups keep
referential stability.

## Offscreen messages

Settled messages (everything except the streaming last one) get
`content-visibility: auto`, so long threads skip layout and paint for
messages scrolled out of view. `contain-intrinsic-size: auto` keeps
remembered heights, so scroll positions stay accurate once a message has
been painted.

## Removed

`ReasoningSummaryDisplay`, `agentChatMessagesComponentState`,
`CHAT_THREADS_PAGE_SIZE`, `AgentResponseFormat` and `getFieldIcon` had
no consumers. `TextWithChatReferences` and
`protectChatReferencesForMarkdown` shared a duplicated segment-slicing
loop, now in `getChatReferenceSegments`, and the nine identical per-tag
markdown component entries collapse into `createChatReferenceElement`.
The branch lands at +354/−329 including the new test suite; production
code is net negative.

Incidental: `marked` added to jest's `transformIgnorePatterns` allowlist
(ESM-only, previously imported by no test).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MN8FVc63J4SJQHXWwzUwkh)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23831?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-08-05 20:31:57 +02:00
committed by GitHub
parent c35e364562
commit 393e62ba9f
24 changed files with 366 additions and 322 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ const jestConfig = {
testEnvironmentOptions: {},
transformIgnorePatterns: [
'/node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core)/.*)',
'../../node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core)/.*)',
'/node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core|marked)/.*)',
'../../node_modules/(?!(twenty-ui|apollo-upload-client|extract-files|is-plain-obj|@preact/signals-core|marked)/.*)',
'../../twenty-ui/',
],
transform: {
@@ -1,6 +1,18 @@
import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { agentChatNonLastMessageIdsComponentSelector } from '@/ai/states/selectors/agentChatNonLastMessageIdsComponentSelector';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { styled } from '@linaria/react';
// Settled messages never change, so let the browser skip layout and paint for
// the ones scrolled out of view. `auto` in contain-intrinsic-size keeps the
// last rendered height once a message has been painted; the estimate only
// applies to never-painted messages, where scroll anchoring absorbs the
// correction. 160px approximates a typical assistant message so the scrollbar
// stays close to truth on long resumed threads.
const StyledSettledMessage = styled.div`
contain-intrinsic-size: auto 160px;
content-visibility: auto;
`;
export const AiChatNonLastMessageIdsList = () => {
const agentChatNonLastMessageIds = useAtomComponentSelectorValue(
@@ -8,6 +20,8 @@ export const AiChatNonLastMessageIdsList = () => {
);
return agentChatNonLastMessageIds.map((messageId) => (
<AiChatMessage key={messageId} messageId={messageId} />
<StyledSettledMessage key={messageId}>
<AiChatMessage messageId={messageId} />
</StyledSettledMessage>
));
};
@@ -7,8 +7,9 @@ import {
} from '@/ai/components/LazyMarkdownRendererStyledComponents';
import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock';
import { TextWithChatReferences } from '@/ai/components/TextWithChatReferences';
import { EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE } from '@/ai/constants/EmptyMarkdownBlockSplitCache';
import { getMarkdownBlocksIncrementally } from '@/ai/utils/getMarkdownBlocksIncrementally';
import { protectChatReferencesForMarkdown } from '@/ai/utils/protectChatReferencesForMarkdown';
import { marked } from 'marked';
import {
cloneElement,
isValidElement,
@@ -16,7 +17,7 @@ import {
memo,
Suspense,
useContext,
useMemo,
useRef,
} from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { getSafeUrl, isDefined } from 'twenty-shared/utils';
@@ -48,6 +49,12 @@ const processChildrenForChatReferences = (
return children;
};
const createChatReferenceElement =
(Element: React.ElementType) =>
({ children }: { children?: React.ReactNode }) => (
<Element>{processChildrenForChatReferences(children)}</Element>
);
// react-markdown uses each entry as the JSX element type, so rebuilding this map
// per render would remount every node on every streamed chunk.
const MARKDOWN_COMPONENTS = {
@@ -56,38 +63,16 @@ const MARKDOWN_COMPONENTS = {
<table>{children}</table>
</StyledTableScrollContainer>
),
p: ({ children }: { children?: React.ReactNode }) => (
<StyledParagraph>
{processChildrenForChatReferences(children)}
</StyledParagraph>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td>{processChildrenForChatReferences(children)}</td>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th>{processChildrenForChatReferences(children)}</th>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li>{processChildrenForChatReferences(children)}</li>
),
h1: ({ children }: { children?: React.ReactNode }) => (
<h1>{processChildrenForChatReferences(children)}</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2>{processChildrenForChatReferences(children)}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3>{processChildrenForChatReferences(children)}</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4>{processChildrenForChatReferences(children)}</h4>
),
h5: ({ children }: { children?: React.ReactNode }) => (
<h5>{processChildrenForChatReferences(children)}</h5>
),
h6: ({ children }: { children?: React.ReactNode }) => (
<h6>{processChildrenForChatReferences(children)}</h6>
),
p: createChatReferenceElement(StyledParagraph),
td: createChatReferenceElement('td'),
th: createChatReferenceElement('th'),
li: createChatReferenceElement('li'),
h1: createChatReferenceElement('h1'),
h2: createChatReferenceElement('h2'),
h3: createChatReferenceElement('h3'),
h4: createChatReferenceElement('h4'),
h5: createChatReferenceElement('h5'),
h6: createChatReferenceElement('h6'),
a: ({
children,
href,
@@ -170,23 +155,29 @@ const LoadingSkeleton = () => {
);
};
// Protecting per block behind the memo means only the streaming tail blocks
// pay the reference-parsing cost on each flush; settled blocks never re-run it.
const MemoizedMarkdownBlock = memo(
({ blockText }: { blockText: string }) => (
<MarkdownRenderer>{blockText}</MarkdownRenderer>
<MarkdownRenderer>
{protectChatReferencesForMarkdown(blockText)}
</MarkdownRenderer>
),
(previousProps, nextProps) => previousProps.blockText === nextProps.blockText,
);
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
const protectedText = useMemo(
() => protectChatReferencesForMarkdown(text),
[text],
);
// Not state: the blocks are a pure function of `text`, the ref only caches
// the previous split so streaming appends skip re-tokenizing settled blocks.
// oxlint-disable-next-line twenty/no-state-useref
const blockSplitCacheRef = useRef(EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE);
const markdownBlocks = useMemo(
() => marked.lexer(protectedText).map((token) => token.raw),
[protectedText],
);
const { blocks: markdownBlocks, cache } = getMarkdownBlocksIncrementally({
text,
cache: blockSplitCacheRef.current,
});
blockSplitCacheRef.current = cache;
return (
<StyledMarkdownContainer
@@ -1,117 +0,0 @@
import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { IconBrain, IconChevronDown, IconChevronUp } from 'twenty-ui/icon';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import { t } from '@lingui/core/macro';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledThinkingText = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledReasoningContainer = styled.div`
background: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
padding: ${themeCssVariables.spacing[3]};
`;
const StyledReasoningText = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
line-height: ${themeCssVariables.text.lineHeight.lg};
white-space: pre-wrap;
`;
const StyledToggleButton = styled.div`
align-items: center;
background: none;
border: none;
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
display: flex;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]} 0;
transition: color calc(${themeCssVariables.animation.duration.normal} * 1s);
&:hover {
color: ${themeCssVariables.font.color.secondary};
}
`;
const StyledIconContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
export const ReasoningSummaryDisplay = ({
content,
isThinking = false,
}: {
content: string;
isThinking?: boolean;
}) => {
const { theme } = useContext(ThemeContext);
const [isExpanded, setIsExpanded] = useState(false);
const hasContent = content.trim().length > 0;
if (!hasContent) {
return null;
}
return (
<StyledContainer>
{isThinking && (
<>
<ShimmeringText>
<StyledIconContainer>
<IconBrain size={theme.icon.size.sm} />
<StyledThinkingText>{t`Thinking...`}</StyledThinkingText>
</StyledIconContainer>
</ShimmeringText>
<StyledReasoningContainer>
<StyledReasoningText>{content}</StyledReasoningText>
</StyledReasoningContainer>
</>
)}
{hasContent && !isThinking && (
<>
<StyledToggleButton onClick={() => setIsExpanded(!isExpanded)}>
<StyledIconContainer>
<IconBrain size={theme.icon.size.sm} />
<span>{t`Finished thinking`}</span>
</StyledIconContainer>
{isExpanded ? (
<IconChevronUp size={theme.icon.size.sm} />
) : (
<IconChevronDown size={theme.icon.size.sm} />
)}
</StyledToggleButton>
<AnimatedExpandableContainer isExpanded={isExpanded}>
<StyledReasoningContainer>
<StyledReasoningText>{content}</StyledReasoningText>
</StyledReasoningContainer>
</AnimatedExpandableContainer>
</>
)}
</StyledContainer>
);
};
@@ -1,6 +1,5 @@
import { ChatReferenceChip } from '@/ai/components/ChatReferenceChip';
import { findChatReferences } from '@/ai/utils/findChatReferences';
import { type ReactNode } from 'react';
import { getChatReferenceSegments } from '@/ai/utils/getChatReferenceSegments';
type TextWithChatReferencesProps = {
text: string;
@@ -9,30 +8,15 @@ type TextWithChatReferencesProps = {
export const TextWithChatReferences = ({
text,
}: TextWithChatReferencesProps) => {
const references = findChatReferences(text);
if (references.length === 0) {
return <>{text}</>;
}
const parts: ReactNode[] = [];
let lastIndex = 0;
for (const reference of references) {
if (reference.index > lastIndex) {
parts.push(text.slice(lastIndex, reference.index));
}
parts.push(
<ChatReferenceChip key={reference.index} reference={reference} />,
);
lastIndex = reference.index + reference.fullMatch.length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return <>{parts}</>;
return (
<>
{getChatReferenceSegments(text).map((segment) =>
typeof segment === 'string' ? (
segment
) : (
<ChatReferenceChip key={segment.index} reference={segment} />
),
)}
</>
);
};
@@ -1,9 +1,9 @@
import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay';
import { flatObjectMetadataItemsSelector } from '@/object-metadata/states/flatObjectMetadataItemsSelector';
import { objectMetadataItemsByIdMapSelector } from '@/object-metadata/states/objectMetadataItemsByIdMapSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useViewById } from '@/views/hooks/useViewById';
import { AppPath } from 'twenty-shared/types';
import { findById, getAppPath, isDefined } from 'twenty-shared/utils';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/icon';
import { useTheme } from 'twenty-ui/theme-constants';
@@ -17,12 +17,12 @@ export const ViewLink = ({ viewId, displayName }: ViewLinkProps) => {
const { getIcon } = useIcons();
const { view } = useViewById(viewId);
const flatObjectMetadataItems = useAtomStateValue(
flatObjectMetadataItemsSelector,
const objectMetadataItemsByIdMap = useAtomStateValue(
objectMetadataItemsByIdMapSelector,
);
const objectMetadataItem = isDefined(view)
? flatObjectMetadataItems.find(findById(view.objectMetadataId))
? objectMetadataItemsByIdMap.get(view.objectMetadataId)
: undefined;
if (!isDefined(view) || !isDefined(objectMetadataItem)) {
@@ -1 +1,5 @@
export const CHAT_REFERENCE_OPEN_PATTERN = '\\[\\[+';
// The lookbehind anchors the match to the start of a bracket run. Without it,
// every position inside a long run is a candidate start and the greedy + makes
// the scan quadratic in the run length; a run start always yields the same
// match, so no valid marker is lost.
export const CHAT_REFERENCE_OPEN_PATTERN = '(?<!\\[)\\[\\[+';
@@ -1 +0,0 @@
export const CHAT_THREADS_PAGE_SIZE = 20;
@@ -0,0 +1,8 @@
import { type MarkdownBlockSplitCache } from '@/ai/types/MarkdownBlockSplitCache';
export const EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE: MarkdownBlockSplitCache = {
text: '',
blocks: [],
stablePrefix: '',
stableBlocks: [],
};
@@ -1,11 +0,0 @@
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const agentChatMessagesComponentState = createAtomComponentState<
ExtendedUIMessage[]
>({
key: 'agentChatMessagesComponentState',
defaultValue: [],
componentInstanceContext: AgentChatComponentInstanceContext,
});
@@ -1,8 +0,0 @@
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export type AgentResponseFormat =
| { type: 'text' }
| {
type: 'json';
schema: BaseOutputSchemaV2;
};
@@ -0,0 +1,6 @@
export type MarkdownBlockSplitCache = {
text: string;
blocks: string[];
stablePrefix: string;
stableBlocks: string[];
};
@@ -1,40 +0,0 @@
import { getFieldIcon } from '@/ai/utils/getFieldIcon';
describe('getFieldIcon', () => {
describe('supported field types', () => {
it('should return IconAbc for string field type', () => {
expect(getFieldIcon('string')).toBe('IconAbc');
});
it('should return IconText for number field type', () => {
expect(getFieldIcon('number')).toBe('IconText');
});
it('should return IconCheckbox for boolean field type', () => {
expect(getFieldIcon('boolean')).toBe('IconCheckbox');
});
});
describe('unsupported and edge cases', () => {
it('should return IconQuestionMark for an unsupported field type', () => {
expect(getFieldIcon('totally-unknown-type' as any)).toBe(
'IconQuestionMark',
);
});
it('should return IconQuestionMark for undefined', () => {
expect(getFieldIcon(undefined)).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for null', () => {
expect(getFieldIcon(null as any)).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for empty string', () => {
expect(getFieldIcon('' as any)).toBe('IconQuestionMark');
});
});
describe('consistency', () => {
it('should return the same icon for the same field type', () => {
const result1 = getFieldIcon('string');
const result2 = getFieldIcon('string');
expect(result1).toBe(result2);
});
});
});
@@ -0,0 +1,109 @@
import { marked } from 'marked';
import { EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE } from '@/ai/constants/EmptyMarkdownBlockSplitCache';
import { type MarkdownBlockSplitCache } from '@/ai/types/MarkdownBlockSplitCache';
import { getMarkdownBlocksIncrementally } from '@/ai/utils/getMarkdownBlocksIncrementally';
const lexerBlocks = (text: string): string[] =>
marked.lexer(text).map((token) => token.raw);
const streamThrough = (text: string, chunkSize: number): string[] => {
let cache: MarkdownBlockSplitCache = EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE;
let blocks: string[] = [];
for (let end = chunkSize; end < text.length + chunkSize; end += chunkSize) {
const result = getMarkdownBlocksIncrementally({
text: text.slice(0, Math.min(end, text.length)),
cache,
});
cache = result.cache;
blocks = result.blocks;
}
return blocks;
};
const FIXTURES: Record<string, string> = {
paragraphs: 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.',
looseList: '- a\n\n- b\n\n- c\n\nplain paragraph after list',
listWithLooseContinuation:
'- item one\n\n continued loose content\n\n- item two',
nestedList: '1. first\n - sub a\n\n - sub b\n2. second',
table: '| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nafter table',
codeFence: 'before\n\n```js\nconst x = 1;\n\nconst y = 2;\n```\n\nafter',
unclosedCodeFence: 'before\n\n```js\nconst x = 1;\n\nstill code',
setextHeading: 'Title\n=====\n\nBody text\n\nSub\n-----\n\nmore',
mixedBlocks:
'# H1\n\ntext\n\n## H2\n\n- list\n- items\n\n> quote\n> more quote\n\nend',
blockquotes: '> a\n\n> b\n\ntext',
chatReferences:
'Check [[field:12345678-1234-5678-abcd-123456789012:Annual Revenue[[/field]] and\n\n- [[view:12345678-1234-5678-abcd-123456789012:All[[/view]]\n\n| [[object:company:Companies[[/object]] | x |\n|---|---|\n| a | b |',
windowsLineEndings: 'line one\r\n\r\n- a\r\n\r\n- b\r\n\r\nend',
};
describe('getMarkdownBlocksIncrementally', () => {
it.each(Object.entries(FIXTURES))(
'should match a full marked.lexer split when streamed char by char for %s',
(_name, text) => {
let cache: MarkdownBlockSplitCache = EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE;
for (let end = 1; end <= text.length; end++) {
const prefix = text.slice(0, end);
const result = getMarkdownBlocksIncrementally({ text: prefix, cache });
cache = result.cache;
expect(result.blocks).toEqual(lexerBlocks(prefix));
}
},
);
it.each(Object.entries(FIXTURES))(
'should match a full marked.lexer split when streamed in chunks for %s',
(_name, text) => {
for (const chunkSize of [3, 7, 20]) {
expect(streamThrough(text, chunkSize)).toEqual(lexerBlocks(text));
}
},
);
it('should return the cached blocks when the text is unchanged', () => {
const text = 'A paragraph.\n\nAnother paragraph.';
const firstResult = getMarkdownBlocksIncrementally({
text,
cache: EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE,
});
const secondResult = getMarkdownBlocksIncrementally({
text,
cache: firstResult.cache,
});
expect(secondResult.blocks).toBe(firstResult.blocks);
expect(secondResult.cache).toBe(firstResult.cache);
});
it('should recover with a full split when the text is not an append', () => {
const firstResult = getMarkdownBlocksIncrementally({
text: 'First paragraph.\n\nSecond paragraph.\n\nThird one.',
cache: EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE,
});
const replacedText = 'Completely different text.\n\nWith new blocks.';
const secondResult = getMarkdownBlocksIncrementally({
text: replacedText,
cache: firstResult.cache,
});
expect(secondResult.blocks).toEqual(lexerBlocks(replacedText));
});
it('should handle an empty text', () => {
const result = getMarkdownBlocksIncrementally({
text: '',
cache: EMPTY_MARKDOWN_BLOCK_SPLIT_CACHE,
});
expect(result.blocks).toEqual([]);
});
});
@@ -7,6 +7,10 @@ import { getSurplusCloseBracketLength } from '@/ai/utils/getSurplusCloseBracketL
import { isDefined } from 'twenty-shared/utils';
export const findChatReferences = (text: string): ChatReferenceMatch[] => {
if (!text.includes('[[')) {
return [];
}
const starts: ChatReferenceStart[] = [];
CHAT_REFERENCE_START_REGEX.lastIndex = 0;
@@ -0,0 +1,25 @@
import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch';
import { findChatReferences } from '@/ai/utils/findChatReferences';
export const getChatReferenceSegments = (
text: string,
): Array<string | ChatReferenceMatch> => {
const references = findChatReferences(text);
const segments: Array<string | ChatReferenceMatch> = [];
let lastIndex = 0;
for (const reference of references) {
if (reference.index > lastIndex) {
segments.push(text.slice(lastIndex, reference.index));
}
segments.push(reference);
lastIndex = reference.index + reference.fullMatch.length;
}
if (lastIndex < text.length) {
segments.push(text.slice(lastIndex));
}
return segments;
};
@@ -1,14 +0,0 @@
import { type AgentResponseFieldType } from 'twenty-shared/ai';
export const getFieldIcon = (fieldType?: AgentResponseFieldType): string => {
switch (fieldType) {
case 'string':
return 'IconAbc';
case 'number':
return 'IconText';
case 'boolean':
return 'IconCheckbox';
default:
return 'IconQuestionMark';
}
};
@@ -0,0 +1,73 @@
import { type MarkdownBlockSplitCache } from '@/ai/types/MarkdownBlockSplitCache';
import { Lexer } from 'marked';
// Appended text can reopen the last block, and a list followed by a blank-line
// token merges with a later item ("- a\n\n" + "- b" is one loose list), so the
// two trailing blocks must be re-tokenized on every flush. Anything before them
// was terminated by content that is still present, so it can never change.
const UNSTABLE_TRAILING_BLOCK_COUNT = 2;
// marked.lexer normalizes line endings before tokenizing; blockTokens does not,
// so normalize here to keep raw offsets aligned with the sliced text.
const normalizeLineEndings = (text: string): string =>
text.replace(/\r\n|\r/g, '\n');
const splitIntoBlocks = (text: string): string[] =>
new Lexer().blockTokens(text, []).map((token) => token.raw);
// A streamed message only grows, so instead of re-tokenizing the whole message
// on every flush, reuse the blocks that can no longer change and re-tokenize
// only the unstable tail, keeping each flush O(appended text) instead of
// O(whole message). Any non-append change misses the stablePrefix guard and
// falls back to a full split.
export const getMarkdownBlocksIncrementally = ({
text,
cache,
}: {
text: string;
cache: MarkdownBlockSplitCache;
}): { blocks: string[]; cache: MarkdownBlockSplitCache } => {
const normalizedText = normalizeLineEndings(text);
if (normalizedText === cache.text) {
return { blocks: cache.blocks, cache };
}
const canReuseStableBlocks =
cache.stablePrefix.length > 0 &&
normalizedText.startsWith(cache.stablePrefix);
const stableBlocks = canReuseStableBlocks ? cache.stableBlocks : [];
const stablePrefix = canReuseStableBlocks ? cache.stablePrefix : '';
const tailBlocks = splitIntoBlocks(normalizedText.slice(stablePrefix.length));
const blocks = [...stableBlocks, ...tailBlocks];
const nextStableBlocks = blocks.slice(
0,
Math.max(0, blocks.length - UNSTABLE_TRAILING_BLOCK_COUNT),
);
// Append newly stabilized raws instead of re-joining the whole prefix on
// every flush. The stable set shrinks when the tail collapses into fewer
// than two blocks; then trim the raws that fell out of it.
const nextStablePrefix =
nextStableBlocks.length >= stableBlocks.length
? stablePrefix + nextStableBlocks.slice(stableBlocks.length).join('')
: stablePrefix.slice(
0,
stablePrefix.length -
stableBlocks
.slice(nextStableBlocks.length)
.reduce((removedLength, raw) => removedLength + raw.length, 0),
);
return {
blocks,
cache: {
text: normalizedText,
blocks,
stablePrefix: nextStablePrefix,
stableBlocks: nextStableBlocks,
},
};
};
@@ -1,30 +1,22 @@
import { escapeMarkdownForChatReference } from '@/ai/utils/escapeMarkdownForChatReference';
import { findChatReferences } from '@/ai/utils/findChatReferences';
import { formatChatReference } from '@/ai/utils/formatChatReference';
import { getChatReferenceSegments } from '@/ai/utils/getChatReferenceSegments';
export const protectChatReferencesForMarkdown = (text: string): string => {
const references = findChatReferences(text);
const segments = getChatReferenceSegments(text);
if (references.length === 0) {
if (segments.length === 1 && typeof segments[0] === 'string') {
return text;
}
const parts: string[] = [];
let lastIndex = 0;
for (const reference of references) {
parts.push(text.slice(lastIndex, reference.index));
parts.push(
formatChatReference({
...reference,
displayName: escapeMarkdownForChatReference(reference.displayName),
}),
);
lastIndex = reference.index + reference.fullMatch.length;
}
parts.push(text.slice(lastIndex));
return parts.join('');
return segments
.map((segment) =>
typeof segment === 'string'
? segment
: formatChatReference({
...segment,
displayName: escapeMarkdownForChatReference(segment.displayName),
}),
)
.join('');
};
@@ -1,32 +1,29 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { objectMetadataItemsWithFieldsSelector } from '@/object-metadata/states/objectMetadataItemsWithFieldsSelector';
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
import { objectMetadataItemsByIdMapSelector } from '@/object-metadata/states/objectMetadataItemsByIdMapSelector';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
import { findById, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
export const fieldMetadataItemByIdSelector = createAtomFamilySelector({
key: 'fieldMetadataItemByIdSelector',
get:
({ fieldMetadataItemId }: { fieldMetadataItemId: string }) =>
({ get }) => {
const objectMetadataItems = get(objectMetadataItemsWithFieldsSelector);
const flattenedFieldMetadataItems = get(
flattenedFieldMetadataItemsSelector,
const foundFieldMetadataItem = get(fieldMetadataItemByIdMapSelector).get(
fieldMetadataItemId,
);
const foundObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.fields.some(findById(fieldMetadataItemId)),
);
if (!isDefined(foundObjectMetadataItem)) {
if (
!isDefined(foundFieldMetadataItem) ||
!isDefined(foundFieldMetadataItem.objectMetadataId)
) {
return {};
}
const foundFieldMetadataItem = flattenedFieldMetadataItems.find(
findById(fieldMetadataItemId),
);
const foundObjectMetadataItem = get(
objectMetadataItemsByIdMapSelector,
).get(foundFieldMetadataItem.objectMetadataId);
if (!isDefined(foundFieldMetadataItem)) {
if (!isDefined(foundObjectMetadataItem)) {
return {};
}
@@ -35,4 +32,7 @@ export const fieldMetadataItemByIdSelector = createAtomFamilySelector({
foundObjectMetadataItem,
};
},
areEqual: (previous, next) =>
previous.foundFieldMetadataItem === next.foundFieldMetadataItem &&
previous.foundObjectMetadataItem === next.foundObjectMetadataItem,
});
@@ -1,4 +1,5 @@
import { objectMetadataItemsWithFieldsSelector } from '@/object-metadata/states/objectMetadataItemsWithFieldsSelector';
import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/states/objectMetadataItemsByNamePluralMapSelector';
import { objectMetadataItemsByNameSingularMapSelector } from '@/object-metadata/states/objectMetadataItemsByNameSingularMapSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
@@ -15,23 +16,18 @@ export const objectMetadataItemFamilySelector = createAtomFamilySelector<
get:
({ objectNameType, objectName }: ObjectMetadataItemSelector) =>
({ get }) => {
const objectMetadataItems = get(objectMetadataItemsWithFieldsSelector);
if (objectNameType === 'singular') {
return (
objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectName,
) ?? null
get(objectMetadataItemsByNameSingularMapSelector).get(objectName) ??
null
);
} else if (objectNameType === 'plural') {
return (
objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.namePlural === objectName,
) ?? null
get(objectMetadataItemsByNamePluralMapSelector).get(objectName) ??
null
);
}
return null;
},
areEqual: (previous, next) => previous === next,
});
@@ -0,0 +1,19 @@
import { objectMetadataItemsWithFieldsSelector } from '@/object-metadata/states/objectMetadataItemsWithFieldsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const objectMetadataItemsByIdMapSelector = createAtomSelector<
Map<string, EnrichedObjectMetadataItem>
>({
key: 'objectMetadataItemsByIdMapSelector',
get: ({ get }) => {
const objectMetadataItems = get(objectMetadataItemsWithFieldsSelector);
return new Map(
objectMetadataItems.map((objectMetadataItem) => [
objectMetadataItem.id,
objectMetadataItem,
]),
);
},
});
@@ -1,5 +1,5 @@
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { viewsByIdMapSelector } from '@/views/states/selectors/viewsByIdMapSelector';
import { type View } from '@/views/types/View';
export const viewFromViewIdFamilySelector = createAtomFamilySelector<
@@ -10,7 +10,7 @@ export const viewFromViewIdFamilySelector = createAtomFamilySelector<
get:
({ viewId }) =>
({ get }) => {
const views = get(viewsSelector);
return views?.find((view) => view.id === viewId);
return get(viewsByIdMapSelector).get(viewId);
},
areEqual: (previous, next) => previous === next,
});
@@ -0,0 +1,10 @@
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { type ViewWithRelations } from '@/views/types/ViewWithRelations';
export const viewsByIdMapSelector = createAtomSelector<
Map<string, ViewWithRelations>
>({
key: 'viewsByIdMapSelector',
get: ({ get }) => new Map(get(viewsSelector).map((view) => [view.id, view])),
});