mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 07:04:25 +00:00
Add artifact message persistence for session reconstruction
- Add ArtifactMessage type as core part of AppMessage union (not CustomMessages) - ArtifactsRuntimeProvider appends artifact messages on create/update/delete - MessageList filters out artifact messages (UI display only) - artifacts.ts reconstructFromMessages handles artifact messages - Export ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION from main index - Fix artifact creation bug: pass filename as title instead of mimeType Changes: - web-ui/src/components/Messages.ts: Add ArtifactMessage to BaseMessage union - web-ui/src/components/MessageList.ts: Skip artifact messages in render - web-ui/src/components/sandbox/ArtifactsRuntimeProvider.ts: Append messages, fix title parameter - web-ui/src/ChatPanel.ts: Pass agent.appendMessage callback - web-ui/src/tools/artifacts/artifacts.ts: Handle artifact messages in reconstructFromMessages - web-ui/src/index.ts: Export ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION - web-ui/example/src/custom-messages.ts: Update message transformer to filter artifacts
This commit is contained in:
parent
0eaa879d46
commit
4d2ca6ab2a
20 changed files with 669 additions and 239 deletions
|
|
@ -37,6 +37,11 @@ export class MessageList extends LitElement {
|
|||
const items: Array<{ key: string; template: TemplateResult }> = [];
|
||||
let index = 0;
|
||||
for (const msg of this.messages) {
|
||||
// Skip artifact messages - they're for session persistence only, not UI display
|
||||
if (msg.role === "artifact") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try custom renderer first
|
||||
const customTemplate = renderMessage(msg);
|
||||
if (customTemplate) {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,18 @@ import { i18n } from "../utils/i18n.js";
|
|||
|
||||
export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] };
|
||||
|
||||
// Artifact message type for session persistence
|
||||
export interface ArtifactMessage {
|
||||
role: "artifact";
|
||||
action: "create" | "update" | "delete";
|
||||
filename: string;
|
||||
content?: string;
|
||||
title?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Base message union
|
||||
type BaseMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType;
|
||||
type BaseMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType | ArtifactMessage;
|
||||
|
||||
// Extensible interface - apps can extend via declaration merging
|
||||
// Example:
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export class SandboxIframe extends LitElement {
|
|||
return new Promise((resolve, reject) => {
|
||||
// 4. Create execution consumer for lifecycle messages
|
||||
const executionConsumer: MessageConsumer = {
|
||||
handleMessage(message: any): boolean {
|
||||
async handleMessage(message: any): Promise<boolean> {
|
||||
if (message.type === "console") {
|
||||
logs.push({
|
||||
type: message.method === "error" ? "error" : "log",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
import { ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION } from "../../prompts/tool-prompts.js";
|
||||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
/**
|
||||
* Artifacts Runtime Provider
|
||||
*
|
||||
* Provides programmatic access to session artifacts from sandboxed code.
|
||||
* Allows code to create, read, update, and delete artifacts dynamically.
|
||||
*/
|
||||
export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
|
||||
constructor(
|
||||
private getArtifactsFn: () => Map<string, { content: string }>,
|
||||
private createArtifactFn: (filename: string, content: string, title?: string) => Promise<void>,
|
||||
private updateArtifactFn: (filename: string, content: string, title?: string) => Promise<void>,
|
||||
private deleteArtifactFn: (filename: string) => Promise<void>,
|
||||
private appendMessageFn?: (message: any) => void,
|
||||
) {}
|
||||
|
||||
getData(): Record<string, any> {
|
||||
// No initial data injection needed - artifacts are accessed via async functions
|
||||
return {};
|
||||
}
|
||||
|
||||
getRuntime(): (sandboxId: string) => void {
|
||||
// This function will be stringified, so no external references!
|
||||
return (sandboxId: string) => {
|
||||
// Helper to send message and wait for response
|
||||
const sendArtifactMessage = (action: string, data: any): Promise<any> => {
|
||||
console.log("Sending artifact message:", action, data);
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = `artifact_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data.type === "artifact-response" && event.data.messageId === messageId) {
|
||||
window.removeEventListener("message", handler);
|
||||
if (event.data.success) {
|
||||
resolve(event.data.result);
|
||||
} else {
|
||||
reject(new Error(event.data.error || "Artifact operation failed"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handler);
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "artifact-operation",
|
||||
sandboxId,
|
||||
messageId,
|
||||
action,
|
||||
data,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-parse/stringify for .json files
|
||||
const isJsonFile = (filename: string) => filename.endsWith(".json");
|
||||
|
||||
(window as any).hasArtifact = async (filename: string): Promise<boolean> => {
|
||||
return await sendArtifactMessage("has", { filename });
|
||||
};
|
||||
|
||||
(window as any).getArtifact = async (filename: string): Promise<any> => {
|
||||
const content = await sendArtifactMessage("get", { filename });
|
||||
// Auto-parse .json files
|
||||
if (isJsonFile(filename)) {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse JSON from ${filename}: ${e}`);
|
||||
}
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
(window as any).createArtifact = async (filename: string, content: any, mimeType?: string): Promise<void> => {
|
||||
let finalContent = content;
|
||||
let finalMimeType = mimeType;
|
||||
|
||||
// Auto-stringify .json files
|
||||
if (isJsonFile(filename) && typeof content !== "string") {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
} else if (typeof content === "string") {
|
||||
finalContent = content;
|
||||
finalMimeType = mimeType || "text/plain";
|
||||
} else {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
}
|
||||
|
||||
await sendArtifactMessage("create", { filename, content: finalContent, mimeType: finalMimeType });
|
||||
};
|
||||
|
||||
(window as any).updateArtifact = async (filename: string, content: any, mimeType?: string): Promise<void> => {
|
||||
let finalContent = content;
|
||||
let finalMimeType = mimeType;
|
||||
|
||||
// Auto-stringify .json files
|
||||
if (isJsonFile(filename) && typeof content !== "string") {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
} else if (typeof content === "string") {
|
||||
finalContent = content;
|
||||
finalMimeType = mimeType || "text/plain";
|
||||
} else {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
}
|
||||
|
||||
await sendArtifactMessage("update", { filename, content: finalContent, mimeType: finalMimeType });
|
||||
};
|
||||
|
||||
(window as any).deleteArtifact = async (filename: string): Promise<void> => {
|
||||
await sendArtifactMessage("delete", { filename });
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async handleMessage(message: any, respond: (response: any) => void): Promise<boolean> {
|
||||
if (message.type !== "artifact-operation") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { action, data, messageId } = message;
|
||||
|
||||
const sendResponse = (success: boolean, result?: any, error?: string) => {
|
||||
respond({
|
||||
type: "artifact-response",
|
||||
messageId,
|
||||
success,
|
||||
result,
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "has": {
|
||||
const artifacts = this.getArtifactsFn();
|
||||
const exists = artifacts.has(data.filename);
|
||||
sendResponse(true, exists);
|
||||
break;
|
||||
}
|
||||
|
||||
case "get": {
|
||||
const artifacts = this.getArtifactsFn();
|
||||
const artifact = artifacts.get(data.filename);
|
||||
if (!artifact) {
|
||||
sendResponse(false, undefined, `Artifact not found: ${data.filename}`);
|
||||
} else {
|
||||
sendResponse(true, artifact.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "create": {
|
||||
try {
|
||||
// Note: mimeType parameter is ignored - artifact type is inferred from filename extension
|
||||
// Third parameter is title, defaults to filename
|
||||
await this.createArtifactFn(data.filename, data.content, data.filename);
|
||||
// Append artifact message for session persistence
|
||||
this.appendMessageFn?.({
|
||||
role: "artifact",
|
||||
action: "create",
|
||||
filename: data.filename,
|
||||
content: data.content,
|
||||
title: data.filename,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
sendResponse(true);
|
||||
} catch (err: any) {
|
||||
sendResponse(false, undefined, err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "update": {
|
||||
try {
|
||||
// Note: mimeType parameter is ignored - artifact type is inferred from filename extension
|
||||
// Third parameter is title, defaults to filename
|
||||
await this.updateArtifactFn(data.filename, data.content, data.filename);
|
||||
// Append artifact message for session persistence
|
||||
this.appendMessageFn?.({
|
||||
role: "artifact",
|
||||
action: "update",
|
||||
filename: data.filename,
|
||||
content: data.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
sendResponse(true);
|
||||
} catch (err: any) {
|
||||
sendResponse(false, undefined, err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
try {
|
||||
await this.deleteArtifactFn(data.filename);
|
||||
// Append artifact message for session persistence
|
||||
this.appendMessageFn?.({
|
||||
role: "artifact",
|
||||
action: "delete",
|
||||
filename: data.filename,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
sendResponse(true);
|
||||
} catch (err: any) {
|
||||
sendResponse(false, undefined, err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sendResponse(false, undefined, `Unknown artifact action: ${action}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
sendResponse(false, undefined, error.message);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return ARTIFACTS_RUNTIME_PROVIDER_DESCRIPTION;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { ATTACHMENTS_RUNTIME_DESCRIPTION } from "../../prompts/tool-prompts.js";
|
||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
||||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
|
|
@ -96,4 +97,8 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
|
|||
};
|
||||
};
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return ATTACHMENTS_RUNTIME_DESCRIPTION;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface MessageConsumer {
|
|||
* Handle a message from a sandbox.
|
||||
* @returns true if message was consumed (stops propagation), false otherwise
|
||||
*/
|
||||
handleMessage(message: any): boolean;
|
||||
handleMessage(message: any): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -111,7 +111,7 @@ export class SandboxMessageRouter {
|
|||
private setupListener(): void {
|
||||
if (this.messageListener) return;
|
||||
|
||||
this.messageListener = (e: MessageEvent) => {
|
||||
this.messageListener = async (e: MessageEvent) => {
|
||||
const { sandboxId } = e.data;
|
||||
if (!sandboxId) return;
|
||||
|
||||
|
|
@ -129,14 +129,14 @@ export class SandboxMessageRouter {
|
|||
// 1. Try provider handlers first (for bidirectional comm like memory)
|
||||
for (const provider of context.providers) {
|
||||
if (provider.handleMessage) {
|
||||
const handled = provider.handleMessage(e.data, respond);
|
||||
const handled = await provider.handleMessage(e.data, respond);
|
||||
if (handled) return; // Stop if handled
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Broadcast to consumers (for one-way messages like console)
|
||||
for (const consumer of context.consumers) {
|
||||
const consumed = consumer.handleMessage(e.data);
|
||||
const consumed = await consumer.handleMessage(e.data);
|
||||
if (consumed) break; // Stop if consumed
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,5 +26,11 @@ export interface SandboxRuntimeProvider {
|
|||
* @param respond - Function to send a response back to the sandbox
|
||||
* @returns true if message was handled, false otherwise
|
||||
*/
|
||||
handleMessage?(message: any, respond: (response: any) => void): boolean;
|
||||
handleMessage?(message: any, respond: (response: any) => void): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Optional documentation describing what globals/functions this provider injects.
|
||||
* This will be appended to tool descriptions dynamically so the LLM knows what's available.
|
||||
*/
|
||||
getDescription?(): string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue