Add custom message extension system with typed renderers and message transformer

- Implement CustomMessages interface for type-safe message extension via declaration merging
- Add MessageRenderer<T> with generic typing for custom message rendering
- Add messageTransformer to Agent for filtering/transforming messages before LLM
- Move message filtering from transports to Agent (separation of concerns)
- Add message renderer registry with typed role support
- Update web-ui example with SystemNotificationMessage demo
- Add custom transformer that converts notifications to <system> tags
- Add SessionListDialog onDelete callback for active session cleanup
- Handle non-existent session IDs in URL (redirect to new session)
- Update both web-ui example and browser extension with session fixes
This commit is contained in:
Mario Zechner 2025-10-06 13:45:08 +02:00
parent cf6b3466f8
commit 05dfaa11a8
12 changed files with 457 additions and 152 deletions

View file

@ -0,0 +1,107 @@
import { Alert } from "@mariozechner/mini-lit";
import type { Message } from "@mariozechner/pi-ai";
import { html } from "lit";
import { registerMessageRenderer } from "@mariozechner/pi-web-ui";
import type { AppMessage, MessageRenderer } from "@mariozechner/pi-web-ui";
// ============================================================================
// 1. EXTEND AppMessage TYPE VIA DECLARATION MERGING
// ============================================================================
// Define custom message types
export interface SystemNotificationMessage {
role: "system-notification";
message: string;
variant: "default" | "destructive";
timestamp: string;
}
// Extend CustomMessages interface via declaration merging
declare module "@mariozechner/pi-web-ui" {
interface CustomMessages {
"system-notification": SystemNotificationMessage;
}
}
// ============================================================================
// 2. CREATE CUSTOM RENDERER (TYPED TO SystemNotificationMessage)
// ============================================================================
const systemNotificationRenderer: MessageRenderer<SystemNotificationMessage> = {
render: (notification) => {
// notification is fully typed as SystemNotificationMessage!
return html`
<div class="px-4">
${Alert({
variant: notification.variant,
children: html`
<div class="flex flex-col gap-1">
<div>${notification.message}</div>
<div class="text-xs opacity-70">${new Date(notification.timestamp).toLocaleTimeString()}</div>
</div>
`,
})}
</div>
`;
},
};
// ============================================================================
// 3. REGISTER RENDERER
// ============================================================================
export function registerCustomMessageRenderers() {
registerMessageRenderer("system-notification", systemNotificationRenderer);
}
// ============================================================================
// 4. HELPER TO CREATE CUSTOM MESSAGES
// ============================================================================
export function createSystemNotification(
message: string,
variant: "default" | "destructive" = "default",
): SystemNotificationMessage {
return {
role: "system-notification",
message,
variant,
timestamp: new Date().toISOString(),
};
}
// ============================================================================
// 5. CUSTOM MESSAGE TRANSFORMER
// ============================================================================
// Transform custom messages to user messages with <system> tags so LLM can see them
export function customMessageTransformer(messages: AppMessage[]): Message[] {
return messages
.filter((m) => {
// Keep LLM-compatible messages + custom messages
return (
m.role === "user" ||
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>`,
} as Message;
}
// Strip attachments from user messages
if (m.role === "user") {
const { attachments, ...rest } = m as any;
return rest as Message;
}
return m as Message;
});
}

View file

@ -3,9 +3,10 @@ import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { getModel } from "@mariozechner/pi-ai";
import {
Agent,
AgentState,
type AgentState,
ApiKeyPromptDialog,
ApiKeysTab,
type AppMessage,
AppStorage,
ChatPanel,
PersistentStorageDialog,
@ -16,10 +17,13 @@ import {
setAppStorage,
SettingsDialog,
} from "@mariozechner/pi-web-ui";
import type { AppMessage } from "@mariozechner/pi-web-ui";
import { html, render } from "lit";
import { History, Plus, Settings } from "lucide";
import { Bell, History, Plus, Settings } from "lucide";
import "./app.css";
import { createSystemNotification, customMessageTransformer, registerCustomMessageRenderers } from "./custom-messages.js";
// Register custom message renderers
registerCustomMessageRenderers();
const storage = new AppStorage({
sessions: new SessionIndexedDBBackend("pi-web-ui-sessions"),
@ -104,6 +108,8 @@ Feel free to use these tools when needed to provide accurate and helpful respons
tools: [],
},
transport,
// Custom transformer: convert system notifications to user messages with <system> tags
messageTransformer: customMessageTransformer,
});
agentUnsubscribe = agent.subscribe((event: any) => {
@ -133,13 +139,13 @@ Feel free to use these tools when needed to provide accurate and helpful respons
await chatPanel.setAgent(agent);
};
const loadSession = async (sessionId: string) => {
if (!storage.sessions) return;
const loadSession = async (sessionId: string): Promise<boolean> => {
if (!storage.sessions) return false;
const sessionData = await storage.sessions.loadSession(sessionId);
if (!sessionData) {
console.error("Session not found:", sessionId);
return;
return false;
}
currentSessionId = sessionId;
@ -155,6 +161,7 @@ const loadSession = async (sessionId: string) => {
updateUrl(sessionId);
renderApp();
return true;
};
const newSession = () => {
@ -180,9 +187,17 @@ const renderApp = () => {
size: "sm",
children: icon(History, "sm"),
onClick: () => {
SessionListDialog.open(async (sessionId) => {
await loadSession(sessionId);
});
SessionListDialog.open(
async (sessionId) => {
await loadSession(sessionId);
},
(deletedSessionId) => {
// If the deleted session is the current one, start a new session
if (deletedSessionId === currentSessionId) {
newSession();
}
},
);
},
title: "Sessions",
})}
@ -246,6 +261,20 @@ const renderApp = () => {
: html`<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>`}
</div>
<div class="flex items-center gap-1 px-2">
${Button({
variant: "ghost",
size: "sm",
children: icon(Bell, "sm"),
onClick: () => {
// Demo: Inject custom message
if (agent) {
agent.appendMessage(
createSystemNotification("This is a custom message! It appears in the UI but is never sent to the LLM."),
);
}
},
title: "Demo: Add Custom Notification",
})}
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
@ -298,7 +327,12 @@ async function initApp() {
const sessionIdFromUrl = urlParams.get("session");
if (sessionIdFromUrl) {
await loadSession(sessionIdFromUrl);
const loaded = await loadSession(sessionIdFromUrl);
if (!loaded) {
// Session doesn't exist, redirect to new session
newSession();
return;
}
} else {
await createAgent();
}