From 393e62ba9f098850b7a1c25674d9a5e9a2c3d37f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 5 Aug 2026 20:31:57 +0200 Subject: [PATCH] Make AI chat streaming render cost independent of message length (#23831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.** `(? 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)_ Review in cubic --- packages/twenty-front/jest.config.mjs | 4 +- .../AiChatNonLastMessageIdsList.tsx | 16 ++- .../ai/components/LazyMarkdownRenderer.tsx | 77 +++++------- .../ai/components/ReasoningSummaryDisplay.tsx | 117 ------------------ .../ai/components/TextWithChatReferences.tsx | 40 ++---- .../src/modules/ai/components/ViewLink.tsx | 10 +- .../ai/constants/ChatReferenceOpenPattern.ts | 6 +- .../src/modules/ai/constants/ChatThreads.ts | 1 - .../constants/EmptyMarkdownBlockSplitCache.ts | 8 ++ .../states/agentChatMessagesComponentState.ts | 11 -- .../modules/ai/types/AgentResponseFormat.ts | 8 -- .../ai/types/MarkdownBlockSplitCache.ts | 6 + .../ai/utils/__tests__/getFieldIcon.test.ts | 40 ------ .../getMarkdownBlocksIncrementally.test.ts | 109 ++++++++++++++++ .../modules/ai/utils/findChatReferences.ts | 4 + .../ai/utils/getChatReferenceSegments.ts | 25 ++++ .../src/modules/ai/utils/getFieldIcon.ts | 14 --- .../utils/getMarkdownBlocksIncrementally.ts | 73 +++++++++++ .../utils/protectChatReferencesForMarkdown.ts | 34 ++--- .../states/fieldMetadataItemByIdSelector.ts | 32 ++--- .../objectMetadataItemFamilySelector.ts | 18 ++- .../objectMetadataItemsByIdMapSelector.ts | 19 +++ .../selectors/viewFromViewIdFamilySelector.ts | 6 +- .../states/selectors/viewsByIdMapSelector.ts | 10 ++ 24 files changed, 366 insertions(+), 322 deletions(-) delete mode 100644 packages/twenty-front/src/modules/ai/components/ReasoningSummaryDisplay.tsx delete mode 100644 packages/twenty-front/src/modules/ai/constants/ChatThreads.ts create mode 100644 packages/twenty-front/src/modules/ai/constants/EmptyMarkdownBlockSplitCache.ts delete mode 100644 packages/twenty-front/src/modules/ai/states/agentChatMessagesComponentState.ts delete mode 100644 packages/twenty-front/src/modules/ai/types/AgentResponseFormat.ts create mode 100644 packages/twenty-front/src/modules/ai/types/MarkdownBlockSplitCache.ts delete mode 100644 packages/twenty-front/src/modules/ai/utils/__tests__/getFieldIcon.test.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/getChatReferenceSegments.ts delete mode 100644 packages/twenty-front/src/modules/ai/utils/getFieldIcon.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/getMarkdownBlocksIncrementally.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByIdMapSelector.ts create mode 100644 packages/twenty-front/src/modules/views/states/selectors/viewsByIdMapSelector.ts diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 98bd2e6012..806bc10ad7 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -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: { diff --git a/packages/twenty-front/src/modules/ai/components/AiChatNonLastMessageIdsList.tsx b/packages/twenty-front/src/modules/ai/components/AiChatNonLastMessageIdsList.tsx index 0bd93b5b42..ea78826e09 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatNonLastMessageIdsList.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatNonLastMessageIdsList.tsx @@ -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) => ( - + + + )); }; diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index c4b49e51f2..2550988679 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -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 }) => ( + {processChildrenForChatReferences(children)} + ); + // 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 = { {children}
), - p: ({ children }: { children?: React.ReactNode }) => ( - - {processChildrenForChatReferences(children)} - - ), - td: ({ children }: { children?: React.ReactNode }) => ( - {processChildrenForChatReferences(children)} - ), - th: ({ children }: { children?: React.ReactNode }) => ( - {processChildrenForChatReferences(children)} - ), - li: ({ children }: { children?: React.ReactNode }) => ( -
  • {processChildrenForChatReferences(children)}
  • - ), - h1: ({ children }: { children?: React.ReactNode }) => ( -

    {processChildrenForChatReferences(children)}

    - ), - h2: ({ children }: { children?: React.ReactNode }) => ( -

    {processChildrenForChatReferences(children)}

    - ), - h3: ({ children }: { children?: React.ReactNode }) => ( -

    {processChildrenForChatReferences(children)}

    - ), - h4: ({ children }: { children?: React.ReactNode }) => ( -

    {processChildrenForChatReferences(children)}

    - ), - h5: ({ children }: { children?: React.ReactNode }) => ( -
    {processChildrenForChatReferences(children)}
    - ), - h6: ({ children }: { children?: React.ReactNode }) => ( -
    {processChildrenForChatReferences(children)}
    - ), + 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 }) => ( - {blockText} + + {protectChatReferencesForMarkdown(blockText)} + ), (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 ( { - const { theme } = useContext(ThemeContext); - const [isExpanded, setIsExpanded] = useState(false); - - const hasContent = content.trim().length > 0; - - if (!hasContent) { - return null; - } - - return ( - - {isThinking && ( - <> - - - - {t`Thinking...`} - - - - {content} - - - )} - - {hasContent && !isThinking && ( - <> - setIsExpanded(!isExpanded)}> - - - {t`Finished thinking`} - - {isExpanded ? ( - - ) : ( - - )} - - - - - {content} - - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/ai/components/TextWithChatReferences.tsx b/packages/twenty-front/src/modules/ai/components/TextWithChatReferences.tsx index aa93f696dd..b917f7811b 100644 --- a/packages/twenty-front/src/modules/ai/components/TextWithChatReferences.tsx +++ b/packages/twenty-front/src/modules/ai/components/TextWithChatReferences.tsx @@ -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( - , - ); - - 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 + ) : ( + + ), + )} + + ); }; diff --git a/packages/twenty-front/src/modules/ai/components/ViewLink.tsx b/packages/twenty-front/src/modules/ai/components/ViewLink.tsx index bdb812b7d0..1e7f1bf1e0 100644 --- a/packages/twenty-front/src/modules/ai/components/ViewLink.tsx +++ b/packages/twenty-front/src/modules/ai/components/ViewLink.tsx @@ -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)) { diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts index b55da758a6..3edde75f9c 100644 --- a/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts +++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts @@ -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 = '(?({ - key: 'agentChatMessagesComponentState', - defaultValue: [], - componentInstanceContext: AgentChatComponentInstanceContext, -}); diff --git a/packages/twenty-front/src/modules/ai/types/AgentResponseFormat.ts b/packages/twenty-front/src/modules/ai/types/AgentResponseFormat.ts deleted file mode 100644 index 84f422fde9..0000000000 --- a/packages/twenty-front/src/modules/ai/types/AgentResponseFormat.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow'; - -export type AgentResponseFormat = - | { type: 'text' } - | { - type: 'json'; - schema: BaseOutputSchemaV2; - }; diff --git a/packages/twenty-front/src/modules/ai/types/MarkdownBlockSplitCache.ts b/packages/twenty-front/src/modules/ai/types/MarkdownBlockSplitCache.ts new file mode 100644 index 0000000000..e22dcb301f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/types/MarkdownBlockSplitCache.ts @@ -0,0 +1,6 @@ +export type MarkdownBlockSplitCache = { + text: string; + blocks: string[]; + stablePrefix: string; + stableBlocks: string[]; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getFieldIcon.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getFieldIcon.test.ts deleted file mode 100644 index 371eaff161..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/getFieldIcon.test.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts new file mode 100644 index 0000000000..a6928ab13e --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/getMarkdownBlocksIncrementally.test.ts @@ -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 = { + 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([]); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts index 0eea298426..6deb3f0969 100644 --- a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts +++ b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts @@ -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; diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceSegments.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceSegments.ts new file mode 100644 index 0000000000..0c04cc918e --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceSegments.ts @@ -0,0 +1,25 @@ +import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch'; +import { findChatReferences } from '@/ai/utils/findChatReferences'; + +export const getChatReferenceSegments = ( + text: string, +): Array => { + const references = findChatReferences(text); + const segments: Array = []; + 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; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/getFieldIcon.ts b/packages/twenty-front/src/modules/ai/utils/getFieldIcon.ts deleted file mode 100644 index 71994e4401..0000000000 --- a/packages/twenty-front/src/modules/ai/utils/getFieldIcon.ts +++ /dev/null @@ -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'; - } -}; diff --git a/packages/twenty-front/src/modules/ai/utils/getMarkdownBlocksIncrementally.ts b/packages/twenty-front/src/modules/ai/utils/getMarkdownBlocksIncrementally.ts new file mode 100644 index 0000000000..e0fc7b91ce --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getMarkdownBlocksIncrementally.ts @@ -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, + }, + }; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts b/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts index b6cced8e3b..2c3f89fbe2 100644 --- a/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts +++ b/packages/twenty-front/src/modules/ai/utils/protectChatReferencesForMarkdown.ts @@ -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(''); }; diff --git a/packages/twenty-front/src/modules/object-metadata/states/fieldMetadataItemByIdSelector.ts b/packages/twenty-front/src/modules/object-metadata/states/fieldMetadataItemByIdSelector.ts index e878c18747..dabd4cfa7e 100644 --- a/packages/twenty-front/src/modules/object-metadata/states/fieldMetadataItemByIdSelector.ts +++ b/packages/twenty-front/src/modules/object-metadata/states/fieldMetadataItemByIdSelector.ts @@ -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, }); diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts index bff15a9ef1..3fad4fd25c 100644 --- a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts +++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemFamilySelector.ts @@ -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, }); diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByIdMapSelector.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByIdMapSelector.ts new file mode 100644 index 0000000000..b1132e1369 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsByIdMapSelector.ts @@ -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 +>({ + key: 'objectMetadataItemsByIdMapSelector', + get: ({ get }) => { + const objectMetadataItems = get(objectMetadataItemsWithFieldsSelector); + + return new Map( + objectMetadataItems.map((objectMetadataItem) => [ + objectMetadataItem.id, + objectMetadataItem, + ]), + ); + }, +}); diff --git a/packages/twenty-front/src/modules/views/states/selectors/viewFromViewIdFamilySelector.ts b/packages/twenty-front/src/modules/views/states/selectors/viewFromViewIdFamilySelector.ts index a51fc49595..6c3aa8f788 100644 --- a/packages/twenty-front/src/modules/views/states/selectors/viewFromViewIdFamilySelector.ts +++ b/packages/twenty-front/src/modules/views/states/selectors/viewFromViewIdFamilySelector.ts @@ -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, }); diff --git a/packages/twenty-front/src/modules/views/states/selectors/viewsByIdMapSelector.ts b/packages/twenty-front/src/modules/views/states/selectors/viewsByIdMapSelector.ts new file mode 100644 index 0000000000..73ba98eb8a --- /dev/null +++ b/packages/twenty-front/src/modules/views/states/selectors/viewsByIdMapSelector.ts @@ -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 +>({ + key: 'viewsByIdMapSelector', + get: ({ get }) => new Map(get(viewsSelector).map((view) => [view.id, view])), +});