import "@mariozechner/mini-lit/dist/ThemeToggle.js"; import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core"; import { getModel } from "@jaeswift/jae-ai"; import { type AgentState, ApiKeyPromptDialog, AppStorage, ChatPanel, CustomProvidersStore, createJavaScriptReplTool, IndexedDBStorageBackend, ProviderKeysStore, ProvidersModelsTab, ProxyTab, SessionListDialog, SessionsStore, SettingsDialog, SettingsStore, setAppStorage, } from "@jaeswift/jae-web-ui"; import { html, render } from "lit"; import { Brain, Download, History, Keyboard, Plus, Settings } from "lucide"; import "./app.css"; import { createImageGenTool, createTTSTool, createWebSearchTool } from "@jaeswift/jae-web-ui"; 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 type { CommandPalette } from "./components/command-palette.js"; import type { CostTracker } from "./components/cost-tracker.js"; import type { KeyboardShortcuts } from "./components/keyboard-shortcuts.js"; import type { MemoryManager } from "./components/memory-manager.js"; import { exportSessionAsJson, exportSessionAsMarkdown } from "./components/session-export.js"; import { customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js"; import "./components/command-palette.js"; import "./components/keyboard-shortcuts.js"; import "./components/memory-manager.js"; import "./components/cost-tracker.js"; import { JaeEmptyState } from "./components/empty-state.js"; import type { JaeUtilityToggle, UtilityVisibility } from "./components/utility-toggle.js"; import "./components/empty-state.js"; import "./components/utility-toggle.js"; import type { JaeSessionSidebar } from "./components/session-sidebar.js"; import "./components/session-sidebar.js"; import type { JaeBrowserPanel } from "./components/browser-panel.js"; import type { JaeTerminalPanel } from "./components/terminal-panel.js"; import "./components/terminal-panel.js"; import "./components/browser-panel.js"; registerCustomMessageRenderers(); const settings = new SettingsStore(); const providerKeys = new ProviderKeysStore(); const sessions = new SessionsStore(); const customProviders = new CustomProvidersStore(); const configs = [ settings.getConfig(), SessionsStore.getMetadataConfig(), providerKeys.getConfig(), customProviders.getConfig(), sessions.getConfig(), ]; const backend = new IndexedDBStorageBackend({ dbName: "jae-web-ui-example", version: 2, stores: configs, }); settings.setBackend(backend); providerKeys.setBackend(backend); customProviders.setBackend(backend); sessions.setBackend(backend); const storage = new AppStorage(settings, providerKeys, sessions, customProviders, backend); setAppStorage(storage); let currentSessionId: string | undefined; let currentTitle = ""; let isEditingTitle = false; let agent: Agent; let rightPanel: "none" | "terminal" | "browser" = "none"; let sidebarWidth = 220; let rightPanelWidth = 480; let hasStarted = false; let terminalPanel: JaeTerminalPanel | null = null; const browserPanel: JaeBrowserPanel | null = null; let chatPanel: ChatPanel; let agentUnsubscribe: (() => void) | undefined; 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; const sidebar = document.createElement("jae-session-sidebar") as JaeSessionSidebar; sidebar.onLoadSession = async (id: string) => { await loadSession(id); }; sidebar.onNewSession = () => newSession(); sidebar.addEventListener("delete-session", async (e: Event) => { const id = (e as CustomEvent).detail; if (storage.sessions) { await storage.sessions.delete(id); if (id === currentSessionId) newSession(); await refreshSidebar(); } }); const utilityToggle = document.createElement("jae-utility-toggle") as JaeUtilityToggle; utilityToggle.addEventListener("visibility-change", (e: Event) => { const vis = (e as CustomEvent).detail; const chatEl = document.getElementById("chat-wrapper"); if (chatEl) { chatEl.classList.toggle("hide-tool-calls", !vis.showToolCalls); chatEl.classList.toggle("hide-thinking", !vis.showThinking); chatEl.classList.toggle("hide-system-msgs", !vis.showSystemMessages); chatEl.classList.toggle("hide-timestamps", !vis.showTimestamps); } }); document.body.appendChild(commandPalette); document.body.appendChild(keyboardShortcuts); document.body.appendChild(memoryManager); const refreshSidebar = async () => { if (storage.sessions) { const all = await storage.sessions.getAllMetadata(); sidebar.setSessions(all); sidebar.currentSessionId = currentSessionId; } }; 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(); } }); 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", "markdown"], action: () => handleExport("markdown"), }, { id: "export-json", label: "Export as JSON", description: "Download current session as .json", keywords: ["export", "download", "json"], action: () => handleExport("json"), }, { id: "memory", label: "Memory Manager", description: "Browse and manage stored memories", keywords: ["memory", "remember", "recall"], action: () => memoryManager.show(), }, { id: "settings", label: "Settings", description: "Configure providers and models", keywords: ["settings", "config", "provider", "api", "model"], action: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), }, { id: "shortcuts", label: "Keyboard Shortcuts", description: "View all keyboard shortcuts", shortcut: "?", keywords: ["keyboard", "shortcuts", "help"], action: () => keyboardShortcuts.show(), }, { id: "cost", label: "Token Usage & Cost", description: "View API usage stats for this session", keywords: ["tokens", "cost", "usage"], action: () => costTracker.dispatchEvent(new MouseEvent("click")), }, ]); } function handleExport(format: "markdown" | "json" = "markdown") { if (!agent) return; const messages = agent.state.messages; const title = currentTitle || "JAE Session"; if (format === "markdown") exportSessionAsMarkdown(messages, title); else exportSessionAsJson(messages, title); } const generateTitle = (messages: AgentMessage[]): string => { const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments"); if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return ""; let text = ""; const content = firstUserMsg.content; if (typeof content === "string") { text = content; } else { const textBlocks = content.filter((c: any) => c.type === "text"); text = textBlocks.map((c: any) => c.text || "").join(" "); } text = text.trim(); if (!text) return ""; const sentenceEnd = text.search(/[.!?]/); if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1); return text.length <= 50 ? text : text.substring(0, 47) + "..."; }; const shouldSaveSession = (messages: AgentMessage[]): boolean => { const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments"); const hasAssistantMsg = messages.some((m: any) => m.role === "assistant"); return hasUserMsg && hasAssistantMsg; }; const saveSession = async () => { if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return; const state = agent.state; if (!shouldSaveSession(state.messages)) return; try { const sessionData = { id: currentSessionId, title: currentTitle, model: state.model!, thinkingLevel: state.thinkingLevel, messages: state.messages, createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), }; const metadata = { id: currentSessionId, title: currentTitle, createdAt: sessionData.createdAt, lastModified: sessionData.lastModified, messageCount: state.messages.length, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, modelId: state.model?.id || null, thinkingLevel: state.thinkingLevel, preview: generateTitle(state.messages), }; await storage.sessions.save(sessionData, metadata); await refreshSidebar(); } catch (err) { console.error("Failed to save session:", err); } }; const updateUrl = (sessionId: string) => { const url = new URL(window.location.href); url.searchParams.set("session", sessionId); window.history.replaceState({}, "", url); }; const createAgent = async (initialState?: Partial) => { if (agentUnsubscribe) agentUnsubscribe(); agent = new Agent({ initialState: initialState || { systemPrompt: "You are JAE, a helpful AI assistant and coding agent with access to tools including web search, image generation, JavaScript REPL, text-to-speech, and artifact creation. Use these tools whenever helpful.", model: getModel("venice", "llama-3.3-70b"), thinkingLevel: "off", messages: [], tools: [], }, convertToLlm: customConvertToLlm, onApiKeyRequired: async (provider: string) => { const key = await ApiKeyPromptDialog.prompt(provider); if (key) await providerKeys.set(provider, key); return key; }, getProviderApiKey: async (provider: string) => providerKeys.get(provider), onStateChange: async (state: AgentState, prevState: AgentState | undefined) => { if (state.messages.length > 0) hasStarted = true; 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); } } await saveSession(); } renderApp(); }, createTools: async (runtimeProvidersFactory: any) => { const replTool = createJavaScriptReplTool(); replTool.runtimeProvidersFactory = runtimeProvidersFactory; return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()]; }, }); costTracker.bindAgent(agent); chatPanel?.setAgent(agent); if (!currentSessionId) currentSessionId = crypto.randomUUID(); }; const loadSession = async (sessionId: string): Promise => { if (!storage.sessions) return false; const sessionData = await storage.sessions.get(sessionId); if (!sessionData) return false; currentSessionId = sessionId; hasStarted = sessionData.messages.length > 0; const metadata = await storage.sessions.getMetadata(sessionId); currentTitle = metadata?.title || ""; await createAgent({ model: sessionData.model, thinkingLevel: sessionData.thinkingLevel, messages: sessionData.messages, tools: [], }); sidebar.currentSessionId = currentSessionId; updateUrl(sessionId); renderApp(); return true; }; const newSession = () => { currentSessionId = undefined; currentTitle = ""; isEditingTitle = false; hasStarted = false; createAgent().then(() => renderApp()); }; const handleSuggestion = (e: Event) => { const text = (e as CustomEvent).detail; if (!text) return; // Try ChatPanel.agentInterface.setInput first if (chatPanel?.agentInterface) { chatPanel.agentInterface.setInput(text); // Focus the textarea after injection requestAnimationFrame(() => { const ta = (document.querySelector("message-editor textarea") as HTMLTextAreaElement) || (document.querySelector("textarea") as HTMLTextAreaElement); if (ta) ta.focus(); }); } else { const ta = (document.querySelector("message-editor textarea") as HTMLTextAreaElement) || (document.querySelector("textarea") as HTMLTextAreaElement); if (ta) { ta.value = text; ta.dispatchEvent(new Event("input", { bubbles: true })); ta.focus(); } } }; const getModelLabel = (): string | null => { if (!agent?.state?.model) return null; const m = agent.state.model as any; return m.name || m.id || null; }; const renderApp = () => { const app = document.getElementById("app"); if (!app) return; const hasMessages = hasStarted || !!agent?.state?.messages?.length; render( html`
${Button({ variant: "ghost", size: "sm", children: icon(History, "sm"), onClick: () => SessionListDialog.open( async (id) => { await loadSession(id); }, (id) => { if (id === currentSessionId) newSession(); }, ), title: "Sessions", })} ${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session (Ctrl+N)" })}
JAE ${getModelLabel() ? html`${getModelLabel()}` : html``}
${ currentTitle ? isEditingTitle ? html`
${Input({ type: "text", value: currentTitle, className: "text-sm w-64", onChange: async (e: Event) => { const v = (e.target as HTMLInputElement).value.trim(); if (v && v !== currentTitle && storage.sessions && currentSessionId) { await storage.sessions.updateTitle(currentSessionId, v); currentTitle = v; await refreshSidebar(); } isEditingTitle = false; renderApp(); }, onKeyDown: async (e: KeyboardEvent) => { if (e.key === "Enter") { const v = (e.target as HTMLInputElement).value.trim(); if (v && v !== currentTitle && storage.sessions && currentSessionId) { await storage.sessions.updateTitle(currentSessionId, v); currentTitle = v; await refreshSidebar(); } isEditingTitle = false; renderApp(); } else if (e.key === "Escape") { isEditingTitle = false; renderApp(); } }, })}
` : html`` : html`` }
${costTracker} ${Button({ variant: "ghost", size: "sm", children: icon(Brain, "sm"), onClick: () => memoryManager.show(), title: "Memory Manager" })} ${Button({ variant: "ghost", size: "sm", children: icon(Download, "sm"), onClick: () => handleExport(), title: "Export Session (Ctrl+E)" })} ${Button({ variant: "ghost", size: "sm", children: icon(Keyboard, "sm"), onClick: () => keyboardShortcuts.show(), title: "Keyboard Shortcuts (?)" })} ${Button({ variant: "ghost", size: "sm", children: html`⌘K`, onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })} ${utilityToggle} ${Button({ variant: "ghost", size: "sm", children: html``, onClick: () => { rightPanel = rightPanel === "terminal" ? "none" : "terminal"; renderApp(); if (rightPanel === "terminal") requestAnimationFrame(() => { terminalPanel = document.querySelector("jae-terminal-panel") as JaeTerminalPanel; terminalPanel?.connect(); }); }, title: "Toggle Terminal", })} ${Button({ variant: "ghost", size: "sm", children: html``, onClick: () => { rightPanel = rightPanel === "browser" ? "none" : "browser"; renderApp(); }, title: "Toggle Browser", })} ${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), title: "Settings" })}
{ e.preventDefault(); const sx = e.clientX, sw = sidebarWidth; const mv = (me: MouseEvent) => { sidebarWidth = Math.max(150, Math.min(420, sw + me.clientX - sx)); const w = document.getElementById("sidebar-wrap"); if (w) w.style.width = sidebarWidth + "px"; }; const up = () => { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); renderApp(); }; document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up); }} @mouseenter=${(e: Event) => { (e.currentTarget as HTMLElement).style.background = "rgba(128,128,128,0.4)"; }} @mouseleave=${(e: Event) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} >
${chatPanel}
${ rightPanel !== "none" ? html`
{ e.preventDefault(); const sx = e.clientX, sw = rightPanelWidth; const mv = (me: MouseEvent) => { rightPanelWidth = Math.max(280, Math.min(800, sw - (me.clientX - sx))); const p = document.getElementById("right-panel"); if (p) p.style.width = rightPanelWidth + "px"; }; const up = () => { document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up); renderApp(); }; document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up); }} @mouseenter=${(e: Event) => { (e.currentTarget as HTMLElement).style.background = "rgba(128,128,128,0.4)"; }} @mouseleave=${(e: Event) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} >
${rightPanel === "terminal" ? html`` : html``} ${rightPanel === "browser" ? html`` : html``}
` : html`` }
`, app, ); }; async function initApp() { const app = document.getElementById("app"); if (!app) throw new Error("App container not found"); render( html`
Loading...
`, app, ); chatPanel = new ChatPanel(); setupCommands(); await refreshSidebar(); const sessionIdFromUrl = new URLSearchParams(window.location.search).get("session"); if (sessionIdFromUrl) { const loaded = await loadSession(sessionIdFromUrl); if (!loaded) { newSession(); return; } } else { await createAgent(); } renderApp(); } initApp();