Files
openclaw/ui/src/ui/chat/grouped-render.ts
Peter Steinberger 2fc926ab1c Merge pull request #1329 from dlauer/feature/agent-avatar-support
feat: add avatar support for agent identity
2026-01-22 04:09:00 +00:00

194 lines
5.7 KiB
TypeScript

import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown";
import type { MessageGroup } from "../types/chat-types";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
import {
extractText,
extractThinking,
formatReasoningMarkdown,
} from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
export function renderReadingIndicatorGroup() {
return html`
<div class="chat-group assistant">
${renderAvatar("assistant")}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
<span></span><span></span><span></span>
</span>
</div>
</div>
</div>
`;
}
export function renderStreamingGroup(
text: string,
startedAt: number,
onOpenSidebar?: (content: string) => void,
) {
const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
return html`
<div class="chat-group assistant">
${renderAvatar("assistant")}
<div class="chat-group-messages">
${renderGroupedMessage(
{
role: "assistant",
content: [{ type: "text", text }],
timestamp: startedAt,
},
{ isStreaming: true, showReasoning: false },
onOpenSidebar,
)}
<div class="chat-group-footer">
<span class="chat-sender-name">Assistant</span>
<span class="chat-group-timestamp">${timestamp}</span>
</div>
</div>
</div>
`;
}
export function renderMessageGroup(
group: MessageGroup,
opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean },
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
const who =
normalizedRole === "user"
? "You"
: normalizedRole === "assistant"
? "Assistant"
: normalizedRole;
const roleClass =
normalizedRole === "user"
? "user"
: normalizedRole === "assistant"
? "assistant"
: "other";
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
return html`
<div class="chat-group ${roleClass}">
${renderAvatar(group.role)}
<div class="chat-group-messages">
${group.messages.map((item, index) =>
renderGroupedMessage(
item.message,
{
isStreaming:
group.isStreaming && index === group.messages.length - 1,
showReasoning: opts.showReasoning,
},
opts.onOpenSidebar,
),
)}
<div class="chat-group-footer">
<span class="chat-sender-name">${who}</span>
<span class="chat-group-timestamp">${timestamp}</span>
</div>
</div>
</div>
`;
}
function renderAvatar(role: string, avatarUrl?: string) {
const normalized = normalizeRoleForGrouping(role);
const initial =
normalized === "user"
? "U"
: normalized === "assistant"
? "A"
: normalized === "tool"
? "⚙"
: "?";
const className =
normalized === "user"
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
? "tool"
: "other";
// If avatar URL is provided for assistant, show image
if (avatarUrl && normalized === "assistant") {
return html`<img class="chat-avatar ${className}" src="${avatarUrl}" alt="Assistant" />`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
}
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },
onOpenSidebar?: (content: string) => void,
) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const isToolResult =
isToolResultMessage(message) ||
role.toLowerCase() === "toolresult" ||
role.toLowerCase() === "tool_result" ||
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string";
const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0;
const extractedText = extractText(message);
const extractedThinking =
opts.showReasoning && role === "assistant" ? extractThinking(message) : null;
const markdownBase = extractedText?.trim() ? extractedText : null;
const reasoningMarkdown = extractedThinking
? formatReasoningMarkdown(extractedThinking)
: null;
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const bubbleClasses = [
"chat-bubble",
canCopyMarkdown ? "has-copy" : "",
opts.isStreaming ? "streaming" : "",
"fade-in",
]
.filter(Boolean)
.join(" ");
if (!markdown && hasToolCards && isToolResult) {
return html`${toolCards.map((card) =>
renderToolCardSidebar(card, onOpenSidebar),
)}`;
}
if (!markdown && !hasToolCards) return nothing;
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
`;
}