Skip to content

Commit

Permalink
fix: render html text in Theia AI markdown renderers
Browse files Browse the repository at this point in the history
Adapt rendering of all Theia AI markdown renderers to use markdown-it directly.

The MarkdownRenderer exposed by Theia automatically strips html tags while we still want
to show them as text in requests and responses. The html is not interpreted and therefore
can't be used for injections.

fixes #14208
  • Loading branch information
eneufeld authored Sep 30, 2024
1 parent 1f5eb2b commit 363865f
Show file tree
Hide file tree
Showing 2 changed files with 43 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,21 @@
// *****************************************************************************

import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { inject, injectable } from '@theia/core/shared/inversify';
import { injectable } from '@theia/core/shared/inversify';
import {
ChatResponseContent,
InformationalChatResponseContent,
MarkdownChatResponseContent,
} from '@theia/ai-chat/lib/common';
import { ReactNode, useEffect, useRef } from '@theia/core/shared/react';
import * as React from '@theia/core/shared/react';
import * as markdownit from '@theia/core/shared/markdown-it';
import * as DOMPurify from '@theia/core/shared/dompurify';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';

@injectable()
export class MarkdownPartRenderer implements ChatResponsePartRenderer<MarkdownChatResponseContent | InformationalChatResponseContent> {
@inject(MarkdownRenderer) private renderer: MarkdownRenderer;
protected readonly markdownIt = markdownit();
canHandle(response: ChatResponseContent): number {
if (MarkdownChatResponseContent.is(response)) {
return 10;
Expand All @@ -38,34 +39,52 @@ export class MarkdownPartRenderer implements ChatResponsePartRenderer<MarkdownCh
}
return -1;
}
private renderMarkdown(md: MarkdownString): HTMLElement {
return this.renderer.render(md).element;
}
render(response: MarkdownChatResponseContent | InformationalChatResponseContent): ReactNode {
// TODO let the user configure whether they want to see informational content
if (InformationalChatResponseContent.is(response)) {
// null is valid in React
// eslint-disable-next-line no-null/no-null
return null;
}
return <MarkdownWrapper data={response.content} renderCallback={this.renderMarkdown.bind(this)}></MarkdownWrapper>;

return <MarkdownRender response={response} />;
}

}

export const MarkdownWrapper = (props: { data: MarkdownString, renderCallback: (md: MarkdownString) => HTMLElement }) => {
// eslint-disable-next-line no-null/no-null
const ref: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
const MarkdownRender = ({ response }: { response: MarkdownChatResponseContent | InformationalChatResponseContent }) => {
const ref = useMarkdownRendering(response.content);

useEffect(() => {
const myDomElement = props.renderCallback(props.data);
return <div ref={ref}></div>;
};

/**
* This hook uses markdown-it directly to render markdown.
* The reason to use markdown-it directly is that the MarkdownRenderer is
* overriden by theia with a monaco version. This monaco version strips all html
* tags from the markdown with empty content.
* This leads to unexpected behavior when rendering markdown with html tags.
*
* @param markdown the string to render as markdown
* @returns the ref to use in an element to render the markdown
*/
export const useMarkdownRendering = (markdown: string | MarkdownString) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement | null>(null);
const markdownString = typeof markdown === 'string' ? markdown : markdown.value;
useEffect(() => {
const markdownIt = markdownit();
const host = document.createElement('div');
const html = markdownIt.render(markdownString);
host.innerHTML = DOMPurify.sanitize(html, {
ALLOW_UNKNOWN_PROTOCOLS: true // DOMPurify usually strips non http(s) links from hrefs
});
while (ref?.current?.firstChild) {
ref.current.removeChild(ref.current.firstChild);
}

ref?.current?.appendChild(myDomElement);
}, [props.data.value]);
ref?.current?.appendChild(host);
}, [markdownString]);

return <div ref={ref}></div>;
return ref;
};
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import {
TreeProps,
TreeWidget,
} from '@theia/core/lib/browser';
import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
import {
inject,
injectable,
Expand All @@ -44,10 +43,9 @@ import {
} from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';

import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-contribution';
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { MarkdownWrapper } from '../chat-response-renderer/markdown-part-renderer';
import { useMarkdownRendering } from '../chat-response-renderer/markdown-part-renderer';

// TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model
export interface RequestNode extends TreeNode {
Expand Down Expand Up @@ -76,9 +74,6 @@ export class ChatViewTreeWidget extends TreeWidget {
@inject(ContributionProvider) @named(ChatNodeToolbarActionContribution)
protected readonly chatNodeToolbarActionContributions: ContributionProvider<ChatNodeToolbarActionContribution>;

@inject(MarkdownRenderer)
private renderer: MarkdownRenderer;

@inject(ChatAgentService)
protected chatAgentService: ChatAgentService;

Expand Down Expand Up @@ -336,16 +331,7 @@ export class ChatViewTreeWidget extends TreeWidget {
}

private renderChatRequest(node: RequestNode): React.ReactNode {
const text = node.request.request.displayText ?? node.request.request.text;
const markdownString = new MarkdownStringImpl(text, { supportHtml: true, isTrusted: true });
return (
<div className={'theia-RequestNode'}>
{<MarkdownWrapper
data={markdownString}
renderCallback={() => this.renderer.render(markdownString).element}
></MarkdownWrapper>}
</div>
);
return <ChatRequestRender node={node} />;
}

private renderChatResponse(node: ResponseNode): React.ReactNode {
Expand Down Expand Up @@ -389,6 +375,13 @@ export class ChatViewTreeWidget extends TreeWidget {
}
}

const ChatRequestRender = ({ node }: { node: RequestNode }) => {
const text = node.request.request.displayText ?? node.request.request.text;
const ref = useMarkdownRendering(text);

return <div className={'theia-RequestNode'} ref={ref}></div>;
};

const ProgressMessage = (c: ChatProgressMessage) => (
<div className='theia-ResponseNode-ProgressMessage'>
<Indicator {...c} /> {c.content}
Expand Down

0 comments on commit 363865f

Please sign in to comment.