From 05dfaa11a889327db7ba71da99f82ad040a8c6cf Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 6 Oct 2025 13:45:08 +0200 Subject: [PATCH] Add custom message extension system with typed renderers and message transformer - Implement CustomMessages interface for type-safe message extension via declaration merging - Add MessageRenderer 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 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 --- packages/browser-extension/src/sidepanel.ts | 20 +- packages/web-ui/README.md | 291 +++++++++++------- .../web-ui/example/src/custom-messages.ts | 107 +++++++ packages/web-ui/example/src/main.ts | 54 +++- packages/web-ui/src/agent/agent.ts | 32 +- .../src/agent/transports/AppTransport.ts | 12 +- .../src/agent/transports/ProviderTransport.ts | 12 +- packages/web-ui/src/components/MessageList.ts | 16 +- packages/web-ui/src/components/Messages.ts | 18 +- .../components/message-renderer-registry.ts | 28 ++ .../web-ui/src/dialogs/SessionListDialog.ts | 9 +- packages/web-ui/src/index.ts | 10 +- 12 files changed, 457 insertions(+), 152 deletions(-) create mode 100644 packages/web-ui/example/src/custom-messages.ts create mode 100644 packages/web-ui/src/components/message-renderer-registry.ts diff --git a/packages/browser-extension/src/sidepanel.ts b/packages/browser-extension/src/sidepanel.ts index d715a364..956b3b3e 100644 --- a/packages/browser-extension/src/sidepanel.ts +++ b/packages/browser-extension/src/sidepanel.ts @@ -189,9 +189,17 @@ const renderApp = () => { size: "sm", children: icon(History, "sm"), onClick: () => { - SessionListDialog.open((sessionId) => { - loadSession(sessionId); - }); + SessionListDialog.open( + (sessionId) => { + loadSession(sessionId); + }, + (deletedSessionId) => { + // If the deleted session is the current one, start a new session + if (deletedSessionId === currentSessionId) { + newSession(); + } + }, + ); }, title: "Sessions", })} @@ -334,10 +342,14 @@ async function initApp() { renderApp(); return; + } else { + // Session doesn't exist, redirect to new session + newSession(); + return; } } - // No session or session not found - create new agent + // No session - create new agent await createAgent(); renderApp(); } diff --git a/packages/web-ui/README.md b/packages/web-ui/README.md index 37156bce..73a1684e 100644 --- a/packages/web-ui/README.md +++ b/packages/web-ui/README.md @@ -6,13 +6,13 @@ Built with [mini-lit](https://github.com/mariozechner/mini-lit) web components a ## Features -- 🎨 **Modern Chat Interface** - Complete chat UI with message history, streaming responses, and tool execution -- 🔧 **Tool Support** - Built-in renderers for calculator, bash, time, and custom tools -- 📎 **Attachments** - PDF, Office documents, images with preview and text extraction -- 🎭 **Artifacts** - HTML, SVG, Markdown, and text artifact rendering with sandboxed execution -- 🔌 **Pluggable Transports** - Direct API calls or proxy server support -- 🌐 **Platform Agnostic** - Works in browser extensions, web apps, VS Code extensions, Electron apps -- 🎯 **TypeScript** - Full type safety with TypeScript +- Modern Chat Interface - Complete chat UI with message history, streaming responses, and tool execution +- Tool Support - Built-in renderers for calculator, bash, time, and custom tools +- Attachments - PDF, Office documents, images with preview and text extraction +- Artifacts - HTML, SVG, Markdown, and text artifact rendering with sandboxed execution +- Pluggable Transports - Direct API calls or proxy server support +- Platform Agnostic - Works in browser extensions, web apps, VS Code extensions, Electron apps +- TypeScript - Full type safety with TypeScript ## Installation @@ -25,14 +25,35 @@ npm install @mariozechner/pi-web-ui See the [example](./example) directory for a complete working application. ```typescript -import { ChatPanel } from '@mariozechner/pi-web-ui'; -import { calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai'; +import { Agent, ChatPanel, ProviderTransport, AppStorage, + SessionIndexedDBBackend, setAppStorage } from '@mariozechner/pi-web-ui'; +import { getModel } from '@mariozechner/pi-ai'; import '@mariozechner/pi-web-ui/app.css'; -// Create a chat panel +// Set up storage +const storage = new AppStorage({ + sessions: new SessionIndexedDBBackend('my-app-sessions'), +}); +setAppStorage(storage); + +// Create transport +const transport = new ProviderTransport(); + +// Create agent +const agent = new Agent({ + initialState: { + systemPrompt: 'You are a helpful assistant.', + model: getModel('anthropic', 'claude-sonnet-4-5-20250929'), + thinkingLevel: 'off', + messages: [], + tools: [], + }, + transport, +}); + +// Create chat panel and attach agent const chatPanel = new ChatPanel(); -chatPanel.systemPrompt = 'You are a helpful assistant.'; -chatPanel.additionalTools = [calculateTool, getCurrentTimeTool]; +await chatPanel.setAgent(agent); document.body.appendChild(chatPanel); ``` @@ -49,96 +70,94 @@ npm run dev ### ChatPanel -The main chat interface component. Manages agent sessions, model selection, and conversation flow. +The main chat interface component. Displays messages, handles input, and coordinates with the Agent. ```typescript -import { ChatPanel } from '@mariozechner/pi-web-ui'; +import { ChatPanel, ApiKeyPromptDialog } from '@mariozechner/pi-web-ui'; -const panel = new ChatPanel({ - initialModel: 'anthropic/claude-sonnet-4-20250514', - systemPrompt: 'You are a helpful assistant.', - transportMode: 'direct', // or 'proxy' +const chatPanel = new ChatPanel(); + +// Optional: Handle API key prompts +chatPanel.onApiKeyRequired = async (provider: string) => { + return await ApiKeyPromptDialog.prompt(provider); +}; + +// Attach an agent +await chatPanel.setAgent(agent); +``` + +### Agent + +Core state manager that handles conversation state, tool execution, and streaming. + +```typescript +import { Agent, ProviderTransport } from '@mariozechner/pi-web-ui'; +import { getModel } from '@mariozechner/pi-ai'; + +const agent = new Agent({ + initialState: { + model: getModel('anthropic', 'claude-sonnet-4-5-20250929'), + systemPrompt: 'You are a helpful assistant.', + thinkingLevel: 'off', + messages: [], + tools: [], + }, + transport: new ProviderTransport(), }); + +// Subscribe to events +agent.subscribe((event) => { + if (event.type === 'state-update') { + console.log('Messages:', event.state.messages); + } +}); + +// Send a message +await agent.send('Hello!'); ``` ### AgentInterface -Lower-level chat interface for custom implementations. +Lower-level chat interface for custom implementations. Used internally by ChatPanel. ```typescript import { AgentInterface } from '@mariozechner/pi-web-ui'; const chat = new AgentInterface(); -chat.session = myAgentSession; +await chat.setAgent(agent); ``` -## State Management - -### AgentSession - -Manages conversation state, tool execution, and streaming. - -```typescript -import { AgentSession, DirectTransport } from '@mariozechner/pi-web-ui'; -import { getModel, calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai'; - -const session = new AgentSession({ - initialState: { - model: getModel('anthropic', 'claude-3-5-haiku-20241022'), - systemPrompt: 'You are a helpful assistant.', - tools: [calculateTool, getCurrentTimeTool], - messages: [], - }, - transportMode: 'direct', -}); - -// Subscribe to state changes -session.subscribe((state) => { - console.log('Messages:', state.messages); - console.log('Streaming:', state.streaming); -}); - -// Send a message -await session.send('What is 25 * 18?'); -``` - -### Transports +## Transports Transport layers handle communication with AI providers. -#### DirectTransport +### ProviderTransport -Calls AI provider APIs directly from the browser using API keys stored locally. +The main transport that calls AI provider APIs using stored API keys. ```typescript -import { DirectTransport, KeyStore } from '@mariozechner/pi-web-ui'; +import { ProviderTransport } from '@mariozechner/pi-web-ui'; -// Set API keys -const keyStore = new KeyStore(); -await keyStore.setKey('anthropic', 'sk-ant-...'); -await keyStore.setKey('openai', 'sk-...'); +const transport = new ProviderTransport(); -// Use direct transport (default) -const session = new AgentSession({ - transportMode: 'direct', - // ... +const agent = new Agent({ + initialState: { /* ... */ }, + transport, }); ``` -#### ProxyTransport +### AppTransport -Routes requests through a proxy server using auth tokens. +Alternative transport for proxying requests through a custom server. ```typescript -import { ProxyTransport, setAuthToken } from '@mariozechner/pi-web-ui'; +import { AppTransport } from '@mariozechner/pi-web-ui'; -// Set auth token -setAuthToken('your-auth-token'); +const transport = new AppTransport(); -// Use proxy transport -const session = new AgentSession({ - transportMode: 'proxy', - // ... +const agent = new Agent({ + initialState: { /* ... */ }, + transport, }); ``` @@ -163,22 +182,46 @@ const myRenderer: ToolRenderer = { registerToolRenderer('my_tool', myRenderer); ``` -## Artifacts +## Storage -Render rich content with sandboxed execution. +The package provides flexible storage backends for API keys, settings, and session persistence. + +### AppStorage + +Central storage configuration for the application. ```typescript -import { artifactTools } from '@mariozechner/pi-web-ui'; -import { getModel } from '@mariozechner/pi-ai'; +import { AppStorage, setAppStorage, SessionIndexedDBBackend } from '@mariozechner/pi-web-ui'; -const session = new AgentSession({ - initialState: { - tools: [...artifactTools], - // ... - } +const storage = new AppStorage({ + sessions: new SessionIndexedDBBackend('my-app-sessions'), }); -// AI can now create HTML artifacts, SVG diagrams, etc. +setAppStorage(storage); +``` + +### Available Backends + +- `LocalStorageBackend` - Uses browser localStorage +- `IndexedDBBackend` - Uses IndexedDB for larger data +- `SessionIndexedDBBackend` - Specialized for session storage +- `ChromeStorageBackend` - For browser extensions using chrome.storage API + +### Session Management + +```typescript +import { getAppStorage } from '@mariozechner/pi-web-ui'; + +const storage = getAppStorage(); + +// Save session +await storage.sessions?.saveSession(sessionId, agentState, undefined, title); + +// Load session +const sessionData = await storage.sessions?.loadSession(sessionId); + +// List sessions +const sessions = await storage.sessions?.listSessions(); ``` ## Styling @@ -198,50 +241,82 @@ Or customize with your own Tailwind config: @tailwind utilities; ``` +## Dialogs + +The package includes several dialog components for common interactions. + +### SettingsDialog + +Settings dialog with tabbed interface for API keys, proxy configuration, etc. + +```typescript +import { SettingsDialog, ApiKeysTab, ProxyTab } from '@mariozechner/pi-web-ui'; + +// Open settings with tabs +SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]); +``` + +### SessionListDialog + +Display and load saved sessions. + +```typescript +import { SessionListDialog } from '@mariozechner/pi-web-ui'; + +SessionListDialog.open(async (sessionId) => { + await loadSession(sessionId); +}); +``` + +### ApiKeyPromptDialog + +Prompt user for API key when needed. + +```typescript +import { ApiKeyPromptDialog } from '@mariozechner/pi-web-ui'; + +const apiKey = await ApiKeyPromptDialog.prompt('anthropic'); +``` + +### PersistentStorageDialog + +Request persistent storage permission. + +```typescript +import { PersistentStorageDialog } from '@mariozechner/pi-web-ui'; + +await PersistentStorageDialog.request(); +``` + ## Platform Integration ### Browser Extension ```typescript -import { ChatPanel, KeyStore } from '@mariozechner/pi-web-ui'; +import { AppStorage, ChromeStorageBackend, Agent, ProviderTransport } from '@mariozechner/pi-web-ui'; -// Use chrome.storage for persistence -const keyStore = new KeyStore({ - get: async (key) => { - const result = await chrome.storage.local.get(key); - return result[key]; - }, - set: async (key, value) => { - await chrome.storage.local.set({ [key]: value }); - } +const storage = new AppStorage({ + providerKeys: new ChromeStorageBackend(), + settings: new ChromeStorageBackend(), }); +setAppStorage(storage); ``` ### Web Application ```typescript -import { ChatPanel } from '@mariozechner/pi-web-ui'; +import { AppStorage, SessionIndexedDBBackend, setAppStorage } from '@mariozechner/pi-web-ui'; -// Uses localStorage by default -const panel = new ChatPanel(); -document.querySelector('#app').appendChild(panel); -``` - -### VS Code Extension - -```typescript -import { AgentSession, DirectTransport } from '@mariozechner/pi-web-ui'; - -// Custom storage using VS Code's globalState -const storage = { - get: async (key) => context.globalState.get(key), - set: async (key, value) => context.globalState.update(key, value) -}; +const storage = new AppStorage({ + sessions: new SessionIndexedDBBackend('my-app-sessions'), +}); +setAppStorage(storage); ``` ## Examples -See the [browser-extension](../browser-extension) package for a complete implementation example. +- [example/](./example) - Complete web application with session management +- [browser-extension](../browser-extension) - Browser extension implementation ## API Reference diff --git a/packages/web-ui/example/src/custom-messages.ts b/packages/web-ui/example/src/custom-messages.ts new file mode 100644 index 00000000..876785e0 --- /dev/null +++ b/packages/web-ui/example/src/custom-messages.ts @@ -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 = { + render: (notification) => { + // notification is fully typed as SystemNotificationMessage! + return html` +
+ ${Alert({ + variant: notification.variant, + children: html` +
+
${notification.message}
+
${new Date(notification.timestamp).toLocaleTimeString()}
+
+ `, + })} +
+ `; + }, +}; + +// ============================================================================ +// 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 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: `${notification.message}`, + } 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; + }); +} diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 332e19e1..1e795a0f 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -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 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 => { + 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`Pi Web UI Example`}
+ ${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", + })} ${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(); } diff --git a/packages/web-ui/src/agent/agent.ts b/packages/web-ui/src/agent/agent.ts index aebfcd4f..96f44d38 100644 --- a/packages/web-ui/src/agent/agent.ts +++ b/packages/web-ui/src/agent/agent.ts @@ -13,6 +13,23 @@ import type { Attachment } from "../utils/attachment-utils.js"; import type { AgentRunConfig, AgentTransport } from "./transports/types.js"; import type { DebugLogEntry } from "./types.js"; +// Default transformer: Keep only LLM-compatible messages, strip app-specific fields +function defaultMessageTransformer(messages: AppMessage[]): Message[] { + return messages + .filter((m) => { + // Only keep standard LLM message roles + return m.role === "user" || m.role === "assistant" || m.role === "toolResult"; + }) + .map((m) => { + if (m.role === "user") { + // Strip attachments field (app-specific) + const { attachments, ...rest } = m as any; + return rest as Message; + } + return m as Message; + }); +} + export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high"; export interface AgentState { @@ -36,6 +53,8 @@ export interface AgentOptions { initialState?: Partial; debugListener?: (entry: DebugLogEntry) => void; transport: AgentTransport; + // Transform app messages to LLM-compatible messages before sending to transport + messageTransformer?: (messages: AppMessage[]) => Message[]; } export class Agent { @@ -54,11 +73,13 @@ export class Agent { private abortController?: AbortController; private transport: AgentTransport; private debugListener?: (entry: DebugLogEntry) => void; + private messageTransformer: (messages: AppMessage[]) => Message[]; constructor(opts: AgentOptions) { this._state = { ...this._state, ...opts.initialState }; this.debugListener = opts.debugListener; this.transport = opts.transport; + this.messageTransformer = opts.messageTransformer || defaultMessageTransformer; } get state(): AgentState { @@ -147,8 +168,12 @@ export class Agent { let partial: Message | null = null; let turnDebug: DebugLogEntry | null = null; let turnStart = 0; + + // Transform app messages to LLM-compatible messages + const llmMessages = this.messageTransformer(this._state.messages); + for await (const ev of this.transport.run( - this._state.messages as Message[], + llmMessages, userMessage as Message, cfg, this.abortController.signal, @@ -156,11 +181,10 @@ export class Agent { switch (ev.type) { case "turn_start": { turnStart = performance.now(); - // Build request context snapshot - const existing = this._state.messages as Message[]; + // Build request context snapshot (use transformed messages) const ctx: Context = { systemPrompt: this._state.systemPrompt, - messages: [...existing], + messages: [...llmMessages], tools: this._state.tools, }; turnDebug = { diff --git a/packages/web-ui/src/agent/transports/AppTransport.ts b/packages/web-ui/src/agent/transports/AppTransport.ts index 58b5c0b3..1400f03b 100644 --- a/packages/web-ui/src/agent/transports/AppTransport.ts +++ b/packages/web-ui/src/agent/transports/AppTransport.ts @@ -341,18 +341,10 @@ export class AppTransport implements AgentTransport { ); }; - // Filter out attachments from messages - const filteredMessages = messages.map((m) => { - if (m.role === "user") { - const { attachments, ...rest } = m as any; - return rest; - } - return m; - }); - + // Messages are already LLM-compatible (filtered by Agent) const context: AgentContext = { systemPrompt: cfg.systemPrompt, - messages: filteredMessages, + messages, tools: cfg.tools, }; diff --git a/packages/web-ui/src/agent/transports/ProviderTransport.ts b/packages/web-ui/src/agent/transports/ProviderTransport.ts index 58861399..8678364a 100644 --- a/packages/web-ui/src/agent/transports/ProviderTransport.ts +++ b/packages/web-ui/src/agent/transports/ProviderTransport.ts @@ -27,18 +27,10 @@ export class ProviderTransport implements AgentTransport { }; } - // Filter out attachments from messages - const filteredMessages = messages.map((m) => { - if (m.role === "user") { - const { attachments, ...rest } = m as any; - return rest; - } - return m; - }); - + // Messages are already LLM-compatible (filtered by Agent) const context: AgentContext = { systemPrompt: cfg.systemPrompt, - messages: filteredMessages, + messages, tools: cfg.tools, }; diff --git a/packages/web-ui/src/components/MessageList.ts b/packages/web-ui/src/components/MessageList.ts index 7c6dc790..28628c48 100644 --- a/packages/web-ui/src/components/MessageList.ts +++ b/packages/web-ui/src/components/MessageList.ts @@ -2,15 +2,16 @@ import { html } from "@mariozechner/mini-lit"; import type { AgentTool, AssistantMessage as AssistantMessageType, - Message, ToolResultMessage as ToolResultMessageType, } from "@mariozechner/pi-ai"; import { LitElement, type TemplateResult } from "lit"; import { property } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; +import type { AppMessage } from "./Messages.js"; +import { renderMessage } from "./message-renderer-registry.js"; export class MessageList extends LitElement { - @property({ type: Array }) messages: Message[] = []; + @property({ type: Array }) messages: AppMessage[] = []; @property({ type: Array }) tools: AgentTool[] = []; @property({ type: Object }) pendingToolCalls?: Set; @property({ type: Boolean }) isStreaming: boolean = false; @@ -36,6 +37,15 @@ export class MessageList extends LitElement { const items: Array<{ key: string; template: TemplateResult }> = []; let index = 0; for (const msg of this.messages) { + // Try custom renderer first + const customTemplate = renderMessage(msg); + if (customTemplate) { + items.push({ key: `msg:${index}`, template: customTemplate }); + index++; + continue; + } + + // Fall back to built-in renderers if (msg.role === "user") { items.push({ key: `msg:${index}`, @@ -58,7 +68,7 @@ export class MessageList extends LitElement { index++; } else { // Skip standalone toolResult messages; they are rendered via paired tool-message above - // For completeness, other roles are not expected + // Skip unknown roles } } return items; diff --git a/packages/web-ui/src/components/Messages.ts b/packages/web-ui/src/components/Messages.ts index 951f49b2..1b0827b6 100644 --- a/packages/web-ui/src/components/Messages.ts +++ b/packages/web-ui/src/components/Messages.ts @@ -16,7 +16,23 @@ import { formatUsage } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] }; -export type AppMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType; + +// Base message union +type BaseMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType; + +// 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 { diff --git a/packages/web-ui/src/components/message-renderer-registry.ts b/packages/web-ui/src/components/message-renderer-registry.ts new file mode 100644 index 00000000..eac4689e --- /dev/null +++ b/packages/web-ui/src/components/message-renderer-registry.ts @@ -0,0 +1,28 @@ +import type { TemplateResult } from "lit"; +import type { AppMessage } from "./Messages.js"; + +// Extract role type from AppMessage union +export type MessageRole = AppMessage["role"]; + +// Generic message renderer typed to specific message type +export interface MessageRenderer { + render(message: TMessage): TemplateResult; +} + +// Registry of custom message renderers by role +const messageRenderers = new Map>(); + +export function registerMessageRenderer( + role: TRole, + renderer: MessageRenderer>, +): void { + messageRenderers.set(role, renderer); +} + +export function getMessageRenderer(role: MessageRole): MessageRenderer | undefined { + return messageRenderers.get(role); +} + +export function renderMessage(message: AppMessage): TemplateResult | undefined { + return messageRenderers.get(message.role)?.render(message); +} diff --git a/packages/web-ui/src/dialogs/SessionListDialog.ts b/packages/web-ui/src/dialogs/SessionListDialog.ts index e5126521..1fa17735 100644 --- a/packages/web-ui/src/dialogs/SessionListDialog.ts +++ b/packages/web-ui/src/dialogs/SessionListDialog.ts @@ -11,13 +11,15 @@ export class SessionListDialog extends DialogBase { @state() private loading = true; private onSelectCallback?: (sessionId: string) => void; + private onDeleteCallback?: (sessionId: string) => void; protected modalWidth = "min(600px, 90vw)"; protected modalHeight = "min(700px, 90vh)"; - static async open(onSelect: (sessionId: string) => void) { + static async open(onSelect: (sessionId: string) => void, onDelete?: (sessionId: string) => void) { const dialog = new SessionListDialog(); dialog.onSelectCallback = onSelect; + dialog.onDeleteCallback = onDelete; dialog.open(); await dialog.loadSessions(); } @@ -54,6 +56,11 @@ export class SessionListDialog extends DialogBase { await storage.sessions.deleteSession(sessionId); await this.loadSessions(); + + // Notify callback that session was deleted + if (this.onDeleteCallback) { + this.onDeleteCallback(sessionId); + } } catch (err) { console.error("Failed to delete session:", err); } diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 1793ce02..466b17f5 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -17,8 +17,16 @@ export { Input } from "./components/Input.js"; export { MessageEditor } from "./components/MessageEditor.js"; export { MessageList } from "./components/MessageList.js"; // Message components -export type { AppMessage } from "./components/Messages.js"; +export type { AppMessage, CustomMessages } from "./components/Messages.js"; export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js"; +// Message renderer registry +export { + getMessageRenderer, + type MessageRenderer, + type MessageRole, + registerMessageRenderer, + renderMessage, +} from "./components/message-renderer-registry.js"; export { type SandboxFile, SandboxIframe,