import cls from 'classnames'; import React, { useEffect, useRef, useState } from 'react'; import { MarkdownReactParser, MarkdownReactRenderer } from '@opensumi/ide-components/lib/markdown-react'; import { IMarkedOptions, marked } from '@opensumi/ide-components/lib/utils'; import { AppConfig, ConfigProvider, useInjectable } from '@opensumi/ide-core-browser'; import { IMarkdownString, MarkdownString } from '@opensumi/monaco-editor-core/esm/vs/base/common/htmlContent'; import { CodeEditorWithHighlight } from './ChatEditor'; import styles from './components.module.less'; import type { Token, Tokens, TokensList } from 'marked'; interface MarkdownProps { markdown: IMarkdownString | string; agentId?: string; command?: string; relationId?: string; className?: string; fillInIncompleteTokens?: boolean; // 补齐不完整的 token,如代码块或表格 markedOptions?: IMarkedOptions; hideInsert?: boolean; } export const ChatMarkdown = (props: MarkdownProps) => { const ref = useRef(null); const appConfig = useInjectable(AppConfig); const [reactParser, setReactParser] = useState(); const [tokensList, setTokensList] = useState(); useEffect(() => { const element = ref.current; if (!element) { return; } const markdown: IMarkdownString = typeof props.markdown === 'string' ? new MarkdownString(props.markdown) : props.markdown; const renderer: MarkdownReactRenderer = new MarkdownReactRenderer(); renderer.code = (code, lang) => { const language = postProcessCodeBlockLanguageId(lang); return (
{language}
); }; renderer.codespan = (code) => {code}; const reactParser = new MarkdownReactParser({ renderer }); const markedOptions = { ...marked.defaults, ...props.markedOptions, renderer: reactParser, smartypants: false, }; let value = markdown.value ?? ''; if (value.length > 100_000) { value = `${value.slice(0, 100_000)}…`; } let renderedMarkdown: string; let tokensList: TokensList; if (props.fillInIncompleteTokens) { const tokens = marked.lexer(value, markedOptions); const newTokens = fillInIncompleteTokens(tokens); renderedMarkdown = marked.parser(newTokens, markedOptions); tokensList = newTokens; } else { const tokens = marked.lexer(value, markedOptions); renderedMarkdown = marked.parser(tokens, markedOptions); tokensList = tokens; } setTokensList(tokensList); setReactParser(reactParser); }, [props.markdown]); return (
{tokensList && reactParser && reactParser.parse(tokensList)}
); }; export function postProcessCodeBlockLanguageId(lang: string | undefined): string { if (!lang) { return ''; } const parts = lang.split(/[\s+|:|,|\{|\?]/, 1); if (parts.length) { return parts[0]; } return lang; } export function fillInIncompleteTokens(tokens: TokensList): TokensList { let i: number; let newTokens: Token[] | undefined; for (i = 0; i < tokens.length; i++) { const token = tokens[i]; // 代码块补全,完整的代码块 type=code if (token.type === 'paragraph' && token.raw.match(/(\n|^)```/)) { newTokens = completeCodeBlock(tokens.slice(i)); break; } // 表格补全,完整的表格 type=table if (token.type === 'paragraph' && token.raw.match(/(\n|^)\|/)) { newTokens = completeTable(tokens.slice(i)); break; } // 单行 token 补全,如 `a **a if (token.type === 'paragraph' && i === tokens.length - 1) { // Only operates on a single token, because any newline that follows this should break these patterns const newToken = completeSingleLinePattern(token); if (newToken) { newTokens = [newToken]; break; } } } if (newTokens) { const newTokensList = [...tokens.slice(0, i), ...newTokens] as TokensList; newTokensList.links = tokens.links; return newTokensList; } return tokens; } function completeCodeBlock(tokens: Token[]): Token[] { const mergedRawText = mergeRawTokenText(tokens); return marked.lexer(mergedRawText + '\n```'); } function completeCodespan(token: Token): Token { return completeWithString(token, '`'); } function completeStar(tokens: Token): Token { return completeWithString(tokens, '*'); } function completeUnderscore(tokens: Token): Token { return completeWithString(tokens, '_'); } function completeLinkTarget(tokens: Token): Token { return completeWithString(tokens, ')'); } function completeLinkText(tokens: Token): Token { return completeWithString(tokens, '](about:blank)'); } function completeDoublestar(tokens: Token): Token { return completeWithString(tokens, '**'); } function completeDoubleUnderscore(tokens: Token): Token { return completeWithString(tokens, '__'); } function completeWithString(tokens: Token[] | Token, closingString: string): Token { const mergedRawText = mergeRawTokenText(Array.isArray(tokens) ? tokens : [tokens]); // If it was completed correctly, this should be a single token. // Expecting either a Paragraph or a List return marked.lexer(mergedRawText + closingString)[0] as Token; } function completeTable(tokens: Token[]): Token[] | undefined { const mergedRawText = mergeRawTokenText(tokens); const lines = mergedRawText.split('\n'); let numCols = 0; let hasSeparatorRow = false; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // 确定第一行有多少个 | if (typeof numCols === 'undefined' && line.match(/^\s*\|/)) { const line1Matches = line.match(/(\|[^\|]+)(?=\||$)/g); if (line1Matches) { numCols = line1Matches.length; } } else if (typeof numCols === 'number') { // 确定最后一行为分割行,否则表格可能不完整,此时不做任何渲染 if (line.match(/^\s*\|/) && i === lines.length - 1) { hasSeparatorRow = true; } else { return undefined; } } } if (numCols > 0) { const prefixText = hasSeparatorRow ? lines.slice(0, -1).join('\n') : mergedRawText; const line1EndsInPipe = !!prefixText.match(/\|\s*$/); const newRawText = prefixText + (line1EndsInPipe ? '' : '|') + `\n|${' --- |'.repeat(numCols)}`; return marked.lexer(newRawText); } return undefined; } function mergeRawTokenText(tokens: Token[]): string { return tokens.reduce((mergedTokenText, token) => mergedTokenText + token.raw, ''); } function completeSingleLinePattern(token: Tokens.ListItem | Tokens.Paragraph | Tokens.Generic): Token | undefined { for (const { type, raw } of token.tokens ?? []) { if (type !== 'text') { continue; } const lines = raw.split('\n'); const lastLine = lines[lines.length - 1]; const patterns = [ { condition: lastLine.includes('`'), action: completeCodespan }, { condition: lastLine.includes('**'), action: completeDoublestar }, { condition: lastLine.match(/\*\w/), action: completeStar }, { condition: lastLine.match(/(^|\s)__\w/), action: completeDoubleUnderscore }, { condition: lastLine.match(/(^|\s)_\w/), action: completeUnderscore }, { condition: lastLine.match(/(^|\s)\[.*\]\(\w*/), action: completeLinkTarget }, { condition: lastLine.match(/(^|\s)\[\w/), action: completeLinkText }, ]; for (const pattern of patterns) { if (pattern.condition) { return pattern.action(token); } } } return undefined; }