feat: add comprehensive web UI features - command palette, memory manager, cost tracker, diff viewer, mermaid diagrams, keyboard shortcuts, session export
Some checks are pending
CI / build-check-test (push) Waiting to run

This commit is contained in:
JAE 2026-03-25 23:51:40 +00:00
parent 903540fa95
commit 4cdf01ba9e
14 changed files with 2392 additions and 332 deletions

1144
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,14 @@
"clean": "shx rm -rf dist"
},
"dependencies": {
"@mariozechner/mini-lit": "^0.2.0",
"@jaeswift/jae-ai": "file:../../ai",
"@jaeswift/jae-web-ui": "file:../",
"@mariozechner/mini-lit": "^0.2.0",
"@tailwindcss/vite": "^4.1.17",
"diff2html": "^3.4.56",
"lit": "^3.3.1",
"lucide": "^0.544.0"
"lucide": "^0.544.0",
"mermaid": "^11.13.0"
},
"devDependencies": {
"typescript": "^5.7.3",

View file

@ -0,0 +1,120 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
export interface Command {
id: string;
label: string;
description?: string;
icon?: string;
shortcut?: string;
action: () => void;
keywords?: string[];
}
@customElement("command-palette")
export class CommandPalette extends LitElement {
@state() private open = false;
@state() private query = "";
@state() private selectedIndex = 0;
private commands: Command[] = [];
protected override createRenderRoot() { return this; }
setCommands(commands: Command[]) {
this.commands = commands;
}
show() {
this.open = true;
this.query = "";
this.selectedIndex = 0;
this.requestUpdate();
requestAnimationFrame(() => {
const input = this.querySelector("input") as HTMLInputElement;
if (input) input.focus();
});
}
hide() {
this.open = false;
this.requestUpdate();
}
get filteredCommands(): Command[] {
if (!this.query) return this.commands;
const q = this.query.toLowerCase();
return this.commands.filter(c =>
c.label.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q) ||
c.keywords?.some(k => k.toLowerCase().includes(q))
);
}
private handleKeyDown(e: KeyboardEvent) {
const cmds = this.filteredCommands;
if (e.key === "ArrowDown") {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, cmds.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
} else if (e.key === "Enter") {
e.preventDefault();
if (cmds[this.selectedIndex]) {
cmds[this.selectedIndex].action();
this.hide();
}
} else if (e.key === "Escape") {
this.hide();
}
}
override render() {
if (!this.open) return html``;
const cmds = this.filteredCommands;
return html`
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20" @click=${(e: Event) => { if (e.target === e.currentTarget) this.hide(); }}>
<div class="w-full max-w-xl bg-popover border border-border rounded-xl shadow-2xl overflow-hidden">
<div class="flex items-center gap-3 px-4 py-3 border-b border-border">
<span class="text-muted-foreground text-sm">&#x2318;</span>
<input
type="text"
placeholder="Type a command..."
class="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
.value=${this.query}
@input=${(e: Event) => { this.query = (e.target as HTMLInputElement).value; this.selectedIndex = 0; }}
@keydown=${this.handleKeyDown}
/>
<kbd class="text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5">ESC</kbd>
</div>
<div class="max-h-80 overflow-y-auto py-1">
${cmds.length === 0 ? html`<div class="px-4 py-6 text-center text-sm text-muted-foreground">No commands found</div>` : ""}
${cmds.map((cmd, i) => html`
<button
class="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-secondary transition-colors text-left ${
i === this.selectedIndex ? "bg-secondary" : ""
}"
@click=${() => { cmd.action(); this.hide(); }}
@mouseover=${() => { this.selectedIndex = i; }}
>
<div class="flex flex-col gap-0.5">
<span class="font-medium">${cmd.label}</span>
${cmd.description ? html`<span class="text-xs text-muted-foreground">${cmd.description}</span>` : ""}
</div>
${cmd.shortcut ? html`<kbd class="text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5 shrink-0">${cmd.shortcut}</kbd>` : ""}
</button>
`)}
</div>
<div class="px-4 py-2 border-t border-border text-xs text-muted-foreground flex items-center gap-4">
<span>&#x2191;&#x2193; Navigate</span>
<span>&#x21B5; Select</span>
<span>ESC Close</span>
</div>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,97 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { Agent } from "@jaeswift/jae-agent-core";
export interface UsageSnapshot {
inputTokens: number;
outputTokens: number;
totalTokens: number;
estimatedCost: number;
model: string;
requestCount: number;
}
// Very rough cost estimates per 1M tokens for common models
const MODEL_COSTS: Record<string, { input: number; output: number }> = {
default: { input: 3.0, output: 15.0 },
};
function estimateCost(model: string, input: number, output: number): number {
const costs = MODEL_COSTS[model] || MODEL_COSTS.default;
return (input / 1_000_000) * costs.input + (output / 1_000_000) * costs.output;
}
@customElement("cost-tracker")
export class CostTracker extends LitElement {
@state() private inputTokens = 0;
@state() private outputTokens = 0;
@state() private requestCount = 0;
@state() private modelId = "";
@state() private expanded = false;
private unsubscribe?: () => void;
protected override createRenderRoot() { return this; }
bindAgent(agent: Agent) {
if (this.unsubscribe) this.unsubscribe();
this.inputTokens = 0;
this.outputTokens = 0;
this.requestCount = 0;
this.modelId = agent.state.model?.id || "";
this.unsubscribe = agent.subscribe((event) => {
if (event.type === "message" && event.message.role === "assistant") {
const msg = event.message as any;
if (msg.usage) {
this.inputTokens += msg.usage.inputTokens || 0;
this.outputTokens += msg.usage.outputTokens || 0;
this.requestCount += 1;
}
}
});
}
get totalTokens() { return this.inputTokens + this.outputTokens; }
get estimatedCost() { return estimateCost(this.modelId, this.inputTokens, this.outputTokens); }
reset() {
this.inputTokens = 0;
this.outputTokens = 0;
this.requestCount = 0;
}
override render() {
const cost = this.estimatedCost;
return html`
<button
class="flex items-center gap-1.5 px-2 py-1 text-xs rounded hover:bg-secondary transition-colors text-muted-foreground"
@click=${() => { this.expanded = !this.expanded; this.requestUpdate(); }}
title="Token usage & cost"
>
<span class="font-mono">${this.totalTokens > 0 ? this.totalTokens.toLocaleString() : "0"} tok</span>
<span class="text-muted-foreground/50">|</span>
<span class="font-mono">$${cost.toFixed(4)}</span>
</button>
${this.expanded ? html`
<div class="absolute top-12 right-2 z-50 bg-popover border border-border rounded-lg shadow-xl p-4 min-w-52">
<div class="font-semibold text-sm mb-3 flex items-center justify-between">
<span>Token Usage</span>
<button class="text-muted-foreground hover:text-foreground text-xs" @click=${() => this.reset()}>Reset</button>
</div>
<div class="flex flex-col gap-2 text-xs">
<div class="flex justify-between"><span class="text-muted-foreground">Input tokens</span><span class="font-mono">${this.inputTokens.toLocaleString()}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Output tokens</span><span class="font-mono">${this.outputTokens.toLocaleString()}</span></div>
<div class="flex justify-between font-medium border-t border-border pt-2"><span>Total tokens</span><span class="font-mono">${this.totalTokens.toLocaleString()}</span></div>
<div class="flex justify-between text-yellow-600 dark:text-yellow-400"><span>Est. cost</span><span class="font-mono">$${cost.toFixed(6)}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Requests</span><span class="font-mono">${this.requestCount}</span></div>
</div>
</div>
` : ""}
`;
}
}
export function createCostTracker(): CostTracker {
return document.createElement("cost-tracker") as CostTracker;
}

View file

@ -0,0 +1,60 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
@customElement("keyboard-shortcuts")
export class KeyboardShortcuts extends LitElement {
@state() private open = false;
protected override createRenderRoot() { return this; }
show() { this.open = true; this.requestUpdate(); }
hide() { this.open = false; this.requestUpdate(); }
toggle() { this.open = !this.open; this.requestUpdate(); }
private readonly shortcuts = [
{ group: "General", items: [
{ key: "Cmd+K", desc: "Open command palette" },
{ key: "?", desc: "Show keyboard shortcuts" },
{ key: "Ctrl+L", desc: "Open model selector" },
{ key: "Esc", desc: "Close dialogs / abort generation" },
]},
{ group: "Sessions", items: [
{ key: "Ctrl+N", desc: "New session" },
{ key: "Ctrl+H", desc: "Session history" },
{ key: "Ctrl+E", desc: "Export session" },
]},
{ group: "Tools & Features", items: [
{ key: "/memory", desc: "Open memory manager" },
{ key: "/clear", desc: "Clear conversation" },
{ key: "/model", desc: "Switch model" },
]},
];
override render() {
if (!this.open) return html``;
return html`
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click=${(e: Event) => { if (e.target === e.currentTarget) this.hide(); }}>
<div class="bg-popover border border-border rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-lg">Keyboard Shortcuts</h2>
<button class="text-muted-foreground hover:text-foreground" @click=${() => this.hide()}>&#x2715;</button>
</div>
${this.shortcuts.map(group => html`
<div class="mb-4">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">${group.group}</div>
<div class="flex flex-col gap-1">
${group.items.map(item => html`
<div class="flex items-center justify-between py-1">
<span class="text-sm">${item.desc}</span>
<kbd class="text-xs bg-secondary border border-border rounded px-2 py-0.5 font-mono">${item.key}</kbd>
</div>
`)}
</div>
</div>
`)}
</div>
</div>
`;
}
}

View file

@ -0,0 +1,148 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
export interface MemoryEntry {
id: string;
content: string;
tags: string[];
timestamp: string;
}
const DB_NAME = "jae-memory";
const DB_VERSION = 1;
const STORE_NAME = "memories";
let _db: IDBDatabase | null = null;
async function openDB(): Promise<IDBDatabase> {
if (_db) return _db;
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME, { keyPath: "id" });
req.onsuccess = () => { _db = req.result; resolve(_db); };
req.onerror = () => reject(req.error);
});
}
export async function memoryLoad(): Promise<MemoryEntry[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
export async function memorySave(content: string, tags: string[] = []): Promise<string> {
const db = await openDB();
const entry: MemoryEntry = { id: crypto.randomUUID(), content, tags, timestamp: new Date().toISOString() };
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve(entry.id);
tx.onerror = () => reject(tx.error);
});
}
export async function memoryDelete(id: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
@customElement("memory-manager")
export class MemoryManager extends LitElement {
@state() private open = false;
@state() private entries: MemoryEntry[] = [];
@state() private loading = false;
@state() private newContent = "";
@state() private newTags = "";
@state() private filter = "";
protected override createRenderRoot() { return this; }
async show() {
this.open = true;
this.loading = true;
this.requestUpdate();
this.entries = await memoryLoad();
this.loading = false;
this.requestUpdate();
}
hide() { this.open = false; this.requestUpdate(); }
get filtered() {
if (!this.filter) return this.entries;
const q = this.filter.toLowerCase();
return this.entries.filter(e => e.content.toLowerCase().includes(q) || e.tags.some(t => t.toLowerCase().includes(q)));
}
async deleteEntry(id: string) {
await memoryDelete(id);
this.entries = this.entries.filter(e => e.id !== id);
this.requestUpdate();
}
async addEntry() {
if (!this.newContent.trim()) return;
const tags = this.newTags.split(",").map(t => t.trim()).filter(Boolean);
await memorySave(this.newContent.trim(), tags);
this.newContent = "";
this.newTags = "";
this.entries = await memoryLoad();
this.requestUpdate();
}
override render() {
if (!this.open) return html``;
const entries = this.filtered;
return html`
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click=${(e: Event) => { if (e.target === e.currentTarget) this.hide(); }}>
<div class="bg-popover border border-border rounded-xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col max-h-[80vh]">
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<h2 class="font-semibold text-lg">&#x1F9E0; Memory Manager</h2>
<button class="text-muted-foreground hover:text-foreground" @click=${() => this.hide()}>&#x2715;</button>
</div>
<div class="px-6 py-3 border-b border-border shrink-0">
<input type="text" placeholder="Filter memories..." class="w-full bg-secondary rounded-lg px-3 py-2 text-sm outline-none"
.value=${this.filter} @input=${(e: Event) => { this.filter = (e.target as HTMLInputElement).value; }} />
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
${this.loading ? html`<div class="text-center text-muted-foreground py-8">Loading...</div>` : ""}
${!this.loading && entries.length === 0 ? html`<div class="text-center text-muted-foreground py-8">No memories stored yet</div>` : ""}
<div class="flex flex-col gap-2">
${entries.map(entry => html`
<div class="flex gap-3 p-3 rounded-lg border border-border bg-background group">
<div class="flex-1 min-w-0">
<div class="text-sm">${entry.content}</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-muted-foreground">${entry.timestamp.slice(0, 10)}</span>
${entry.tags.map(tag => html`<span class="text-xs bg-secondary px-1.5 py-0.5 rounded">${tag}</span>`)}
</div>
</div>
<button class="shrink-0 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
@click=${() => this.deleteEntry(entry.id)} title="Delete">&#x1F5D1;</button>
</div>
`)}
</div>
</div>
<div class="px-6 py-4 border-t border-border shrink-0">
<div class="flex flex-col gap-2">
<textarea placeholder="New memory content..." class="w-full bg-secondary rounded-lg px-3 py-2 text-sm outline-none resize-none"
rows="2" .value=${this.newContent}
@input=${(e: Event) => { this.newContent = (e.target as HTMLTextAreaElement).value; }}></textarea>
<div class="flex gap-2">
<input type="text" placeholder="Tags (comma separated)" class="flex-1 bg-secondary rounded-lg px-3 py-2 text-sm outline-none"
.value=${this.newTags} @input=${(e: Event) => { this.newTags = (e.target as HTMLInputElement).value; }} />
<button class="bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90"
@click=${() => this.addEntry()}>Save</button>
</div>
</div>
</div>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,45 @@
import type { AgentMessage } from "@jaeswift/jae-agent-core";
export function exportSessionAsMarkdown(messages: AgentMessage[], title: string): void {
const lines: string[] = [
`# ${title || "JAE Session Export"}`,
``,
`*Exported: ${new Date().toLocaleString()}*`,
``,
`---`,
``,
];
for (const msg of messages) {
if (msg.role === "user") {
const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
lines.push(`## 👤 User`, ``, content, ``, `---`, ``);
} else if (msg.role === "assistant") {
const m = msg as any;
const textBlocks = Array.isArray(m.content)
? m.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("\n")
: m.content || "";
lines.push(`## 🤖 Assistant`, ``, textBlocks, ``, `---`, ``);
}
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `jae-session-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
}
export function exportSessionAsJson(messages: AgentMessage[], title: string): void {
const data = { title, exportedAt: new Date().toISOString(), messages };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `jae-session-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -1,31 +1,41 @@
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core";
import { getModel } from "@jaeswift/jae-ai";
import {
type AgentState,
ApiKeyPromptDialog,
AppStorage,
ChatPanel,
CustomProvidersStore,
createJavaScriptReplTool,
IndexedDBStorageBackend,
// PersistentStorageDialog, // TODO: Fix - currently broken
ProviderKeysStore,
ProvidersModelsTab,
ProxyTab,
SessionListDialog,
SessionsStore,
SettingsDialog,
SettingsStore,
setAppStorage,
type AgentState,
ApiKeyPromptDialog,
AppStorage,
ChatPanel,
CustomProvidersStore,
createJavaScriptReplTool,
IndexedDBStorageBackend,
ProviderKeysStore,
ProvidersModelsTab,
ProxyTab,
SessionListDialog,
SessionsStore,
SettingsDialog,
SettingsStore,
setAppStorage,
} from "@jaeswift/jae-web-ui";
import { html, render } from "lit";
import { Bell, History, Plus, Settings } from "lucide";
import { Bell, Brain, Download, History, Keyboard, Plus, Settings, Terminal } from "lucide";
import "./app.css";
import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import { createSystemNotification, customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js";
import { createWebSearchTool, createImageGenTool, createTTSTool } from "@jaeswift/jae-web-ui";
import { CommandPalette } from "./components/command-palette.js";
import { KeyboardShortcuts } from "./components/keyboard-shortcuts.js";
import { MemoryManager } from "./components/memory-manager.js";
import { CostTracker } from "./components/cost-tracker.js";
import { exportSessionAsMarkdown, exportSessionAsJson } from "./components/session-export.js";
import "./components/command-palette.js";
import "./components/keyboard-shortcuts.js";
import "./components/memory-manager.js";
import "./components/cost-tracker.js";
// Register custom message renderers
registerCustomMessageRenderers();
@ -36,29 +46,25 @@ const providerKeys = new ProviderKeysStore();
const sessions = new SessionsStore();
const customProviders = new CustomProvidersStore();
// Gather configs
const configs = [
settings.getConfig(),
SessionsStore.getMetadataConfig(),
providerKeys.getConfig(),
customProviders.getConfig(),
sessions.getConfig(),
settings.getConfig(),
SessionsStore.getMetadataConfig(),
providerKeys.getConfig(),
customProviders.getConfig(),
sessions.getConfig(),
];
// Create backend
const backend = new IndexedDBStorageBackend({
dbName: "jae-web-ui-example",
version: 2, // Incremented for custom-providers store
stores: configs,
dbName: "jae-web-ui-example",
version: 2,
stores: configs,
});
// Wire backend to stores
settings.setBackend(backend);
providerKeys.setBackend(backend);
customProviders.setBackend(backend);
sessions.setBackend(backend);
// Create and set app storage
const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend);
setAppStorage(storage);
@ -69,353 +75,323 @@ let agent: Agent;
let chatPanel: ChatPanel;
let agentUnsubscribe: (() => void) | undefined;
// --- Feature instances ---
const commandPalette = document.createElement("command-palette") as CommandPalette;
const keyboardShortcuts = document.createElement("keyboard-shortcuts") as KeyboardShortcuts;
const memoryManager = document.createElement("memory-manager") as MemoryManager;
const costTracker = document.createElement("cost-tracker") as CostTracker;
document.body.appendChild(commandPalette);
document.body.appendChild(keyboardShortcuts);
document.body.appendChild(memoryManager);
// --- Global keyboard handler ---
window.addEventListener("keydown", (e: KeyboardEvent) => {
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key === "k") { e.preventDefault(); commandPalette.show(); return; }
if (meta && e.key === "e") { e.preventDefault(); handleExport(); return; }
if (meta && e.key === "n") { e.preventDefault(); newSession(); return; }
if (e.key === "?" && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
keyboardShortcuts.toggle();
}
});
// --- Setup command palette commands ---
function setupCommands() {
commandPalette.setCommands([
{
id: "new-session", label: "New Session", description: "Start a fresh conversation",
shortcut: "Ctrl+N", keywords: ["new", "fresh", "start"],
action: newSession,
},
{
id: "sessions", label: "Session History", description: "Browse and load past sessions",
shortcut: "Ctrl+H", keywords: ["history", "sessions", "past"],
action: () => SessionListDialog.open(async (id) => await loadSession(id), (id) => { if (id === currentSessionId) newSession(); }),
},
{
id: "export-md", label: "Export as Markdown", description: "Download current session as .md",
shortcut: "Ctrl+E", keywords: ["export", "download", "save", "markdown"],
action: () => handleExport("markdown"),
},
{
id: "export-json", label: "Export as JSON", description: "Download current session as .json",
keywords: ["export", "download", "save", "json"],
action: () => handleExport("json"),
},
{
id: "memory", label: "Memory Manager", description: "Browse and manage stored memories",
keywords: ["memory", "remember", "recall", "brain"],
action: () => memoryManager.show(),
},
{
id: "settings", label: "Settings", description: "Configure providers and models",
keywords: ["settings", "config", "provider", "api", "key", "model"],
action: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
},
{
id: "shortcuts", label: "Keyboard Shortcuts", description: "View all keyboard shortcuts",
shortcut: "?", keywords: ["keyboard", "shortcuts", "help", "keys"],
action: () => keyboardShortcuts.show(),
},
{
id: "cost", label: "Token Usage & Cost", description: "View API usage stats for this session",
keywords: ["tokens", "cost", "usage", "spend"],
action: () => costTracker.dispatchEvent(new MouseEvent("click")),
},
]);
}
function handleExport(format: "markdown" | "json" = "markdown") {
if (!agent) return;
const messages = agent.state.messages;
const title = currentTitle || "JAE Session";
if (format === "markdown") exportSessionAsMarkdown(messages, title);
else exportSessionAsJson(messages, title);
}
const generateTitle = (messages: AgentMessage[]): string => {
const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments");
if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return "";
let text = "";
const content = firstUserMsg.content;
if (typeof content === "string") {
text = content;
} else {
const textBlocks = content.filter((c: any) => c.type === "text");
text = textBlocks.map((c: any) => c.text || "").join(" ");
}
text = text.trim();
if (!text) return "";
const sentenceEnd = text.search(/[.!?]/);
if (sentenceEnd > 0 && sentenceEnd <= 50) {
return text.substring(0, sentenceEnd + 1);
}
return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments");
if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return "";
let text = "";
const content = firstUserMsg.content;
if (typeof content === "string") { text = content; }
else { const textBlocks = content.filter((c: any) => c.type === "text"); text = textBlocks.map((c: any) => c.text || "").join(" "); }
text = text.trim();
if (!text) return "";
const sentenceEnd = text.search(/[.!?]/);
if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1);
return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
};
const shouldSaveSession = (messages: AgentMessage[]): boolean => {
const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments");
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
return hasUserMsg && hasAssistantMsg;
const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments");
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
return hasUserMsg && hasAssistantMsg;
};
const saveSession = async () => {
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
const state = agent.state;
if (!shouldSaveSession(state.messages)) return;
try {
// Create session data
const sessionData = {
id: currentSessionId,
title: currentTitle,
model: state.model!,
thinkingLevel: state.thinkingLevel,
messages: state.messages,
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
// Create session metadata
const metadata = {
id: currentSessionId,
title: currentTitle,
createdAt: sessionData.createdAt,
lastModified: sessionData.lastModified,
messageCount: state.messages.length,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
modelId: state.model?.id || null,
thinkingLevel: state.thinkingLevel,
preview: generateTitle(state.messages),
};
await storage.sessions.save(sessionData, metadata);
} catch (err) {
console.error("Failed to save session:", err);
}
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
const state = agent.state;
if (!shouldSaveSession(state.messages)) return;
try {
const sessionData = {
id: currentSessionId, title: currentTitle, model: state.model!,
thinkingLevel: state.thinkingLevel, messages: state.messages,
createdAt: new Date().toISOString(), lastModified: new Date().toISOString(),
};
const metadata = {
id: currentSessionId, title: currentTitle,
createdAt: sessionData.createdAt, lastModified: sessionData.lastModified,
messageCount: state.messages.length,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
modelId: state.model?.id || null, thinkingLevel: state.thinkingLevel,
preview: generateTitle(state.messages),
};
await storage.sessions.save(sessionData, metadata);
} catch (err) { console.error("Failed to save session:", err); }
};
const updateUrl = (sessionId: string) => {
const url = new URL(window.location.href);
url.searchParams.set("session", sessionId);
window.history.replaceState({}, "", url);
const url = new URL(window.location.href);
url.searchParams.set("session", sessionId);
window.history.replaceState({}, "", url);
};
const createAgent = async (initialState?: Partial<AgentState>) => {
if (agentUnsubscribe) {
agentUnsubscribe();
}
if (agentUnsubscribe) agentUnsubscribe();
agent = new Agent({
initialState: initialState || {
systemPrompt: `You are a helpful AI assistant with access to various tools.
agent = new Agent({
initialState: initialState || {
systemPrompt: `You are a helpful AI assistant with access to various tools.
Available tools:
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.)
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment
- Web Search: Search the web via DuckDuckGo for current information
- Image Generation: Generate images using Venice AI
- Text to Speech: Convert text to audio
- Memory: Save and recall information across sessions
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
Feel free to use these tools when needed to provide accurate and helpful responses.`,
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
thinkingLevel: "off",
messages: [],
tools: [],
},
// Custom transformer: convert custom messages to LLM-compatible format
convertToLlm: customConvertToLlm,
});
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
thinkingLevel: "off",
messages: [],
tools: [],
},
convertToLlm: customConvertToLlm,
onApiKeyRequired: async (provider) => {
const key = await ApiKeyPromptDialog.prompt(provider);
if (key) await providerKeys.set(provider, key);
return key;
},
getProviderApiKey: async (provider) => providerKeys.get(provider),
onStateChange: async (state, prevState) => {
if (prevState?.messages.length !== state.messages.length) {
if (!currentTitle) {
const generated = generateTitle(state.messages);
if (generated) {
currentTitle = generated;
if (!currentSessionId) currentSessionId = crypto.randomUUID();
updateUrl(currentSessionId);
renderApp();
}
}
await saveSession();
}
},
createTools: async (runtimeProvidersFactory) => {
const replTool = createJavaScriptReplTool();
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()];
},
});
agentUnsubscribe = agent.subscribe((event: any) => {
if (event.type === "state-update") {
const messages = event.state.messages;
// Bind cost tracker to new agent
costTracker.bindAgent(agent);
// Generate title after first successful response
if (!currentTitle && shouldSaveSession(messages)) {
currentTitle = generateTitle(messages);
}
// Create session ID on first successful save
if (!currentSessionId && shouldSaveSession(messages)) {
currentSessionId = crypto.randomUUID();
updateUrl(currentSessionId);
}
// Auto-save
if (currentSessionId) {
saveSession();
}
renderApp();
}
});
await chatPanel.setAgent(agent, {
onApiKeyRequired: async (provider: string) => {
return await ApiKeyPromptDialog.prompt(provider);
},
toolsFactory: (_agent, _agentInterface, _artifactsPanel, runtimeProvidersFactory) => {
// Create javascript_repl tool with access to attachments + artifacts
const replTool = createJavaScriptReplTool();
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()];
},
});
chatPanel?.setAgent(agent);
if (!currentSessionId) currentSessionId = crypto.randomUUID();
};
const loadSession = async (sessionId: string): Promise<boolean> => {
if (!storage.sessions) return false;
const sessionData = await storage.sessions.get(sessionId);
if (!sessionData) {
console.error("Session not found:", sessionId);
return false;
}
currentSessionId = sessionId;
const metadata = await storage.sessions.getMetadata(sessionId);
currentTitle = metadata?.title || "";
await createAgent({
model: sessionData.model,
thinkingLevel: sessionData.thinkingLevel,
messages: sessionData.messages,
tools: [],
});
updateUrl(sessionId);
renderApp();
return true;
if (!storage.sessions) return false;
const sessionData = await storage.sessions.get(sessionId);
if (!sessionData) { console.error("Session not found:", sessionId); return false; }
currentSessionId = sessionId;
const metadata = await storage.sessions.getMetadata(sessionId);
currentTitle = metadata?.title || "";
await createAgent({
model: sessionData.model, thinkingLevel: sessionData.thinkingLevel,
messages: sessionData.messages, tools: [],
});
updateUrl(sessionId);
renderApp();
return true;
};
const newSession = () => {
const url = new URL(window.location.href);
url.search = "";
window.location.href = url.toString();
const url = new URL(window.location.href);
url.search = "";
window.location.href = url.toString();
};
// ============================================================================
// RENDER
// ============================================================================
const renderApp = () => {
const app = document.getElementById("app");
if (!app) return;
const app = document.getElementById("app");
if (!app) return;
const appHtml = html`
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-border shrink-0">
<div class="flex items-center gap-2 px-4 py-">
${Button({
variant: "ghost",
size: "sm",
children: icon(History, "sm"),
onClick: () => {
SessionListDialog.open(
async (sessionId) => {
await loadSession(sessionId);
},
(deletedSessionId) => {
// Only reload if the current session was deleted
if (deletedSessionId === currentSessionId) {
newSession();
}
},
);
},
title: "Sessions",
})}
${Button({
variant: "ghost",
size: "sm",
children: icon(Plus, "sm"),
onClick: newSession,
title: "New Session",
})}
const appHtml = html`
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-border shrink-0 relative">
<div class="flex items-center gap-2 px-4 py-1">
${Button({ variant: "ghost", size: "sm", children: icon(History, "sm"),
onClick: () => SessionListDialog.open(
async (id) => { await loadSession(id); },
(id) => { if (id === currentSessionId) newSession(); }
), title: "Sessions (Ctrl+H)" })}
${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session (Ctrl+N)" })}
${
currentTitle
? isEditingTitle
? html`<div class="flex items-center gap-2">
${Input({
type: "text", value: currentTitle, className: "text-sm w-64",
onChange: async (e: Event) => {
const newTitle = (e.target as HTMLInputElement).value.trim();
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
await storage.sessions.updateTitle(currentSessionId, newTitle);
currentTitle = newTitle;
}
isEditingTitle = false; renderApp();
},
onKeyDown: async (e: KeyboardEvent) => {
if (e.key === "Enter") {
const newTitle = (e.target as HTMLInputElement).value.trim();
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
await storage.sessions.updateTitle(currentSessionId, newTitle);
currentTitle = newTitle;
}
isEditingTitle = false; renderApp();
} else if (e.key === "Escape") { isEditingTitle = false; renderApp(); }
},
})}
</div>`
: html`<button
class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors"
@click=${() => { isEditingTitle = true; renderApp();
requestAnimationFrame(() => {
const input = app?.querySelector('input[type="text"]') as HTMLInputElement;
if (input) { input.focus(); input.select(); }
});
}}
title="Click to edit title">${currentTitle}</button>`
: html`<span class="text-base font-semibold text-foreground">JAE Web UI</span>`
}
</div>
${
currentTitle
? isEditingTitle
? html`<div class="flex items-center gap-2">
${Input({
type: "text",
value: currentTitle,
className: "text-sm w-64",
onChange: async (e: Event) => {
const newTitle = (e.target as HTMLInputElement).value.trim();
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
await storage.sessions.updateTitle(currentSessionId, newTitle);
currentTitle = newTitle;
}
isEditingTitle = false;
renderApp();
},
onKeyDown: async (e: KeyboardEvent) => {
if (e.key === "Enter") {
const newTitle = (e.target as HTMLInputElement).value.trim();
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
await storage.sessions.updateTitle(currentSessionId, newTitle);
currentTitle = newTitle;
}
isEditingTitle = false;
renderApp();
} else if (e.key === "Escape") {
isEditingTitle = false;
renderApp();
}
},
})}
</div>`
: html`<button
class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors"
@click=${() => {
isEditingTitle = true;
renderApp();
requestAnimationFrame(() => {
const input = app?.querySelector('input[type="text"]') as HTMLInputElement;
if (input) {
input.focus();
input.select();
}
});
}}
title="Click to edit title"
>
${currentTitle}
</button>`
: html`<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>`
}
</div>
<div class="flex items-center gap-1 px-2">
${Button({
variant: "ghost",
size: "sm",
children: icon(Bell, "sm"),
onClick: () => {
// Demo: Inject custom message (will appear on next agent run)
if (agent) {
agent.steer(
createSystemNotification(
"This is a custom message! It appears in the UI but is never sent to the LLM.",
),
);
}
},
title: "Demo: Add Custom Notification",
})}
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
size: "sm",
children: icon(Settings, "sm"),
onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
title: "Settings",
})}
</div>
</div>
<!-- Right header controls -->
<div class="flex items-center gap-1 px-2">
<!-- Cost tracker -->
${costTracker}
<!-- Memory manager -->
${Button({ variant: "ghost", size: "sm", children: icon(Brain, "sm"),
onClick: () => memoryManager.show(), title: "Memory Manager" })}
<!-- Export -->
${Button({ variant: "ghost", size: "sm", children: icon(Download, "sm"),
onClick: () => handleExport(), title: "Export Session (Ctrl+E)" })}
<!-- Keyboard shortcuts -->
${Button({ variant: "ghost", size: "sm", children: icon(Keyboard, "sm"),
onClick: () => keyboardShortcuts.show(), title: "Keyboard Shortcuts (?)" })}
<!-- Command palette -->
${Button({ variant: "ghost", size: "sm",
children: html`<span class="text-xs font-mono px-1">&#x2318;K</span>`,
onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })}
<theme-toggle></theme-toggle>
${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"),
onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
title: "Settings" })}
</div>
</div>
<!-- Chat Panel -->
${chatPanel}
</div>
`;
<!-- Chat Panel -->
${chatPanel}
</div>
`;
render(appHtml, app);
render(appHtml, app);
};
// ============================================================================
// INIT
// ============================================================================
async function initApp() {
const app = document.getElementById("app");
if (!app) throw new Error("App container not found");
const app = document.getElementById("app");
if (!app) throw new Error("App container not found");
// Show loading
render(
html`
<div class="w-full h-screen flex items-center justify-center bg-background text-foreground">
<div class="text-muted-foreground">Loading...</div>
</div>
`,
app,
);
render(
html`<div class="w-full h-screen flex items-center justify-center bg-background text-foreground">
<div class="text-muted-foreground">Loading...</div>
</div>`,
app,
);
// TODO: Fix PersistentStorageDialog - currently broken
// Request persistent storage
// if (storage.sessions) {
// await PersistentStorageDialog.request();
// }
chatPanel = new ChatPanel();
setupCommands();
// Create ChatPanel
chatPanel = new ChatPanel();
const urlParams = new URLSearchParams(window.location.search);
const sessionIdFromUrl = urlParams.get("session");
// Check for session in URL
const urlParams = new URLSearchParams(window.location.search);
const sessionIdFromUrl = urlParams.get("session");
if (sessionIdFromUrl) {
const loaded = await loadSession(sessionIdFromUrl);
if (!loaded) { newSession(); return; }
} else {
await createAgent();
}
if (sessionIdFromUrl) {
const loaded = await loadSession(sessionIdFromUrl);
if (!loaded) {
// Session doesn't exist, redirect to new session
newSession();
return;
}
} else {
await createAgent();
}
renderApp();
renderApp();
}
initApp();

