mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 14:05:08 +00:00
Refactor artifacts renderer and add Console component
- Extract ArtifactsToolRenderer from ArtifactsPanel into standalone renderer - Fix ChatPanel to register ArtifactsToolRenderer instead of panel - Implement command-specific rendering logic (create/update/rewrite/get/logs/delete) - Create reusable Console component with copy button and autoscroll toggle - Replace custom console implementation with ExpandableSection and Console - Fix Lit reactivity for HtmlArtifact logs using spread operator - Add Lucide icons (FileCode2, ChevronsDown, Lock) for UI consistency - Follow skill.ts patterns with renderHeader and state handling - Add i18n strings for all artifact actions and console features
This commit is contained in:
parent
a8159f504f
commit
8ec9805112
19 changed files with 716 additions and 526 deletions
|
|
@ -4,7 +4,7 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||||
import type { AgentInterface } from "./components/AgentInterface.js";
|
import type { AgentInterface } from "./components/AgentInterface.js";
|
||||||
import "./components/AgentInterface.js";
|
import "./components/AgentInterface.js";
|
||||||
import type { Agent } from "./agent/agent.js";
|
import type { Agent } from "./agent/agent.js";
|
||||||
import { ArtifactsPanel } from "./tools/artifacts/index.js";
|
import { ArtifactsPanel, ArtifactsToolRenderer } from "./tools/artifacts/index.js";
|
||||||
import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
|
import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
|
||||||
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
import { registerToolRenderer } from "./tools/renderer-registry.js";
|
||||||
import { i18n } from "./utils/i18n.js";
|
import { i18n } from "./utils/i18n.js";
|
||||||
|
|
@ -78,7 +78,8 @@ export class ChatPanel extends LitElement {
|
||||||
if (this.sandboxUrlProvider) {
|
if (this.sandboxUrlProvider) {
|
||||||
this.artifactsPanel.sandboxUrlProvider = this.sandboxUrlProvider;
|
this.artifactsPanel.sandboxUrlProvider = this.sandboxUrlProvider;
|
||||||
}
|
}
|
||||||
registerToolRenderer("artifacts", this.artifactsPanel);
|
// Register the standalone tool renderer (not the panel itself)
|
||||||
|
registerToolRenderer("artifacts", new ArtifactsToolRenderer());
|
||||||
|
|
||||||
// Attachments provider
|
// Attachments provider
|
||||||
const getAttachments = () => {
|
const getAttachments = () => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { i18n } from "../utils/i18n.js";
|
||||||
|
|
||||||
export class ConsoleBlock extends LitElement {
|
export class ConsoleBlock extends LitElement {
|
||||||
@property() content: string = "";
|
@property() content: string = "";
|
||||||
|
@property() variant: "default" | "error" = "default";
|
||||||
@state() private copied = false;
|
@state() private copied = false;
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||||
|
|
@ -38,6 +39,9 @@ export class ConsoleBlock extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
|
const isError = this.variant === "error";
|
||||||
|
const textClass = isError ? "text-destructive" : "text-foreground";
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="border border-border rounded-lg overflow-hidden">
|
<div class="border border-border rounded-lg overflow-hidden">
|
||||||
<div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
|
<div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
|
||||||
|
|
@ -52,7 +56,7 @@ export class ConsoleBlock extends LitElement {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="console-scroll overflow-auto max-h-64">
|
<div class="console-scroll overflow-auto max-h-64">
|
||||||
<pre class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs text-foreground font-mono whitespace-pre-wrap">
|
<pre class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs ${textClass} font-mono whitespace-pre-wrap">
|
||||||
${this.content || ""}</pre
|
${this.content || ""}</pre
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
46
packages/web-ui/src/components/ExpandableSection.ts
Normal file
46
packages/web-ui/src/components/ExpandableSection.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { icon } from "@mariozechner/mini-lit";
|
||||||
|
import { html, LitElement, type TemplateResult } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable expandable section component for tool renderers.
|
||||||
|
* Captures children in connectedCallback and re-renders them in the details area.
|
||||||
|
*/
|
||||||
|
@customElement("expandable-section")
|
||||||
|
export class ExpandableSection extends LitElement {
|
||||||
|
@property() summary!: string;
|
||||||
|
@property({ type: Boolean }) defaultExpanded = false;
|
||||||
|
@state() private expanded = false;
|
||||||
|
private capturedChildren: Node[] = [];
|
||||||
|
|
||||||
|
protected createRenderRoot() {
|
||||||
|
return this; // light DOM
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
// Capture children before first render
|
||||||
|
this.capturedChildren = Array.from(this.childNodes);
|
||||||
|
// Clear children (we'll re-insert them in render)
|
||||||
|
this.innerHTML = "";
|
||||||
|
this.expanded = this.defaultExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
override render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
@click=${() => {
|
||||||
|
this.expanded = !this.expanded;
|
||||||
|
}}
|
||||||
|
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
||||||
|
>
|
||||||
|
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
|
||||||
|
<span>${this.summary}</span>
|
||||||
|
</button>
|
||||||
|
${this.expanded ? html`<div class="mt-2">${this.capturedChildren}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,10 @@ import type {
|
||||||
ToolResultMessage as ToolResultMessageType,
|
ToolResultMessage as ToolResultMessageType,
|
||||||
UserMessage as UserMessageType,
|
UserMessage as UserMessageType,
|
||||||
} from "@mariozechner/pi-ai";
|
} from "@mariozechner/pi-ai";
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-ai/dist/agent/types.js";
|
|
||||||
import { LitElement, type TemplateResult } from "lit";
|
import { LitElement, type TemplateResult } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import { Bug, Loader, Wrench } from "lucide";
|
import { Bug, Loader, Wrench } from "lucide";
|
||||||
import { renderToolParams, renderToolResult } from "../tools/index.js";
|
import { renderTool } from "../tools/index.js";
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
import type { Attachment } from "../utils/attachment-utils.js";
|
||||||
import { formatUsage } from "../utils/format.js";
|
import { formatUsage } from "../utils/format.js";
|
||||||
import { i18n } from "../utils/i18n.js";
|
import { i18n } from "../utils/i18n.js";
|
||||||
|
|
@ -149,7 +148,7 @@ export class AssistantMessage extends LitElement {
|
||||||
@customElement("tool-message-debug")
|
@customElement("tool-message-debug")
|
||||||
export class ToolMessageDebugView extends LitElement {
|
export class ToolMessageDebugView extends LitElement {
|
||||||
@property({ type: Object }) callArgs: any;
|
@property({ type: Object }) callArgs: any;
|
||||||
@property({ type: String }) result?: AgentToolResult<any>;
|
@property({ type: Object }) result?: ToolResultMessageType;
|
||||||
@property({ type: Boolean }) hasResult: boolean = false;
|
@property({ type: Boolean }) hasResult: boolean = false;
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||||
|
|
@ -205,7 +204,6 @@ export class ToolMessage extends LitElement {
|
||||||
@property({ type: Boolean }) pending: boolean = false;
|
@property({ type: Boolean }) pending: boolean = false;
|
||||||
@property({ type: Boolean }) aborted: boolean = false;
|
@property({ type: Boolean }) aborted: boolean = false;
|
||||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
@property({ type: Boolean }) isStreaming: boolean = false;
|
||||||
@state() private _showDebug = false;
|
|
||||||
|
|
||||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||||
return this;
|
return this;
|
||||||
|
|
@ -216,94 +214,15 @@ export class ToolMessage extends LitElement {
|
||||||
this.style.display = "block";
|
this.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleDebug = () => {
|
|
||||||
this._showDebug = !this._showDebug;
|
|
||||||
};
|
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const toolLabel = this.tool?.label || this.toolCall.name;
|
|
||||||
const toolName = this.tool?.name || this.toolCall.name;
|
const toolName = this.tool?.name || this.toolCall.name;
|
||||||
const isError = this.result?.isError === true;
|
|
||||||
const hasResult = !!this.result;
|
|
||||||
|
|
||||||
let statusIcon: TemplateResult;
|
// Render tool content (renderer handles errors and styling)
|
||||||
if (this.pending || (this.isStreaming && !hasResult)) {
|
const toolContent = renderTool(toolName, this.toolCall.arguments, this.result, this.isStreaming || this.pending);
|
||||||
statusIcon = html`<span class="inline-block text-muted-foreground animate-spin">${icon(Loader, "sm")}</span>`;
|
|
||||||
} else if (this.aborted && !hasResult) {
|
|
||||||
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
|
|
||||||
} else if (hasResult && isError) {
|
|
||||||
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
|
|
||||||
} else if (hasResult) {
|
|
||||||
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
|
|
||||||
} else {
|
|
||||||
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize error text
|
|
||||||
let errorMessage = this.result?.output || "";
|
|
||||||
if (isError) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(errorMessage);
|
|
||||||
if ((parsed as any).error) errorMessage = (parsed as any).error;
|
|
||||||
else if ((parsed as any).message) errorMessage = (parsed as any).message;
|
|
||||||
} catch {}
|
|
||||||
errorMessage = errorMessage.replace(/^(Tool )?Error:\s*/i, "");
|
|
||||||
errorMessage = errorMessage.replace(/^Error:\s*/i, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const paramsTpl = renderToolParams(
|
|
||||||
toolName,
|
|
||||||
this.toolCall.arguments,
|
|
||||||
this.isStreaming || (this.pending && !hasResult),
|
|
||||||
);
|
|
||||||
const resultTpl =
|
|
||||||
hasResult && !isError ? renderToolResult(toolName, this.toolCall.arguments, this.result!) : undefined;
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground">
|
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground shadow-xs">
|
||||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
${toolContent}
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${statusIcon}
|
|
||||||
<span class="font-medium">${toolLabel}</span>
|
|
||||||
</div>
|
|
||||||
${Button({
|
|
||||||
variant: this._showDebug ? "default" : "ghost",
|
|
||||||
size: "sm",
|
|
||||||
onClick: this.toggleDebug,
|
|
||||||
children: icon(Bug, "sm"),
|
|
||||||
className: "text-muted-foreground",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${
|
|
||||||
this._showDebug
|
|
||||||
? html`<tool-message-debug
|
|
||||||
.callArgs=${this.toolCall.arguments}
|
|
||||||
.result=${this.result}
|
|
||||||
.hasResult=${!!this.result}
|
|
||||||
></tool-message-debug>`
|
|
||||||
: html`
|
|
||||||
<div class="mt-2 text-sm text-muted-foreground">${paramsTpl}</div>
|
|
||||||
${
|
|
||||||
this.pending && !hasResult
|
|
||||||
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Waiting for tool result…")}</div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${
|
|
||||||
this.aborted && !hasResult
|
|
||||||
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Call was aborted; no result.")}</div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${
|
|
||||||
hasResult && isError
|
|
||||||
? html`<div class="mt-2 p-2 border border-destructive rounded bg-destructive/10 text-sm text-destructive">
|
|
||||||
${errorMessage}
|
|
||||||
</div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
${resultTpl ? html`<div class="mt-2">${resultTpl}</div>` : ""}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export { ChatPanel } from "./ChatPanel.js";
|
||||||
export { AgentInterface } from "./components/AgentInterface.js";
|
export { AgentInterface } from "./components/AgentInterface.js";
|
||||||
export { AttachmentTile } from "./components/AttachmentTile.js";
|
export { AttachmentTile } from "./components/AttachmentTile.js";
|
||||||
export { ConsoleBlock } from "./components/ConsoleBlock.js";
|
export { ConsoleBlock } from "./components/ConsoleBlock.js";
|
||||||
|
export { ExpandableSection } from "./components/ExpandableSection.js";
|
||||||
export { Input } from "./components/Input.js";
|
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";
|
||||||
|
|
@ -60,13 +61,15 @@ export type {
|
||||||
// Artifacts
|
// Artifacts
|
||||||
export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
|
export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
|
||||||
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js";
|
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js";
|
||||||
|
export { ArtifactsToolRenderer } from "./tools/artifacts/artifacts-tool-renderer.js";
|
||||||
export { HtmlArtifact } from "./tools/artifacts/HtmlArtifact.js";
|
export { HtmlArtifact } from "./tools/artifacts/HtmlArtifact.js";
|
||||||
export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js";
|
export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js";
|
||||||
export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
|
export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
|
||||||
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
|
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
|
||||||
// Tools
|
// Tools
|
||||||
export { getToolRenderer, registerToolRenderer, renderToolParams, renderToolResult } from "./tools/index.js";
|
export { getToolRenderer, registerToolRenderer, renderTool } from "./tools/index.js";
|
||||||
export { createJavaScriptReplTool, javascriptReplTool } from "./tools/javascript-repl.js";
|
export { createJavaScriptReplTool, javascriptReplTool } from "./tools/javascript-repl.js";
|
||||||
|
export { renderHeader } from "./tools/renderer-registry.js";
|
||||||
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
|
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
|
||||||
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
|
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
|
||||||
// Tool renderers
|
// Tool renderers
|
||||||
|
|
|
||||||
93
packages/web-ui/src/tools/artifacts/Console.ts
Normal file
93
packages/web-ui/src/tools/artifacts/Console.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { icon } from "@mariozechner/mini-lit";
|
||||||
|
import "@mariozechner/mini-lit/dist/CopyButton.js";
|
||||||
|
import { html, LitElement, type TemplateResult } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
||||||
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
|
import { ChevronDown, ChevronRight, ChevronsDown, Lock } from "lucide";
|
||||||
|
import { i18n } from "../../utils/i18n.js";
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
type: "log" | "error";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement("artifact-console")
|
||||||
|
export class Console extends LitElement {
|
||||||
|
@property({ attribute: false }) logs: LogEntry[] = [];
|
||||||
|
@state() private expanded = false;
|
||||||
|
@state() private autoscroll = true;
|
||||||
|
private logsContainerRef: Ref<HTMLDivElement> = createRef();
|
||||||
|
|
||||||
|
protected createRenderRoot() {
|
||||||
|
return this; // light DOM
|
||||||
|
}
|
||||||
|
|
||||||
|
override updated() {
|
||||||
|
// Autoscroll to bottom when new logs arrive
|
||||||
|
if (this.autoscroll && this.expanded && this.logsContainerRef.value) {
|
||||||
|
this.logsContainerRef.value.scrollTop = this.logsContainerRef.value.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLogsText(): string {
|
||||||
|
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
override render(): TemplateResult {
|
||||||
|
const errorCount = this.logs.filter((l) => l.type === "error").length;
|
||||||
|
const summary =
|
||||||
|
errorCount > 0
|
||||||
|
? `${i18n("console")} (${errorCount} ${errorCount === 1 ? "error" : "errors"})`
|
||||||
|
: `${i18n("console")} (${this.logs.length})`;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="border-t border-border p-2">
|
||||||
|
<div class="flex items-center gap-2 w-full">
|
||||||
|
<button
|
||||||
|
@click=${() => {
|
||||||
|
this.expanded = !this.expanded;
|
||||||
|
}}
|
||||||
|
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors flex-1 text-left"
|
||||||
|
>
|
||||||
|
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
|
||||||
|
<span>${summary}</span>
|
||||||
|
</button>
|
||||||
|
${
|
||||||
|
this.expanded
|
||||||
|
? html`
|
||||||
|
<button
|
||||||
|
@click=${() => {
|
||||||
|
this.autoscroll = !this.autoscroll;
|
||||||
|
}}
|
||||||
|
class="p-1 rounded transition-colors ${this.autoscroll ? "bg-accent text-accent-foreground" : "hover:bg-muted"}"
|
||||||
|
title=${this.autoscroll ? i18n("Autoscroll enabled") : i18n("Autoscroll disabled")}
|
||||||
|
>
|
||||||
|
${icon(this.autoscroll ? ChevronsDown : Lock, "sm")}
|
||||||
|
</button>
|
||||||
|
<copy-button .text=${this.getLogsText()} title=${i18n("Copy logs")} .showText=${false} class="!bg-transparent hover:!bg-accent"></copy-button>
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
this.expanded
|
||||||
|
? html`
|
||||||
|
<div class="max-h-48 overflow-y-auto space-y-1 mt-2" ${ref(this.logsContainerRef)}>
|
||||||
|
${repeat(
|
||||||
|
this.logs,
|
||||||
|
(_log, index) => index,
|
||||||
|
(log) => html`
|
||||||
|
<div class="text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}">
|
||||||
|
[${log.type}] ${log.text}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ import type { Attachment } from "../../utils/attachment-utils.js";
|
||||||
import { i18n } from "../../utils/i18n.js";
|
import { i18n } from "../../utils/i18n.js";
|
||||||
import "../../components/SandboxedIframe.js";
|
import "../../components/SandboxedIframe.js";
|
||||||
import { ArtifactElement } from "./ArtifactElement.js";
|
import { ArtifactElement } from "./ArtifactElement.js";
|
||||||
|
import type { Console } from "./Console.js";
|
||||||
|
import "./Console.js";
|
||||||
|
|
||||||
@customElement("html-artifact")
|
@customElement("html-artifact")
|
||||||
export class HtmlArtifact extends ArtifactElement {
|
export class HtmlArtifact extends ArtifactElement {
|
||||||
|
|
@ -22,14 +24,12 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
|
|
||||||
// Refs for DOM elements
|
// Refs for DOM elements
|
||||||
private sandboxIframeRef: Ref<SandboxIframe> = createRef();
|
private sandboxIframeRef: Ref<SandboxIframe> = createRef();
|
||||||
private consoleLogsRef: Ref<HTMLDivElement> = createRef();
|
private consoleRef: Ref<Console> = createRef();
|
||||||
private consoleButtonRef: Ref<HTMLButtonElement> = createRef();
|
|
||||||
|
|
||||||
// Store message handler so we can remove it
|
// Store message handler so we can remove it
|
||||||
private messageHandler?: (e: MessageEvent) => void;
|
private messageHandler?: (e: MessageEvent) => void;
|
||||||
|
|
||||||
@state() private viewMode: "preview" | "code" = "preview";
|
@state() private viewMode: "preview" | "code" = "preview";
|
||||||
@state() private consoleOpen = false;
|
|
||||||
|
|
||||||
private setViewMode(mode: "preview" | "code") {
|
private setViewMode(mode: "preview" | "code") {
|
||||||
this.viewMode = mode;
|
this.viewMode = mode;
|
||||||
|
|
@ -62,13 +62,9 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
if (oldValue !== value) {
|
if (oldValue !== value) {
|
||||||
// Reset logs when content changes
|
// Reset logs when content changes
|
||||||
this.logs = [];
|
this.logs = [];
|
||||||
if (this.consoleLogsRef.value) {
|
|
||||||
this.consoleLogsRef.value.innerHTML = "";
|
|
||||||
}
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
// Execute content in sandbox if it exists
|
// Execute content in sandbox if it exists
|
||||||
if (this.sandboxIframeRef.value && value) {
|
if (this.sandboxIframeRef.value && value) {
|
||||||
this.updateConsoleButton();
|
|
||||||
this.executeContent(value);
|
this.executeContent(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,11 +91,15 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
if (e.data.sandboxId !== sandboxId) return;
|
if (e.data.sandboxId !== sandboxId) return;
|
||||||
|
|
||||||
if (e.data.type === "console") {
|
if (e.data.type === "console") {
|
||||||
this.logs.push({
|
// Create new array reference for Lit reactivity
|
||||||
type: e.data.method === "error" ? "error" : "log",
|
this.logs = [
|
||||||
text: e.data.text,
|
...this.logs,
|
||||||
});
|
{
|
||||||
this.updateConsoleButton();
|
type: e.data.method === "error" ? "error" : "log",
|
||||||
|
text: e.data.text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
this.requestUpdate(); // Re-render to show console
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("message", this.messageHandler);
|
window.addEventListener("message", this.messageHandler);
|
||||||
|
|
@ -137,39 +137,6 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateConsoleButton() {
|
|
||||||
const button = this.consoleButtonRef.value;
|
|
||||||
if (!button) return;
|
|
||||||
|
|
||||||
const errorCount = this.logs.filter((l) => l.type === "error").length;
|
|
||||||
const text =
|
|
||||||
errorCount > 0
|
|
||||||
? `${i18n("console")} <span class="text-destructive">${errorCount} errors</span>`
|
|
||||||
: `${i18n("console")} (${this.logs.length})`;
|
|
||||||
button.innerHTML = `<span>${text}</span><span>${this.consoleOpen ? "▼" : "▶"}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private toggleConsole() {
|
|
||||||
this.consoleOpen = !this.consoleOpen;
|
|
||||||
this.requestUpdate();
|
|
||||||
|
|
||||||
// Populate console logs if opening
|
|
||||||
if (this.consoleOpen) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (this.consoleLogsRef.value) {
|
|
||||||
// Populate with existing logs
|
|
||||||
this.consoleLogsRef.value.innerHTML = "";
|
|
||||||
this.logs.forEach((log) => {
|
|
||||||
const logEl = document.createElement("div");
|
|
||||||
logEl.className = `text-xs font-mono ${log.type === "error" ? "text-destructive" : "text-muted-foreground"}`;
|
|
||||||
logEl.textContent = `[${log.type}] ${log.text}`;
|
|
||||||
this.consoleLogsRef.value!.appendChild(logEl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getLogs(): string {
|
public getLogs(): string {
|
||||||
if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename);
|
if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename);
|
||||||
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
|
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
|
||||||
|
|
@ -184,26 +151,7 @@ export class HtmlArtifact extends ArtifactElement {
|
||||||
<sandbox-iframe class="flex-1" ${ref(this.sandboxIframeRef)}></sandbox-iframe>
|
<sandbox-iframe class="flex-1" ${ref(this.sandboxIframeRef)}></sandbox-iframe>
|
||||||
${
|
${
|
||||||
this.logs.length > 0
|
this.logs.length > 0
|
||||||
? html`
|
? html`<artifact-console .logs=${this.logs} ${ref(this.consoleRef)}></artifact-console>`
|
||||||
<div class="border-t border-border">
|
|
||||||
<button
|
|
||||||
@click=${() => this.toggleConsole()}
|
|
||||||
class="w-full px-3 py-1 text-xs text-left hover:bg-muted flex items-center justify-between"
|
|
||||||
${ref(this.consoleButtonRef)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
>${i18n("console")}
|
|
||||||
${
|
|
||||||
this.logs.filter((l) => l.type === "error").length > 0
|
|
||||||
? html`<span class="text-destructive">${this.logs.filter((l) => l.type === "error").length} errors</span>`
|
|
||||||
: `(${this.logs.length})`
|
|
||||||
}</span
|
|
||||||
>
|
|
||||||
<span>${this.consoleOpen ? "▼" : "▶"}</span>
|
|
||||||
</button>
|
|
||||||
${this.consoleOpen ? html` <div class="max-h-48 overflow-y-auto bg-muted/50 p-2" ${ref(this.consoleLogsRef)}></div> ` : ""}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
209
packages/web-ui/src/tools/artifacts/artifacts-tool-renderer.ts
Normal file
209
packages/web-ui/src/tools/artifacts/artifacts-tool-renderer.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { Diff, html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||||
|
import "@mariozechner/mini-lit/dist/CodeBlock.js";
|
||||||
|
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { FileCode2 } from "lucide";
|
||||||
|
import "../../components/ConsoleBlock.js";
|
||||||
|
import { i18n } from "../../utils/i18n.js";
|
||||||
|
import { renderHeader } from "../renderer-registry.js";
|
||||||
|
import type { ToolRenderer } from "../types.js";
|
||||||
|
import type { ArtifactsParams } from "./artifacts.js";
|
||||||
|
|
||||||
|
// Helper to determine language for syntax highlighting
|
||||||
|
function getLanguageFromFilename(filename?: string): string {
|
||||||
|
if (!filename) return "text";
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase();
|
||||||
|
const languageMap: Record<string, string> = {
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
html: "html",
|
||||||
|
css: "css",
|
||||||
|
scss: "scss",
|
||||||
|
json: "json",
|
||||||
|
py: "python",
|
||||||
|
md: "markdown",
|
||||||
|
svg: "xml",
|
||||||
|
xml: "xml",
|
||||||
|
yaml: "yaml",
|
||||||
|
yml: "yaml",
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
sql: "sql",
|
||||||
|
java: "java",
|
||||||
|
c: "c",
|
||||||
|
cpp: "cpp",
|
||||||
|
cs: "csharp",
|
||||||
|
go: "go",
|
||||||
|
rs: "rust",
|
||||||
|
php: "php",
|
||||||
|
rb: "ruby",
|
||||||
|
swift: "swift",
|
||||||
|
kt: "kotlin",
|
||||||
|
r: "r",
|
||||||
|
};
|
||||||
|
return languageMap[ext || ""] || "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, undefined> {
|
||||||
|
render(
|
||||||
|
params: ArtifactsParams | undefined,
|
||||||
|
result: ToolResultMessage<undefined> | undefined,
|
||||||
|
isStreaming?: boolean,
|
||||||
|
): TemplateResult {
|
||||||
|
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
|
||||||
|
|
||||||
|
// Helper to get command labels
|
||||||
|
const getCommandLabels = (command: string): { streaming: string; complete: string } => {
|
||||||
|
const labels: Record<string, { streaming: string; complete: string }> = {
|
||||||
|
create: { streaming: i18n("Creating artifact"), complete: i18n("Created artifact") },
|
||||||
|
update: { streaming: i18n("Updating artifact"), complete: i18n("Updated artifact") },
|
||||||
|
rewrite: { streaming: i18n("Rewriting artifact"), complete: i18n("Rewrote artifact") },
|
||||||
|
get: { streaming: i18n("Getting artifact"), complete: i18n("Got artifact") },
|
||||||
|
delete: { streaming: i18n("Deleting artifact"), complete: i18n("Deleted artifact") },
|
||||||
|
logs: { streaming: i18n("Getting logs"), complete: i18n("Got logs") },
|
||||||
|
};
|
||||||
|
return labels[command] || { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
if (result?.isError) {
|
||||||
|
const command = params?.command;
|
||||||
|
const filename = params?.filename;
|
||||||
|
const labels = command
|
||||||
|
? getCommandLabels(command)
|
||||||
|
: { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") };
|
||||||
|
const headerText = filename ? `${labels.streaming} ${filename}` : labels.streaming;
|
||||||
|
|
||||||
|
// For create/update/rewrite errors, show code block + console/error
|
||||||
|
if (command === "create" || command === "update" || command === "rewrite") {
|
||||||
|
const content = command === "update" ? params?.new_str || params?.old_str || "" : params?.content || "";
|
||||||
|
|
||||||
|
const isHtml = filename?.endsWith(".html");
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, FileCode2, headerText)}
|
||||||
|
${content ? html`<code-block .code=${content} language=${getLanguageFromFilename(filename)}></code-block>` : ""}
|
||||||
|
${
|
||||||
|
isHtml
|
||||||
|
? html`<console-block .content=${result.output || i18n("An error occurred")} variant="error"></console-block>`
|
||||||
|
: html`<div class="text-sm text-destructive">${result.output || i18n("An error occurred")}</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, just show error message
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, FileCode2, headerText)}
|
||||||
|
<div class="text-sm text-destructive">${result.output || i18n("An error occurred")}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full params + result
|
||||||
|
if (result && params) {
|
||||||
|
const { command, filename, content } = params;
|
||||||
|
const labels = command
|
||||||
|
? getCommandLabels(command)
|
||||||
|
: { streaming: i18n("Processing artifact"), complete: i18n("Processed artifact") };
|
||||||
|
const headerText = filename ? `${labels.complete} ${filename}` : labels.complete;
|
||||||
|
|
||||||
|
// GET command: show code block with file content
|
||||||
|
if (command === "get") {
|
||||||
|
const fileContent = result.output || i18n("(no output)");
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, FileCode2, headerText)}
|
||||||
|
<code-block .code=${fileContent} language=${getLanguageFromFilename(filename)}></code-block>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGS command: show console block
|
||||||
|
if (command === "logs") {
|
||||||
|
const logs = result.output || i18n("(no output)");
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, FileCode2, headerText)}
|
||||||
|
<console-block .content=${logs}></console-block>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE/UPDATE/REWRITE: always show code block, + console block for .html files
|
||||||
|
if (command === "create" || command === "update" || command === "rewrite") {
|
||||||
|
const codeContent = content || "";
|
||||||
|
const isHtml = filename?.endsWith(".html");
|
||||||
|
const logs = result.output || "";
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, FileCode2, headerText)}
|
||||||
|
${codeContent ? html`<code-block .code=${codeContent} language=${getLanguageFromFilename(filename)}></code-block>` : ""}
|
||||||
|
${isHtml && logs ? html`<console-block .content=${logs}></console-block>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DELETE, just show header
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, FileCode2, headerText)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params only (streaming or waiting for result)
|
||||||
|
if (params) {
|
||||||
|
const { command, filename, content, old_str, new_str } = params;
|
||||||
|
|
||||||
|
// If no command yet
|
||||||
|
if (!command) {
|
||||||
|
return renderHeader(state, FileCode2, i18n("Preparing artifact..."));
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = getCommandLabels(command);
|
||||||
|
const headerText = filename ? `${labels.streaming} ${filename}` : labels.streaming;
|
||||||
|
|
||||||
|
// Render based on command type
|
||||||
|
switch (command) {
|
||||||
|
case "create":
|
||||||
|
case "rewrite":
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, FileCode2, headerText)}
|
||||||
|
${
|
||||||
|
content
|
||||||
|
? html`<code-block .code=${content} language=${getLanguageFromFilename(filename)} class="mt-2"></code-block>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
case "update":
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, FileCode2, headerText)}
|
||||||
|
${
|
||||||
|
old_str !== undefined && new_str !== undefined
|
||||||
|
? Diff({ oldText: old_str, newText: new_str, className: "mt-2" })
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
case "get":
|
||||||
|
case "delete":
|
||||||
|
case "logs":
|
||||||
|
default:
|
||||||
|
return renderHeader(state, FileCode2, headerText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No params or result yet
|
||||||
|
return renderHeader(state, FileCode2, i18n("Preparing artifact..."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Button, Diff, icon } from "@mariozechner/mini-lit";
|
import { Button, icon } from "@mariozechner/mini-lit";
|
||||||
import { type AgentTool, type Message, StringEnum, type ToolCall, type ToolResultMessage } from "@mariozechner/pi-ai";
|
import { type AgentTool, type Message, StringEnum, type ToolCall, type ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import { html, LitElement, type TemplateResult } from "lit";
|
import { html, LitElement, type TemplateResult } from "lit";
|
||||||
|
|
@ -7,14 +7,12 @@ import { createRef, type Ref, ref } from "lit/directives/ref.js";
|
||||||
import { X } from "lucide";
|
import { X } from "lucide";
|
||||||
import type { Attachment } from "../../utils/attachment-utils.js";
|
import type { Attachment } from "../../utils/attachment-utils.js";
|
||||||
import { i18n } from "../../utils/i18n.js";
|
import { i18n } from "../../utils/i18n.js";
|
||||||
import type { ToolRenderer } from "../types.js";
|
|
||||||
import type { ArtifactElement } from "./ArtifactElement.js";
|
import type { ArtifactElement } from "./ArtifactElement.js";
|
||||||
import { HtmlArtifact } from "./HtmlArtifact.js";
|
import { HtmlArtifact } from "./HtmlArtifact.js";
|
||||||
import { MarkdownArtifact } from "./MarkdownArtifact.js";
|
import { MarkdownArtifact } from "./MarkdownArtifact.js";
|
||||||
import { SvgArtifact } from "./SvgArtifact.js";
|
import { SvgArtifact } from "./SvgArtifact.js";
|
||||||
import { TextArtifact } from "./TextArtifact.js";
|
import { TextArtifact } from "./TextArtifact.js";
|
||||||
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
|
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
|
||||||
import "@mariozechner/mini-lit/dist/CodeBlock.js";
|
|
||||||
|
|
||||||
// Simple artifact model
|
// Simple artifact model
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
|
|
@ -38,13 +36,8 @@ const artifactsParamsSchema = Type.Object({
|
||||||
});
|
});
|
||||||
export type ArtifactsParams = Static<typeof artifactsParamsSchema>;
|
export type ArtifactsParams = Static<typeof artifactsParamsSchema>;
|
||||||
|
|
||||||
// Minimal helper to render plain text outputs consistently
|
|
||||||
function plainOutput(text: string): TemplateResult {
|
|
||||||
return html`<div class="text-xs text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement("artifacts-panel")
|
@customElement("artifacts-panel")
|
||||||
export class ArtifactsPanel extends LitElement implements ToolRenderer<ArtifactsParams, undefined> {
|
export class ArtifactsPanel extends LitElement {
|
||||||
@state() private _artifacts = new Map<string, Artifact>();
|
@state() private _artifacts = new Map<string, Artifact>();
|
||||||
@state() private _activeFilename: string | null = null;
|
@state() private _activeFilename: string | null = null;
|
||||||
|
|
||||||
|
|
@ -107,43 +100,6 @@ export class ArtifactsPanel extends LitElement implements ToolRenderer<Artifacts
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to determine language for syntax highlighting
|
|
||||||
private getLanguageFromFilename(filename?: string): string {
|
|
||||||
if (!filename) return "text";
|
|
||||||
const ext = filename.split(".").pop()?.toLowerCase();
|
|
||||||
const languageMap: Record<string, string> = {
|
|
||||||
js: "javascript",
|
|
||||||
jsx: "javascript",
|
|
||||||
ts: "typescript",
|
|
||||||
tsx: "typescript",
|
|
||||||
html: "html",
|
|
||||||
css: "css",
|
|
||||||
scss: "scss",
|
|
||||||
json: "json",
|
|
||||||
py: "python",
|
|
||||||
md: "markdown",
|
|
||||||
svg: "xml",
|
|
||||||
xml: "xml",
|
|
||||||
yaml: "yaml",
|
|
||||||
yml: "yaml",
|
|
||||||
sh: "bash",
|
|
||||||
bash: "bash",
|
|
||||||
sql: "sql",
|
|
||||||
java: "java",
|
|
||||||
c: "c",
|
|
||||||
cpp: "cpp",
|
|
||||||
cs: "csharp",
|
|
||||||
go: "go",
|
|
||||||
rs: "rust",
|
|
||||||
php: "php",
|
|
||||||
rb: "ruby",
|
|
||||||
swift: "swift",
|
|
||||||
kt: "kotlin",
|
|
||||||
r: "r",
|
|
||||||
};
|
|
||||||
return languageMap[ext || ""] || "text";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create artifact element
|
// Get or create artifact element
|
||||||
private getOrCreateArtifactElement(filename: string, content: string, title: string): ArtifactElement {
|
private getOrCreateArtifactElement(filename: string, content: string, title: string): ArtifactElement {
|
||||||
let element = this.artifactElements.get(filename);
|
let element = this.artifactElements.get(filename);
|
||||||
|
|
@ -330,153 +286,6 @@ CRITICAL REMINDER FOR ALL ARTIFACTS:
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolRenderer implementation
|
|
||||||
renderParams(params: ArtifactsParams, isStreaming?: boolean): TemplateResult {
|
|
||||||
if (isStreaming && !params.command) {
|
|
||||||
return html`<div class="text-sm text-muted-foreground">${i18n("Processing artifact...")}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let commandLabel = i18n("Processing");
|
|
||||||
if (params.command) {
|
|
||||||
switch (params.command) {
|
|
||||||
case "create":
|
|
||||||
commandLabel = i18n("Create");
|
|
||||||
break;
|
|
||||||
case "update":
|
|
||||||
commandLabel = i18n("Update");
|
|
||||||
break;
|
|
||||||
case "rewrite":
|
|
||||||
commandLabel = i18n("Rewrite");
|
|
||||||
break;
|
|
||||||
case "get":
|
|
||||||
commandLabel = i18n("Get");
|
|
||||||
break;
|
|
||||||
case "delete":
|
|
||||||
commandLabel = i18n("Delete");
|
|
||||||
break;
|
|
||||||
case "logs":
|
|
||||||
commandLabel = i18n("Get logs");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
commandLabel = params.command.charAt(0).toUpperCase() + params.command.slice(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const filename = params.filename || "";
|
|
||||||
|
|
||||||
switch (params.command) {
|
|
||||||
case "create":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">${i18n("Create")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
params.content
|
|
||||||
? html`<code-block
|
|
||||||
.code=${params.content}
|
|
||||||
language=${this.getLanguageFromFilename(params.filename)}
|
|
||||||
class="mt-2"
|
|
||||||
></code-block>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "update":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">${i18n("Update")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
params.old_str !== undefined && params.new_str !== undefined
|
|
||||||
? Diff({ oldText: params.old_str, newText: params.new_str, className: "mt-2" })
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "rewrite":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">${i18n("Rewrite")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
params.content
|
|
||||||
? html`<code-block
|
|
||||||
.code=${params.content}
|
|
||||||
language=${this.getLanguageFromFilename(params.filename)}
|
|
||||||
class="mt-2"
|
|
||||||
></code-block>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "get":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-medium">${i18n("Get")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "delete":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-medium">${i18n("Delete")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
case "logs":
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-medium">${i18n("Get logs")}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
default:
|
|
||||||
// Fallback for any command not yet handled during streaming
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="text-sm cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1"
|
|
||||||
@click=${() => this.openArtifact(params.filename)}
|
|
||||||
>
|
|
||||||
<span class="font-medium">${commandLabel}</span>
|
|
||||||
<span class="text-muted-foreground ml-1">${filename}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(params: ArtifactsParams, result: ToolResultMessage<undefined>): TemplateResult {
|
|
||||||
// Make result clickable to focus the referenced file when applicable
|
|
||||||
const content = result.output || i18n("(no output)");
|
|
||||||
return html`
|
|
||||||
<div class="cursor-pointer hover:bg-muted/50 rounded-sm px-2 py-1" @click=${() => this.openArtifact(params.filename)}>
|
|
||||||
${plainOutput(content)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-apply artifacts by scanning a message list (optional utility)
|
// Re-apply artifacts by scanning a message list (optional utility)
|
||||||
public async reconstructFromMessages(messages: Array<Message | { role: "aborted" }>): Promise<void> {
|
public async reconstructFromMessages(messages: Array<Message | { role: "aborted" }>): Promise<void> {
|
||||||
const toolCalls = new Map<string, ToolCall>();
|
const toolCalls = new Map<string, ToolCall>();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { ArtifactElement } from "./ArtifactElement.js";
|
export { ArtifactElement } from "./ArtifactElement.js";
|
||||||
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./artifacts.js";
|
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./artifacts.js";
|
||||||
|
export { ArtifactsToolRenderer } from "./artifacts-tool-renderer.js";
|
||||||
export { HtmlArtifact } from "./HtmlArtifact.js";
|
export { HtmlArtifact } from "./HtmlArtifact.js";
|
||||||
export { MarkdownArtifact } from "./MarkdownArtifact.js";
|
export { MarkdownArtifact } from "./MarkdownArtifact.js";
|
||||||
export { SvgArtifact } from "./SvgArtifact.js";
|
export { SvgArtifact } from "./SvgArtifact.js";
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,19 @@ registerToolRenderer("bash", new BashRenderer());
|
||||||
const defaultRenderer = new DefaultRenderer();
|
const defaultRenderer = new DefaultRenderer();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render tool call parameters
|
* Render tool - unified function that handles params, result, and streaming state
|
||||||
*/
|
*/
|
||||||
export function renderToolParams(toolName: string, params: any, isStreaming?: boolean): TemplateResult {
|
export function renderTool(
|
||||||
|
toolName: string,
|
||||||
|
params: any | undefined,
|
||||||
|
result: ToolResultMessage | undefined,
|
||||||
|
isStreaming?: boolean,
|
||||||
|
): TemplateResult {
|
||||||
const renderer = getToolRenderer(toolName);
|
const renderer = getToolRenderer(toolName);
|
||||||
if (renderer) {
|
if (renderer) {
|
||||||
return renderer.renderParams(params, isStreaming);
|
return renderer.render(params, result, isStreaming);
|
||||||
}
|
}
|
||||||
return defaultRenderer.renderParams(params, isStreaming);
|
return defaultRenderer.render(params, result, isStreaming);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render tool result
|
|
||||||
*/
|
|
||||||
export function renderToolResult(toolName: string, params: any, result: ToolResultMessage): TemplateResult {
|
|
||||||
const renderer = getToolRenderer(toolName);
|
|
||||||
if (renderer) {
|
|
||||||
return renderer.renderResult(params, result);
|
|
||||||
}
|
|
||||||
return defaultRenderer.renderResult(params, result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { registerToolRenderer, getToolRenderer };
|
export { registerToolRenderer, getToolRenderer };
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { html, i18n, type TemplateResult } from "@mariozechner/mini-lit";
|
import { html, i18n, type TemplateResult } from "@mariozechner/mini-lit";
|
||||||
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
|
import { Code } from "lucide";
|
||||||
import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js";
|
import { type SandboxFile, SandboxIframe, type SandboxResult } from "../components/SandboxedIframe.js";
|
||||||
import type { Attachment } from "../utils/attachment-utils.js";
|
import type { Attachment } from "../utils/attachment-utils.js";
|
||||||
|
|
||||||
import { registerToolRenderer } from "./renderer-registry.js";
|
import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
|
||||||
import type { ToolRenderer } from "./types.js";
|
import type { ToolRenderer } from "./types.js";
|
||||||
|
|
||||||
// Execute JavaScript code with attachments using SandboxedIframe
|
// Execute JavaScript code with attachments using SandboxedIframe
|
||||||
|
|
@ -92,6 +93,7 @@ export type JavaScriptReplToolResult = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const javascriptReplSchema = Type.Object({
|
const javascriptReplSchema = Type.Object({
|
||||||
|
title: Type.String({ description: "Brief title describing what the code snippet tries to achieve" }),
|
||||||
code: Type.String({ description: "JavaScript code to execute" }),
|
code: Type.String({ description: "JavaScript code to execute" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -245,63 +247,76 @@ interface JavaScriptReplResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const javascriptReplRenderer: ToolRenderer<JavaScriptReplParams, JavaScriptReplResult> = {
|
export const javascriptReplRenderer: ToolRenderer<JavaScriptReplParams, JavaScriptReplResult> = {
|
||||||
renderParams(params: JavaScriptReplParams, isStreaming?: boolean): TemplateResult {
|
render(
|
||||||
if (isStreaming && (!params.code || params.code.length === 0)) {
|
params: JavaScriptReplParams | undefined,
|
||||||
return html`<div class="text-sm text-muted-foreground">Writing JavaScript code...</div>`;
|
result: ToolResultMessage<JavaScriptReplResult> | undefined,
|
||||||
|
isStreaming?: boolean,
|
||||||
|
): TemplateResult {
|
||||||
|
// Determine status
|
||||||
|
const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "inprogress";
|
||||||
|
|
||||||
|
// With result: show params + result
|
||||||
|
if (result && params) {
|
||||||
|
const output = result.output || "";
|
||||||
|
const files = result.details?.files || [];
|
||||||
|
|
||||||
|
const attachments: Attachment[] = files.map((f, i) => {
|
||||||
|
// Decode base64 content for text files to show in overlay
|
||||||
|
let extractedText: string | undefined;
|
||||||
|
const isTextBased =
|
||||||
|
f.mimeType?.startsWith("text/") ||
|
||||||
|
f.mimeType === "application/json" ||
|
||||||
|
f.mimeType === "application/javascript" ||
|
||||||
|
f.mimeType?.includes("xml");
|
||||||
|
|
||||||
|
if (isTextBased && f.contentBase64) {
|
||||||
|
try {
|
||||||
|
extractedText = atob(f.contentBase64);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to decode base64 content for", f.fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `repl-${Date.now()}-${i}`,
|
||||||
|
type: f.mimeType?.startsWith("image/") ? "image" : "document",
|
||||||
|
fileName: f.fileName || `file-${i}`,
|
||||||
|
mimeType: f.mimeType || "application/octet-stream",
|
||||||
|
size: f.size ?? 0,
|
||||||
|
content: f.contentBase64,
|
||||||
|
preview: f.mimeType?.startsWith("image/") ? f.contentBase64 : undefined,
|
||||||
|
extractedText,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, Code, i18n("Executing JavaScript"))}
|
||||||
|
<code-block .code=${params.code || ""} language="javascript"></code-block>
|
||||||
|
${output ? html`<console-block .content=${output} .variant=${result.isError ? "error" : "default"}></console-block>` : ""}
|
||||||
|
${
|
||||||
|
attachments.length
|
||||||
|
? html`<div class="flex flex-wrap gap-2">
|
||||||
|
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
|
||||||
|
</div>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
// Just params (streaming or waiting for result)
|
||||||
<div class="text-sm text-muted-foreground mb-2">${i18n("Executing JavaScript")}</div>
|
if (params) {
|
||||||
<code-block .code=${params.code || ""} language="javascript"></code-block>
|
return html`
|
||||||
`;
|
<div class="space-y-3">
|
||||||
},
|
${renderHeader(state, Code, i18n("Executing JavaScript"))}
|
||||||
|
${params.code ? html`<code-block .code=${params.code} language="javascript"></code-block>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
renderResult(_params: JavaScriptReplParams, result: ToolResultMessage<JavaScriptReplResult>): TemplateResult {
|
// No params or result yet
|
||||||
// Console output is in the main output field, files are in details
|
return renderHeader(state, Code, i18n("Preparing JavaScript..."));
|
||||||
const output = result.output || "";
|
|
||||||
const files = result.details?.files || [];
|
|
||||||
|
|
||||||
const attachments: Attachment[] = files.map((f, i) => {
|
|
||||||
// Decode base64 content for text files to show in overlay
|
|
||||||
let extractedText: string | undefined;
|
|
||||||
const isTextBased =
|
|
||||||
f.mimeType?.startsWith("text/") ||
|
|
||||||
f.mimeType === "application/json" ||
|
|
||||||
f.mimeType === "application/javascript" ||
|
|
||||||
f.mimeType?.includes("xml");
|
|
||||||
|
|
||||||
if (isTextBased && f.contentBase64) {
|
|
||||||
try {
|
|
||||||
extractedText = atob(f.contentBase64);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to decode base64 content for", f.fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `repl-${Date.now()}-${i}`,
|
|
||||||
type: f.mimeType?.startsWith("image/") ? "image" : "document",
|
|
||||||
fileName: f.fileName || `file-${i}`,
|
|
||||||
mimeType: f.mimeType || "application/octet-stream",
|
|
||||||
size: f.size ?? 0,
|
|
||||||
content: f.contentBase64,
|
|
||||||
preview: f.mimeType?.startsWith("image/") ? f.contentBase64 : undefined,
|
|
||||||
extractedText,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
${output ? html`<console-block .content=${output}></console-block>` : ""}
|
|
||||||
${
|
|
||||||
attachments.length
|
|
||||||
? html`<div class="flex flex-wrap gap-2">
|
|
||||||
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
|
|
||||||
</div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { html, icon, type TemplateResult } from "@mariozechner/mini-lit";
|
||||||
|
import { Loader } from "lucide";
|
||||||
import type { ToolRenderer } from "./types.js";
|
import type { ToolRenderer } from "./types.js";
|
||||||
|
|
||||||
// Registry of tool renderers
|
// Registry of tool renderers
|
||||||
|
|
@ -16,3 +18,39 @@ export function registerToolRenderer(toolName: string, renderer: ToolRenderer):
|
||||||
export function getToolRenderer(toolName: string): ToolRenderer | undefined {
|
export function getToolRenderer(toolName: string): ToolRenderer | undefined {
|
||||||
return toolRenderers.get(toolName);
|
return toolRenderers.get(toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to render a header for tool renderers
|
||||||
|
* Shows icon on left when complete/error, spinner on right when in progress
|
||||||
|
*/
|
||||||
|
export function renderHeader(state: "inprogress" | "complete" | "error", toolIcon: any, text: string): TemplateResult {
|
||||||
|
const statusIcon = (iconComponent: any, color: string) =>
|
||||||
|
html`<span class="inline-block ${color}">${icon(iconComponent, "sm")}</span>`;
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case "inprogress":
|
||||||
|
return html`
|
||||||
|
<div class="flex items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${statusIcon(toolIcon, "text-foreground")}
|
||||||
|
<span>${text}</span>
|
||||||
|
</div>
|
||||||
|
${statusIcon(Loader, "text-foreground animate-spin")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case "complete":
|
||||||
|
return html`
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
${statusIcon(toolIcon, "text-green-600 dark:text-green-500")}
|
||||||
|
<span>${text}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case "error":
|
||||||
|
return html`
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
${statusIcon(toolIcon, "text-destructive")}
|
||||||
|
<span>${text}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { SquareTerminal } from "lucide";
|
||||||
import { i18n } from "../../utils/i18n.js";
|
import { i18n } from "../../utils/i18n.js";
|
||||||
|
import { renderHeader } from "../renderer-registry.js";
|
||||||
import type { ToolRenderer } from "../types.js";
|
import type { ToolRenderer } from "../types.js";
|
||||||
|
|
||||||
interface BashParams {
|
interface BashParams {
|
||||||
|
|
@ -9,37 +11,32 @@ interface BashParams {
|
||||||
|
|
||||||
// Bash tool has undefined details (only uses output)
|
// Bash tool has undefined details (only uses output)
|
||||||
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
|
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
|
||||||
renderParams(params: BashParams, isStreaming?: boolean): TemplateResult {
|
render(params: BashParams | undefined, result: ToolResultMessage<undefined> | undefined): TemplateResult {
|
||||||
if (isStreaming && (!params.command || params.command.length === 0)) {
|
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
|
||||||
return html`<div class="text-sm text-muted-foreground">${i18n("Writing command...")}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
// With result: show command + output
|
||||||
<div class="text-sm text-muted-foreground">
|
if (result && params?.command) {
|
||||||
<span>${i18n("Running command:")}</span>
|
const output = result.output || "";
|
||||||
<code class="ml-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.command}</code>
|
const combined = output ? `> ${params.command}\n\n${output}` : `> ${params.command}`;
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(_params: BashParams, result: ToolResultMessage<undefined>): TemplateResult {
|
|
||||||
const output = result.output || "";
|
|
||||||
const isError = result.isError === true;
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return html`
|
return html`
|
||||||
<div class="text-sm">
|
<div class="space-y-3">
|
||||||
<div class="text-destructive font-medium mb-1">${i18n("Command failed:")}</div>
|
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
|
||||||
<pre class="text-xs font-mono text-destructive bg-destructive/10 p-2 rounded overflow-x-auto">${output}</pre>
|
<console-block .content=${combined} .variant=${result.isError ? "error" : "default"}></console-block>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display the command output
|
// Just params (streaming or waiting)
|
||||||
return html`
|
if (params?.command) {
|
||||||
<div class="text-sm">
|
return html`
|
||||||
<pre class="text-xs font-mono text-foreground bg-muted/50 p-2 rounded overflow-x-auto">${output}</pre>
|
<div class="space-y-3">
|
||||||
</div>
|
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
|
||||||
`;
|
<console-block .content=${`> ${params.command}`}></console-block>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No params yet
|
||||||
|
return renderHeader(state, SquareTerminal, i18n("Waiting for command..."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { Calculator } from "lucide";
|
||||||
import { i18n } from "../../utils/i18n.js";
|
import { i18n } from "../../utils/i18n.js";
|
||||||
|
import { renderHeader } from "../renderer-registry.js";
|
||||||
import type { ToolRenderer } from "../types.js";
|
import type { ToolRenderer } from "../types.js";
|
||||||
|
|
||||||
interface CalculateParams {
|
interface CalculateParams {
|
||||||
|
|
@ -9,41 +11,38 @@ interface CalculateParams {
|
||||||
|
|
||||||
// Calculate tool has undefined details (only uses output)
|
// Calculate tool has undefined details (only uses output)
|
||||||
export class CalculateRenderer implements ToolRenderer<CalculateParams, undefined> {
|
export class CalculateRenderer implements ToolRenderer<CalculateParams, undefined> {
|
||||||
renderParams(params: CalculateParams, isStreaming?: boolean): TemplateResult {
|
render(params: CalculateParams | undefined, result: ToolResultMessage<undefined> | undefined): TemplateResult {
|
||||||
if (isStreaming && !params.expression) {
|
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
|
||||||
return html`<div class="text-sm text-muted-foreground">${i18n("Writing expression...")}</div>`;
|
|
||||||
|
// Full params + full result
|
||||||
|
if (result && params?.expression) {
|
||||||
|
const output = result.output || "";
|
||||||
|
|
||||||
|
// Error: show expression in header, error below
|
||||||
|
if (result.isError) {
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, Calculator, params.expression)}
|
||||||
|
<div class="text-sm text-destructive">${output}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: show expression = result in header
|
||||||
|
return renderHeader(state, Calculator, `${params.expression} = ${output}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
// Full params, no result: just show header with expression in it
|
||||||
<div class="text-sm text-muted-foreground">
|
if (params?.expression) {
|
||||||
<span>${i18n("Calculating")}</span>
|
return renderHeader(state, Calculator, `${i18n("Calculating")} ${params.expression}`);
|
||||||
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.expression}</code>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(_params: CalculateParams, result: ToolResultMessage<undefined>): TemplateResult {
|
|
||||||
// Parse the output to make it look nicer
|
|
||||||
const output = result.output || "";
|
|
||||||
const isError = result.isError === true;
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return html`<div class="text-sm text-destructive">${output}</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to split on = to show expression and result separately
|
// Partial params (empty expression), no result
|
||||||
const parts = output.split(" = ");
|
if (params && !params.expression) {
|
||||||
if (parts.length === 2) {
|
return renderHeader(state, Calculator, i18n("Writing expression..."));
|
||||||
return html`
|
|
||||||
<div class="text-sm font-mono">
|
|
||||||
<span class="text-muted-foreground">${parts[0]}</span>
|
|
||||||
<span class="text-muted-foreground mx-1">=</span>
|
|
||||||
<span class="text-foreground font-semibold">${parts[1]}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to showing the whole output
|
// No params, no result
|
||||||
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
|
return renderHeader(state, Calculator, i18n("Waiting for expression..."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,34 @@ import { i18n } from "../../utils/i18n.js";
|
||||||
import type { ToolRenderer } from "../types.js";
|
import type { ToolRenderer } from "../types.js";
|
||||||
|
|
||||||
export class DefaultRenderer implements ToolRenderer {
|
export class DefaultRenderer implements ToolRenderer {
|
||||||
renderParams(params: any, isStreaming?: boolean): TemplateResult {
|
render(params: any | undefined, result: ToolResultMessage | undefined, isStreaming?: boolean): TemplateResult {
|
||||||
let text: string;
|
// Show result if available
|
||||||
let isJson = false;
|
if (result) {
|
||||||
|
const text = result.output || i18n("(no output)");
|
||||||
|
return html`<div class="text-sm text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Show params
|
||||||
text = JSON.stringify(JSON.parse(params), null, 2);
|
if (params) {
|
||||||
isJson = true;
|
let text: string;
|
||||||
} catch {
|
|
||||||
try {
|
try {
|
||||||
text = JSON.stringify(params, null, 2);
|
text = JSON.stringify(JSON.parse(params), null, 2);
|
||||||
isJson = true;
|
|
||||||
} catch {
|
} catch {
|
||||||
text = String(params);
|
try {
|
||||||
|
text = JSON.stringify(params, null, 2);
|
||||||
|
} catch {
|
||||||
|
text = String(params);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStreaming && (!text || text === "{}" || text === "null")) {
|
||||||
|
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool parameters...")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<console-block .content=${text}></console-block>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStreaming && (!text || text === "{}" || text === "null")) {
|
// No params or result yet
|
||||||
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool parameters...")}</div>`;
|
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool...")}</div>`;
|
||||||
}
|
|
||||||
|
|
||||||
return html`<console-block .content=${text}></console-block>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(_params: any, result: ToolResultMessage): TemplateResult {
|
|
||||||
// Just show the output field - that's what was sent to the LLM
|
|
||||||
const text = result.output || i18n("(no output)");
|
|
||||||
|
|
||||||
return html`<div class="text-sm text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { Clock } from "lucide";
|
||||||
import { i18n } from "../../utils/i18n.js";
|
import { i18n } from "../../utils/i18n.js";
|
||||||
|
import { renderHeader } from "../renderer-registry.js";
|
||||||
import type { ToolRenderer } from "../types.js";
|
import type { ToolRenderer } from "../types.js";
|
||||||
|
|
||||||
interface GetCurrentTimeParams {
|
interface GetCurrentTimeParams {
|
||||||
|
|
@ -9,31 +11,59 @@ interface GetCurrentTimeParams {
|
||||||
|
|
||||||
// GetCurrentTime tool has undefined details (only uses output)
|
// GetCurrentTime tool has undefined details (only uses output)
|
||||||
export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams, undefined> {
|
export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams, undefined> {
|
||||||
renderParams(params: GetCurrentTimeParams, isStreaming?: boolean): TemplateResult {
|
render(params: GetCurrentTimeParams | undefined, result: ToolResultMessage<undefined> | undefined): TemplateResult {
|
||||||
if (params.timezone) {
|
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
|
||||||
return html`
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<span>${i18n("Getting current time in")}</span>
|
|
||||||
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.timezone}</code>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<span>${i18n("Getting current date and time")}${isStreaming ? "..." : ""}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResult(_params: GetCurrentTimeParams, result: ToolResultMessage<undefined>): TemplateResult {
|
// Full params + full result
|
||||||
const output = result.output || "";
|
if (result && params) {
|
||||||
const isError = result.isError === true;
|
const output = result.output || "";
|
||||||
|
const headerText = params.timezone
|
||||||
|
? `${i18n("Getting current time in")} ${params.timezone}`
|
||||||
|
: i18n("Getting current date and time");
|
||||||
|
|
||||||
if (isError) {
|
// Error: show header, error below
|
||||||
return html`<div class="text-sm text-destructive">${output}</div>`;
|
if (result.isError) {
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, Clock, headerText)}
|
||||||
|
<div class="text-sm text-destructive">${output}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: show time in header
|
||||||
|
return renderHeader(state, Clock, `${headerText}: ${output}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display the date/time result
|
// Full result, no params
|
||||||
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
|
if (result) {
|
||||||
|
const output = result.output || "";
|
||||||
|
|
||||||
|
// Error: show header, error below
|
||||||
|
if (result.isError) {
|
||||||
|
return html`
|
||||||
|
<div class="space-y-3">
|
||||||
|
${renderHeader(state, Clock, i18n("Getting current date and time"))}
|
||||||
|
<div class="text-sm text-destructive">${output}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: show time in header
|
||||||
|
return renderHeader(state, Clock, `${i18n("Getting current date and time")}: ${output}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full params, no result: show timezone info in header
|
||||||
|
if (params?.timezone) {
|
||||||
|
return renderHeader(state, Clock, `${i18n("Getting current time in")} ${params.timezone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial params (no timezone) or empty params, no result
|
||||||
|
if (params) {
|
||||||
|
return renderHeader(state, Clock, i18n("Getting current date and time"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No params, no result
|
||||||
|
return renderHeader(state, Clock, i18n("Getting time..."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
|
|
||||||
export interface ToolRenderer<TParams = any, TDetails = any> {
|
export interface ToolRenderer<TParams = any, TDetails = any> {
|
||||||
renderParams(params: TParams, isStreaming?: boolean): TemplateResult;
|
render(
|
||||||
renderResult(params: TParams, result: ToolResultMessage<TDetails>): TemplateResult;
|
params: TParams | undefined,
|
||||||
|
result: ToolResultMessage<TDetails> | undefined,
|
||||||
|
isStreaming?: boolean,
|
||||||
|
): TemplateResult;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,13 @@ declare module "@mariozechner/mini-lit" {
|
||||||
"Preparing tool parameters...": string;
|
"Preparing tool parameters...": string;
|
||||||
"(no output)": string;
|
"(no output)": string;
|
||||||
"Writing expression...": string;
|
"Writing expression...": string;
|
||||||
|
"Waiting for expression...": string;
|
||||||
Calculating: string;
|
Calculating: string;
|
||||||
"Getting current time in": string;
|
"Getting current time in": string;
|
||||||
"Getting current date and time": string;
|
"Getting current date and time": string;
|
||||||
|
"Waiting for command...": string;
|
||||||
"Writing command...": string;
|
"Writing command...": string;
|
||||||
"Running command:": string;
|
"Running command...": string;
|
||||||
"Command failed:": string;
|
"Command failed:": string;
|
||||||
"Enter Auth Token": string;
|
"Enter Auth Token": string;
|
||||||
"Please enter your auth token.": string;
|
"Please enter your auth token.": string;
|
||||||
|
|
@ -78,8 +80,32 @@ declare module "@mariozechner/mini-lit" {
|
||||||
"JavaScript code to execute": string;
|
"JavaScript code to execute": string;
|
||||||
"Writing JavaScript code...": string;
|
"Writing JavaScript code...": string;
|
||||||
"Executing JavaScript": string;
|
"Executing JavaScript": string;
|
||||||
|
"Preparing JavaScript...": string;
|
||||||
|
"Preparing command...": string;
|
||||||
|
"Preparing calculation...": string;
|
||||||
|
"Preparing tool...": string;
|
||||||
|
"Getting time...": string;
|
||||||
// Artifacts strings
|
// Artifacts strings
|
||||||
"Processing artifact...": string;
|
"Processing artifact...": string;
|
||||||
|
"Preparing artifact...": string;
|
||||||
|
"Processing artifact": string;
|
||||||
|
"Processed artifact": string;
|
||||||
|
"Creating artifact": string;
|
||||||
|
"Created artifact": string;
|
||||||
|
"Updating artifact": string;
|
||||||
|
"Updated artifact": string;
|
||||||
|
"Rewriting artifact": string;
|
||||||
|
"Rewrote artifact": string;
|
||||||
|
"Getting artifact": string;
|
||||||
|
"Got artifact": string;
|
||||||
|
"Deleting artifact": string;
|
||||||
|
"Deleted artifact": string;
|
||||||
|
"Getting logs": string;
|
||||||
|
"Got logs": string;
|
||||||
|
"An error occurred": string;
|
||||||
|
"Copy logs": string;
|
||||||
|
"Autoscroll enabled": string;
|
||||||
|
"Autoscroll disabled": string;
|
||||||
Processing: string;
|
Processing: string;
|
||||||
Create: string;
|
Create: string;
|
||||||
Rewrite: string;
|
Rewrite: string;
|
||||||
|
|
@ -199,13 +225,15 @@ export const translations = {
|
||||||
"No session set": "No session set",
|
"No session set": "No session set",
|
||||||
"Preparing tool parameters...": "Preparing tool parameters...",
|
"Preparing tool parameters...": "Preparing tool parameters...",
|
||||||
"(no output)": "(no output)",
|
"(no output)": "(no output)",
|
||||||
|
"Waiting for expression...": "Waiting for expression...",
|
||||||
"Writing expression...": "Writing expression...",
|
"Writing expression...": "Writing expression...",
|
||||||
Calculating: "Calculating",
|
Calculating: "Calculating",
|
||||||
"Getting current time in": "Getting current time in",
|
"Getting current time in": "Getting current time in",
|
||||||
"Getting current date and time": "Getting current date and time",
|
"Getting current date and time": "Getting current date and time",
|
||||||
|
"Waiting for command...": "Waiting for command...",
|
||||||
"Writing command...": "Writing command...",
|
"Writing command...": "Writing command...",
|
||||||
"Running command:": "Running command:",
|
"Running command...": "Running command...",
|
||||||
"Command failed:": "Command failed:",
|
"Command failed": "Command failed",
|
||||||
"Enter Auth Token": "Enter Auth Token",
|
"Enter Auth Token": "Enter Auth Token",
|
||||||
"Please enter your auth token.": "Please enter your auth token.",
|
"Please enter your auth token.": "Please enter your auth token.",
|
||||||
"Auth token is required for proxy transport": "Auth token is required for proxy transport",
|
"Auth token is required for proxy transport": "Auth token is required for proxy transport",
|
||||||
|
|
@ -219,8 +247,32 @@ export const translations = {
|
||||||
"JavaScript code to execute": "JavaScript code to execute",
|
"JavaScript code to execute": "JavaScript code to execute",
|
||||||
"Writing JavaScript code...": "Writing JavaScript code...",
|
"Writing JavaScript code...": "Writing JavaScript code...",
|
||||||
"Executing JavaScript": "Executing JavaScript",
|
"Executing JavaScript": "Executing JavaScript",
|
||||||
|
"Preparing JavaScript...": "Preparing JavaScript...",
|
||||||
|
"Preparing command...": "Preparing command...",
|
||||||
|
"Preparing calculation...": "Preparing calculation...",
|
||||||
|
"Preparing tool...": "Preparing tool...",
|
||||||
|
"Getting time...": "Getting time...",
|
||||||
// Artifacts strings
|
// Artifacts strings
|
||||||
"Processing artifact...": "Processing artifact...",
|
"Processing artifact...": "Processing artifact...",
|
||||||
|
"Preparing artifact...": "Preparing artifact...",
|
||||||
|
"Processing artifact": "Processing artifact",
|
||||||
|
"Processed artifact": "Processed artifact",
|
||||||
|
"Creating artifact": "Creating artifact",
|
||||||
|
"Created artifact": "Created artifact",
|
||||||
|
"Updating artifact": "Updating artifact",
|
||||||
|
"Updated artifact": "Updated artifact",
|
||||||
|
"Rewriting artifact": "Rewriting artifact",
|
||||||
|
"Rewrote artifact": "Rewrote artifact",
|
||||||
|
"Getting artifact": "Getting artifact",
|
||||||
|
"Got artifact": "Got artifact",
|
||||||
|
"Deleting artifact": "Deleting artifact",
|
||||||
|
"Deleted artifact": "Deleted artifact",
|
||||||
|
"Getting logs": "Getting logs",
|
||||||
|
"Got logs": "Got logs",
|
||||||
|
"An error occurred": "An error occurred",
|
||||||
|
"Copy logs": "Copy logs",
|
||||||
|
"Autoscroll enabled": "Autoscroll enabled",
|
||||||
|
"Autoscroll disabled": "Autoscroll disabled",
|
||||||
Processing: "Processing",
|
Processing: "Processing",
|
||||||
Create: "Create",
|
Create: "Create",
|
||||||
Rewrite: "Rewrite",
|
Rewrite: "Rewrite",
|
||||||
|
|
@ -281,6 +333,7 @@ export const translations = {
|
||||||
tokens: "tokens",
|
tokens: "tokens",
|
||||||
Delete: "Delete",
|
Delete: "Delete",
|
||||||
"Drop files here": "Drop files here",
|
"Drop files here": "Drop files here",
|
||||||
|
"Command failed:": "Command failed:",
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
...defaultGerman,
|
...defaultGerman,
|
||||||
|
|
@ -342,13 +395,15 @@ export const translations = {
|
||||||
"No session set": "Keine Sitzung gesetzt",
|
"No session set": "Keine Sitzung gesetzt",
|
||||||
"Preparing tool parameters...": "Bereite Tool-Parameter vor...",
|
"Preparing tool parameters...": "Bereite Tool-Parameter vor...",
|
||||||
"(no output)": "(keine Ausgabe)",
|
"(no output)": "(keine Ausgabe)",
|
||||||
|
"Waiting for expression...": "Warte auf Ausdruck",
|
||||||
"Writing expression...": "Schreibe Ausdruck...",
|
"Writing expression...": "Schreibe Ausdruck...",
|
||||||
Calculating: "Berechne",
|
Calculating: "Berechne",
|
||||||
"Getting current time in": "Hole aktuelle Zeit in",
|
"Getting current time in": "Hole aktuelle Zeit in",
|
||||||
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
|
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
|
||||||
|
"Waiting for command...": "Warte auf Befehl...",
|
||||||
"Writing command...": "Schreibe Befehl...",
|
"Writing command...": "Schreibe Befehl...",
|
||||||
"Running command:": "Führe Befehl aus:",
|
"Running command...": "Führe Befehl aus...",
|
||||||
"Command failed:": "Befehl fehlgeschlagen:",
|
"Command failed": "Befehl fehlgeschlagen",
|
||||||
"Enter Auth Token": "Auth-Token eingeben",
|
"Enter Auth Token": "Auth-Token eingeben",
|
||||||
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
|
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
|
||||||
"Auth token is required for proxy transport": "Auth-Token ist für Proxy-Transport erforderlich",
|
"Auth token is required for proxy transport": "Auth-Token ist für Proxy-Transport erforderlich",
|
||||||
|
|
@ -362,8 +417,32 @@ export const translations = {
|
||||||
"JavaScript code to execute": "Auszuführender JavaScript-Code",
|
"JavaScript code to execute": "Auszuführender JavaScript-Code",
|
||||||
"Writing JavaScript code...": "Schreibe JavaScript-Code...",
|
"Writing JavaScript code...": "Schreibe JavaScript-Code...",
|
||||||
"Executing JavaScript": "Führe JavaScript aus",
|
"Executing JavaScript": "Führe JavaScript aus",
|
||||||
|
"Preparing JavaScript...": "Bereite JavaScript vor...",
|
||||||
|
"Preparing command...": "Bereite Befehl vor...",
|
||||||
|
"Preparing calculation...": "Bereite Berechnung vor...",
|
||||||
|
"Preparing tool...": "Bereite Tool vor...",
|
||||||
|
"Getting time...": "Hole Zeit...",
|
||||||
// Artifacts strings
|
// Artifacts strings
|
||||||
"Processing artifact...": "Verarbeite Artefakt...",
|
"Processing artifact...": "Verarbeite Artefakt...",
|
||||||
|
"Preparing artifact...": "Bereite Artefakt vor...",
|
||||||
|
"Processing artifact": "Verarbeite Artefakt",
|
||||||
|
"Processed artifact": "Artefakt verarbeitet",
|
||||||
|
"Creating artifact": "Erstelle Artefakt",
|
||||||
|
"Created artifact": "Artefakt erstellt",
|
||||||
|
"Updating artifact": "Aktualisiere Artefakt",
|
||||||
|
"Updated artifact": "Artefakt aktualisiert",
|
||||||
|
"Rewriting artifact": "Überschreibe Artefakt",
|
||||||
|
"Rewrote artifact": "Artefakt überschrieben",
|
||||||
|
"Getting artifact": "Hole Artefakt",
|
||||||
|
"Got artifact": "Artefakt geholt",
|
||||||
|
"Deleting artifact": "Lösche Artefakt",
|
||||||
|
"Deleted artifact": "Artefakt gelöscht",
|
||||||
|
"Getting logs": "Hole Logs",
|
||||||
|
"Got logs": "Logs geholt",
|
||||||
|
"An error occurred": "Ein Fehler ist aufgetreten",
|
||||||
|
"Copy logs": "Logs kopieren",
|
||||||
|
"Autoscroll enabled": "Automatisches Scrollen aktiviert",
|
||||||
|
"Autoscroll disabled": "Automatisches Scrollen deaktiviert",
|
||||||
Processing: "Verarbeitung",
|
Processing: "Verarbeitung",
|
||||||
Create: "Erstellen",
|
Create: "Erstellen",
|
||||||
Rewrite: "Überschreiben",
|
Rewrite: "Überschreiben",
|
||||||
|
|
@ -424,6 +503,7 @@ export const translations = {
|
||||||
tokens: "Tokens",
|
tokens: "Tokens",
|
||||||
Delete: "Löschen",
|
Delete: "Löschen",
|
||||||
"Drop files here": "Dateien hier ablegen",
|
"Drop files here": "Dateien hier ablegen",
|
||||||
|
"Command failed:": "Befehl fehlgeschlagen:",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue