Add ToolRenderResult interface for custom tool rendering

- Changed ToolRenderer return type from TemplateResult to ToolRenderResult
- ToolRenderResult = { content: TemplateResult, isCustom: boolean }
- isCustom: true = no card wrapper, false = wrap in card
- Updated all existing tool renderers to return new format
- Updated Messages.ts to handle custom rendering

This enables tools to render without default card chrome when needed.
This commit is contained in:
Mario Zechner 2025-10-11 04:40:42 +02:00
parent 3db2a6fe2c
commit b129154cc8
23 changed files with 423 additions and 180 deletions

View file

@ -19,10 +19,10 @@ import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
export class AgentInterface extends LitElement {
// Optional external session: when provided, this component becomes a view over the session
@property({ attribute: false }) session?: Agent;
@property() enableAttachments = true;
@property() enableModelSelector = true;
@property() enableThinkingSelector = true;
@property() showThemeToggle = false;
@property({ type: Boolean }) enableAttachments = true;
@property({ type: Boolean }) enableModelSelector = true;
@property({ type: Boolean }) enableThinkingSelector = true;
@property({ type: Boolean }) showThemeToggle = false;
// Optional custom API key prompt handler - if not provided, uses default dialog
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
// Optional callback called before sending a message
@ -52,6 +52,10 @@ export class AgentInterface extends LitElement {
update();
}
public setAutoScroll(enabled: boolean) {
this._autoScroll = enabled;
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}

View file

@ -231,16 +231,22 @@ export class ToolMessage extends LitElement {
const result: ToolResultMessageType<any> | undefined = this.aborted
? { role: "toolResult", isError: true, output: "", toolCallId: this.toolCall.id, toolName: this.toolCall.name }
: this.result;
const toolContent = renderTool(
const renderResult = renderTool(
toolName,
this.toolCall.arguments,
result,
!this.aborted && (this.isStreaming || this.pending),
);
// Handle custom rendering (no card wrapper)
if (renderResult.isCustom) {
return renderResult.content;
}
// Default: wrap in card
return html`
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground shadow-xs">
${toolContent}
${renderResult.content}
</div>
`;
}

View file

@ -25,6 +25,15 @@ export interface SandboxResult {
*/
export type SandboxUrlProvider = () => string;
/**
* Escape HTML special sequences in code to prevent premature tag closure
* @param code Code that will be injected into <script> tags
* @returns Escaped code safe for injection
*/
function escapeScriptContent(code: string): string {
return code.replace(/<\/script/gi, "<\\/script");
}
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
private iframe?: HTMLIFrameElement;
@ -79,6 +88,26 @@ export class SandboxIframe extends LitElement {
// loadContent is always used for HTML artifacts
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers, true);
// Validate HTML before loading
const validationError = this.validateHtml(completeHtml);
if (validationError) {
console.error("HTML validation failed:", validationError);
// Show error in iframe instead of crashing
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
this.iframe.srcdoc = `
<html>
<body style="font-family: monospace; padding: 20px; background: #fff; color: #000;">
<h3 style="color: #c00;">HTML Validation Error</h3>
<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap;">${validationError}</pre>
</body>
</html>
`;
this.appendChild(this.iframe);
return;
}
// Remove previous iframe if exists
this.iframe?.remove();
@ -104,10 +133,11 @@ export class SandboxIframe extends LitElement {
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready message directly
// Listen for sandbox-ready and sandbox-error messages directly
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
@ -121,7 +151,27 @@ export class SandboxIframe extends LitElement {
}
};
const errorHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-error" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// The sandbox.js already sent us the error via postMessage.
// We need to convert it to an execution-error message that the execute() consumer will handle.
// Simulate receiving an execution-error from the sandbox
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
}
@ -228,15 +278,22 @@ export class SandboxIframe extends LitElement {
resolve({
success: false,
console: consoleProvider.getLogs(),
error: { message: "Execution timeout (30s)", stack: "" },
error: { message: "Execution timeout (120s)", stack: "" },
files,
});
}
}, 30000);
}, 120000);
// 4. Prepare HTML and create iframe
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers, isHtmlArtifact);
// 5. Validate HTML before sending to sandbox
const validationError = this.validateHtml(completeHtml);
if (validationError) {
reject(new Error(`HTML validation failed: ${validationError}`));
return;
}
if (this.sandboxUrlProvider) {
// Browser extension mode: wait for sandbox-ready
this.iframe = document.createElement("iframe");
@ -247,10 +304,11 @@ export class SandboxIframe extends LitElement {
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready message directly
// Listen for sandbox-ready and sandbox-error messages
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
@ -264,7 +322,25 @@ export class SandboxIframe extends LitElement {
}
};
const errorHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-error" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Convert sandbox-error to execution-error for the execution consumer
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
} else {
@ -282,6 +358,27 @@ export class SandboxIframe extends LitElement {
});
}
/**
* Validate HTML using DOMParser - returns error message if invalid, null if valid
* Note: JavaScript syntax validation is done in sandbox.js to avoid CSP restrictions
*/
private validateHtml(html: string): string | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Check for parser errors
const parserError = doc.querySelector("parsererror");
if (parserError) {
return parserError.textContent || "Unknown parse error";
}
return null;
} catch (error: any) {
return error.message || "Unknown validation error";
}
}
/**
* Prepare complete HTML document with runtime + user code
* PUBLIC so HtmlArtifact can use it for download button
@ -315,6 +412,9 @@ export class SandboxIframe extends LitElement {
return runtime + userCode;
} else {
// REPL - wrap code in HTML with runtime and call complete() when done
// Escape </script> in user code to prevent premature tag closure
const escapedUserCode = escapeScriptContent(userCode);
return `<!DOCTYPE html>
<html>
<head>
@ -326,7 +426,7 @@ export class SandboxIframe extends LitElement {
try {
// Wrap user code in async function to capture return value
const userCodeFunc = async () => {
${userCode}
${escapedUserCode}
};
const returnValue = await userCodeFunc();

View file

@ -59,7 +59,6 @@ export class RuntimeMessageRouter {
// Setup global listener if not already done
this.setupListener();
console.log(`Registered sandbox: ${sandboxId}, providers: ${providers.length}, consumers: ${consumers.length}`);
}
/**
@ -72,7 +71,6 @@ export class RuntimeMessageRouter {
if (context) {
context.iframe = iframe;
}
console.log("Set iframe for sandbox:", sandboxId);
}
/**
@ -96,7 +94,6 @@ export class RuntimeMessageRouter {
this.userScriptMessageListener = null;
}
}
console.log("Unregistered sandbox:", sandboxId);
}
/**
@ -108,7 +105,6 @@ export class RuntimeMessageRouter {
if (context) {
context.consumers.add(consumer);
}
console.log("Added consumer for sandbox:", sandboxId);
}
/**
@ -119,7 +115,6 @@ export class RuntimeMessageRouter {
if (context) {
context.consumers.delete(consumer);
}
console.log("Removed consumer for sandbox:", sandboxId);
}
/**
@ -132,18 +127,8 @@ export class RuntimeMessageRouter {
const { sandboxId, messageId } = e.data;
if (!sandboxId) return;
console.log(
"[ROUTER] Received message for sandbox:",
sandboxId,
"type:",
e.data.type,
"full message:",
e.data,
);
const context = this.sandboxes.get(sandboxId);
if (!context) {
console.log("[ROUTER] No context found for sandbox:", sandboxId);
return;
}
@ -161,19 +146,15 @@ export class RuntimeMessageRouter {
};
// 1. Try provider handlers first (for bidirectional comm)
console.log("[ROUTER] Broadcasting to", context.providers.length, "providers");
for (const provider of context.providers) {
if (provider.handleMessage) {
console.log("[ROUTER] Calling provider.handleMessage for", provider.constructor.name);
await provider.handleMessage(e.data, respond);
// Don't stop - let consumers also handle the message
}
}
// 2. Broadcast to consumers (one-way messages or lifecycle events)
console.log("[ROUTER] Broadcasting to", context.consumers.size, "consumers");
for (const consumer of context.consumers) {
console.log("[ROUTER] Calling consumer.handleMessage");
await consumer.handleMessage(e.data);
// Don't stop - let all consumers see the message
}
@ -186,7 +167,6 @@ export class RuntimeMessageRouter {
if (!this.userScriptMessageListener) {
// Guard: check if we're in extension context
if (typeof chrome === "undefined" || !chrome.runtime?.onUserScriptMessage) {
console.log("[RuntimeMessageRouter] User script API not available (not in extension context)");
return;
}
@ -197,8 +177,6 @@ export class RuntimeMessageRouter {
const context = this.sandboxes.get(sandboxId);
if (!context) return false;
console.log("Router received user script message for sandbox:", sandboxId, message);
const respond = (response: any) => {
sendResponse({
...response,
@ -227,7 +205,6 @@ export class RuntimeMessageRouter {
};
chrome.runtime.onUserScriptMessage.addListener(this.userScriptMessageListener);
console.log("[RuntimeMessageRouter] Registered chrome.runtime.onUserScriptMessage listener");
}
}
}