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"
|
||||
},
|
||||
"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",
|
||||
|
|
|
|||
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,3 +1,4 @@
|
|||
|
||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core";
|
||||
import { getModel } from "@jaeswift/jae-ai";
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
CustomProvidersStore,
|
||||
createJavaScriptReplTool,
|
||||
IndexedDBStorageBackend,
|
||||
// PersistentStorageDialog, // TODO: Fix - currently broken
|
||||
ProviderKeysStore,
|
||||
ProvidersModelsTab,
|
||||
ProxyTab,
|
||||
|
|
@ -20,12 +20,22 @@ import {
|
|||
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,7 +46,6 @@ const providerKeys = new ProviderKeysStore();
|
|||
const sessions = new SessionsStore();
|
||||
const customProviders = new CustomProvidersStore();
|
||||
|
||||
// Gather configs
|
||||
const configs = [
|
||||
settings.getConfig(),
|
||||
SessionsStore.getMetadataConfig(),
|
||||
|
|
@ -45,20 +54,17 @@ const configs = [
|
|||
sessions.getConfig(),
|
||||
];
|
||||
|
||||
// Create backend
|
||||
const backend = new IndexedDBStorageBackend({
|
||||
dbName: "jae-web-ui-example",
|
||||
version: 2, // Incremented for custom-providers store
|
||||
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,27 +75,91 @@ 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(" ");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1);
|
||||
return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
|
||||
};
|
||||
|
||||
|
|
@ -101,52 +171,24 @@ const shouldSaveSession = (messages: AgentMessage[]): boolean => {
|
|||
|
||||
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(),
|
||||
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,
|
||||
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,
|
||||
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);
|
||||
}
|
||||
} catch (err) { console.error("Failed to save session:", err); }
|
||||
};
|
||||
|
||||
const updateUrl = (sessionId: string) => {
|
||||
|
|
@ -156,16 +198,18 @@ const updateUrl = (sessionId: string) => {
|
|||
};
|
||||
|
||||
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.
|
||||
|
||||
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.`,
|
||||
|
|
@ -174,67 +218,52 @@ Feel free to use these tools when needed to provide accurate and helpful respons
|
|||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
// Custom transformer: convert custom messages to LLM-compatible format
|
||||
convertToLlm: customConvertToLlm,
|
||||
});
|
||||
|
||||
agentUnsubscribe = agent.subscribe((event: any) => {
|
||||
if (event.type === "state-update") {
|
||||
const messages = event.state.messages;
|
||||
|
||||
// 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();
|
||||
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);
|
||||
}
|
||||
|
||||
// Auto-save
|
||||
if (currentSessionId) {
|
||||
saveSession();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
|
||||
await chatPanel.setAgent(agent, {
|
||||
onApiKeyRequired: async (provider: string) => {
|
||||
return await ApiKeyPromptDialog.prompt(provider);
|
||||
}
|
||||
await saveSession();
|
||||
}
|
||||
},
|
||||
toolsFactory: (_agent, _agentInterface, _artifactsPanel, runtimeProvidersFactory) => {
|
||||
// Create javascript_repl tool with access to attachments + artifacts
|
||||
createTools: async (runtimeProvidersFactory) => {
|
||||
const replTool = createJavaScriptReplTool();
|
||||
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
|
||||
return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()];
|
||||
},
|
||||
});
|
||||
|
||||
// Bind cost tracker to new agent
|
||||
costTracker.bindAgent(agent);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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: [],
|
||||
model: sessionData.model, thinkingLevel: sessionData.thinkingLevel,
|
||||
messages: sessionData.messages, tools: [],
|
||||
});
|
||||
|
||||
updateUrl(sessionId);
|
||||
renderApp();
|
||||
return true;
|
||||
|
|
@ -256,51 +285,27 @@ const renderApp = () => {
|
|||
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",
|
||||
})}
|
||||
|
||||
<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",
|
||||
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();
|
||||
isEditingTitle = false; renderApp();
|
||||
},
|
||||
onKeyDown: async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
|
|
@ -309,60 +314,45 @@ const renderApp = () => {
|
|||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
} else if (e.key === "Escape") {
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
}
|
||||
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();
|
||||
@click=${() => { isEditingTitle = true; renderApp();
|
||||
requestAnimationFrame(() => {
|
||||
const input = app?.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
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>`
|
||||
title="Click to edit title">${currentTitle}</button>`
|
||||
: html`<span class="text-base font-semibold text-foreground">JAE Web UI</span>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Right header controls -->
|
||||
<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",
|
||||
})}
|
||||
<!-- 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">⌘K</span>`,
|
||||
onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })}
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Settings, "sm"),
|
||||
${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"),
|
||||
onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
|
||||
title: "Settings",
|
||||
})}
|
||||
title: "Settings" })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -381,36 +371,22 @@ async function initApp() {
|
|||
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">
|
||||
html`<div class="w-full h-screen flex items-center justify-center bg-background text-foreground">
|
||||
<div class="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
`,
|
||||
</div>`,
|
||||
app,
|
||||
);
|
||||
|
||||
// TODO: Fix PersistentStorageDialog - currently broken
|
||||
// Request persistent storage
|
||||
// if (storage.sessions) {
|
||||
// await PersistentStorageDialog.request();
|
||||
// }
|
||||
|
||||
// Create ChatPanel
|
||||
chatPanel = new ChatPanel();
|
||||
setupCommands();
|
||||
|
||||
// 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) {
|
||||
// Session doesn't exist, redirect to new session
|
||||
newSession();
|
||||
return;
|
||||
}
|
||||
if (!loaded) { newSession(); return; }
|
||||
} else {
|
||||
await createAgent();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
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