Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ai): invoke OpenerService for markdown links in chat UI #14602

Merged
merged 1 commit into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// *****************************************************************************

import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { injectable } from '@theia/core/shared/inversify';
import { inject, injectable } from '@theia/core/shared/inversify';
import {
ChatResponseContent,
InformationalChatResponseContent,
Expand All @@ -26,9 +26,12 @@ 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 { OpenerService, open } from '@theia/core/lib/browser';
import { URI } from '@theia/core';

@injectable()
export class MarkdownPartRenderer implements ChatResponsePartRenderer<MarkdownChatResponseContent | InformationalChatResponseContent> {
@inject(OpenerService) protected readonly openerService: OpenerService;
protected readonly markdownIt = markdownit();
canHandle(response: ChatResponseContent): number {
if (MarkdownChatResponseContent.is(response)) {
Expand All @@ -47,13 +50,12 @@ export class MarkdownPartRenderer implements ChatResponsePartRenderer<MarkdownCh
return null;
}

return <MarkdownRender response={response} />;
return <MarkdownRender response={response} openerService={this.openerService} />;
}

}

const MarkdownRender = ({ response }: { response: MarkdownChatResponseContent | InformationalChatResponseContent }) => {
const ref = useMarkdownRendering(response.content);
const MarkdownRender = ({ response, openerService }: { response: MarkdownChatResponseContent | InformationalChatResponseContent; openerService: OpenerService }) => {
const ref = useMarkdownRendering(response.content, openerService);

return <div ref={ref}></div>;
};
Expand All @@ -62,31 +64,56 @@ const MarkdownRender = ({ response }: { response: MarkdownChatResponseContent |
* This hook uses markdown-it directly to render markdown.
* The reason to use markdown-it directly is that the MarkdownRenderer is
* overridden 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.
* tags from the markdown with empty content. This leads to unexpected behavior when
* rendering markdown with html tags.
*
* Moreover, we want to intercept link clicks to use the Theia OpenerService instead of the default browser behavior.
*
* @param markdown the string to render as markdown
* @param skipSurroundingParagraph whether to remove a surrounding paragraph element (default: false)
* @param openerService the service to handle link opening
* @returns the ref to use in an element to render the markdown
*/
export const useMarkdownRendering = (markdown: string | MarkdownString, skipSurroundingParagraph: boolean = false) => {
export const useMarkdownRendering = (markdown: string | MarkdownString, openerService: OpenerService, skipSurroundingParagraph: boolean = false) => {
// null is valid in React
// 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');
// markdownIt always puts the content in a paragraph element, so we remove it if we don't want it

// markdownIt always puts the content in a paragraph element, so we remove it if we don't want that
const html = skipSurroundingParagraph ? markdownIt.render(markdownString).replace(/^<p>|<\/p>|<p><\/p>$/g, '') : markdownIt.render(markdownString);

host.innerHTML = DOMPurify.sanitize(html, {
ALLOW_UNKNOWN_PROTOCOLS: true // DOMPurify usually strips non http(s) links from hrefs
// DOMPurify usually strips non http(s) links from hrefs
// but we want to allow them (see handleClick via OpenerService below)
ALLOW_UNKNOWN_PROTOCOLS: true
});
while (ref?.current?.firstChild) {
ref.current.removeChild(ref.current.firstChild);
}

ref?.current?.appendChild(host);
}, [markdownString]);

// intercept link clicks to use the Theia OpenerService instead of the default browser behavior
const handleClick = (event: MouseEvent) => {
let target = event.target as HTMLElement;
while (target && target.tagName !== 'A') {
target = target.parentElement as HTMLElement;
}
if (target && target.tagName === 'A') {
const href = target.getAttribute('href');
if (href) {
open(openerService, new URI(href));
event.preventDefault();
}
}
};

ref?.current?.addEventListener('click', handleClick);
return () => ref.current?.removeEventListener('click', handleClick);
}, [markdownString, skipSurroundingParagraph, openerService]);

return ref;
};
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
Key,
KeyCode,
NodeProps,
OpenerService,
TreeModel,
TreeNode,
TreeProps,
Expand Down Expand Up @@ -88,6 +89,9 @@ export class ChatViewTreeWidget extends TreeWidget {
@inject(CommandRegistry)
private commandRegistry: CommandRegistry;

@inject(OpenerService)
protected readonly openerService: OpenerService;

@inject(HoverService)
private hoverService: HoverService;

Expand Down Expand Up @@ -370,6 +374,7 @@ export class ChatViewTreeWidget extends TreeWidget {
hoverService={this.hoverService}
chatAgentService={this.chatAgentService}
variableService={this.variableService}
openerService={this.openerService}
/>;
}

Expand Down Expand Up @@ -432,12 +437,13 @@ export class ChatViewTreeWidget extends TreeWidget {

const ChatRequestRender = (
{
node, hoverService, chatAgentService, variableService
node, hoverService, chatAgentService, variableService, openerService
}: {
node: RequestNode,
hoverService: HoverService,
chatAgentService: ChatAgentService,
variableService: AIVariableService
variableService: AIVariableService,
openerService: OpenerService
}) => {
const parts = node.request.message.parts;
return (
Expand Down Expand Up @@ -465,7 +471,7 @@ const ChatRequestRender = (
);
} else {
// maintain the leading and trailing spaces with explicit `&nbsp;`, otherwise they would get trimmed by the markdown renderer
const ref = useMarkdownRendering(part.text.replace(/^\s|\s$/g, '&nbsp;'), true);
const ref = useMarkdownRendering(part.text.replace(/^\s|\s$/g, '&nbsp;'), openerService, true);
return (
<span key={index} ref={ref}></span>
);
Expand Down
Loading