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
Some checks are pending
CI / build-check-test (push) Waiting to run
This commit is contained in:
parent
903540fa95
commit
4cdf01ba9e
14 changed files with 2392 additions and 332 deletions
1144
package-lock.json
generated
1144
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,12 +11,14 @@
|
||||||
"clean": "shx rm -rf dist"
|
"clean": "shx rm -rf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/mini-lit": "^0.2.0",
|
|
||||||
"@jaeswift/jae-ai": "file:../../ai",
|
"@jaeswift/jae-ai": "file:../../ai",
|
||||||
"@jaeswift/jae-web-ui": "file:../",
|
"@jaeswift/jae-web-ui": "file:../",
|
||||||
|
"@mariozechner/mini-lit": "^0.2.0",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"diff2html": "^3.4.56",
|
||||||
"lit": "^3.3.1",
|
"lit": "^3.3.1",
|
||||||
"lucide": "^0.544.0"
|
"lucide": "^0.544.0",
|
||||||
|
"mermaid": "^11.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|
|
||||||
120
packages/web-ui/example/src/components/command-palette.ts
Normal file
120
packages/web-ui/example/src/components/command-palette.ts
Normal 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">⌘</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>↑↓ Navigate</span>
|
||||||
|
<span>↵ Select</span>
|
||||||
|
<span>ESC Close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
packages/web-ui/example/src/components/cost-tracker.ts
Normal file
97
packages/web-ui/example/src/components/cost-tracker.ts
Normal 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;
|
||||||
|
}
|
||||||
60
packages/web-ui/example/src/components/keyboard-shortcuts.ts
Normal file
60
packages/web-ui/example/src/components/keyboard-shortcuts.ts
Normal 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()}>✕</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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
packages/web-ui/example/src/components/memory-manager.ts
Normal file
148
packages/web-ui/example/src/components/memory-manager.ts
Normal 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">🧠 Memory Manager</h2>
|
||||||
|
<button class="text-muted-foreground hover:text-foreground" @click=${() => this.hide()}>✕</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">🗑</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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/web-ui/example/src/components/session-export.ts
Normal file
45
packages/web-ui/example/src/components/session-export.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,41 @@
|
||||||
|
|
||||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||||
import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core";
|
import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core";
|
||||||
import { getModel } from "@jaeswift/jae-ai";
|
import { getModel } from "@jaeswift/jae-ai";
|
||||||
import {
|
import {
|
||||||
type AgentState,
|
type AgentState,
|
||||||
ApiKeyPromptDialog,
|
ApiKeyPromptDialog,
|
||||||
AppStorage,
|
AppStorage,
|
||||||
ChatPanel,
|
ChatPanel,
|
||||||
CustomProvidersStore,
|
CustomProvidersStore,
|
||||||
createJavaScriptReplTool,
|
createJavaScriptReplTool,
|
||||||
IndexedDBStorageBackend,
|
IndexedDBStorageBackend,
|
||||||
// PersistentStorageDialog, // TODO: Fix - currently broken
|
ProviderKeysStore,
|
||||||
ProviderKeysStore,
|
ProvidersModelsTab,
|
||||||
ProvidersModelsTab,
|
ProxyTab,
|
||||||
ProxyTab,
|
SessionListDialog,
|
||||||
SessionListDialog,
|
SessionsStore,
|
||||||
SessionsStore,
|
SettingsDialog,
|
||||||
SettingsDialog,
|
SettingsStore,
|
||||||
SettingsStore,
|
setAppStorage,
|
||||||
setAppStorage,
|
|
||||||
} from "@jaeswift/jae-web-ui";
|
} from "@jaeswift/jae-web-ui";
|
||||||
import { html, render } from "lit";
|
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 "./app.css";
|
||||||
import { icon } from "@mariozechner/mini-lit";
|
import { icon } from "@mariozechner/mini-lit";
|
||||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||||
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
||||||
import { createSystemNotification, customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.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
|
// Register custom message renderers
|
||||||
registerCustomMessageRenderers();
|
registerCustomMessageRenderers();
|
||||||
|
|
@ -36,29 +46,25 @@ const providerKeys = new ProviderKeysStore();
|
||||||
const sessions = new SessionsStore();
|
const sessions = new SessionsStore();
|
||||||
const customProviders = new CustomProvidersStore();
|
const customProviders = new CustomProvidersStore();
|
||||||
|
|
||||||
// Gather configs
|
|
||||||
const configs = [
|
const configs = [
|
||||||
settings.getConfig(),
|
settings.getConfig(),
|
||||||
SessionsStore.getMetadataConfig(),
|
SessionsStore.getMetadataConfig(),
|
||||||
providerKeys.getConfig(),
|
providerKeys.getConfig(),
|
||||||
customProviders.getConfig(),
|
customProviders.getConfig(),
|
||||||
sessions.getConfig(),
|
sessions.getConfig(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create backend
|
|
||||||
const backend = new IndexedDBStorageBackend({
|
const backend = new IndexedDBStorageBackend({
|
||||||
dbName: "jae-web-ui-example",
|
dbName: "jae-web-ui-example",
|
||||||
version: 2, // Incremented for custom-providers store
|
version: 2,
|
||||||
stores: configs,
|
stores: configs,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wire backend to stores
|
|
||||||
settings.setBackend(backend);
|
settings.setBackend(backend);
|
||||||
providerKeys.setBackend(backend);
|
providerKeys.setBackend(backend);
|
||||||
customProviders.setBackend(backend);
|
customProviders.setBackend(backend);
|
||||||
sessions.setBackend(backend);
|
sessions.setBackend(backend);
|
||||||
|
|
||||||
// Create and set app storage
|
|
||||||
const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend);
|
const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend);
|
||||||
setAppStorage(storage);
|
setAppStorage(storage);
|
||||||
|
|
||||||
|
|
@ -69,353 +75,323 @@ let agent: Agent;
|
||||||
let chatPanel: ChatPanel;
|
let chatPanel: ChatPanel;
|
||||||
let agentUnsubscribe: (() => void) | undefined;
|
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 generateTitle = (messages: AgentMessage[]): string => {
|
||||||
const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments");
|
const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments");
|
||||||
if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return "";
|
if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return "";
|
||||||
|
let text = "";
|
||||||
let text = "";
|
const content = firstUserMsg.content;
|
||||||
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(" "); }
|
||||||
if (typeof content === "string") {
|
text = text.trim();
|
||||||
text = content;
|
if (!text) return "";
|
||||||
} else {
|
const sentenceEnd = text.search(/[.!?]/);
|
||||||
const textBlocks = content.filter((c: any) => c.type === "text");
|
if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1);
|
||||||
text = textBlocks.map((c: any) => c.text || "").join(" ");
|
return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
|
||||||
}
|
|
||||||
|
|
||||||
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 shouldSaveSession = (messages: AgentMessage[]): boolean => {
|
||||||
const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments");
|
const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments");
|
||||||
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
|
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
|
||||||
return hasUserMsg && hasAssistantMsg;
|
return hasUserMsg && hasAssistantMsg;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSession = async () => {
|
const saveSession = async () => {
|
||||||
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
|
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
|
||||||
|
const state = agent.state;
|
||||||
const state = agent.state;
|
if (!shouldSaveSession(state.messages)) return;
|
||||||
if (!shouldSaveSession(state.messages)) return;
|
try {
|
||||||
|
const sessionData = {
|
||||||
try {
|
id: currentSessionId, title: currentTitle, model: state.model!,
|
||||||
// Create session data
|
thinkingLevel: state.thinkingLevel, messages: state.messages,
|
||||||
const sessionData = {
|
createdAt: new Date().toISOString(), lastModified: new Date().toISOString(),
|
||||||
id: currentSessionId,
|
};
|
||||||
title: currentTitle,
|
const metadata = {
|
||||||
model: state.model!,
|
id: currentSessionId, title: currentTitle,
|
||||||
thinkingLevel: state.thinkingLevel,
|
createdAt: sessionData.createdAt, lastModified: sessionData.lastModified,
|
||||||
messages: state.messages,
|
messageCount: state.messages.length,
|
||||||
createdAt: new Date().toISOString(),
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||||
lastModified: new Date().toISOString(),
|
modelId: state.model?.id || null, thinkingLevel: state.thinkingLevel,
|
||||||
};
|
preview: generateTitle(state.messages),
|
||||||
|
};
|
||||||
// Create session metadata
|
await storage.sessions.save(sessionData, metadata);
|
||||||
const metadata = {
|
} catch (err) { console.error("Failed to save session:", err); }
|
||||||
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 updateUrl = (sessionId: string) => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("session", sessionId);
|
url.searchParams.set("session", sessionId);
|
||||||
window.history.replaceState({}, "", url);
|
window.history.replaceState({}, "", url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createAgent = async (initialState?: Partial<AgentState>) => {
|
const createAgent = async (initialState?: Partial<AgentState>) => {
|
||||||
if (agentUnsubscribe) {
|
if (agentUnsubscribe) agentUnsubscribe();
|
||||||
agentUnsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
agent = new Agent({
|
agent = new Agent({
|
||||||
initialState: initialState || {
|
initialState: initialState || {
|
||||||
systemPrompt: `You are a helpful AI assistant with access to various tools.
|
systemPrompt: `You are a helpful AI assistant with access to various tools.
|
||||||
|
|
||||||
Available 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
|
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
|
||||||
|
|
||||||
Feel free to use these tools when needed to provide accurate and helpful responses.`,
|
Feel free to use these tools when needed to provide accurate and helpful responses.`,
|
||||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||||
thinkingLevel: "off",
|
thinkingLevel: "off",
|
||||||
messages: [],
|
messages: [],
|
||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
// Custom transformer: convert custom messages to LLM-compatible format
|
convertToLlm: customConvertToLlm,
|
||||||
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) => {
|
// Bind cost tracker to new agent
|
||||||
if (event.type === "state-update") {
|
costTracker.bindAgent(agent);
|
||||||
const messages = event.state.messages;
|
|
||||||
|
|
||||||
// Generate title after first successful response
|
chatPanel?.setAgent(agent);
|
||||||
if (!currentTitle && shouldSaveSession(messages)) {
|
if (!currentSessionId) currentSessionId = crypto.randomUUID();
|
||||||
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()];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSession = async (sessionId: string): Promise<boolean> => {
|
const loadSession = async (sessionId: string): Promise<boolean> => {
|
||||||
if (!storage.sessions) return false;
|
if (!storage.sessions) return false;
|
||||||
|
const sessionData = await storage.sessions.get(sessionId);
|
||||||
const sessionData = await storage.sessions.get(sessionId);
|
if (!sessionData) { console.error("Session not found:", sessionId); return false; }
|
||||||
if (!sessionData) {
|
currentSessionId = sessionId;
|
||||||
console.error("Session not found:", sessionId);
|
const metadata = await storage.sessions.getMetadata(sessionId);
|
||||||
return false;
|
currentTitle = metadata?.title || "";
|
||||||
}
|
await createAgent({
|
||||||
|
model: sessionData.model, thinkingLevel: sessionData.thinkingLevel,
|
||||||
currentSessionId = sessionId;
|
messages: sessionData.messages, tools: [],
|
||||||
const metadata = await storage.sessions.getMetadata(sessionId);
|
});
|
||||||
currentTitle = metadata?.title || "";
|
updateUrl(sessionId);
|
||||||
|
renderApp();
|
||||||
await createAgent({
|
return true;
|
||||||
model: sessionData.model,
|
|
||||||
thinkingLevel: sessionData.thinkingLevel,
|
|
||||||
messages: sessionData.messages,
|
|
||||||
tools: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
updateUrl(sessionId);
|
|
||||||
renderApp();
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const newSession = () => {
|
const newSession = () => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.search = "";
|
url.search = "";
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// RENDER
|
// RENDER
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
const renderApp = () => {
|
const renderApp = () => {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
|
||||||
const appHtml = html`
|
const appHtml = html`
|
||||||
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between border-b border-border shrink-0">
|
<div class="flex items-center justify-between border-b border-border shrink-0 relative">
|
||||||
<div class="flex items-center gap-2 px-4 py-">
|
<div class="flex items-center gap-2 px-4 py-1">
|
||||||
${Button({
|
${Button({ variant: "ghost", size: "sm", children: icon(History, "sm"),
|
||||||
variant: "ghost",
|
onClick: () => SessionListDialog.open(
|
||||||
size: "sm",
|
async (id) => { await loadSession(id); },
|
||||||
children: icon(History, "sm"),
|
(id) => { if (id === currentSessionId) newSession(); }
|
||||||
onClick: () => {
|
), title: "Sessions (Ctrl+H)" })}
|
||||||
SessionListDialog.open(
|
${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session (Ctrl+N)" })}
|
||||||
async (sessionId) => {
|
${
|
||||||
await loadSession(sessionId);
|
currentTitle
|
||||||
},
|
? isEditingTitle
|
||||||
(deletedSessionId) => {
|
? html`<div class="flex items-center gap-2">
|
||||||
// Only reload if the current session was deleted
|
${Input({
|
||||||
if (deletedSessionId === currentSessionId) {
|
type: "text", value: currentTitle, className: "text-sm w-64",
|
||||||
newSession();
|
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;
|
||||||
title: "Sessions",
|
}
|
||||||
})}
|
isEditingTitle = false; renderApp();
|
||||||
${Button({
|
},
|
||||||
variant: "ghost",
|
onKeyDown: async (e: KeyboardEvent) => {
|
||||||
size: "sm",
|
if (e.key === "Enter") {
|
||||||
children: icon(Plus, "sm"),
|
const newTitle = (e.target as HTMLInputElement).value.trim();
|
||||||
onClick: newSession,
|
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
||||||
title: "New Session",
|
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>
|
||||||
|
|
||||||
${
|
<!-- Right header controls -->
|
||||||
currentTitle
|
<div class="flex items-center gap-1 px-2">
|
||||||
? isEditingTitle
|
<!-- Cost tracker -->
|
||||||
? html`<div class="flex items-center gap-2">
|
${costTracker}
|
||||||
${Input({
|
<!-- Memory manager -->
|
||||||
type: "text",
|
${Button({ variant: "ghost", size: "sm", children: icon(Brain, "sm"),
|
||||||
value: currentTitle,
|
onClick: () => memoryManager.show(), title: "Memory Manager" })}
|
||||||
className: "text-sm w-64",
|
<!-- Export -->
|
||||||
onChange: async (e: Event) => {
|
${Button({ variant: "ghost", size: "sm", children: icon(Download, "sm"),
|
||||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
onClick: () => handleExport(), title: "Export Session (Ctrl+E)" })}
|
||||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
<!-- Keyboard shortcuts -->
|
||||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
${Button({ variant: "ghost", size: "sm", children: icon(Keyboard, "sm"),
|
||||||
currentTitle = newTitle;
|
onClick: () => keyboardShortcuts.show(), title: "Keyboard Shortcuts (?)" })}
|
||||||
}
|
<!-- Command palette -->
|
||||||
isEditingTitle = false;
|
${Button({ variant: "ghost", size: "sm",
|
||||||
renderApp();
|
children: html`<span class="text-xs font-mono px-1">⌘K</span>`,
|
||||||
},
|
onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })}
|
||||||
onKeyDown: async (e: KeyboardEvent) => {
|
<theme-toggle></theme-toggle>
|
||||||
if (e.key === "Enter") {
|
${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"),
|
||||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
|
||||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
title: "Settings" })}
|
||||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
</div>
|
||||||
currentTitle = newTitle;
|
</div>
|
||||||
}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Chat Panel -->
|
<!-- Chat Panel -->
|
||||||
${chatPanel}
|
${chatPanel}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
render(appHtml, app);
|
render(appHtml, app);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// INIT
|
// INIT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
if (!app) throw new Error("App container not found");
|
if (!app) throw new Error("App container not found");
|
||||||
|
|
||||||
// Show loading
|
render(
|
||||||
render(
|
html`<div class="w-full h-screen flex items-center justify-center bg-background text-foreground">
|
||||||
html`
|
<div class="text-muted-foreground">Loading...</div>
|
||||||
<div class="w-full h-screen flex items-center justify-center bg-background text-foreground">
|
</div>`,
|
||||||
<div class="text-muted-foreground">Loading...</div>
|
app,
|
||||||
</div>
|
);
|
||||||
`,
|
|
||||||
app,
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Fix PersistentStorageDialog - currently broken
|
chatPanel = new ChatPanel();
|
||||||
// Request persistent storage
|
setupCommands();
|
||||||
// if (storage.sessions) {
|
|
||||||
// await PersistentStorageDialog.request();
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Create ChatPanel
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
chatPanel = new ChatPanel();
|
const sessionIdFromUrl = urlParams.get("session");
|
||||||
|
|
||||||
// Check for session in URL
|
if (sessionIdFromUrl) {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const loaded = await loadSession(sessionIdFromUrl);
|
||||||
const sessionIdFromUrl = urlParams.get("session");
|
if (!loaded) { newSession(); return; }
|
||||||
|
} else {
|
||||||
|
await createAgent();
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionIdFromUrl) {
|
renderApp();
|
||||||
const loaded = await loadSession(sessionIdFromUrl);
|
|
||||||
if (!loaded) {
|
|
||||||
// Session doesn't exist, redirect to new session
|
|
||||||
newSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await createAgent();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderApp();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initApp();
|
initApp();
|
||||||
|
|
|
||||||
|
|
@ -120,3 +120,10 @@ export { i18n, setLanguage, translations } from "./utils/i18n.js";
|
||||||
export { applyProxyIfNeeded, createStreamFn, isCorsError, shouldUseProxyForProvider } from "./utils/proxy-utils.js";
|
export { applyProxyIfNeeded, createStreamFn, isCorsError, shouldUseProxyForProvider } from "./utils/proxy-utils.js";
|
||||||
|
|
||||||
export { VeniceModelBrowser } from "./components/VeniceModelBrowser.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";
|
||||||
|
|
|
||||||
98
packages/web-ui/src/tools/diff-viewer.ts
Normal file
98
packages/web-ui/src/tools/diff-viewer.ts
Normal 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;
|
||||||
|
}
|
||||||
164
packages/web-ui/src/tools/memory-tool.ts
Normal file
164
packages/web-ui/src/tools/memory-tool.ts
Normal 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];
|
||||||
|
}
|
||||||
103
packages/web-ui/src/tools/mermaid-diagram.ts
Normal file
103
packages/web-ui/src/tools/mermaid-diagram.ts
Normal 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;
|
||||||
|
}
|
||||||
47
packages/web-ui/src/tools/renderers/DiffRenderer.ts
Normal file
47
packages/web-ui/src/tools/renderers/DiffRenderer.ts
Normal 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());
|
||||||
53
packages/web-ui/src/tools/renderers/MermaidRenderer.ts
Normal file
53
packages/web-ui/src/tools/renderers/MermaidRenderer.ts
Normal 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());
|
||||||
Loading…
Add table
Reference in a new issue