Add defaultConvertToLlm to web-ui, simplify example

- web-ui exports: defaultConvertToLlm, convertAttachments, isUserMessageWithAttachments, isArtifactMessage
- defaultConvertToLlm handles UserMessageWithAttachments and filters ArtifactMessage
- Example's customMessageTransformer now extends defaultConvertToLlm
- Removes duplicated attachment conversion logic from example
This commit is contained in:
Mario Zechner 2025-12-28 11:06:26 +01:00
parent 7a39f9eb11
commit 13a1991ec2
6 changed files with 127 additions and 76 deletions

View file

@ -1,7 +1,7 @@
import { Alert } from "@mariozechner/mini-lit/dist/Alert.js";
import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
import type { AgentMessage, Attachment, MessageRenderer, UserMessageWithAttachments } from "@mariozechner/pi-web-ui";
import { registerMessageRenderer } from "@mariozechner/pi-web-ui";
import type { Message } from "@mariozechner/pi-ai";
import type { AgentMessage, MessageRenderer } from "@mariozechner/pi-web-ui";
import { defaultConvertToLlm, registerMessageRenderer } from "@mariozechner/pi-web-ui";
import { html } from "lit";
// ============================================================================
@ -75,72 +75,25 @@ export function createSystemNotification(
// 5. CUSTOM MESSAGE TRANSFORMER
// ============================================================================
// Convert attachments to content blocks
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;
}
// Transform custom messages to LLM-compatible messages
/**
* Custom message transformer that extends defaultConvertToLlm.
* Handles system-notification messages by converting them to user messages.
*/
export function customMessageTransformer(messages: AgentMessage[]): Message[] {
return messages
.filter((m) => {
// Filter out artifact messages - they're for session reconstruction only
if (m.role === "artifact") {
return false;
}
// First, handle our custom system-notification type
const processed = messages.map((m): AgentMessage => {
if (m.role === "system-notification") {
const notification = m as SystemNotificationMessage;
// Convert to user message with <system> tags
return {
role: "user",
content: `<system>${notification.message}</system>`,
timestamp: Date.now(),
};
}
return m;
});
// Keep LLM-compatible messages + custom messages
return (
m.role === "user" ||
m.role === "user-with-attachments" ||
m.role === "assistant" ||
m.role === "toolResult" ||
m.role === "system-notification"
);
})
.map((m) => {
// Transform system notifications to user messages
if (m.role === "system-notification") {
const notification = m as SystemNotificationMessage;
return {
role: "user",
content: `<system>${notification.message}</system>`,
timestamp: Date.now(),
} as Message;
}
// Convert user-with-attachments to user message with content blocks
if (m.role === "user-with-attachments") {
const msg = m as UserMessageWithAttachments;
const textContent: (TextContent | ImageContent)[] =
typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : [...msg.content];
if (msg.attachments) {
textContent.push(...convertAttachments(msg.attachments));
}
return {
role: "user",
content: textContent,
timestamp: msg.timestamp,
} as Message;
}
return m as Message;
});
// Then use defaultConvertToLlm for standard handling
return defaultConvertToLlm(processed);
}

View file

@ -74,8 +74,8 @@ let chatPanel: ChatPanel;
let agentUnsubscribe: (() => void) | undefined;
const generateTitle = (messages: AgentMessage[]): string => {
const firstUserMsg = messages.find((m) => m.role === "user");
if (!firstUserMsg || firstUserMsg.role !== "user") return "";
const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments");
if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return "";
let text = "";
const content = firstUserMsg.content;
@ -98,7 +98,7 @@ const generateTitle = (messages: AgentMessage[]): string => {
};
const shouldSaveSession = (messages: AgentMessage[]): boolean => {
const hasUserMsg = messages.some((m: any) => m.role === "user");
const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments");
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
return hasUserMsg && hasAssistantMsg;
};

View file

@ -50,7 +50,7 @@ export class MessageList extends LitElement {
}
// Fall back to built-in renderers
if (msg.role === "user") {
if (msg.role === "user" || msg.role === "user-with-attachments") {
items.push({
key: `msg:${index}`,
template: html`<user-message .message=${msg}></user-message>`,

View file

@ -285,3 +285,93 @@ export class AbortedMessage extends LitElement {
return html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`;
}
}
// ============================================================================
// 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);
}

View file

@ -74,7 +74,7 @@ export class StreamingMessageContainer extends LitElement {
if (msg.role === "toolResult") {
// Skip standalone tool result in streaming; the stable list will render paired tool-message
return html``;
} else if (msg.role === "user") {
} else if (msg.role === "user" || msg.role === "user-with-attachments") {
// Skip standalone tool result in streaming; the stable list will render it immediiately
return html``;
} else if (msg.role === "assistant") {

View file

@ -12,8 +12,16 @@ export { Input } from "./components/Input.js";
export { MessageEditor } from "./components/MessageEditor.js";
export { MessageList } from "./components/MessageList.js";
// Message components
export type { UserMessageWithAttachments } from "./components/Messages.js";
export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js";
export type { ArtifactMessage, UserMessageWithAttachments } from "./components/Messages.js";
export {
AssistantMessage,
convertAttachments,
defaultConvertToLlm,
isArtifactMessage,
isUserMessageWithAttachments,
ToolMessage,
UserMessage,
} from "./components/Messages.js";
// Message renderer registry
export {
getMessageRenderer,