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

@ -189,9 +189,17 @@ const renderApp = () => {
size: "sm", size: "sm",
children: icon(History, "sm"), children: icon(History, "sm"),
onClick: () => { onClick: () => {
SessionListDialog.open((sessionId) => { SessionListDialog.open(
loadSession(sessionId); (sessionId) => {
}); loadSession(sessionId);
},
(deletedSessionId) => {
// If the deleted session is the current one, start a new session
if (deletedSessionId === currentSessionId) {
newSession();
}
},
);
}, },
title: "Sessions", title: "Sessions",
})} })}
@ -334,10 +342,14 @@ async function initApp() {
renderApp(); renderApp();
return; 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(); await createAgent();
renderApp(); renderApp();
} }

View file

@ -6,13 +6,13 @@ Built with [mini-lit](https://github.com/mariozechner/mini-lit) web components a
## Features ## Features
- 🎨 **Modern Chat Interface** - Complete chat UI with message history, streaming responses, and tool execution - 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 - Tool Support - Built-in renderers for calculator, bash, time, and custom tools
- 📎 **Attachments** - PDF, Office documents, images with preview and text extraction - Attachments - PDF, Office documents, images with preview and text extraction
- 🎭 **Artifacts** - HTML, SVG, Markdown, and text artifact rendering with sandboxed execution - Artifacts - HTML, SVG, Markdown, and text artifact rendering with sandboxed execution
- 🔌 **Pluggable Transports** - Direct API calls or proxy server support - Pluggable Transports - Direct API calls or proxy server support
- 🌐 **Platform Agnostic** - Works in browser extensions, web apps, VS Code extensions, Electron apps - Platform Agnostic - Works in browser extensions, web apps, VS Code extensions, Electron apps
- 🎯 **TypeScript** - Full type safety with TypeScript - TypeScript - Full type safety with TypeScript
## Installation ## Installation
@ -25,14 +25,35 @@ npm install @mariozechner/pi-web-ui
See the [example](./example) directory for a complete working application. See the [example](./example) directory for a complete working application.
```typescript ```typescript
import { ChatPanel } from '@mariozechner/pi-web-ui'; import { Agent, ChatPanel, ProviderTransport, AppStorage,
import { calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai'; SessionIndexedDBBackend, setAppStorage } from '@mariozechner/pi-web-ui';
import { getModel } from '@mariozechner/pi-ai';
import '@mariozechner/pi-web-ui/app.css'; 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(); const chatPanel = new ChatPanel();
chatPanel.systemPrompt = 'You are a helpful assistant.'; await chatPanel.setAgent(agent);
chatPanel.additionalTools = [calculateTool, getCurrentTimeTool];
document.body.appendChild(chatPanel); document.body.appendChild(chatPanel);
``` ```
@ -49,96 +70,94 @@ npm run dev
### ChatPanel ### 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 ```typescript
import { ChatPanel } from '@mariozechner/pi-web-ui'; import { ChatPanel, ApiKeyPromptDialog } from '@mariozechner/pi-web-ui';
const panel = new ChatPanel({ const chatPanel = new ChatPanel();
initialModel: 'anthropic/claude-sonnet-4-20250514',
systemPrompt: 'You are a helpful assistant.', // Optional: Handle API key prompts
transportMode: 'direct', // or 'proxy' 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 ### AgentInterface
Lower-level chat interface for custom implementations. Lower-level chat interface for custom implementations. Used internally by ChatPanel.
```typescript ```typescript
import { AgentInterface } from '@mariozechner/pi-web-ui'; import { AgentInterface } from '@mariozechner/pi-web-ui';
const chat = new AgentInterface(); const chat = new AgentInterface();
chat.session = myAgentSession; await chat.setAgent(agent);
``` ```
## State Management ## Transports
### 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
Transport layers handle communication with AI providers. 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 ```typescript
import { DirectTransport, KeyStore } from '@mariozechner/pi-web-ui'; import { ProviderTransport } from '@mariozechner/pi-web-ui';
// Set API keys const transport = new ProviderTransport();
const keyStore = new KeyStore();
await keyStore.setKey('anthropic', 'sk-ant-...');
await keyStore.setKey('openai', 'sk-...');
// Use direct transport (default) const agent = new Agent({
const session = new AgentSession({ initialState: { /* ... */ },
transportMode: 'direct', transport,
// ...
}); });
``` ```
#### ProxyTransport ### AppTransport
Routes requests through a proxy server using auth tokens. Alternative transport for proxying requests through a custom server.
```typescript ```typescript
import { ProxyTransport, setAuthToken } from '@mariozechner/pi-web-ui'; import { AppTransport } from '@mariozechner/pi-web-ui';
// Set auth token const transport = new AppTransport();
setAuthToken('your-auth-token');
// Use proxy transport const agent = new Agent({
const session = new AgentSession({ initialState: { /* ... */ },
transportMode: 'proxy', transport,
// ...
}); });
``` ```
@ -163,22 +182,46 @@ const myRenderer: ToolRenderer = {
registerToolRenderer('my_tool', myRenderer); 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 ```typescript
import { artifactTools } from '@mariozechner/pi-web-ui'; import { AppStorage, setAppStorage, SessionIndexedDBBackend } from '@mariozechner/pi-web-ui';
import { getModel } from '@mariozechner/pi-ai';
const session = new AgentSession({ const storage = new AppStorage({
initialState: { sessions: new SessionIndexedDBBackend('my-app-sessions'),
tools: [...artifactTools],
// ...
}
}); });
// 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 ## Styling
@ -198,50 +241,82 @@ Or customize with your own Tailwind config:
@tailwind utilities; @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 ## Platform Integration
### Browser Extension ### Browser Extension
```typescript ```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 storage = new AppStorage({
const keyStore = new KeyStore({ providerKeys: new ChromeStorageBackend(),
get: async (key) => { settings: new ChromeStorageBackend(),
const result = await chrome.storage.local.get(key);
return result[key];
},
set: async (key, value) => {
await chrome.storage.local.set({ [key]: value });
}
}); });
setAppStorage(storage);
``` ```
### Web Application ### Web Application
```typescript ```typescript
import { ChatPanel } from '@mariozechner/pi-web-ui'; import { AppStorage, SessionIndexedDBBackend, setAppStorage } from '@mariozechner/pi-web-ui';
// Uses localStorage by default const storage = new AppStorage({
const panel = new ChatPanel(); sessions: new SessionIndexedDBBackend('my-app-sessions'),
document.querySelector('#app').appendChild(panel); });
``` setAppStorage(storage);
### 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)
};
``` ```
## Examples ## 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 ## API Reference

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

View file

@ -13,6 +13,23 @@ import type { Attachment } from "../utils/attachment-utils.js";
import type { AgentRunConfig, AgentTransport } from "./transports/types.js"; import type { AgentRunConfig, AgentTransport } from "./transports/types.js";
import type { DebugLogEntry } from "./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 type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
export interface AgentState { export interface AgentState {
@ -36,6 +53,8 @@ export interface AgentOptions {
initialState?: Partial<AgentState>; initialState?: Partial<AgentState>;
debugListener?: (entry: DebugLogEntry) => void; debugListener?: (entry: DebugLogEntry) => void;
transport: AgentTransport; transport: AgentTransport;
// Transform app messages to LLM-compatible messages before sending to transport
messageTransformer?: (messages: AppMessage[]) => Message[];
} }
export class Agent { export class Agent {
@ -54,11 +73,13 @@ export class Agent {
private abortController?: AbortController; private abortController?: AbortController;
private transport: AgentTransport; private transport: AgentTransport;
private debugListener?: (entry: DebugLogEntry) => void; private debugListener?: (entry: DebugLogEntry) => void;
private messageTransformer: (messages: AppMessage[]) => Message[];
constructor(opts: AgentOptions) { constructor(opts: AgentOptions) {
this._state = { ...this._state, ...opts.initialState }; this._state = { ...this._state, ...opts.initialState };
this.debugListener = opts.debugListener; this.debugListener = opts.debugListener;
this.transport = opts.transport; this.transport = opts.transport;
this.messageTransformer = opts.messageTransformer || defaultMessageTransformer;
} }
get state(): AgentState { get state(): AgentState {
@ -147,8 +168,12 @@ export class Agent {
let partial: Message | null = null; let partial: Message | null = null;
let turnDebug: DebugLogEntry | null = null; let turnDebug: DebugLogEntry | null = null;
let turnStart = 0; 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( for await (const ev of this.transport.run(
this._state.messages as Message[], llmMessages,
userMessage as Message, userMessage as Message,
cfg, cfg,
this.abortController.signal, this.abortController.signal,
@ -156,11 +181,10 @@ export class Agent {
switch (ev.type) { switch (ev.type) {
case "turn_start": { case "turn_start": {
turnStart = performance.now(); turnStart = performance.now();
// Build request context snapshot // Build request context snapshot (use transformed messages)
const existing = this._state.messages as Message[];
const ctx: Context = { const ctx: Context = {
systemPrompt: this._state.systemPrompt, systemPrompt: this._state.systemPrompt,
messages: [...existing], messages: [...llmMessages],
tools: this._state.tools, tools: this._state.tools,
}; };
turnDebug = { turnDebug = {

View file

@ -341,18 +341,10 @@ export class AppTransport implements AgentTransport {
); );
}; };
// Filter out attachments from messages // Messages are already LLM-compatible (filtered by Agent)
const filteredMessages = messages.map((m) => {
if (m.role === "user") {
const { attachments, ...rest } = m as any;
return rest;
}
return m;
});
const context: AgentContext = { const context: AgentContext = {
systemPrompt: cfg.systemPrompt, systemPrompt: cfg.systemPrompt,
messages: filteredMessages, messages,
tools: cfg.tools, tools: cfg.tools,
}; };

View file

@ -27,18 +27,10 @@ export class ProviderTransport implements AgentTransport {
}; };
} }
// Filter out attachments from messages // Messages are already LLM-compatible (filtered by Agent)
const filteredMessages = messages.map((m) => {
if (m.role === "user") {
const { attachments, ...rest } = m as any;
return rest;
}
return m;
});
const context: AgentContext = { const context: AgentContext = {
systemPrompt: cfg.systemPrompt, systemPrompt: cfg.systemPrompt,
messages: filteredMessages, messages,
tools: cfg.tools, tools: cfg.tools,
}; };

View file

@ -2,15 +2,16 @@ import { html } from "@mariozechner/mini-lit";
import type { import type {
AgentTool, AgentTool,
AssistantMessage as AssistantMessageType, AssistantMessage as AssistantMessageType,
Message,
ToolResultMessage as ToolResultMessageType, ToolResultMessage as ToolResultMessageType,
} from "@mariozechner/pi-ai"; } from "@mariozechner/pi-ai";
import { LitElement, type TemplateResult } from "lit"; import { LitElement, type TemplateResult } from "lit";
import { property } from "lit/decorators.js"; import { property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.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 { export class MessageList extends LitElement {
@property({ type: Array }) messages: Message[] = []; @property({ type: Array }) messages: AppMessage[] = [];
@property({ type: Array }) tools: AgentTool[] = []; @property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Object }) pendingToolCalls?: Set<string>; @property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Boolean }) isStreaming: boolean = false; @property({ type: Boolean }) isStreaming: boolean = false;
@ -36,6 +37,15 @@ export class MessageList extends LitElement {
const items: Array<{ key: string; template: TemplateResult }> = []; const items: Array<{ key: string; template: TemplateResult }> = [];
let index = 0; let index = 0;
for (const msg of this.messages) { 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") { if (msg.role === "user") {
items.push({ items.push({
key: `msg:${index}`, key: `msg:${index}`,
@ -58,7 +68,7 @@ export class MessageList extends LitElement {
index++; index++;
} else { } else {
// Skip standalone toolResult messages; they are rendered via paired tool-message above // Skip standalone toolResult messages; they are rendered via paired tool-message above
// For completeness, other roles are not expected // Skip unknown roles
} }
} }
return items; return items;

View file

@ -16,7 +16,23 @@ import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js"; import { i18n } from "../utils/i18n.js";
export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] }; 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") @customElement("user-message")
export class UserMessage extends LitElement { export class UserMessage extends LitElement {

View file

@ -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<TMessage extends AppMessage = AppMessage> {
render(message: TMessage): TemplateResult;
}
// Registry of custom message renderers by role
const messageRenderers = new Map<MessageRole, MessageRenderer<any>>();
export function registerMessageRenderer<TRole extends MessageRole>(
role: TRole,
renderer: MessageRenderer<Extract<AppMessage, { role: TRole }>>,
): 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);
}

View file

@ -11,13 +11,15 @@ export class SessionListDialog extends DialogBase {
@state() private loading = true; @state() private loading = true;
private onSelectCallback?: (sessionId: string) => void; private onSelectCallback?: (sessionId: string) => void;
private onDeleteCallback?: (sessionId: string) => void;
protected modalWidth = "min(600px, 90vw)"; protected modalWidth = "min(600px, 90vw)";
protected modalHeight = "min(700px, 90vh)"; 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(); const dialog = new SessionListDialog();
dialog.onSelectCallback = onSelect; dialog.onSelectCallback = onSelect;
dialog.onDeleteCallback = onDelete;
dialog.open(); dialog.open();
await dialog.loadSessions(); await dialog.loadSessions();
} }
@ -54,6 +56,11 @@ export class SessionListDialog extends DialogBase {
await storage.sessions.deleteSession(sessionId); await storage.sessions.deleteSession(sessionId);
await this.loadSessions(); await this.loadSessions();
// Notify callback that session was deleted
if (this.onDeleteCallback) {
this.onDeleteCallback(sessionId);
}
} catch (err) { } catch (err) {
console.error("Failed to delete session:", err); console.error("Failed to delete session:", err);
} }

View file

@ -17,8 +17,16 @@ export { Input } from "./components/Input.js";
export { MessageEditor } from "./components/MessageEditor.js"; export { MessageEditor } from "./components/MessageEditor.js";
export { MessageList } from "./components/MessageList.js"; export { MessageList } from "./components/MessageList.js";
// Message components // 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"; 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 { export {
type SandboxFile, type SandboxFile,
SandboxIframe, SandboxIframe,