Skip to content

Commit

Permalink
feat(ai): add hovers for agents and variables (#14498)
Browse files Browse the repository at this point in the history
* Highlights the parsed chat request agent and variable parts
* Adds hover information for the agent and variable parts
* Adds hover information for the agent label in response headers
* Add comments explaining the need for string replacements
  • Loading branch information
planger authored Nov 27, 2024
1 parent a6e788e commit 3f2f672
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,23 @@ 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
* overriden by theia with a monaco version. This monaco version strips all html
* 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.
*
* @param markdown the string to render as markdown
* @param skipSurroundingParagraph whether to remove a surrounding paragraph element (default: false)
* @returns the ref to use in an element to render the markdown
*/
export const useMarkdownRendering = (markdown: string | MarkdownString) => {
export const useMarkdownRendering = (markdown: string | MarkdownString, skipSurroundingParagraph: boolean = false) => {
// 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);
// markdownIt always puts the content in a paragraph element, so we remove it if we don't want it
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
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,23 @@
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import {
ChatAgent,
ChatAgentService,
ChatModel,
ChatProgressMessage,
ChatRequestModel,
ChatResponseContent,
ChatResponseModel,
ParsedChatRequestAgentPart,
ParsedChatRequestVariablePart,
} from '@theia/ai-chat';
import { CommandRegistry, ContributionProvider } from '@theia/core';
import {
codicon,
CommonCommands,
CompositeTreeNode,
ContextMenuRenderer,
HoverService,
Key,
KeyCode,
NodeProps,
Expand All @@ -46,6 +50,7 @@ import * as React from '@theia/core/shared/react';
import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-contribution';
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { useMarkdownRendering } from '../chat-response-renderer/markdown-part-renderer';
import { AIVariableService } from '@theia/ai-core';

// TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model
export interface RequestNode extends TreeNode {
Expand Down Expand Up @@ -77,9 +82,15 @@ export class ChatViewTreeWidget extends TreeWidget {
@inject(ChatAgentService)
protected chatAgentService: ChatAgentService;

@inject(AIVariableService)
protected readonly variableService: AIVariableService;

@inject(CommandRegistry)
private commandRegistry: CommandRegistry;

@inject(HoverService)
private hoverService: HoverService;

protected _shouldScrollToEnd = true;

protected isEnabled = false;
Expand Down Expand Up @@ -274,11 +285,25 @@ export class ChatViewTreeWidget extends TreeWidget {
.filter(action => this.commandRegistry.isEnabled(action.commandId, node))
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
: [];
const agentLabel = React.createRef<HTMLHeadingElement>();
const agentDescription = this.getAgent(node)?.description;
return <React.Fragment>
<div className='theia-ChatNodeHeader'>
<div className={`theia-AgentAvatar ${this.getAgentIconClassName(node)}`}></div>
<h3 className='theia-AgentLabel'>{this.getAgentLabel(node)}</h3>
{inProgress && !waitingForInput && <span className='theia-ChatContentInProgress'>Generating</span>}
<h3 ref={agentLabel}
className='theia-AgentLabel'
onMouseEnter={() => {
if (agentDescription) {
this.hoverService.requestHover({
content: agentDescription,
target: agentLabel.current!,
position: 'right'
});
}
}}>
{this.getAgentLabel(node)}
</h3>
{inProgress && <span className='theia-ChatContentInProgress'>Generating</span>}
{inProgress && waitingForInput && <span className='theia-ChatContentInProgress'>Waiting for input</span>}
<div className='theia-ChatNodeToolbar'>
{!inProgress &&
Expand Down Expand Up @@ -311,8 +336,14 @@ export class ChatViewTreeWidget extends TreeWidget {
// TODO find user name
return 'You';
}
const agent = node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined;
return agent?.name ?? 'AI';
return this.getAgent(node)?.name ?? 'AI';
}

private getAgent(node: RequestNode | ResponseNode): ChatAgent | undefined {
if (isRequestNode(node)) {
return undefined;
}
return node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined;
}

private getAgentIconClassName(node: RequestNode | ResponseNode): string | undefined {
Expand All @@ -334,7 +365,12 @@ export class ChatViewTreeWidget extends TreeWidget {
}

private renderChatRequest(node: RequestNode): React.ReactNode {
return <ChatRequestRender node={node} />;
return <ChatRequestRender
node={node}
hoverService={this.hoverService}
chatAgentService={this.chatAgentService}
variableService={this.variableService}
/>;
}

private renderChatResponse(node: ResponseNode): React.ReactNode {
Expand Down Expand Up @@ -394,11 +430,79 @@ export class ChatViewTreeWidget extends TreeWidget {
}
}

const ChatRequestRender = ({ node }: { node: RequestNode }) => {
const text = node.request.request.displayText ?? node.request.request.text;
const ref = useMarkdownRendering(text);
const ChatRequestRender = (
{
node, hoverService, chatAgentService, variableService
}: {
node: RequestNode,
hoverService: HoverService,
chatAgentService: ChatAgentService,
variableService: AIVariableService
}) => {
const parts = node.request.message.parts;
return (
<div className="theia-RequestNode">
<p>
{parts.map((part, index) => {
if (part instanceof ParsedChatRequestAgentPart || part instanceof ParsedChatRequestVariablePart) {
let description = undefined;
let className = '';
if (part instanceof ParsedChatRequestAgentPart) {
description = chatAgentService.getAgent(part.agentId)?.description;
className = 'theia-RequestNode-AgentLabel';
} else if (part instanceof ParsedChatRequestVariablePart) {
description = variableService.getVariable(part.variableName)?.description;
className = 'theia-RequestNode-VariableLabel';
}
return (
<HoverableLabel
key={index}
text={part.text}
description={description}
hoverService={hoverService}
className={className}
/>
);
} 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);
return (
<span key={index} ref={ref}></span>
);
}
})}
</p>
</div>
);
};

return <div className={'theia-RequestNode'} ref={ref}></div>;
const HoverableLabel = (
{
text, description, hoverService, className
}: {
text: string,
description?: string,
hoverService: HoverService,
className: string
}) => {
const spanRef = React.createRef<HTMLSpanElement>();
return (
<span
className={className}
ref={spanRef}
onMouseEnter={() => {
if (description) {
hoverService.requestHover({
content: description,
target: spanRef.current!,
position: 'right'
});
}
}}
>
{text}
</span>
);
};

const ProgressMessage = (c: ChatProgressMessage) => (
Expand Down
14 changes: 14 additions & 0 deletions packages/ai-chat-ui/src/browser/style/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ div:last-child > .theia-ChatNode {
font-size: var(--theia-code-font-size);
}

.theia-RequestNode > p div {
display: inline;
}

.theia-RequestNode .theia-RequestNode-AgentLabel,
.theia-RequestNode .theia-RequestNode-VariableLabel {
padding: calc(var(--theia-ui-padding) * 2 / 3);
padding-top: 0px;
padding-bottom: 0px;
border-radius: calc(var(--theia-ui-padding) * 2 / 3);
background: var(--theia-badge-background);
color: var(--theia-badge-foreground);
}

.chat-input-widget {
align-items: flex-end;
display: flex;
Expand Down

0 comments on commit 3f2f672

Please sign in to comment.