mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 22:01:38 +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
|
|
@ -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<AgentState>;
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
@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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue