Files
moreminimore-vibe/src/components/chat/DyadMarkdownParser.tsx
2025-05-12 13:52:54 -07:00

365 lines
8.9 KiB
TypeScript

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 { CodeHighlight } from "./CodeHighlight";
import { useAtomValue } from "jotai";
import { isStreamingAtom } from "@/atoms/chatAtoms";
import { CustomTagState } from "./stateTypes";
import { DyadOutput } from "./DyadOutput";
import { IpcClient } from "@/ipc/ipc_client";
interface DyadMarkdownParserProps {
content: string;
}
type CustomTagInfo = {
tag: string;
attributes: Record<string, string>;
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;
}) => (
<a
{...props}
onClick={(e) => {
const url = props.href;
if (url) {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(url);
}
}}
/>
);
export const VanillaMarkdownParser = ({ content }: { content: string }) => {
return (
<ReactMarkdown
components={{
code: CodeHighlight,
a: customLink,
}}
>
{content}
</ReactMarkdown>
);
};
/**
* Custom component to parse markdown content with Dyad-specific tags
*/
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
content,
}) => {
const isStreaming = useAtomValue(isStreamingAtom);
// Extract content pieces (markdown and custom tags)
const contentPieces = useMemo(() => {
return parseCustomTags(content);
}, [content]);
return (
<>
{contentPieces.map((piece, index) => (
<React.Fragment key={index}>
{piece.type === "markdown"
? piece.content && (
<ReactMarkdown
components={{
code: CodeHighlight,
a: customLink,
}}
>
{piece.content}
</ReactMarkdown>
)
: renderCustomTag(piece.tagInfo, { isStreaming })}
</React.Fragment>
))}
</>
);
};
/**
* 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<string, Set<number>>;
} {
const customTagNames = [
"dyad-write",
"dyad-rename",
"dyad-delete",
"dyad-add-dependency",
"dyad-execute-sql",
"dyad-add-integration",
"dyad-output",
"dyad-chat-summary",
];
let processedContent = content;
// Map to track which tags are in progress and their positions
const inProgressTags = new Map<string, Set<number>>();
// 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(`</${tagName}>`, "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(`</${tagName}>`)
.join("");
// Mark the last N tags as in progress where N is the number of missing closing tags
const inProgressIndexes = new Set<number>();
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-chat-summary",
];
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<string, string> = {};
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-write":
return (
<DyadWrite
node={{
properties: {
path: attributes.path || "",
description: attributes.description || "",
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadWrite>
);
case "dyad-rename":
return (
<DyadRename
node={{
properties: {
from: attributes.from || "",
to: attributes.to || "",
},
}}
>
{content}
</DyadRename>
);
case "dyad-delete":
return (
<DyadDelete
node={{
properties: {
path: attributes.path || "",
},
}}
>
{content}
</DyadDelete>
);
case "dyad-add-dependency":
return (
<DyadAddDependency
node={{
properties: {
packages: attributes.packages || "",
},
}}
>
{content}
</DyadAddDependency>
);
case "dyad-execute-sql":
return (
<DyadExecuteSql
node={{
properties: {
state: getState({ isStreaming, inProgress }),
description: attributes.description || "",
},
}}
>
{content}
</DyadExecuteSql>
);
case "dyad-add-integration":
return (
<DyadAddIntegration
node={{
properties: {
provider: attributes.provider || "",
},
}}
>
{content}
</DyadAddIntegration>
);
case "dyad-output":
return (
<DyadOutput
type={attributes.type as "warning" | "error"}
message={attributes.message}
>
{content}
</DyadOutput>
);
case "dyad-chat-summary":
// Don't render anything for dyad-chat-summary
return null;
default:
return null;
}
}