View file

@ -120,3 +120,10 @@ export { i18n, setLanguage, translations } from "./utils/i18n.js";
export { applyProxyIfNeeded, createStreamFn, isCorsError, shouldUseProxyForProvider } from "./utils/proxy-utils.js";
export { VeniceModelBrowser } from "./components/VeniceModelBrowser.js";
// Venice / community tools
export { createWebSearchTool, webSearchTool } from "./tools/web-search.js";
export { createImageGenTool, imageGenTool } from "./tools/image-gen.js";
export { createTTSTool, ttsTool } from "./tools/voice-tts.js";
export { MermaidRenderer } from "./tools/renderers/MermaidRenderer.js";
export { DiffRenderer } from "./tools/renderers/DiffRenderer.js";

View file

@ -0,0 +1,98 @@
import type { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { html } from "lit";
import { GitCompare } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js";
export interface DiffDetails {
original: string;
modified: string;
filename?: string;
}
interface DiffParams {
original: string;
modified: string;
filename?: string;
}
const diffSchema = Type.Object({
original: Type.String({ description: "Original file content" }),
modified: Type.String({ description: "Modified file content" }),
filename: Type.Optional(Type.String({ description: "Filename for display" })),
});
function computeLineDiff(original: string, modified: string): Array<{ type: "add" | "remove" | "same"; line: string }> {
const oldLines = original.split("\n");
const newLines = modified.split("\n");
const result: Array<{ type: "add" | "remove" | "same"; line: string }> = [];
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
if (i >= oldLines.length) { result.push({ type: "add", line: newLines[i] }); }
else if (i >= newLines.length) { result.push({ type: "remove", line: oldLines[i] }); }
else if (oldLines[i] === newLines[i]) { result.push({ type: "same", line: oldLines[i] }); }
else {
result.push({ type: "remove", line: oldLines[i] });
result.push({ type: "add", line: newLines[i] });
}
}
return result;
}
export const diffTool: AgentTool<typeof diffSchema, DiffDetails> = {
name: "show_diff",
label: "Show Diff",
description: "Show a diff between two versions of code or text",
parameters: diffSchema,
async execute(toolCallId, params, signal) {
return {
content: [{ type: "text", text: `Diff shown for: ${params.filename || "file"}` }],
details: { original: params.original, modified: params.modified, filename: params.filename },
};
},
};
class DiffRenderer implements ToolRenderer<DiffParams, DiffDetails> {
render(params: DiffParams | undefined, result: ToolResultMessage<DiffDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (!result?.details) {
return { content: renderHeader(state, GitCompare, `Diff: ${params?.filename || "file"}`), isCustom: false };
}
const { original, modified, filename } = result.details;
const diffLines = computeLineDiff(original, modified);
const adds = diffLines.filter(l => l.type === "add").length;
const removes = diffLines.filter(l => l.type === "remove").length;
return {
content: html`
<div class="flex flex-col gap-3">
${renderHeader(state, GitCompare, html`Diff: ${filename || "file"} <span class="text-green-500 ml-2">+${adds}</span><span class="text-red-500 ml-1">-${removes}</span>`)}
<div class="rounded border border-border overflow-auto max-h-96 text-xs font-mono">
${diffLines.map((l, i) => html`
<div class="flex gap-0 ${
l.type === "add" ? "bg-green-500/10 text-green-700 dark:text-green-400" :
l.type === "remove" ? "bg-red-500/10 text-red-700 dark:text-red-400" :
"text-muted-foreground"
}">
<span class="w-6 text-center shrink-0 select-none border-r border-border px-1">${i + 1}</span>
<span class="px-2 whitespace-pre">${
l.type === "add" ? "+ " : l.type === "remove" ? "- " : " "
}${l.line}</span>
</div>
`)}
</div>
</div>
`,
isCustom: false,
};
}
}
registerToolRenderer("show_diff", new DiffRenderer());
export function createDiffTool(): AgentTool<typeof diffSchema, DiffDetails> {
return diffTool;
}

