diff --git a/src/modules/blocks/AutoBlocksRenderer.tsx b/src/modules/blocks/AutoBlocksRenderer.tsx index 8bf5fa38b..db09de544 100644 --- a/src/modules/blocks/AutoBlocksRenderer.tsx +++ b/src/modules/blocks/AutoBlocksRenderer.tsx @@ -1,23 +1,20 @@ import * as React from 'react'; import type { Diff as TextDiff } from '@sanity/diff-match-patch'; -import type { SxProps } from '@mui/joy/styles/types'; -import { Button, ColorPaletteProp } from '@mui/joy'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; - import type { DMessageRole } from '~/common/stores/chat/chat.message'; import { ContentScaling } from '~/common/app.theme'; import type { Block, CodeBlock, HtmlBlock, ImageBlock, TextBlock } from './blocks.types'; import { BlocksContainer } from './BlocksContainers'; +import { RenderHtmlResponse } from './html/RenderHtmlResponse'; +import { RenderImageURL } from './image/RenderImageURL'; import { RenderMarkdown, RenderMarkdownMemo } from './markdown/RenderMarkdown'; import { RenderPlainChatText } from './plaintext/RenderPlainChatText'; import { RenderTextDiff } from './textdiff/RenderTextDiff'; -import { heuristicIsBlockPureHTML, RenderHtmlResponse } from './html/RenderHtmlResponse'; -import { heuristicLegacyImageBlocks, heuristicMarkdownImageReferenceBlocks, RenderImageURL } from './image/RenderImageURL'; +import { ToggleExpansionButton } from './ToggleExpansionButton'; +import { parseBlocksFromText } from './blocks.textparser'; import { renderCodeMemoOrNot } from './code/RenderCode'; -import { useScaledCodeSx, useScaledImageSx, useScaledTypographySx, useToggleExpansionButtonSx } from '~/modules/blocks/blocks.styles'; +import { useScaledCodeSx, useScaledImageSx, useScaledTypographySx, useToggleExpansionButtonSx } from './blocks.styles'; // How long is the user collapsed message @@ -43,86 +40,6 @@ function areBlocksEqual(a: Block, b: Block): boolean { } -function parseBlocksFromText(text: string): Block[] { - - // special case: this could be generated by a proxy that returns an HTML page instead of the API response - if (heuristicIsBlockPureHTML(text)) - return [{ type: 'htmlb', html: text }]; - - // special case: markdown image references (e.g. ![alt text](https://example.com/image.png)) - const mdImageBlocks = heuristicMarkdownImageReferenceBlocks(text); - if (mdImageBlocks) - return mdImageBlocks; - - // special case: legacy prodia images - const legacyImageBlocks = heuristicLegacyImageBlocks(text); - if (legacyImageBlocks) - return legacyImageBlocks; - - const regexPatterns = { - // was: \w\x20\\.+-_ for tge filename, but was missing too much - // REVERTED THIS: was: (`{3,}\n?|$), but was matching backticks within blocks. so now it must end with a newline or stop - codeBlock: /`{3,}([\S\x20]+)?\n([\s\S]*?)(`{3,}\n?|$)/g, - htmlCodeBlock: /([\s\S]*?)<\/html>/gi, - svgBlock: //g, - }; - - const blocks: Block[] = []; - let lastIndex = 0; - - while (true) { - - // find the first match (if any) trying all the regexes - let match: RegExpExecArray | null = null; - let matchType: keyof typeof regexPatterns | null = null; - for (const type in regexPatterns) { - const regex = regexPatterns[type as keyof typeof regexPatterns]; - regex.lastIndex = lastIndex; - const currentMatch = regex.exec(text); - if (currentMatch && (match === null || currentMatch.index < match.index)) { - match = currentMatch; - matchType = type as keyof typeof regexPatterns; - } - } - if (match === null) - break; - - // anything leftover before the match is text - if (match.index > lastIndex) - blocks.push({ type: 'textb', content: text.slice(lastIndex, match.index) }); - - // add the block - switch (matchType) { - case 'codeBlock': - const blockTitle: string = (match[1] || '').trim(); - // note: we don't trim blockCode to preserve leading spaces, however if the last line is only made of spaces, we trim that - const blockCode: string = match[2].replace(/\s+$/, ''); - const blockEnd: string = match[3]; - blocks.push({ type: 'codeb', blockTitle, blockCode, complete: blockEnd.startsWith('```') }); - break; - - case 'htmlCodeBlock': - const html: string = `${match[1]}`; - blocks.push({ type: 'codeb', blockTitle: 'html', blockCode: html, complete: true }); - break; - - case 'svgBlock': - blocks.push({ type: 'codeb', blockTitle: 'svg', blockCode: match[0], complete: true }); - break; - } - - // advance the pointer - lastIndex = match.index + match[0].length; - } - - // remainder is text - if (lastIndex < text.length) - blocks.push({ type: 'textb', content: text.slice(lastIndex) }); - - return blocks; -} - - type BlocksRendererProps = { // required text: string; @@ -151,29 +68,6 @@ type BlocksRendererProps = { }; -function ToggleExpansionButton(props: { - color?: ColorPaletteProp; - isCollapsed: boolean; - onToggle: () => void; - sx: SxProps; -}) { - return ( -
- -
- ); -} - - /** * Features: collpase/expand, auto-detects HTML, SVG, Code, etc.. * Used by (and more): diff --git a/src/modules/blocks/ToggleExpansionButton.tsx b/src/modules/blocks/ToggleExpansionButton.tsx new file mode 100644 index 000000000..03b543556 --- /dev/null +++ b/src/modules/blocks/ToggleExpansionButton.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; + +import type { SxProps } from '@mui/joy/styles/types'; +import { Button, ColorPaletteProp } from '@mui/joy'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + + +/** + * Simple button to 'Show more' or 'Show less' content + */ +export function ToggleExpansionButton(props: { + color?: ColorPaletteProp; + isCollapsed: boolean; + onToggle: () => void; + sx: SxProps; +}) { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/modules/blocks/blocks.textparser.ts b/src/modules/blocks/blocks.textparser.ts new file mode 100644 index 000000000..b7a7d2998 --- /dev/null +++ b/src/modules/blocks/blocks.textparser.ts @@ -0,0 +1,83 @@ +import type { Block } from './blocks.types'; +import { heuristicIsBlockPureHTML } from './html/RenderHtmlResponse'; +import { heuristicLegacyImageBlocks, heuristicMarkdownImageReferenceBlocks } from './image/RenderImageURL'; + + +export function parseBlocksFromText(text: string): Block[] { + + // special case: this could be generated by a proxy that returns an HTML page instead of the API response + if (heuristicIsBlockPureHTML(text)) + return [{ type: 'htmlb', html: text }]; + + // special case: markdown image references (e.g. ![alt text](https://example.com/image.png)) + const mdImageBlocks = heuristicMarkdownImageReferenceBlocks(text); + if (mdImageBlocks) + return mdImageBlocks; + + // special case: legacy prodia images + const legacyImageBlocks = heuristicLegacyImageBlocks(text); + if (legacyImageBlocks) + return legacyImageBlocks; + + const regexPatterns = { + // was: \w\x20\\.+-_ for tge filename, but was missing too much + // REVERTED THIS: was: (`{3,}\n?|$), but was matching backticks within blocks. so now it must end with a newline or stop + codeBlock: /`{3,}([\S\x20]+)?\n([\s\S]*?)(`{3,}\n?|$)/g, + htmlCodeBlock: /([\s\S]*?)<\/html>/gi, + svgBlock: //g, + }; + + const blocks: Block[] = []; + let lastIndex = 0; + + while (true) { + + // find the first match (if any) trying all the regexes + let match: RegExpExecArray | null = null; + let matchType: keyof typeof regexPatterns | null = null; + for (const type in regexPatterns) { + const regex = regexPatterns[type as keyof typeof regexPatterns]; + regex.lastIndex = lastIndex; + const currentMatch = regex.exec(text); + if (currentMatch && (match === null || currentMatch.index < match.index)) { + match = currentMatch; + matchType = type as keyof typeof regexPatterns; + } + } + if (match === null) + break; + + // anything leftover before the match is text + if (match.index > lastIndex) + blocks.push({ type: 'textb', content: text.slice(lastIndex, match.index) }); + + // add the block + switch (matchType) { + case 'codeBlock': + const blockTitle: string = (match[1] || '').trim(); + // note: we don't trim blockCode to preserve leading spaces, however if the last line is only made of spaces, we trim that + const blockCode: string = match[2].replace(/\s+$/, ''); + const blockEnd: string = match[3]; + blocks.push({ type: 'codeb', blockTitle, blockCode, complete: blockEnd.startsWith('```') }); + break; + + case 'htmlCodeBlock': + const html: string = `${match[1]}`; + blocks.push({ type: 'codeb', blockTitle: 'html', blockCode: html, complete: true }); + break; + + case 'svgBlock': + blocks.push({ type: 'codeb', blockTitle: 'svg', blockCode: match[0], complete: true }); + break; + } + + // advance the pointer + lastIndex = match.index + match[0].length; + } + + // remainder is text + if (lastIndex < text.length) + blocks.push({ type: 'textb', content: text.slice(lastIndex) }); + + return blocks; +}