From 001966ac29527ce5f84e5c9f7a696353a92b62c8 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 11 Nov 2024 17:02:06 -0800 Subject: [PATCH 1/6] exclude lazy edit tests from search --- .vscode/settings.json | 1 + gui/src/components/find/FindWidget.tsx | 805 +++++++++++++----------- gui/src/components/gui/TimelineItem.tsx | 2 +- 3 files changed, 436 insertions(+), 372 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b77c6d9b08..0979d760e3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,7 @@ "binary/build/**": true, "binary/out/**": true, "binary/tmp/**": true, + "core/edit/lazy/test-examples/**": true, "core/llm/llamaTokenizer.js": true, "core/llm/llamaTokenizer.mjs": true, "core/vendor/**": true, diff --git a/gui/src/components/find/FindWidget.tsx b/gui/src/components/find/FindWidget.tsx index c3fb37ef93..3089803dab 100644 --- a/gui/src/components/find/FindWidget.tsx +++ b/gui/src/components/find/FindWidget.tsx @@ -1,50 +1,61 @@ -import { useRef, useEffect, useState, RefObject, useCallback, useMemo } from "react"; +import { + useRef, + useEffect, + useState, + RefObject, + useCallback, + useMemo, +} from "react"; import { RootState } from "../../redux/store"; import { useSelector } from "react-redux"; import { HeaderButton, Input } from ".."; -import ButtonWithTooltip from "../ButtonWithTooltip"; -import { ArrowDownIcon, ArrowUpIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { + ArrowDownIcon, + ArrowUpIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import HeaderButtonWithToolTip from "../gui/HeaderButtonWithToolTip"; interface SearchMatch { - index: number; - textNode: Text; - overlayRectangle: Rectangle; + index: number; + textNode: Text; + overlayRectangle: Rectangle; } -type ScrollToMatchOption = "closest" | "first" | "none" +type ScrollToMatchOption = "closest" | "first" | "none"; -const SEARCH_DEBOUNCE = 300 -const RESIZE_DEBOUNCE = 200 +const SEARCH_DEBOUNCE = 300; +const RESIZE_DEBOUNCE = 200; interface Rectangle { - top: number; - left: number; - width: number; - height: number; + top: number; + left: number; + width: number; + height: number; } interface HighlightOverlayProps extends Rectangle { - isCurrent: boolean; + isCurrent: boolean; } const HighlightOverlay = (props: HighlightOverlayProps) => { - const { isCurrent, top, left, width, height } = props; - return ( -
- ) -} + const { isCurrent, top, left, width, height } = props; + return ( +
+ ); +}; /* useFindWidget takes a container ref and returns @@ -55,351 +66,403 @@ const HighlightOverlay = (props: HighlightOverlayProps) => { Container must have relative positioning */ export const useFindWidget = (searchRef: RefObject) => { - // Search input, debounced - const [input, setInput] = useState(""); - const debouncedInput = useRef("") - const inputRef = useRef(null) - - // Widget open/closed state - const [open, setOpen] = useState(false); - const openWidget = useCallback(() => { - setOpen(true); - inputRef?.current.select() - }, [setOpen, inputRef]) - - // Search settings and results - const [caseSensitive, setCaseSensitive] = useState(false); - const [useRegex, setUseRegex] = useState(false); - - const [matches, setMatches] = useState([]); - const [currentMatch, setCurrentMatch] = useState(undefined); - - // Navigating between search results - // The "current" search result is highlighted a different color - const scrollToMatch = useCallback((match: SearchMatch) => { - setCurrentMatch(match) - searchRef.current.scrollTo({ - top: match.overlayRectangle.top - searchRef.current.clientHeight / 2, - left: match.overlayRectangle.left - searchRef.current.clientWidth / 2, - behavior: "smooth" - }) - }, [searchRef.current]) - - const nextMatch = useCallback(() => { - if (!currentMatch || (matches.length === 0)) return - const newIndex = (currentMatch.index + 1) % matches.length; - const newMatch = matches[newIndex] - scrollToMatch(newMatch) - }, [scrollToMatch, currentMatch, matches]) - - const previousMatch = useCallback(() => { - if (!currentMatch || (matches.length === 0)) return - const newIndex = currentMatch.index === 0 ? matches.length - 1 : currentMatch.index - 1 - const newMatch = matches[newIndex] - scrollToMatch(newMatch) - }, [scrollToMatch, currentMatch, matches]) - - // Handle keyboard shortcuts for navigation - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.metaKey && event.key.toLowerCase() === "f") { - event.preventDefault(); - event.stopPropagation(); - openWidget(); - } else if (document.activeElement === inputRef.current) { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - } else if (event.key === "Enter") { - event.preventDefault(); - event.stopPropagation(); - if (event.shiftKey) previousMatch() - else nextMatch() - } - } + // Search input, debounced + const [input, setInput] = useState(""); + const debouncedInput = useRef(""); + const inputRef = useRef(null); + + // Widget open/closed state + const [open, setOpen] = useState(false); + const openWidget = useCallback(() => { + setOpen(true); + inputRef?.current.select(); + }, [setOpen, inputRef]); + + // Search settings and results + const [caseSensitive, setCaseSensitive] = useState(false); + const [useRegex, setUseRegex] = useState(false); + + const [matches, setMatches] = useState([]); + const [currentMatch, setCurrentMatch] = useState( + undefined, + ); + + // Navigating between search results + // The "current" search result is highlighted a different color + const scrollToMatch = useCallback( + (match: SearchMatch) => { + setCurrentMatch(match); + searchRef.current.scrollTo({ + top: match.overlayRectangle.top - searchRef.current.clientHeight / 2, + left: match.overlayRectangle.left - searchRef.current.clientWidth / 2, + behavior: "smooth", + }); + }, + [searchRef.current], + ); + + const nextMatch = useCallback(() => { + if (!currentMatch || matches.length === 0) return; + const newIndex = (currentMatch.index + 1) % matches.length; + const newMatch = matches[newIndex]; + scrollToMatch(newMatch); + }, [scrollToMatch, currentMatch, matches]); + + const previousMatch = useCallback(() => { + if (!currentMatch || matches.length === 0) return; + const newIndex = + currentMatch.index === 0 ? matches.length - 1 : currentMatch.index - 1; + const newMatch = matches[newIndex]; + scrollToMatch(newMatch); + }, [scrollToMatch, currentMatch, matches]); + + // Handle keyboard shortcuts for navigation + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.metaKey && event.key.toLowerCase() === "f") { + event.preventDefault(); + event.stopPropagation(); + openWidget(); + } else if (document.activeElement === inputRef.current) { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } else if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) previousMatch(); + else nextMatch(); } - - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [inputRef, matches, nextMatch]); - - // Handle container resize changes - highlight positions must adjust - const [isResizing, setIsResizing] = useState(false); - useEffect(() => { - let timeoutId: NodeJS.Timeout | null = null; - if (!searchRef?.current) return; - - const resizeObserver = new ResizeObserver(entries => { - if (timeoutId) { - clearTimeout(timeoutId); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [inputRef, matches, nextMatch]); + + // Handle container resize changes - highlight positions must adjust + const [isResizing, setIsResizing] = useState(false); + useEffect(() => { + let timeoutId: NodeJS.Timeout | null = null; + if (!searchRef?.current) return; + + const resizeObserver = new ResizeObserver((entries) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + setIsResizing(true); + timeoutId = setTimeout(() => { + setIsResizing(false); + }, RESIZE_DEBOUNCE); + }); + + resizeObserver.observe(searchRef.current); + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (searchRef.current) resizeObserver.unobserve(searchRef.current); + }; + }, [searchRef.current]); + + // Main function for finding matches and generating highlight overlays + const refreshSearch = useCallback( + (scrollTo: ScrollToMatchOption = "none", clearFirst = false) => { + if (clearFirst) setMatches([]); + + const _query = debouncedInput.current; // trimStart - decided no because spaces should be fully searchable + if (!searchRef.current || !_query) { + setMatches([]); + return; + } + const query = caseSensitive ? _query : _query.toLowerCase(); + + // First grab all text nodes + // Skips any elements with the "find-widget-skip" class + const textNodes: Text[] = []; + const walker = document.createTreeWalker( + searchRef.current, + NodeFilter.SHOW_ALL, + { + acceptNode: (node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + if ((node as Element).classList.contains("find-widget-skip")) + return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } else if (node.nodeType === Node.TEXT_NODE) { + if (!node.nodeValue) return NodeFilter.FILTER_REJECT; + const nodeValue = caseSensitive + ? node.nodeValue + : node.nodeValue.toLowerCase(); + if (nodeValue.includes(query)) return NodeFilter.FILTER_ACCEPT; } - setIsResizing(true); - timeoutId = setTimeout(() => { - setIsResizing(false) - }, RESIZE_DEBOUNCE); - }); - - resizeObserver.observe(searchRef.current); - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - if (searchRef.current) resizeObserver.unobserve(searchRef.current); - }; - }, [searchRef.current]); - - // Main function for finding matches and generating highlight overlays - const refreshSearch = useCallback((scrollTo: ScrollToMatchOption = "none", clearFirst = false) => { - if (clearFirst) setMatches([]) - - const _query = debouncedInput.current; // trimStart - decided no because spaces should be fully searchable - if (!searchRef.current || !_query) { - setMatches([]); - return; - }; - const query = caseSensitive ? _query : _query.toLowerCase() - - // First grab all text nodes - // Skips any elements with the "find-widget-skip" class - const textNodes: Text[] = []; - const walker = document.createTreeWalker( - searchRef.current, - NodeFilter.SHOW_ALL, - { - acceptNode: (node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - if ((node as Element).classList.contains("find-widget-skip")) return NodeFilter.FILTER_REJECT; - return NodeFilter.FILTER_ACCEPT; - } else if (node.nodeType === Node.TEXT_NODE) { - if (!node.nodeValue) return NodeFilter.FILTER_REJECT; - const nodeValue = caseSensitive ? node.nodeValue : node.nodeValue.toLowerCase() - if (nodeValue.includes(query)) return NodeFilter.FILTER_ACCEPT; - } - return NodeFilter.FILTER_REJECT - } - } - ); - - while (walker.nextNode()) { - if (walker.currentNode.nodeType === Node.ELEMENT_NODE) continue - textNodes.push(walker.currentNode as Text); + return NodeFilter.FILTER_REJECT; + }, + }, + ); + + while (walker.nextNode()) { + if (walker.currentNode.nodeType === Node.ELEMENT_NODE) continue; + textNodes.push(walker.currentNode as Text); + } + + // Now walk through each node match and extract search results + // One node can have several matches + const newMatches: SearchMatch[] = []; + textNodes.forEach((textNode, idx) => { + // Hacky way to detect code blocks that be wider than client and cause absolute positioning to fail + const highlightFullLine = + textNode.parentElement.className.includes("hljs"); + + let nodeTextValue = caseSensitive + ? textNode.nodeValue + : textNode.nodeValue.toLowerCase(); + let startIndex = 0; + while ((startIndex = nodeTextValue.indexOf(query, startIndex)) !== -1) { + // Create a range to measure the size and position of the match + const range = document.createRange(); + range.setStart(textNode, startIndex); + const endIndex = startIndex + query.length; + range.setEnd(textNode, endIndex); + const rect = range.getBoundingClientRect(); + range.detach(); + startIndex = endIndex; + + const top = + rect.top + + searchRef.current.clientTop + + searchRef.current.scrollTop; + const left = + rect.left + + searchRef.current.clientLeft + + searchRef.current.scrollLeft; + + // Build a match result and push to matches + const newMatch: SearchMatch = { + index: 0, // will set later + textNode, + overlayRectangle: { + top, + left: highlightFullLine ? 2 : left, + width: highlightFullLine + ? searchRef.current.clientWidth - 4 + : rect.width, // equivalent of adding 2 px x padding + height: rect.height, + }, + }; + newMatches.push(newMatch); + + if (highlightFullLine) { + break; // Since highlighting full line no need for multiple overlays, will cause darker highlight + } } - - // Now walk through each node match and extract search results - // One node can have several matches - const newMatches: SearchMatch[] = []; - textNodes.forEach((textNode, idx) => { - // Hacky way to detect code blocks that be wider than client and cause absolute positioning to fail - const highlightFullLine = textNode.parentElement.className.includes("hljs") - - let nodeTextValue = caseSensitive ? textNode.nodeValue : textNode.nodeValue.toLowerCase(); - let startIndex = 0; - while ((startIndex = nodeTextValue.indexOf(query, startIndex)) !== -1) { - // Create a range to measure the size and position of the match - const range = document.createRange(); - range.setStart(textNode, startIndex); - const endIndex = startIndex + query.length - range.setEnd(textNode, endIndex); - const rect = range.getBoundingClientRect(); - range.detach(); - startIndex = endIndex; - - const top = rect.top + searchRef.current.clientTop + searchRef.current.scrollTop - const left = rect.left + searchRef.current.clientLeft + searchRef.current.scrollLeft; - - // Build a match result and push to matches - const newMatch: SearchMatch = { - index: 0, // will set later - textNode, - overlayRectangle: { - top, - left: highlightFullLine ? 2 : left, - width: highlightFullLine ? (searchRef.current.clientWidth - 4) : rect.width, // equivalent of adding 2 px x padding - height: rect.height, - }, - } - newMatches.push(newMatch); - - if (highlightFullLine) { - break; // Since highlighting full line no need for multiple overlays, will cause darker highlight - } - } - }); - - // There will still be duplicate full lines when multiple text nodes are in the same line (e.g. Code highlights) - // Filter them out by using the overlay rectangle as a hash key - const matchHash = Object.fromEntries(newMatches.map(match => [JSON.stringify(match.overlayRectangle), match])) - const filteredMatches = Object.values(matchHash).map((match, index) => ({ ...match, index })); - - // Find match closest to the middle of the view - const verticalMiddle = searchRef.current.scrollTop + searchRef.current.clientHeight / 2 - let closestDist = Infinity - let closestMatchToMiddle: SearchMatch | null = null - filteredMatches.forEach((match) => { - const dist = Math.abs(verticalMiddle - match.overlayRectangle.top); - if (dist < closestDist) { - closestDist = dist; - closestMatchToMiddle = match; - } - }) - - // Update matches and scroll to the closest or first match - setMatches(filteredMatches); - if (query.length > 1 && filteredMatches.length) { - if (scrollTo === "first") { - scrollToMatch(filteredMatches[0]); - } - if (scrollTo === "closest") { - scrollToMatch(closestMatchToMiddle) - } - if (scrollTo === "none") { - if (closestMatchToMiddle) { - setCurrentMatch(closestMatchToMiddle) - } else { - setCurrentMatch(filteredMatches[0]) - } - } + }); + + // There will still be duplicate full lines when multiple text nodes are in the same line (e.g. Code highlights) + // Filter them out by using the overlay rectangle as a hash key + const matchHash = Object.fromEntries( + newMatches.map((match) => [ + JSON.stringify(match.overlayRectangle), + match, + ]), + ); + const filteredMatches = Object.values(matchHash).map((match, index) => ({ + ...match, + index, + })); + + // Find match closest to the middle of the view + const verticalMiddle = + searchRef.current.scrollTop + searchRef.current.clientHeight / 2; + let closestDist = Infinity; + let closestMatchToMiddle: SearchMatch | null = null; + filteredMatches.forEach((match) => { + const dist = Math.abs(verticalMiddle - match.overlayRectangle.top); + if (dist < closestDist) { + closestDist = dist; + closestMatchToMiddle = match; } - }, [searchRef.current, debouncedInput, scrollToMatch, caseSensitive, useRegex]) - - // Triggers that should cause immediate refresh of results to closest search value: - // Input change (debounced) and window click - const lastUpdateRef = useRef(0); - useEffect(() => { - const debounce = () => { - debouncedInput.current = input - lastUpdateRef.current = Date.now(); - refreshSearch("closest"); + }); + + // Update matches and scroll to the closest or first match + setMatches(filteredMatches); + if (query.length > 1 && filteredMatches.length) { + if (scrollTo === "first") { + scrollToMatch(filteredMatches[0]); } - const timeSinceLastUpdate = Date.now() - lastUpdateRef.current; - if (timeSinceLastUpdate >= SEARCH_DEBOUNCE) { - debounce() - } else { - const handler = setTimeout(() => { - debounce() - }, SEARCH_DEBOUNCE - timeSinceLastUpdate); - return () => { - clearTimeout(handler); - }; + if (scrollTo === "closest") { + scrollToMatch(closestMatchToMiddle); } - }, [refreshSearch, input]); - - // Could consider doing any window click but I only handled search div here - // Since usually only clicks in search div will cause content changes in search div - useEffect(() => { - if (!open || !searchRef.current) return - const handleSearchRefClick = () => { - refreshSearch("none"); + if (scrollTo === "none") { + if (closestMatchToMiddle) { + setCurrentMatch(closestMatchToMiddle); + } else { + setCurrentMatch(filteredMatches[0]); + } } - searchRef.current.addEventListener("click", handleSearchRefClick) - return () => { - searchRef.current.removeEventListener("click", handleSearchRefClick); - }; - }, [searchRef.current, refreshSearch, open]); - - // Triggers that should cause results to temporarily disappear and then reload - // Active = LLM is generating, etc. - const active = useSelector((state: RootState) => state.state.active); - useEffect(() => { - if (active || isResizing) setMatches([]) - else refreshSearch("none") - }, [refreshSearch, active]); - - useEffect(() => { - if (!open) setMatches([]) - else refreshSearch("closest") - }, [refreshSearch, open]); - - useEffect(() => { - refreshSearch("closest") - }, [refreshSearch, caseSensitive, useRegex]) - - // Find widget component - const widget = useMemo(() => { - return ( -
- { - setInput(e.target.value); - }} - placeholder="Search..." - /> -

- {matches.length === 0 ? "No results" : `${(currentMatch?.index ?? 0) + 1} of ${matches.length}`} -

-
- { - e.stopPropagation() - previousMatch() - }} - className="h-4 w-4 focus:ring focus:ring-1" - disabled={matches.length < 2 || active} - > - - - { - e.stopPropagation() - nextMatch() - }} - className="h-4 w-4 focus:ring focus:ring-1" - disabled={matches.length < 2 || active} - > - - -
- { - e.stopPropagation() - setCaseSensitive(curr => !curr) - }} - className="h-5 w-6 text-xs focus:ring focus:ring-1 rounded-full border focus:outline-none" - > - Aa - - {/* TODO - add useRegex functionality */} - setOpen(false)} - className="focus:ring focus:ring-1" - > - - -
- ) - }, [open, input, inputRef, caseSensitive, matches, currentMatch, previousMatch, nextMatch]) - - // Generate the highlight overlay elements - const highlights = useMemo(() => { - return matches.map(match => ) - }, [matches, currentMatch]) - - return { - matches, - highlights, - inputRef, - input: debouncedInput, - setInput, - open, - setOpen, - widget + } + }, + [searchRef.current, debouncedInput, scrollToMatch, caseSensitive, useRegex], + ); + + // Triggers that should cause immediate refresh of results to closest search value: + // Input change (debounced) and window click + const lastUpdateRef = useRef(0); + useEffect(() => { + const debounce = () => { + debouncedInput.current = input; + lastUpdateRef.current = Date.now(); + refreshSearch("closest"); + }; + const timeSinceLastUpdate = Date.now() - lastUpdateRef.current; + if (timeSinceLastUpdate >= SEARCH_DEBOUNCE) { + debounce(); + } else { + const handler = setTimeout(() => { + debounce(); + }, SEARCH_DEBOUNCE - timeSinceLastUpdate); + return () => { + clearTimeout(handler); + }; } -} \ No newline at end of file + }, [refreshSearch, input]); + + // Could consider doing any window click but I only handled search div here + // Since usually only clicks in search div will cause content changes in search div + useEffect(() => { + if (!open || !searchRef.current) return; + const handleSearchRefClick = () => { + refreshSearch("none"); + }; + searchRef.current.addEventListener("click", handleSearchRefClick); + return () => { + searchRef.current.removeEventListener("click", handleSearchRefClick); + }; + }, [searchRef.current, refreshSearch, open]); + + // Triggers that should cause results to temporarily disappear and then reload + // Active = LLM is generating, etc. + const active = useSelector((state: RootState) => state.state.active); + useEffect(() => { + if (active || isResizing) setMatches([]); + else refreshSearch("none"); + }, [refreshSearch, active]); + + useEffect(() => { + if (!open) setMatches([]); + else refreshSearch("closest"); + }, [refreshSearch, open]); + + useEffect(() => { + refreshSearch("closest"); + }, [refreshSearch, caseSensitive, useRegex]); + + // Find widget component + const widget = useMemo(() => { + return ( +
+ { + setInput(e.target.value); + }} + placeholder="Search..." + /> +

