mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +00:00
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:
parent
cf6b3466f8
commit
05dfaa11a8
12 changed files with 457 additions and 152 deletions
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
107
packages/web-ui/example/src/custom-messages.ts
Normal file
107
packages/web-ui/example/src/custom-messages.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
28
packages/web-ui/src/components/message-renderer-registry.ts
Normal file
28
packages/web-ui/src/components/message-renderer-registry.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue