import { html } from "@mariozechner/mini-lit"; import type { AgentTool, AssistantMessage as AssistantMessageType, ToolCall, ToolResultMessage as ToolResultMessageType, UserMessage as UserMessageType, } from "@mariozechner/pi-ai"; import { 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"; export type UserMessageWithAttachments = UserMessageType & { 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; } // Base message union type BaseMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType | ArtifactMessage; // Extensible interface - apps can extend via declaration merging // Example: // declare module "@mariozechner/pi-web-ui" { // interface CustomMessages { // "system-notification": SystemNotificationMessage; // } // } export interface CustomMessages { // Empty by default - apps extend via declaration merging } // AppMessage is union of base messages + custom messages export type AppMessage = BaseMessage | CustomMessages[keyof CustomMessages]; @customElement("user-message") export class UserMessage extends LitElement { @property({ type: Object }) message!: UserMessageWithAttachments; 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.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; 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); // 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 ? 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 output = this.pretty(this.result?.output); 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, output: "", toolCallId: this.toolCall.id, toolName: this.toolCall.name } : 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")}`; } }