+ {matches.length === 0 + ? "No results" + : `${(currentMatch?.index ?? 0) + 1} of ${matches.length}`} +

+
+ { + e.stopPropagation(); + previousMatch(); + }} + className="h-4 w-4 focus:ring focus:ring-1" + disabled={matches.length < 2 || active} + > + + + { + e.stopPropagation(); + nextMatch(); + }} + className="h-4 w-4 focus:ring focus:ring-1" + disabled={matches.length < 2 || active} + > + + +
+ { + e.stopPropagation(); + setCaseSensitive((curr) => !curr); + }} + className="h-5 w-6 rounded-full border text-xs focus:outline-none focus:ring focus:ring-1" + > + Aa + + {/* TODO - add useRegex functionality */} + setOpen(false)} + className="focus:ring focus:ring-1" + > + + +
+ ); + }, [ + open, + input, + inputRef, + caseSensitive, + matches, + currentMatch, + previousMatch, + nextMatch, + ]); + + // Generate the highlight overlay elements + const highlights = useMemo(() => { + return matches.map((match) => ( + + )); + }, [matches, currentMatch]); + + return { + matches, + highlights, + inputRef, + input: debouncedInput, + setInput, + open, + setOpen, + widget, + }; +}; diff --git a/gui/src/components/gui/TimelineItem.tsx b/gui/src/components/gui/TimelineItem.tsx index 3796a4cd93..28222abcbb 100644 --- a/gui/src/components/gui/TimelineItem.tsx +++ b/gui/src/components/gui/TimelineItem.tsx @@ -30,7 +30,7 @@ interface TimelineItemProps { item: ChatHistoryItem; open: boolean; onToggle: () => void; - children: any; + children: React.ReactNode; iconElement?: any; } From 099a7a601e7e6ad9b46cfaa1b683c8efba2b7af8 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 11 Nov 2024 17:25:37 -0800 Subject: [PATCH 2/6] start nested markdown patch --- gui/src/components/markdown/StyledMarkdownPreview.tsx | 3 ++- gui/src/components/markdown/utils/patchNestedMarkdown.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 gui/src/components/markdown/utils/patchNestedMarkdown.ts diff --git a/gui/src/components/markdown/StyledMarkdownPreview.tsx b/gui/src/components/markdown/StyledMarkdownPreview.tsx index 77158e96a0..7927638234 100644 --- a/gui/src/components/markdown/StyledMarkdownPreview.tsx +++ b/gui/src/components/markdown/StyledMarkdownPreview.tsx @@ -21,6 +21,7 @@ import FilenameLink from "./FilenameLink"; import StepContainerPreToolbar from "./StepContainerPreToolbar"; import { SyntaxHighlightedPre } from "./SyntaxHighlightedPre"; import StepContainerPreActionButtons from "./StepContainerPreActionButtons"; +import { patchNestedMarkdown } from "./utils/patchNestedMarkdown"; const StyledMarkdown = styled.div<{ fontSize?: number; @@ -253,7 +254,7 @@ const StyledMarkdownPreview = memo(function StyledMarkdownPreview( }); useEffect(() => { - setMarkdownSource(props.source || ""); + setMarkdownSource(patchNestedMarkdown(props.source ?? "")); }, [props.source]); return ( diff --git a/gui/src/components/markdown/utils/patchNestedMarkdown.ts b/gui/src/components/markdown/utils/patchNestedMarkdown.ts new file mode 100644 index 0000000000..dc46904248 --- /dev/null +++ b/gui/src/components/markdown/utils/patchNestedMarkdown.ts @@ -0,0 +1,5 @@ +export const patchNestedMarkdown = (source: string): string => { + const firstCode = source.indexOf("```"); + + return source; +}; From 992bb38d8ad208f13572983a1ef513307c87f98f Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 11 Nov 2024 18:46:26 -0800 Subject: [PATCH 3/6] patch working --- .../markdown/utils/patchNestedMarkdown.ts | 90 ++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/gui/src/components/markdown/utils/patchNestedMarkdown.ts b/gui/src/components/markdown/utils/patchNestedMarkdown.ts index dc46904248..e385711b27 100644 --- a/gui/src/components/markdown/utils/patchNestedMarkdown.ts +++ b/gui/src/components/markdown/utils/patchNestedMarkdown.ts @@ -1,5 +1,89 @@ -export const patchNestedMarkdown = (source: string): string => { - const firstCode = source.indexOf("```"); +/* + This is a patch for outputing markdown code that contains codeblocks + + It notices markdown blocks, keeps track of when that specific block is closed, + and uses ~~~ instead of ``` for that block + + Note, this was benchmarked at sub-millisecond - return source; + // TODO support github-specific markdown as well, edge case +*/ + +export const patchNestedMarkdown = (source: string): string => { + // const start = Date.now(); + let nestCount = 0; + const lines = source.split("\n"); + const trimmedLines = lines.map((l) => l.trim()); + for (let i = 0; i < trimmedLines.length; i++) { + const line = trimmedLines[i]; + if (nestCount) { + if (line.match(/^`+$/)) { + // Ending a block + if (nestCount === 1) lines[i] = "~~~"; // End of markdown block + nestCount--; + } else if (line.startsWith("```")) { + // Going into a nested codeblock + nestCount++; + } + } else { + // Enter the markdown block, start tracking nesting + if (line.startsWith("```md") || line.startsWith("```markdown")) { + nestCount = 1; + lines[i] = lines[i].replace(/^```(md|markdown)/, "~~~"); // Replace backticks with tildes + } + } + } + const out = lines.join("\n"); + // console.log(`patched in ${Date.now() - start}ms`); + return out; }; + +// This version tries to detect if a codeblock without a language specified is a starter codeblock +// It tries again if didn't come back to root nesting, without checking for that ^ +// I didn't use for performance and also because the root nest check doesn't make sense mid-generation + +// export const patchNestedMarkdown = (source: string): string => { +// const start = Date.now(); + +// let attempts = 0; + +// while (attempts <= 2) { +// let nestCount = 0; +// const lines = source.split("\n"); +// const trimmedLines = lines.map((l) => l.trim()); +// for (let i = 0; i < trimmedLines.length; i++) { +// const line = trimmedLines[i]; +// if (nestCount) { +// if (line.match(/^`+$/)) { +// if (attempts === 0 && i !== lines.length && lines[i + 1] !== "") { +// nestCount++; +// continue; +// } +// if (nestCount === 1) lines[i] = "~~~"; +// nestCount--; +// } else if (line.startsWith("```")) { +// // Going into a nested codeblock +// nestCount++; +// } +// } else { +// // Enter the markdown block, start tracking nesting +// if (line.startsWith("```md") || line.startsWith("```markdown")) { +// nestCount = 1; +// lines[i] = lines[i].replace(/^```(md|markdown)/, "~~~"); // Replace backticks with tildes +// } +// } +// } +// if (nestCount === 0) { +// console.log( +// `patch successful in ${Date.now() - start} ms with ${attempts} attempts`, +// ); +// return lines.join("\n"); +// } +// attempts++; +// } + +// console.log( +// `patch failed in ${Date.now() - start} ms with ${attempts} attempts`, +// ); +// return source; +// }; From 1f6ac6b61d5a5144666f3e5aa30fe3779f4acda1 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 11 Nov 2024 18:49:10 -0800 Subject: [PATCH 4/6] skip patch if no --- gui/src/components/markdown/utils/patchNestedMarkdown.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/gui/src/components/markdown/utils/patchNestedMarkdown.ts b/gui/src/components/markdown/utils/patchNestedMarkdown.ts index e385711b27..b349939f4a 100644 --- a/gui/src/components/markdown/utils/patchNestedMarkdown.ts +++ b/gui/src/components/markdown/utils/patchNestedMarkdown.ts @@ -10,6 +10,7 @@ */ export const patchNestedMarkdown = (source: string): string => { + if (!source.includes("```m")) return source; // For performance // const start = Date.now(); let nestCount = 0; const lines = source.split("\n"); From 1589c208dd030a1db7f23842745922d71443fee0 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 11 Nov 2024 18:53:05 -0800 Subject: [PATCH 5/6] markdown patch performance p2 --- gui/src/components/markdown/utils/patchNestedMarkdown.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/components/markdown/utils/patchNestedMarkdown.ts b/gui/src/components/markdown/utils/patchNestedMarkdown.ts index b349939f4a..29b305b26e 100644 --- a/gui/src/components/markdown/utils/patchNestedMarkdown.ts +++ b/gui/src/components/markdown/utils/patchNestedMarkdown.ts @@ -10,7 +10,7 @@ */ export const patchNestedMarkdown = (source: string): string => { - if (!source.includes("```m")) return source; // For performance + if (!source.match(/```(md|markdown)/)) return source; // For performance // const start = Date.now(); let nestCount = 0; const lines = source.split("\n"); @@ -30,7 +30,7 @@ export const patchNestedMarkdown = (source: string): string => { // Enter the markdown block, start tracking nesting if (line.startsWith("```md") || line.startsWith("```markdown")) { nestCount = 1; - lines[i] = lines[i].replace(/^```(md|markdown)/, "~~~"); // Replace backticks with tildes + lines[i] = lines[i].replace(/```(md|markdown)/, "~~~"); // Replace backticks with tildes } } } From c419d15116804cadff974d5b71466625049383d0 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 11 Nov 2024 19:10:38 -0800 Subject: [PATCH 6/6] timeline item build fix --- gui/src/components/gui/TimelineItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/components/gui/TimelineItem.tsx b/gui/src/components/gui/TimelineItem.tsx index 28222abcbb..c3edc4c766 100644 --- a/gui/src/components/gui/TimelineItem.tsx +++ b/gui/src/components/gui/TimelineItem.tsx @@ -30,8 +30,8 @@ interface TimelineItemProps { item: ChatHistoryItem; open: boolean; onToggle: () => void; - children: React.ReactNode; - iconElement?: any; + children: JSX.Element; + iconElement?: JSX.Element; } function TimelineItem(props: TimelineItemProps) {