import React, { useMemo } from "react"; import ReactMarkdown from "react-markdown"; import { DyadWrite } from "./DyadWrite"; import { DyadRename } from "./DyadRename"; import { DyadDelete } from "./DyadDelete"; import { DyadAddDependency } from "./DyadAddDependency"; import { DyadExecuteSql } from "./DyadExecuteSql"; import { DyadAddIntegration } from "./DyadAddIntegration"; import { DyadEdit } from "./DyadEdit"; import { DyadSearchReplace } from "./DyadSearchReplace"; import { DyadCodebaseContext } from "./DyadCodebaseContext"; import { DyadThink } from "./DyadThink"; import { CodeHighlight } from "./CodeHighlight"; import { useAtomValue } from "jotai"; import { isStreamingByIdAtom, selectedChatIdAtom } from "@/atoms/chatAtoms"; import { CustomTagState } from "./stateTypes"; import { DyadOutput } from "./DyadOutput"; import { DyadProblemSummary } from "./DyadProblemSummary"; import { IpcClient } from "@/ipc/ipc_client"; import { DyadMcpToolCall } from "./DyadMcpToolCall"; import { DyadMcpToolResult } from "./DyadMcpToolResult"; import { DyadWebSearchResult } from "./DyadWebSearchResult"; import { DyadWebSearch } from "./DyadWebSearch"; import { DyadWebCrawl } from "./DyadWebCrawl"; import { DyadRead } from "./DyadRead"; import { mapActionToButton } from "./ChatInput"; import { SuggestedAction } from "@/lib/schemas"; interface DyadMarkdownParserProps { content: string; } type CustomTagInfo = { tag: string; attributes: Record; content: string; fullMatch: string; inProgress?: boolean; }; type ContentPiece = | { type: "markdown"; content: string } | { type: "custom-tag"; tagInfo: CustomTagInfo }; const customLink = ({ node: _node, ...props }: { node?: any; [key: string]: any; }) => ( { const url = props.href; if (url) { e.preventDefault(); IpcClient.getInstance().openExternalUrl(url); } }} /> ); export const VanillaMarkdownParser = ({ content }: { content: string }) => { return ( {content} ); }; /** * Custom component to parse markdown content with Dyad-specific tags */ export const DyadMarkdownParser: React.FC = ({ content, }) => { const chatId = useAtomValue(selectedChatIdAtom); const isStreaming = useAtomValue(isStreamingByIdAtom).get(chatId!) ?? false; // Extract content pieces (markdown and custom tags) const contentPieces = useMemo(() => { return parseCustomTags(content); }, [content]); return ( <> {contentPieces.map((piece, index) => ( {piece.type === "markdown" ? piece.content && ( {piece.content} ) : renderCustomTag(piece.tagInfo, { isStreaming })} ))} ); }; /** * Pre-process content to handle unclosed custom tags * Adds closing tags at the end of the content for any unclosed custom tags * Assumes the opening tags are complete and valid * Returns the processed content and a map of in-progress tags */ function preprocessUnclosedTags(content: string): { processedContent: string; inProgressTags: Map>; } { const customTagNames = [ "dyad-write", "dyad-rename", "dyad-delete", "dyad-add-dependency", "dyad-execute-sql", "dyad-add-integration", "dyad-output", "dyad-problem-report", "dyad-chat-summary", "dyad-edit", "dyad-search-replace", "dyad-codebase-context", "dyad-web-search-result", "dyad-web-search", "dyad-web-crawl", "dyad-read", "think", "dyad-command", "dyad-mcp-tool-call", "dyad-mcp-tool-result", ]; let processedContent = content; // Map to track which tags are in progress and their positions const inProgressTags = new Map>(); // For each tag type, check if there are unclosed tags for (const tagName of customTagNames) { // Count opening and closing tags const openTagPattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "g"); const closeTagPattern = new RegExp(``, "g"); // Track the positions of opening tags const openingMatches: RegExpExecArray[] = []; let match; // Reset regex lastIndex to start from the beginning openTagPattern.lastIndex = 0; while ((match = openTagPattern.exec(processedContent)) !== null) { openingMatches.push({ ...match }); } const openCount = openingMatches.length; const closeCount = (processedContent.match(closeTagPattern) || []).length; // If we have more opening than closing tags const missingCloseTags = openCount - closeCount; if (missingCloseTags > 0) { // Add the required number of closing tags at the end processedContent += Array(missingCloseTags) .fill(``) .join(""); // Mark the last N tags as in progress where N is the number of missing closing tags const inProgressIndexes = new Set(); const startIndex = openCount - missingCloseTags; for (let i = startIndex; i < openCount; i++) { inProgressIndexes.add(openingMatches[i].index); } inProgressTags.set(tagName, inProgressIndexes); } } return { processedContent, inProgressTags }; } /** * Parse the content to extract custom tags and markdown sections into a unified array */ function parseCustomTags(content: string): ContentPiece[] { const { processedContent, inProgressTags } = preprocessUnclosedTags(content); const customTagNames = [ "dyad-write", "dyad-rename", "dyad-delete", "dyad-add-dependency", "dyad-execute-sql", "dyad-add-integration", "dyad-output", "dyad-problem-report", "dyad-chat-summary", "dyad-edit", "dyad-search-replace", "dyad-codebase-context", "dyad-web-search-result", "dyad-web-search", "dyad-web-crawl", "dyad-read", "think", "dyad-command", "dyad-mcp-tool-call", "dyad-mcp-tool-result", ]; const tagPattern = new RegExp( `<(${customTagNames.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`, "gs", ); const contentPieces: ContentPiece[] = []; let lastIndex = 0; let match; // Find all custom tags while ((match = tagPattern.exec(processedContent)) !== null) { const [fullMatch, tag, attributesStr, tagContent] = match; const startIndex = match.index; // Add the markdown content before this tag if (startIndex > lastIndex) { contentPieces.push({ type: "markdown", content: processedContent.substring(lastIndex, startIndex), }); } // Parse attributes const attributes: Record = {}; const attrPattern = /(\w+)="([^"]*)"/g; let attrMatch; while ((attrMatch = attrPattern.exec(attributesStr)) !== null) { attributes[attrMatch[1]] = attrMatch[2]; } // Check if this tag was marked as in progress const tagInProgressSet = inProgressTags.get(tag); const isInProgress = tagInProgressSet?.has(startIndex); // Add the tag info contentPieces.push({ type: "custom-tag", tagInfo: { tag, attributes, content: tagContent, fullMatch, inProgress: isInProgress || false, }, }); lastIndex = startIndex + fullMatch.length; } // Add the remaining markdown content if (lastIndex < processedContent.length) { contentPieces.push({ type: "markdown", content: processedContent.substring(lastIndex), }); } return contentPieces; } function getState({ isStreaming, inProgress, }: { isStreaming?: boolean; inProgress?: boolean; }): CustomTagState { if (!inProgress) { return "finished"; } return isStreaming ? "pending" : "aborted"; } /** * Render a custom tag based on its type */ function renderCustomTag( tagInfo: CustomTagInfo, { isStreaming }: { isStreaming: boolean }, ): React.ReactNode { const { tag, attributes, content, inProgress } = tagInfo; switch (tag) { case "dyad-read": return ( {content} ); case "dyad-web-search": return ( {content} ); case "dyad-web-crawl": return ( {content} ); case "dyad-web-search-result": return ( {content} ); case "think": return ( {content} ); case "dyad-write": return ( {content} ); case "dyad-rename": return ( {content} ); case "dyad-delete": return ( {content} ); case "dyad-add-dependency": return ( {content} ); case "dyad-execute-sql": return ( {content} ); case "dyad-add-integration": return ( {content} ); case "dyad-edit": return ( {content} ); case "dyad-search-replace": return ( {content} ); case "dyad-codebase-context": return ( {content} ); case "dyad-mcp-tool-call": return ( {content} ); case "dyad-mcp-tool-result": return ( {content} ); case "dyad-output": return ( {content} ); case "dyad-problem-report": return ( {content} ); case "dyad-chat-summary": // Don't render anything for dyad-chat-summary return null; case "dyad-command": if (attributes.type) { const action = { id: attributes.type, } as SuggestedAction; return <>{mapActionToButton(action)}; } return null; default: return null; } }