import type { AssistantMessage as AssistantMessageType, ImageContent, TextContent, ToolCall, ToolResultMessage as ToolResultMessageType, UserMessage as UserMessageType, } from "@mariozechner/pi-ai"; import { html, LitElement, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { renderTool } from "../tools/index.js"; import type { Attachment } from "../utils/attachment-utils.js"; import { formatUsage } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; import "./ThinkingBlock.js"; import type { AgentTool } from "@mariozechner/pi-agent-core"; export type UserMessageWithAttachments = { role: "user-with-attachments"; content: string | (TextContent | ImageContent)[]; timestamp: number; attachments?: Attachment[]; }; // Artifact message type for session persistence export interface ArtifactMessage { role: "artifact"; action: "create" | "update" | "delete"; filename: string; content?: string; title?: string; timestamp: string; } declare module "@mariozechner/pi-agent-core" { interface CustomAgentMessages { "user-with-attachments": UserMessageWithAttachments; artifact: ArtifactMessage; } } @customElement("user-message") export class UserMessage extends LitElement { @property({ type: Object }) message!: UserMessageWithAttachments | UserMessageType; protected override createRenderRoot(): HTMLElement | DocumentFragment { return this; } override connectedCallback(): void { super.connectedCallback(); this.style.display = "block"; } override render() { const content = typeof this.message.content === "string" ? this.message.content : this.message.content.find((c) => c.type === "text")?.text || ""; return html`
${ this.message.role === "user-with-attachments" && this.message.attachments && this.message.attachments.length > 0 ? html`
${this.message.attachments.map( (attachment) => html` `, )}
` : "" }
`; } } @customElement("assistant-message") export class AssistantMessage extends LitElement { @property({ type: Object }) message!: AssistantMessageType; @property({ type: Array }) tools?: AgentTool[]; @property({ type: Object }) pendingToolCalls?: Set; @property({ type: Boolean }) hideToolCalls = false; @property({ type: Object }) toolResultsById?: Map; @property({ type: Boolean }) isStreaming: boolean = false; @property({ type: Boolean }) hidePendingToolCalls = false; @property({ attribute: false }) onCostClick?: () => void; protected override createRenderRoot(): HTMLElement | DocumentFragment { return this; } override connectedCallback(): void { super.connectedCallback(); this.style.display = "block"; } override render() { // Render content in the order it appears const orderedParts: TemplateResult[] = []; for (const chunk of this.message.content) { if (chunk.type === "text" && chunk.text.trim() !== "") { orderedParts.push(html``); } else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") { orderedParts.push( html``, ); } else if (chunk.type === "toolCall") { if (!this.hideToolCalls) { const tool = this.tools?.find((t) => t.name === chunk.name); const pending = this.pendingToolCalls?.has(chunk.id) ?? false; const result = this.toolResultsById?.get(chunk.id); // Skip rendering pending tool calls when hidePendingToolCalls is true // (used to prevent duplication when StreamingMessageContainer is showing them) if (this.hidePendingToolCalls && pending && !result) { continue; } // A tool call is aborted if the message was aborted and there's no result for this tool call const aborted = this.message.stopReason === "aborted" && !result; orderedParts.push( html``, ); } } } return html`
${orderedParts.length ? html`
${orderedParts}
` : ""} ${ this.message.usage && !this.isStreaming ? this.onCostClick ? html`
${formatUsage(this.message.usage)}
` : html`
${formatUsage(this.message.usage)}
` : "" } ${ this.message.stopReason === "error" && this.message.errorMessage ? html`
${i18n("Error:")} ${this.message.errorMessage}
` : "" } ${ this.message.stopReason === "aborted" ? html`${i18n("Request aborted")}` : "" }
`; } } @customElement("tool-message-debug") export class ToolMessageDebugView extends LitElement { @property({ type: Object }) callArgs: any; @property({ type: Object }) result?: ToolResultMessageType; @property({ type: Boolean }) hasResult: boolean = false; protected override createRenderRoot(): HTMLElement | DocumentFragment { return this; // light DOM for shared styles } override connectedCallback(): void { super.connectedCallback(); this.style.display = "block"; } private pretty(value: unknown): { content: string; isJson: boolean } { try { if (typeof value === "string") { const maybeJson = JSON.parse(value); return { content: JSON.stringify(maybeJson, null, 2), isJson: true }; } return { content: JSON.stringify(value, null, 2), isJson: true }; } catch { return { content: typeof value === "string" ? value : String(value), isJson: false }; } } override render() { const textOutput = this.result?.content ?.filter((c) => c.type === "text") .map((c: any) => c.text) .join("\n") || ""; const output = this.pretty(textOutput); const details = this.pretty(this.result?.details); return html`
${i18n("Call")}
${i18n("Result")}
${ this.hasResult ? html` ` : html`
${i18n("(no result)")}
` }
`; } } @customElement("tool-message") export class ToolMessage extends LitElement { @property({ type: Object }) toolCall!: ToolCall; @property({ type: Object }) tool?: AgentTool; @property({ type: Object }) result?: ToolResultMessageType; @property({ type: Boolean }) pending: boolean = false; @property({ type: Boolean }) aborted: boolean = false; @property({ type: Boolean }) isStreaming: boolean = false; protected override createRenderRoot(): HTMLElement | DocumentFragment { return this; } override connectedCallback(): void { super.connectedCallback(); this.style.display = "block"; } override render() { const toolName = this.tool?.name || this.toolCall.name; // Render tool content (renderer handles errors and styling) const result: ToolResultMessageType | undefined = this.aborted ? { role: "toolResult", isError: true, content: [], toolCallId: this.toolCall.id, toolName: this.toolCall.name, timestamp: Date.now(), } : this.result; const renderResult = renderTool( toolName, this.toolCall.arguments, result, !this.aborted && (this.isStreaming || this.pending), ); // Handle custom rendering (no card wrapper) if (renderResult.isCustom) { return renderResult.content; } // Default: wrap in card return html`
${renderResult.content}
`; } } @customElement("aborted-message") export class AbortedMessage extends LitElement { protected override createRenderRoot(): HTMLElement | DocumentFragment { return this; } override connectedCallback(): void { super.connectedCallback(); this.style.display = "block"; } protected override render(): unknown { return html`${i18n("Request aborted")}`; } } // ============================================================================ // Default Message Transformer // ============================================================================ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { Message } from "@mariozechner/pi-ai"; /** * Convert attachments to content blocks for LLM. * - Images become ImageContent blocks * - Documents with extractedText become TextContent blocks with filename header */ export function convertAttachments(attachments: Attachment[]): (TextContent | ImageContent)[] { const content: (TextContent | ImageContent)[] = []; for (const attachment of attachments) { if (attachment.type === "image") { content.push({ type: "image", data: attachment.content, mimeType: attachment.mimeType, } as ImageContent); } else if (attachment.type === "document" && attachment.extractedText) { content.push({ type: "text", text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`, } as TextContent); } } return content; } /** * Check if a message is a UserMessageWithAttachments. */ export function isUserMessageWithAttachments(msg: AgentMessage): msg is UserMessageWithAttachments { return (msg as UserMessageWithAttachments).role === "user-with-attachments"; } /** * Check if a message is an ArtifactMessage. */ export function isArtifactMessage(msg: AgentMessage): msg is ArtifactMessage { return (msg as ArtifactMessage).role === "artifact"; } /** * Default convertToLlm for web-ui apps. * * Handles: * - UserMessageWithAttachments: converts to user message with content blocks * - ArtifactMessage: filtered out (UI-only, for session reconstruction) * - Standard LLM messages (user, assistant, toolResult): passed through */ export function defaultConvertToLlm(messages: AgentMessage[]): Message[] { return messages .filter((m) => { // Filter out artifact messages - they're for session reconstruction only if (isArtifactMessage(m)) { return false; } return true; }) .map((m): Message | null => { // Convert user-with-attachments to user message with content blocks if (isUserMessageWithAttachments(m)) { const textContent: (TextContent | ImageContent)[] = typeof m.content === "string" ? [{ type: "text", text: m.content }] : [...m.content]; if (m.attachments) { textContent.push(...convertAttachments(m.attachments)); } return { role: "user", content: textContent, timestamp: m.timestamp, } as Message; } // Pass through standard LLM roles if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") { return m as Message; } // Filter out unknown message types return null; }) .filter((m): m is Message => m !== null); }