View file

@ -0,0 +1,164 @@
import type { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { html } from "lit";
import { Brain, BrainCircuit, Trash2 } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js";
export interface MemoryEntry {
id: string;
content: string;
tags: string[];
timestamp: string;
}
export interface MemoryStore {
entries: MemoryEntry[];
}
const DB_NAME = "jae-memory";
const DB_VERSION = 1;
const STORE_NAME = "memories";
let db: IDBDatabase | null = null;
async function openDB(): Promise<IDBDatabase> {
if (db) return db;
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE_NAME, { keyPath: "id" });
};
req.onsuccess = () => { db = req.result; resolve(db); };
req.onerror = () => reject(req.error);
});
}
export async function memorySave(content: string, tags: string[] = []): Promise<string> {
const db = await openDB();
const entry: MemoryEntry = { id: crypto.randomUUID(), content, tags, timestamp: new Date().toISOString() };
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve(entry.id);
tx.onerror = () => reject(tx.error);
});
}
export async function memoryLoad(): Promise<MemoryEntry[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
export async function memorySearch(query: string): Promise<MemoryEntry[]> {
const all = await memoryLoad();
const q = query.toLowerCase();
return all.filter(e => e.content.toLowerCase().includes(q) || e.tags.some(t => t.toLowerCase().includes(q)));
}
export async function memoryDelete(id: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// --- Save Memory Tool ---
const saveMemorySchema = Type.Object({
content: Type.String({ description: "Information to remember" }),
tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation" })),
});
export const saveMemoryTool: AgentTool<typeof saveMemorySchema, { id: string; content: string }> = {
name: "memory_save",
label: "Save Memory",
description: "Save a piece of information to long-term memory for future sessions.",
parameters: saveMemorySchema,
async execute(toolCallId, params, signal) {
const id = await memorySave(params.content, params.tags || []);
return {
content: [{ type: "text", text: `Memory saved with ID: ${id}` }],
details: { id, content: params.content },
};
},
};
// --- Recall Memory Tool ---
const recallMemorySchema = Type.Object({
query: Type.String({ description: "Search query to find relevant memories" }),
});
export const recallMemoryTool: AgentTool<typeof recallMemorySchema, { results: MemoryEntry[] }> = {
name: "memory_recall",
label: "Recall Memory",
description: "Search long-term memory for relevant information.",
parameters: recallMemorySchema,
async execute(toolCallId, params, signal) {
const results = await memorySearch(params.query);
const text = results.length === 0
? `No memories found for: ${params.query}`
: results.map(r => `[${r.timestamp.slice(0, 10)}] ${r.content}`).join("\n\n");
return {
content: [{ type: "text", text }],
details: { results },
};
},
};
// --- Renderers ---
class SaveMemoryRenderer implements ToolRenderer {
render(params: any, result: ToolResultMessage<{ id: string; content: string }> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
return {
content: html`
<div class="flex flex-col gap-2">
${renderHeader(state, Brain, `Memory saved`)}
${result?.details ? html`<div class="text-xs text-muted-foreground truncate">${result.details.content}</div>` : ""}
</div>
`,
isCustom: false,
};
}
}
class RecallMemoryRenderer implements ToolRenderer {
render(params: any, result: ToolResultMessage<{ results: MemoryEntry[] }> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const results = result?.details?.results || [];
return {
content: html`
<div class="flex flex-col gap-3">
${renderHeader(state, BrainCircuit, `Memory recall: ${params?.query || ""}`)}
${results.length > 0 ? html`
<div class="flex flex-col gap-1">
${results.map(r => html`
<div class="text-xs p-2 rounded border border-border">
<div class="text-muted-foreground">${r.timestamp.slice(0, 10)}</div>
<div>${r.content}</div>
</div>
`)}
</div>
` : ""}
</div>
`,
isCustom: false,
};
}
}
registerToolRenderer("memory_save", new SaveMemoryRenderer());
registerToolRenderer("memory_recall", new RecallMemoryRenderer());
export function createMemoryTools() {
return [saveMemoryTool, recallMemoryTool];
}

View file

@ -0,0 +1,103 @@
import type { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { html } from "lit";
import { GitBranch } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js";
export interface MermaidDetails {
diagram: string;
rendered: boolean;
error?: string;
}
interface MermaidParams {
diagram: string;
title?: string;
}
const mermaidSchema = Type.Object({
diagram: Type.String({ description: "Mermaid diagram source code" }),
title: Type.Optional(Type.String({ description: "Optional title for the diagram" })),
});
export const mermaidTool: AgentTool<typeof mermaidSchema, MermaidDetails> = {
name: "render_diagram",
label: "Render Diagram",
description: "Render a Mermaid diagram (flowchart, sequence, gantt, class diagram, etc.)",
parameters: mermaidSchema,
async execute(toolCallId, params, signal) {
return {
content: [{ type: "text", text: `Diagram rendered: ${params.title || "Untitled"}` }],
details: { diagram: params.diagram, rendered: true },
};
},
};
let mermaidLoaded = false;
async function loadMermaid(): Promise<any> {
if ((window as any).mermaid) return (window as any).mermaid;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
script.onload = () => {
const m = (window as any).mermaid;
m.initialize({ startOnLoad: false, theme: document.documentElement.classList.contains("dark") ? "dark" : "default" });
mermaidLoaded = true;
resolve(m);
};
script.onerror = reject;
document.head.appendChild(script);
});
}
class MermaidRenderer implements ToolRenderer<MermaidParams, MermaidDetails> {
render(params: MermaidParams | undefined, result: ToolResultMessage<MermaidDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const diagram = result?.details?.diagram || params?.diagram || "";
const title = params?.title || "Diagram";
if (!diagram) {
return { content: renderHeader(state, GitBranch, "Rendering diagram..."), isCustom: false };
}
const containerId = `mermaid-${Math.random().toString(36).slice(2)}`;
const renderDiagram = async (container: HTMLElement) => {
try {
const mermaid = await loadMermaid();
const { svg } = await mermaid.render(containerId + "-svg", diagram);
container.innerHTML = svg;
container.style.maxWidth = "100%";
const svgEl = container.querySelector("svg");
if (svgEl) {
svgEl.style.maxWidth = "100%";
svgEl.style.height = "auto";
}
} catch (err: any) {
container.innerHTML = `<div class="text-destructive text-sm p-2">Diagram error: ${err.message}</div>`;
}
};
return {
content: html`
<div class="flex flex-col gap-3">
${renderHeader(state, GitBranch, `Diagram: ${title}`)}
<div
class="rounded border border-border bg-background p-4 overflow-x-auto"
${(el: HTMLElement | undefined) => el && renderDiagram(el)}
></div>
</div>
`,
isCustom: false,
};
}
}
registerToolRenderer("render_diagram", new MermaidRenderer());
export function createMermaidTool(): AgentTool<typeof mermaidSchema, MermaidDetails> {
return mermaidTool;
}

View file

@ -0,0 +1,47 @@
import { html } from "lit";
import { FileText } from "lucide";
import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
import { registerToolRenderer } from "../renderer-registry.js";
export class DiffRenderer implements ToolRenderer {
render(params: any, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult {
const rawContent = result?.content;
const resultText = Array.isArray(rawContent)
? rawContent.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n")
: typeof rawContent === "string" ? rawContent : "";
const diff = params?.diff || params?.patch || resultText || "";
const filename = params?.file || params?.filename || "";
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const label = filename ? "Diff: " + filename : "File Diff";
const lines = diff.split("\n");
return {
content: html`
<div class="space-y-3">
${renderHeader(state, FileText, label)}
${diff ? html`
<div class="overflow-auto max-h-96 rounded-lg border border-border">
<pre class="text-xs font-mono p-4">${lines.map((line: string) => {
let cls = "block";
if (line.startsWith("+") && !line.startsWith("+++")) cls = "text-green-500 bg-green-500/10 block px-1";
else if (line.startsWith("-") && !line.startsWith("---")) cls = "text-red-500 bg-red-500/10 block px-1";
else if (line.startsWith("@@")) cls = "text-blue-400 block px-1";
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "text-muted-foreground block px-1";
else cls = "block px-1";
return html`<span class=${cls}>${line}</span>
`;
})}</pre>
</div>
` : ""}
</div>
`,
isCustom: false,
};
}
}
registerToolRenderer("diff", new DiffRenderer());
registerToolRenderer("patch", new DiffRenderer());

View file

@ -0,0 +1,53 @@
import { html } from "lit";
import { GitBranch } from "lucide";
import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
import { registerToolRenderer } from "../renderer-registry.js";
export class MermaidRenderer implements ToolRenderer {
render(params: any, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult {
const rawContent = result?.content;
const resultText = Array.isArray(rawContent)
? rawContent.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n")
: typeof rawContent === "string" ? rawContent : "";
const diagram = params?.diagram || params?.code || resultText || "";
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const id = "mermaid-" + Math.random().toString(36).slice(2);
return {
content: html`
<div class="space-y-3">
${renderHeader(state, GitBranch, "Diagram")}
${diagram ? html`
<div class="p-4 bg-background rounded-lg border border-border overflow-auto">
<div .id=${id} class="mermaid">${diagram}</div>
<script>
(function() {
var el = document.getElementById('${id}');
if (!el) return;
if (window.mermaid) {
window.mermaid.initialize({ startOnLoad: false });
window.mermaid.run({ nodes: [el] });
} else {
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js';
s.onload = function() {
window.mermaid.initialize({ startOnLoad: false });
window.mermaid.run({ nodes: [el] });
};
document.head.appendChild(s);
}
})();
</script>
</div>
` : ""}
</div>
`,
isCustom: false,
};
}
}
registerToolRenderer("mermaid", new MermaidRenderer());
registerToolRenderer("diagram", new MermaidRenderer());