194 lines
5.7 KiB
TypeScript
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>
|
|
`;
|
|
}
|