From 63a773184c9dffeba983114e861c5cd2964c69ca Mon Sep 17 00:00:00 2001 From: JAE Date: Thu, 26 Mar 2026 21:27:24 +0000 Subject: [PATCH] fix: provider filter tabs, model badge always visible, empty-state 4% fade, server deps (ws/concurrently) --- package-lock.json | 4 +- .../coding-agent/src/core/tools/browser.ts | 202 ++--- .../coding-agent/src/core/tools/image-gen.ts | 155 ++-- packages/coding-agent/src/core/tools/index.ts | 34 +- .../coding-agent/src/core/tools/memory.ts | 173 ++-- .../coding-agent/src/core/tools/web-fetch.ts | 101 +-- packages/web-ui/example/package.json | 6 +- .../example/src/components/browser-panel.ts | 199 +++-- .../example/src/components/command-palette.ts | 154 ++-- .../example/src/components/cost-tracker.ts | 112 +-- .../example/src/components/empty-state.ts | 55 +- .../src/components/keyboard-shortcuts.ts | 91 ++- .../example/src/components/memory-manager.ts | 184 +++-- .../example/src/components/session-export.ts | 74 +- .../example/src/components/session-sidebar.ts | 141 ++-- .../example/src/components/terminal-panel.ts | 202 ++--- .../example/src/components/utility-toggle.ts | 104 ++- packages/web-ui/example/src/main.ts | 741 ++++++++++++------ .../src/components/VeniceModelBrowser.ts | 161 ++-- packages/web-ui/src/dialogs/ModelSelector.ts | 54 ++ packages/web-ui/src/index.ts | 16 +- packages/web-ui/src/tools/diff-viewer.ts | 122 +-- packages/web-ui/src/tools/image-gen.ts | 151 ++-- packages/web-ui/src/tools/index.ts | 6 +- packages/web-ui/src/tools/memory-tool.ts | 187 ++--- packages/web-ui/src/tools/mermaid-diagram.ts | 124 +-- .../src/tools/renderers/DiffRenderer.ts | 67 +- .../src/tools/renderers/MermaidRenderer.ts | 44 +- packages/web-ui/src/tools/voice-tts.ts | 119 +-- packages/web-ui/src/tools/web-search.ts | 139 ++-- 30 files changed, 2271 insertions(+), 1651 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6fdfaf9..e185225 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10011,8 +10011,10 @@ "playwright": "^1.58.2" }, "devDependencies": { + "concurrently": "^9.0.0", "typescript": "^5.7.3", - "vite": "^7.1.6" + "vite": "^7.1.6", + "ws": "*" } }, "packages/web-ui/node_modules/@xterm/xterm": { diff --git a/packages/coding-agent/src/core/tools/browser.ts b/packages/coding-agent/src/core/tools/browser.ts index 7ffc023..4518ba9 100644 --- a/packages/coding-agent/src/core/tools/browser.ts +++ b/packages/coding-agent/src/core/tools/browser.ts @@ -1,32 +1,37 @@ - -import { writeFileSync, mkdirSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { AgentTool } from "@jaeswift/jae-agent-core"; import { type Static, Type } from "@sinclair/typebox"; const browserSchema = Type.Object({ - action: Type.Union([ - Type.Literal("navigate"), - Type.Literal("screenshot"), - Type.Literal("click"), - Type.Literal("type"), - Type.Literal("content"), - Type.Literal("close"), - ], { description: "navigate: go to URL | screenshot: capture page | click: click element | type: type into element | content: get page text | close: close browser" }), - url: Type.Optional(Type.String({ description: "URL to navigate to (for navigate action)" })), - selector: Type.Optional(Type.String({ description: "CSS selector (for click/type actions)" })), - text: Type.Optional(Type.String({ description: "Text to type (for type action)" })), - wait: Type.Optional(Type.Number({ description: "Milliseconds to wait after action (default: 1000)" })), + action: Type.Union( + [ + Type.Literal("navigate"), + Type.Literal("screenshot"), + Type.Literal("click"), + Type.Literal("type"), + Type.Literal("content"), + Type.Literal("close"), + ], + { + description: + "navigate: go to URL | screenshot: capture page | click: click element | type: type into element | content: get page text | close: close browser", + }, + ), + url: Type.Optional(Type.String({ description: "URL to navigate to (for navigate action)" })), + selector: Type.Optional(Type.String({ description: "CSS selector (for click/type actions)" })), + text: Type.Optional(Type.String({ description: "Text to type (for type action)" })), + wait: Type.Optional(Type.Number({ description: "Milliseconds to wait after action (default: 1000)" })), }); export type BrowserToolInput = Static; export interface BrowserToolDetails { - action: string; - url?: string; - screenshotPath?: string; - error?: string; + action: string; + url?: string; + screenshotPath?: string; + error?: string; } let _playwright: any = null; @@ -34,93 +39,102 @@ let _browser: any = null; let _page: any = null; async function getPlaywright() { - if (!_playwright) { - try { - const { chromium } = await import("playwright"); - _playwright = chromium; - } catch { - throw new Error("Playwright not installed. Run: npm install -g playwright && npx playwright install chromium"); - } - } - return _playwright; + if (!_playwright) { + try { + const { chromium } = await import("playwright"); + _playwright = chromium; + } catch { + throw new Error("Playwright not installed. Run: npm install -g playwright && npx playwright install chromium"); + } + } + return _playwright; } async function getPage() { - const pw = await getPlaywright(); - if (!_browser) _browser = await pw.launch({ headless: true }); - if (!_page) _page = await _browser.newPage(); - return _page; + const pw = await getPlaywright(); + if (!_browser) _browser = await pw.launch({ headless: true }); + if (!_page) _page = await _browser.newPage(); + return _page; } export const browserTool: AgentTool = { - name: "browser", - label: "Browser", - description: "Control a headless Chromium browser. Navigate pages, take screenshots, click elements, type text, and extract page content. Requires playwright.", - parameters: browserSchema, - async execute(toolCallId, params, signal) { - const { action, url, selector, text, wait = 1000 } = params; + name: "browser", + label: "Browser", + description: + "Control a headless Chromium browser. Navigate pages, take screenshots, click elements, type text, and extract page content. Requires playwright.", + parameters: browserSchema, + async execute(toolCallId, params, signal) { + const { action, url, selector, text, wait = 1000 } = params; - try { - if (action === "close") { - if (_browser) { await _browser.close(); _browser = null; _page = null; } - return { content: [{ type: "text", text: "Browser closed." }], details: { action } }; - } + try { + if (action === "close") { + if (_browser) { + await _browser.close(); + _browser = null; + _page = null; + } + return { content: [{ type: "text", text: "Browser closed." }], details: { action } }; + } - const page = await getPage(); + const page = await getPage(); - if (action === "navigate") { - if (!url) return { content: [{ type: "text", text: "Error: url required for navigate" }], details: { action } }; - await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); - await page.waitForTimeout(wait); - const title = await page.title(); - return { - content: [{ type: "text", text: `Navigated to: ${url}\nPage title: ${title}` }], - details: { action, url }, - }; - } + if (action === "navigate") { + if (!url) + return { content: [{ type: "text", text: "Error: url required for navigate" }], details: { action } }; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(wait); + const title = await page.title(); + return { + content: [{ type: "text", text: `Navigated to: ${url}\nPage title: ${title}` }], + details: { action, url }, + }; + } - if (action === "screenshot") { - const dir = join(tmpdir(), "jae-browser"); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const path = join(dir, `screenshot-${Date.now()}.png`); - await page.screenshot({ path, fullPage: false }); - const currentUrl = page.url(); - return { - content: [{ type: "text", text: `Screenshot saved to: ${path}\nCurrent URL: ${currentUrl}` }], - details: { action, url: currentUrl, screenshotPath: path }, - }; - } + if (action === "screenshot") { + const dir = join(tmpdir(), "jae-browser"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const path = join(dir, `screenshot-${Date.now()}.png`); + await page.screenshot({ path, fullPage: false }); + const currentUrl = page.url(); + return { + content: [{ type: "text", text: `Screenshot saved to: ${path}\nCurrent URL: ${currentUrl}` }], + details: { action, url: currentUrl, screenshotPath: path }, + }; + } - if (action === "content") { - const content = await page.evaluate(() => document.body.innerText); - const truncated = content.slice(0, 8000); - return { - content: [{ type: "text", text: truncated + (content.length > 8000 ? "\n...[truncated]" : "") }], - details: { action, url: page.url() }, - }; - } + if (action === "content") { + const content = await page.evaluate(() => document.body.innerText); + const truncated = content.slice(0, 8000); + return { + content: [{ type: "text", text: truncated + (content.length > 8000 ? "\n...[truncated]" : "") }], + details: { action, url: page.url() }, + }; + } - if (action === "click") { - if (!selector) return { content: [{ type: "text", text: "Error: selector required for click" }], details: { action } }; - await page.click(selector, { timeout: 10000 }); - await page.waitForTimeout(wait); - return { content: [{ type: "text", text: `Clicked: ${selector}` }], details: { action } }; - } + if (action === "click") { + if (!selector) + return { content: [{ type: "text", text: "Error: selector required for click" }], details: { action } }; + await page.click(selector, { timeout: 10000 }); + await page.waitForTimeout(wait); + return { content: [{ type: "text", text: `Clicked: ${selector}` }], details: { action } }; + } - if (action === "type") { - if (!selector) return { content: [{ type: "text", text: "Error: selector required for type" }], details: { action } }; - if (!text) return { content: [{ type: "text", text: "Error: text required for type" }], details: { action } }; - await page.fill(selector, text); - await page.waitForTimeout(wait); - return { content: [{ type: "text", text: `Typed into: ${selector}` }], details: { action } }; - } + if (action === "type") { + if (!selector) + return { content: [{ type: "text", text: "Error: selector required for type" }], details: { action } }; + if (!text) + return { content: [{ type: "text", text: "Error: text required for type" }], details: { action } }; + await page.fill(selector, text); + await page.waitForTimeout(wait); + return { content: [{ type: "text", text: `Typed into: ${selector}` }], details: { action } }; + } - return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: { action } }; - } catch (err: any) { - return { - content: [{ type: "text", text: `Browser error: ${err.message}` }], - details: { action, error: err.message }, - }; - } - }, + return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: { action } }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Browser error: ${err.message}` }], + details: { action, error: err.message }, + }; + } + }, }; diff --git a/packages/coding-agent/src/core/tools/image-gen.ts b/packages/coding-agent/src/core/tools/image-gen.ts index a88872e..7b16f02 100644 --- a/packages/coding-agent/src/core/tools/image-gen.ts +++ b/packages/coding-agent/src/core/tools/image-gen.ts @@ -1,96 +1,101 @@ - -import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { AgentTool } from "@jaeswift/jae-agent-core"; import { type Static, Type } from "@sinclair/typebox"; const imageGenSchema = Type.Object({ - prompt: Type.String({ description: "Image generation prompt" }), - model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })), - width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })), - height: Type.Optional(Type.Number({ description: "Height in pixels (default: 1024)" })), - steps: Type.Optional(Type.Number({ description: "Inference steps (default: 20)" })), - output_dir: Type.Optional(Type.String({ description: "Directory to save image (default: ./images)" })), + prompt: Type.String({ description: "Image generation prompt" }), + model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })), + width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })), + height: Type.Optional(Type.Number({ description: "Height in pixels (default: 1024)" })), + steps: Type.Optional(Type.Number({ description: "Inference steps (default: 20)" })), + output_dir: Type.Optional(Type.String({ description: "Directory to save image (default: ./images)" })), }); export type ImageGenToolInput = Static; export interface ImageGenToolDetails { - path: string; - model: string; - prompt: string; - width: number; - height: number; + path: string; + model: string; + prompt: string; + width: number; + height: number; } export const imageGenTool: AgentTool = { - name: "generate_image", - label: "Generate Image", - description: "Generate an image using Venice AI image models and save it to disk. Uses VENICE_API_KEY or OPENAI_API_KEY env var.", - parameters: imageGenSchema, - async execute(toolCallId, params, signal) { - const { - prompt, - model = "fluently-xl", - width = 1024, - height = 1024, - steps = 20, - output_dir = "./images", - } = params; + name: "generate_image", + label: "Generate Image", + description: + "Generate an image using Venice AI image models and save it to disk. Uses VENICE_API_KEY or OPENAI_API_KEY env var.", + parameters: imageGenSchema, + async execute(toolCallId, params, signal) { + const { + prompt, + model = "fluently-xl", + width = 1024, + height = 1024, + steps = 20, + output_dir = "./images", + } = params; - const apiKey = process.env.VENICE_API_KEY || process.env.OPENAI_API_KEY; - if (!apiKey) { - return { - content: [{ type: "text", text: "Error: VENICE_API_KEY or OPENAI_API_KEY environment variable not set." }], - details: { path: "", model, prompt, width, height }, - }; - } + const apiKey = process.env.VENICE_API_KEY || process.env.OPENAI_API_KEY; + if (!apiKey) { + return { + content: [{ type: "text", text: "Error: VENICE_API_KEY or OPENAI_API_KEY environment variable not set." }], + details: { path: "", model, prompt, width, height }, + }; + } - const body = JSON.stringify({ - model, - prompt, - width, - height, - steps, - return_binary: false, - safe_mode: false, - }); + const body = JSON.stringify({ + model, + prompt, + width, + height, + steps, + return_binary: false, + safe_mode: false, + }); - const res = await fetch("https://api.venice.ai/api/v1/image/generate", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${apiKey}`, - }, - body, - signal: signal ?? AbortSignal.timeout(60000), - }); + const res = await fetch("https://api.venice.ai/api/v1/image/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body, + signal: signal ?? AbortSignal.timeout(60000), + }); - if (!res.ok) { - const err = await res.text(); - return { - content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }], - details: { path: "", model, prompt, width, height }, - }; - } + if (!res.ok) { + const err = await res.text(); + return { + content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }], + details: { path: "", model, prompt, width, height }, + }; + } - const data = await res.json() as any; - const b64 = data?.images?.[0]; - if (!b64) { - return { - content: [{ type: "text", text: "No image returned from Venice API." }], - details: { path: "", model, prompt, width, height }, - }; - } + const data = (await res.json()) as any; + const b64 = data?.images?.[0]; + if (!b64) { + return { + content: [{ type: "text", text: "No image returned from Venice API." }], + details: { path: "", model, prompt, width, height }, + }; + } - if (!existsSync(output_dir)) mkdirSync(output_dir, { recursive: true }); - const filename = `gen-${Date.now()}.png`; - const filepath = join(output_dir, filename); - writeFileSync(filepath, Buffer.from(b64, "base64")); + if (!existsSync(output_dir)) mkdirSync(output_dir, { recursive: true }); + const filename = `gen-${Date.now()}.png`; + const filepath = join(output_dir, filename); + writeFileSync(filepath, Buffer.from(b64, "base64")); - return { - content: [{ type: "text", text: `Image saved to: ${filepath}\nModel: ${model}\nPrompt: ${prompt}\nSize: ${width}x${height}` }], - details: { path: filepath, model, prompt, width, height }, - }; - }, + return { + content: [ + { + type: "text", + text: `Image saved to: ${filepath}\nModel: ${model}\nPrompt: ${prompt}\nSize: ${width}x${height}`, + }, + ], + details: { path: filepath, model, prompt, width, height }, + }; + }, }; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 69662e7..c021f36 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -107,13 +107,22 @@ import { createWriteTool, createWriteToolDefinition, writeTool, writeToolDefinit export type Tool = AgentTool; export type ToolDef = ToolDefinition; - -import { webSearchTool } from "./web-search.js"; -import { webFetchTool } from "./web-fetch.js"; +import { browserTool } from "./browser.js"; import { imageGenTool } from "./image-gen.js"; import { memoryTool } from "./memory.js"; -import { browserTool } from "./browser.js"; -export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool, webSearchTool, webFetchTool, imageGenTool, memoryTool, browserTool]; +import { webFetchTool } from "./web-fetch.js"; +import { webSearchTool } from "./web-search.js"; +export const codingTools: Tool[] = [ + readTool, + bashTool, + editTool, + writeTool, + webSearchTool, + webFetchTool, + imageGenTool, + memoryTool, + browserTool, +]; export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool]; export const allTools = { @@ -198,9 +207,14 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record text.includes(w)).length / Math.max(words.length, 1); + const q = query.toLowerCase(); + const text = (entry.content + " " + entry.tags.join(" ")).toLowerCase(); + const words = q.split(/\s+/); + return words.filter((w) => text.includes(w)).length / Math.max(words.length, 1); } const memorySchema = Type.Object({ - action: Type.Union([ - Type.Literal("save"), - Type.Literal("recall"), - Type.Literal("list"), - Type.Literal("delete"), - ], { description: "save: store info | recall: search by query | list: show all | delete: remove by id" }), - content: Type.Optional(Type.String({ description: "Content to save (required for save action)" })), - query: Type.Optional(Type.String({ description: "Search query (required for recall action)" })), - tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation (optional for save)" })), - id: Type.Optional(Type.String({ description: "Memory ID (required for delete action)" })), - limit: Type.Optional(Type.Number({ description: "Max results to return for recall (default: 5)" })), + action: Type.Union([Type.Literal("save"), Type.Literal("recall"), Type.Literal("list"), Type.Literal("delete")], { + description: "save: store info | recall: search by query | list: show all | delete: remove by id", + }), + content: Type.Optional(Type.String({ description: "Content to save (required for save action)" })), + query: Type.Optional(Type.String({ description: "Search query (required for recall action)" })), + tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation (optional for save)" })), + id: Type.Optional(Type.String({ description: "Memory ID (required for delete action)" })), + limit: Type.Optional(Type.Number({ description: "Max results to return for recall (default: 5)" })), }); export type MemoryToolInput = Static; export interface MemoryToolDetails { - action: string; - count?: number; - id?: string; + action: string; + count?: number; + id?: string; } export const memoryTool: AgentTool = { - name: "memory", - label: "Memory", - description: "Persistent memory across sessions. Save facts, recall by query, list all memories, or delete by ID. Stored in ~/.jae/memory/.", - parameters: memorySchema, - async execute(toolCallId, params, signal) { - const { action, content, query, tags = [], id, limit = 5 } = params; - const memories = loadMemories(); + name: "memory", + label: "Memory", + description: + "Persistent memory across sessions. Save facts, recall by query, list all memories, or delete by ID. Stored in ~/.jae/memory/.", + parameters: memorySchema, + async execute(toolCallId, params, signal) { + const { action, content, query, tags = [], id, limit = 5 } = params; + const memories = loadMemories(); - if (action === "save") { - if (!content) return { content: [{ type: "text", text: "Error: content is required for save action" }], details: { action } }; - const entry: MemoryEntry = { - id: `mem-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - content, - tags, - createdAt: new Date().toISOString(), - }; - memories.push(entry); - saveMemories(memories); - return { content: [{ type: "text", text: `Memory saved. ID: ${entry.id}` }], details: { action, id: entry.id } }; - } + if (action === "save") { + if (!content) + return { + content: [{ type: "text", text: "Error: content is required for save action" }], + details: { action }, + }; + const entry: MemoryEntry = { + id: `mem-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + content, + tags, + createdAt: new Date().toISOString(), + }; + memories.push(entry); + saveMemories(memories); + return { + content: [{ type: "text", text: `Memory saved. ID: ${entry.id}` }], + details: { action, id: entry.id }, + }; + } - if (action === "recall") { - if (!query) return { content: [{ type: "text", text: "Error: query is required for recall action" }], details: { action } }; - const scored = memories - .map(m => ({ m, score: scoreMatch(m, query) })) - .filter(x => x.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map(x => x.m); - const text = scored.length === 0 - ? `No memories found for: ${query}` - : scored.map(m => `[${m.id}] ${m.content}${m.tags.length ? ` (tags: ${m.tags.join(", ")})` : ""}`).join("\n\n"); - return { content: [{ type: "text", text }], details: { action, count: scored.length } }; - } + if (action === "recall") { + if (!query) + return { + content: [{ type: "text", text: "Error: query is required for recall action" }], + details: { action }, + }; + const scored = memories + .map((m) => ({ m, score: scoreMatch(m, query) })) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((x) => x.m); + const text = + scored.length === 0 + ? `No memories found for: ${query}` + : scored + .map((m) => `[${m.id}] ${m.content}${m.tags.length ? ` (tags: ${m.tags.join(", ")})` : ""}`) + .join("\n\n"); + return { content: [{ type: "text", text }], details: { action, count: scored.length } }; + } - if (action === "list") { - const text = memories.length === 0 - ? "No memories stored." - : memories.map(m => `[${m.id}] ${m.content.slice(0, 100)}${m.content.length > 100 ? "..." : ""}`).join("\n"); - return { content: [{ type: "text", text }], details: { action, count: memories.length } }; - } + if (action === "list") { + const text = + memories.length === 0 + ? "No memories stored." + : memories + .map((m) => `[${m.id}] ${m.content.slice(0, 100)}${m.content.length > 100 ? "..." : ""}`) + .join("\n"); + return { content: [{ type: "text", text }], details: { action, count: memories.length } }; + } - if (action === "delete") { - if (!id) return { content: [{ type: "text", text: "Error: id is required for delete action" }], details: { action } }; - const filtered = memories.filter(m => m.id !== id); - if (filtered.length === memories.length) return { content: [{ type: "text", text: `No memory found with ID: ${id}` }], details: { action } }; - saveMemories(filtered); - return { content: [{ type: "text", text: `Memory ${id} deleted.` }], details: { action, id } }; - } + if (action === "delete") { + if (!id) + return { + content: [{ type: "text", text: "Error: id is required for delete action" }], + details: { action }, + }; + const filtered = memories.filter((m) => m.id !== id); + if (filtered.length === memories.length) + return { content: [{ type: "text", text: `No memory found with ID: ${id}` }], details: { action } }; + saveMemories(filtered); + return { content: [{ type: "text", text: `Memory ${id} deleted.` }], details: { action, id } }; + } - return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: { action } }; - }, + return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: { action } }; + }, }; diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts index 91154fb..ed21ae8 100644 --- a/packages/coding-agent/src/core/tools/web-fetch.ts +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -1,67 +1,70 @@ - import type { AgentTool } from "@jaeswift/jae-agent-core"; import { type Static, Type } from "@sinclair/typebox"; const webFetchSchema = Type.Object({ - url: Type.String({ description: "URL to fetch" }), - selector: Type.Optional(Type.String({ description: "CSS selector to extract specific content (optional)" })), + url: Type.String({ description: "URL to fetch" }), + selector: Type.Optional(Type.String({ description: "CSS selector to extract specific content (optional)" })), }); export type WebFetchToolInput = Static; export interface WebFetchToolDetails { - url: string; - status: number; - contentType: string; - truncated: boolean; + url: string; + status: number; + contentType: string; + truncated: boolean; } const MAX_CHARS = 20000; function htmlToText(html: string): string { - return html - .replace(//gi, "") - .replace(//gi, "") - .replace(/<[^>]+>/g, " ") - .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") - .replace(/"/g, '"').replace(/ /g, " ").replace(/'/g, "'") - .replace(/[ \t]+/g, " ") - .replace(/\n{3,}/g, "\n\n") - .trim(); + return html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/ /g, " ") + .replace(/'/g, "'") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); } export const webFetchTool: AgentTool = { - name: "web_fetch", - label: "Web Fetch", - description: "Fetch and read the text content of any web page or URL. Strips HTML to plain text.", - parameters: webFetchSchema, - async execute(toolCallId, params, signal) { - const { url } = params; - try { - const res = await fetch(url, { - headers: { "User-Agent": "JAE-Agent/1.0", "Accept": "text/html,application/xhtml+xml,text/plain,*/*" }, - signal: signal ?? AbortSignal.timeout(15000), - redirect: "follow", - }); - const contentType = res.headers.get("content-type") || ""; - let body = await res.text(); - let text: string; - if (contentType.includes("html")) { - text = htmlToText(body); - } else { - text = body; - } - const truncated = text.length > MAX_CHARS; - if (truncated) text = text.slice(0, MAX_CHARS) + "\n... [truncated]"; - return { - content: [{ type: "text", text: `URL: ${url}\nStatus: ${res.status}\n\n${text}` }], - details: { url, status: res.status, contentType, truncated }, - }; - } catch (err: any) { - return { - content: [{ type: "text", text: `Fetch failed for ${url}: ${err.message}` }], - details: { url, status: 0, contentType: "", truncated: false }, - }; - } - }, + name: "web_fetch", + label: "Web Fetch", + description: "Fetch and read the text content of any web page or URL. Strips HTML to plain text.", + parameters: webFetchSchema, + async execute(toolCallId, params, signal) { + const { url } = params; + try { + const res = await fetch(url, { + headers: { "User-Agent": "JAE-Agent/1.0", Accept: "text/html,application/xhtml+xml,text/plain,*/*" }, + signal: signal ?? AbortSignal.timeout(15000), + redirect: "follow", + }); + const contentType = res.headers.get("content-type") || ""; + const body = await res.text(); + let text: string; + if (contentType.includes("html")) { + text = htmlToText(body); + } else { + text = body; + } + const truncated = text.length > MAX_CHARS; + if (truncated) text = text.slice(0, MAX_CHARS) + "\n... [truncated]"; + return { + content: [{ type: "text", text: `URL: ${url}\nStatus: ${res.status}\n\n${text}` }], + details: { url, status: res.status, contentType, truncated }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Fetch failed for ${url}: ${err.message}` }], + details: { url, status: 0, contentType: "", truncated: false }, + }; + } + }, }; diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 5ca4e2b..1bc0de7 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -29,6 +29,8 @@ }, "devDependencies": { "typescript": "^5.7.3", - "vite": "^7.1.6" + "vite": "^7.1.6", + "ws": "*", + "concurrently": "^9.0.0" } -} \ No newline at end of file +} diff --git a/packages/web-ui/example/src/components/browser-panel.ts b/packages/web-ui/example/src/components/browser-panel.ts index f789157..265516d 100644 --- a/packages/web-ui/example/src/components/browser-panel.ts +++ b/packages/web-ui/example/src/components/browser-panel.ts @@ -1,111 +1,144 @@ -import { LitElement, html } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; -@customElement('jae-browser-panel') +@customElement("jae-browser-panel") export class JaeBrowserPanel extends LitElement { - @state() private url = ''; - @state() private inputUrl = ''; - @state() private screenshot = ''; - @state() private loading = false; - @state() private connected = false; - @state() private error = ''; + @state() private url = ""; + @state() private inputUrl = ""; + @state() private screenshot = ""; + @state() private loading = false; + @state() private connected = false; + @state() private error = ""; - private ws: WebSocket | null = null; - private imgEl: HTMLImageElement | null = null; + private ws: WebSocket | null = null; + private imgEl: HTMLImageElement | null = null; - createRenderRoot() { return this; } + createRenderRoot() { + return this; + } - override connectedCallback() { - super.connectedCallback(); - this.style.display = 'flex'; - this.style.flexDirection = 'column'; - this.style.height = '100%'; - this.style.minHeight = '0'; - this.connect(); - } + override connectedCallback() { + super.connectedCallback(); + this.style.display = "flex"; + this.style.flexDirection = "column"; + this.style.height = "100%"; + this.style.minHeight = "0"; + this.connect(); + } - override disconnectedCallback() { - super.disconnectedCallback(); - this.ws?.close(); - } + override disconnectedCallback() { + super.disconnectedCallback(); + this.ws?.close(); + } - connect() { - this.ws = new WebSocket('ws://localhost:7702'); - this.ws.onopen = () => { this.connected = true; this.requestUpdate(); }; - this.ws.onclose = () => { this.connected = false; this.requestUpdate(); }; - this.ws.onerror = () => { this.connected = false; this.error = 'Browser server not running.'; this.requestUpdate(); }; - this.ws.onmessage = (e) => { - const m = JSON.parse(e.data); - if (m.type === 'screenshot') { - this.loading = false; - this.screenshot = `data:image/jpeg;base64,${m.data}`; - this.url = m.url; - this.inputUrl = m.url; - this.error = ''; - this.requestUpdate(); - } - if (m.type === 'loading') { this.loading = true; this.requestUpdate(); } - if (m.type === 'error') { this.loading = false; this.error = m.msg; this.requestUpdate(); } - }; - } + connect() { + this.ws = new WebSocket("ws://localhost:7702"); + this.ws.onopen = () => { + this.connected = true; + this.requestUpdate(); + }; + this.ws.onclose = () => { + this.connected = false; + this.requestUpdate(); + }; + this.ws.onerror = () => { + this.connected = false; + this.error = "Browser server not running."; + this.requestUpdate(); + }; + this.ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.type === "screenshot") { + this.loading = false; + this.screenshot = `data:image/jpeg;base64,${m.data}`; + this.url = m.url; + this.inputUrl = m.url; + this.error = ""; + this.requestUpdate(); + } + if (m.type === "loading") { + this.loading = true; + this.requestUpdate(); + } + if (m.type === "error") { + this.loading = false; + this.error = m.msg; + this.requestUpdate(); + } + }; + } - navigate(url?: string) { - const target = url || this.inputUrl; - if (!target) return; - this.ws?.send(JSON.stringify({ type: 'navigate', url: target })); - this.loading = true; - this.requestUpdate(); - } + navigate(url?: string) { + const target = url || this.inputUrl; + if (!target) return; + this.ws?.send(JSON.stringify({ type: "navigate", url: target })); + this.loading = true; + this.requestUpdate(); + } - private handleImgClick(e: MouseEvent) { - const img = e.currentTarget as HTMLImageElement; - const rect = img.getBoundingClientRect(); - const scaleX = 1280 / rect.width; - const scaleY = 800 / rect.height; - const x = (e.clientX - rect.left) * scaleX; - const y = (e.clientY - rect.top) * scaleY; - this.ws?.send(JSON.stringify({ type: 'click', x, y })); - this.loading = true; - this.requestUpdate(); - } + private handleImgClick(e: MouseEvent) { + const img = e.currentTarget as HTMLImageElement; + const rect = img.getBoundingClientRect(); + const scaleX = 1280 / rect.width; + const scaleY = 800 / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + this.ws?.send(JSON.stringify({ type: "click", x, y })); + this.loading = true; + this.requestUpdate(); + } - private handleScroll(e: WheelEvent) { - e.preventDefault(); - this.ws?.send(JSON.stringify({ type: 'scroll', dy: e.deltaY })); - } + private handleScroll(e: WheelEvent) { + e.preventDefault(); + this.ws?.send(JSON.stringify({ type: "scroll", dy: e.deltaY })); + } - override render() { - return html` + override render() { + return html`
- - - { this.inputUrl = (e.target as HTMLInputElement).value; }} - @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this.navigate(); }} + @input=${(e: Event) => { + this.inputUrl = (e.target as HTMLInputElement).value; + }} + @keydown=${(e: KeyboardEvent) => { + if (e.key === "Enter") this.navigate(); + }} placeholder="Enter URL and press Enter..." /> -
+
- ${this.loading ? html` + ${ + this.loading + ? html`
Loading...
- ` : html``} - ${this.error ? html` + ` + : html`` + } + ${ + this.error + ? html`
${this.error}
- ` : html``} - ${this.screenshot ? html` + ` + : html`` + } + ${ + this.screenshot + ? html` - ` : !this.error ? html` + ` + : !this.error + ? html`

Enter a URL above to browse

- ` : html``} + ` + : html`` + }
`; - } + } } diff --git a/packages/web-ui/example/src/components/command-palette.ts b/packages/web-ui/example/src/components/command-palette.ts index 873266e..a690912 100644 --- a/packages/web-ui/example/src/components/command-palette.ts +++ b/packages/web-ui/example/src/components/command-palette.ts @@ -1,82 +1,86 @@ - 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[]; + 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; + @state() private open = false; + @state() private query = ""; + @state() private selectedIndex = 0; - private commands: Command[] = []; + private commands: Command[] = []; - protected override createRenderRoot() { return this; } + protected override createRenderRoot() { + return this; + } - setCommands(commands: Command[]) { - this.commands = commands; - } + 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(); - }); - } + 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(); - } + 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)) - ); - } + 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(); - } - } + 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; + override render() { + if (!this.open) return html``; + const cmds = this.filteredCommands; - return html` -
{ if (e.target === e.currentTarget) this.hide(); }}> + return html` +
{ + if (e.target === e.currentTarget) this.hide(); + }}>
@@ -85,20 +89,29 @@ export class CommandPalette extends LitElement { 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; }} + @input=${(e: Event) => { + this.query = (e.target as HTMLInputElement).value; + this.selectedIndex = 0; + }} @keydown=${this.handleKeyDown} /> ESC
${cmds.length === 0 ? html`
No commands found
` : ""} - ${cmds.map((cmd, i) => html` + ${cmds.map( + (cmd, i) => html` - `)} + `, + )}
↑↓ Navigate @@ -116,5 +130,5 @@ export class CommandPalette extends LitElement {
`; - } + } } diff --git a/packages/web-ui/example/src/components/cost-tracker.ts b/packages/web-ui/example/src/components/cost-tracker.ts index 552179f..5e6da19 100644 --- a/packages/web-ui/example/src/components/cost-tracker.ts +++ b/packages/web-ui/example/src/components/cost-tracker.ts @@ -1,79 +1,89 @@ - +import type { Agent } from "@jaeswift/jae-agent-core"; 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; + 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 = { - default: { input: 3.0, output: 15.0 }, + 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; + 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; + @state() private inputTokens = 0; + @state() private outputTokens = 0; + @state() private requestCount = 0; + @state() private modelId = ""; + @state() private expanded = false; - private unsubscribe?: () => void; + private unsubscribe?: () => void; - protected override createRenderRoot() { return this; } + 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; - } - } - }); - } + 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); } + 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; - } + reset() { + this.inputTokens = 0; + this.outputTokens = 0; + this.requestCount = 0; + } - override render() { - const cost = this.estimatedCost; - return html` + override render() { + const cost = this.estimatedCost; + return html` - ${this.expanded ? html` + ${ + this.expanded + ? html`
Token Usage @@ -87,11 +97,13 @@ export class CostTracker extends LitElement {
Requests${this.requestCount}
- ` : ""} + ` + : "" + } `; - } + } } export function createCostTracker(): CostTracker { - return document.createElement("cost-tracker") as CostTracker; + return document.createElement("cost-tracker") as CostTracker; } diff --git a/packages/web-ui/example/src/components/empty-state.ts b/packages/web-ui/example/src/components/empty-state.ts index 76c5bf8..406b6f4 100644 --- a/packages/web-ui/example/src/components/empty-state.ts +++ b/packages/web-ui/example/src/components/empty-state.ts @@ -1,26 +1,39 @@ -import { LitElement, html } from "lit"; +import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; @customElement("jae-empty-state") export class JaeEmptyState extends LitElement { - @property({ type: Boolean }) visible = true; + @property({ type: Boolean }) visible = true; + @property({ type: Boolean }) faded = false; - protected override createRenderRoot() { return this; } + protected override createRenderRoot() { + return this; + } - private _suggestions = [ - { icon: "💻", text: "Write me a TypeScript function that debounces API calls" }, - { icon: "🔍", text: "Search the web for the latest news on AI coding agents" }, - { icon: "🖼️", text: "Generate an image of a black dragon breathing fire" }, - { icon: "📝", text: "Explain how async/await works in JavaScript" }, - { icon: "🔧", text: "Help me debug this code and explain the issue" }, - { icon: "📊", text: "Create a Mermaid diagram of a REST API flow" }, - ]; + private _suggestions = [ + { icon: "💻", text: "Write me a TypeScript function that debounces API calls" }, + { icon: "🔍", text: "Search the web for the latest news on AI coding agents" }, + { icon: "🖼️", text: "Generate an image of a black dragon breathing fire" }, + { icon: "📝", text: "Explain how async/await works in JavaScript" }, + { icon: "🔧", text: "Help me debug this code and explain the issue" }, + { icon: "📊", text: "Create a Mermaid diagram of a REST API flow" }, + ]; - override render() { - if (!this.visible) return html``; - return html` + override render() { + if (!this.visible) return html``; + + if (this.faded) { + return html` +
+ JAE +
+ `; + } + + return html`
-
- -

Hey, I'm JAE

Your AI coding agent. I can write code, search the web, generate images, and a whole lot more.

- -
- ${this._suggestions.map(s => html` + ${this._suggestions.map( + (s) => html` - `)} + `, + )}
`; - } + } } diff --git a/packages/web-ui/example/src/components/keyboard-shortcuts.ts b/packages/web-ui/example/src/components/keyboard-shortcuts.ts index 39cc37c..d93b953 100644 --- a/packages/web-ui/example/src/components/keyboard-shortcuts.ts +++ b/packages/web-ui/example/src/components/keyboard-shortcuts.ts @@ -1,60 +1,85 @@ - import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; @customElement("keyboard-shortcuts") export class KeyboardShortcuts extends LitElement { - @state() private open = false; + @state() private open = false; - protected override createRenderRoot() { return this; } + protected override createRenderRoot() { + return this; + } - show() { this.open = true; this.requestUpdate(); } - hide() { this.open = false; this.requestUpdate(); } - toggle() { this.open = !this.open; this.requestUpdate(); } + 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" }, - ]}, - ]; + 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` -
{ if (e.target === e.currentTarget) this.hide(); }}> + override render() { + if (!this.open) return html``; + return html` +
{ + if (e.target === e.currentTarget) this.hide(); + }}>

Keyboard Shortcuts

- ${this.shortcuts.map(group => html` + ${this.shortcuts.map( + (group) => html`
${group.group}
- ${group.items.map(item => html` + ${group.items.map( + (item) => html`
${item.desc} ${item.key}
- `)} + `, + )}
- `)} + `, + )}
`; - } + } } diff --git a/packages/web-ui/example/src/components/memory-manager.ts b/packages/web-ui/example/src/components/memory-manager.ts index 2e04b7f..c9b9aed 100644 --- a/packages/web-ui/example/src/components/memory-manager.ts +++ b/packages/web-ui/example/src/components/memory-manager.ts @@ -1,12 +1,11 @@ - import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; export interface MemoryEntry { - id: string; - content: string; - tags: string[]; - timestamp: string; + id: string; + content: string; + tags: string[]; + timestamp: string; } const DB_NAME = "jae-memory"; @@ -15,91 +14,106 @@ const STORE_NAME = "memories"; let _db: IDBDatabase | null = null; async function openDB(): Promise { - 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); - }); + 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 { - 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); - }); + 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 { - 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); - }); + 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 { - 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); - }); + 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 = ""; + @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; } + 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(); } + 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))); - } + 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 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(); - } + 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` -
{ if (e.target === e.currentTarget) this.hide(); }}> + override render() { + if (!this.open) return html``; + const entries = this.filtered; + return html` +
{ + if (e.target === e.currentTarget) this.hide(); + }}>

🧠 Memory Manager

@@ -107,35 +121,43 @@ export class MemoryManager extends LitElement {
{ this.filter = (e.target as HTMLInputElement).value; }} /> + .value=${this.filter} @input=${(e: Event) => { + this.filter = (e.target as HTMLInputElement).value; + }} />
${this.loading ? html`
Loading...
` : ""} ${!this.loading && entries.length === 0 ? html`
No memories stored yet
` : ""}
- ${entries.map(entry => html` + ${entries.map( + (entry) => html`
${entry.content}
${entry.timestamp.slice(0, 10)} - ${entry.tags.map(tag => html`${tag}`)} + ${entry.tags.map((tag) => html`${tag}`)}
- `)} + `, + )}
+ @input=${(e: Event) => { + this.newContent = (e.target as HTMLTextAreaElement).value; + }}>
{ this.newTags = (e.target as HTMLInputElement).value; }} /> + .value=${this.newTags} @input=${(e: Event) => { + this.newTags = (e.target as HTMLInputElement).value; + }} />
@@ -144,5 +166,5 @@ export class MemoryManager extends LitElement {
`; - } + } } diff --git a/packages/web-ui/example/src/components/session-export.ts b/packages/web-ui/example/src/components/session-export.ts index 0d4bff6..04afb77 100644 --- a/packages/web-ui/example/src/components/session-export.ts +++ b/packages/web-ui/example/src/components/session-export.ts @@ -1,45 +1,47 @@ - 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()}*`, - ``, - `---`, - ``, - ]; + 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, ``, `---`, ``); - } - } + 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); + 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); + 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); } diff --git a/packages/web-ui/example/src/components/session-sidebar.ts b/packages/web-ui/example/src/components/session-sidebar.ts index 37f80e4..9033654 100644 --- a/packages/web-ui/example/src/components/session-sidebar.ts +++ b/packages/web-ui/example/src/components/session-sidebar.ts @@ -1,70 +1,81 @@ -import { LitElement, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; import type { SessionMetadata } from "@jaeswift/jae-web-ui"; +import { html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; @customElement("jae-session-sidebar") export class JaeSessionSidebar extends LitElement { - @property({ type: Boolean }) collapsed = false; - @property({ type: String }) currentSessionId: string | undefined = undefined; - @property({ attribute: false }) onLoadSession?: (id: string) => void; - @property({ attribute: false }) onNewSession?: () => void; + @property({ type: Boolean }) collapsed = false; + @property({ type: String }) currentSessionId: string | undefined = undefined; + @property({ attribute: false }) onLoadSession?: (id: string) => void; + @property({ attribute: false }) onNewSession?: () => void; - @state() private _sessions: SessionMetadata[] = []; - @state() private _pinnedIds: Set = new Set(); - @state() private _confirmDelete: string | null = null; + @state() private _sessions: SessionMetadata[] = []; + @state() private _pinnedIds: Set = new Set(); + @state() private _confirmDelete: string | null = null; - protected override createRenderRoot() { return this; } + protected override createRenderRoot() { + return this; + } - override connectedCallback() { - super.connectedCallback(); - const raw = localStorage.getItem("jae-pinned-sessions"); - if (raw) { try { this._pinnedIds = new Set(JSON.parse(raw)); } catch {} } - } + override connectedCallback() { + super.connectedCallback(); + const raw = localStorage.getItem("jae-pinned-sessions"); + if (raw) { + try { + this._pinnedIds = new Set(JSON.parse(raw)); + } catch {} + } + } - setSessions(sessions: SessionMetadata[]) { - this._sessions = [...sessions]; - this.requestUpdate(); - } + setSessions(sessions: SessionMetadata[]) { + this._sessions = [...sessions]; + this.requestUpdate(); + } - private _togglePin(e: Event, id: string) { - e.stopPropagation(); - const s = new Set(this._pinnedIds); - s.has(id) ? s.delete(id) : s.add(id); - this._pinnedIds = s; - localStorage.setItem("jae-pinned-sessions", JSON.stringify([...s])); - this.requestUpdate(); - } + private _togglePin(e: Event, id: string) { + e.stopPropagation(); + const s = new Set(this._pinnedIds); + s.has(id) ? s.delete(id) : s.add(id); + this._pinnedIds = s; + localStorage.setItem("jae-pinned-sessions", JSON.stringify([...s])); + this.requestUpdate(); + } - private _deleteSession(e: Event, id: string) { - e.stopPropagation(); - if (this._confirmDelete === id) { - this._confirmDelete = null; - this.dispatchEvent(new CustomEvent("delete-session", { detail: id, bubbles: true, composed: true })); - } else { - this._confirmDelete = id; - this.requestUpdate(); - setTimeout(() => { this._confirmDelete = null; this.requestUpdate(); }, 3000); - } - } + private _deleteSession(e: Event, id: string) { + e.stopPropagation(); + if (this._confirmDelete === id) { + this._confirmDelete = null; + this.dispatchEvent(new CustomEvent("delete-session", { detail: id, bubbles: true, composed: true })); + } else { + this._confirmDelete = id; + this.requestUpdate(); + setTimeout(() => { + this._confirmDelete = null; + this.requestUpdate(); + }, 3000); + } + } - private _fmt(iso: string) { - const ms = Date.now() - new Date(iso).getTime(); - if (ms < 60000) return "just now"; - if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"; - if (ms < 86400000) return Math.floor(ms / 3600000) + "h ago"; - if (ms < 604800000) return Math.floor(ms / 86400000) + "d ago"; - return new Date(iso).toLocaleDateString(); - } + private _fmt(iso: string) { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 60000) return "just now"; + if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"; + if (ms < 86400000) return Math.floor(ms / 3600000) + "h ago"; + if (ms < 604800000) return Math.floor(ms / 86400000) + "d ago"; + return new Date(iso).toLocaleDateString(); + } - override render() { - if (this.collapsed) return html``; - const pinned = this._sessions.filter(s => this._pinnedIds.has(s.id)) - .sort((a, b) => b.lastModified.localeCompare(a.lastModified)); - const rest = this._sessions.filter(s => !this._pinnedIds.has(s.id)) - .sort((a, b) => b.lastModified.localeCompare(a.lastModified)); - const sorted = [...pinned, ...rest]; + override render() { + if (this.collapsed) return html``; + const pinned = this._sessions + .filter((s) => this._pinnedIds.has(s.id)) + .sort((a, b) => b.lastModified.localeCompare(a.lastModified)); + const rest = this._sessions + .filter((s) => !this._pinnedIds.has(s.id)) + .sort((a, b) => b.lastModified.localeCompare(a.lastModified)); + const sorted = [...pinned, ...rest]; - return html` + return html`
Chats @@ -78,18 +89,26 @@ export class JaeSessionSidebar extends LitElement {
- ${sorted.length === 0 ? html` + ${ + sorted.length === 0 + ? html`
💬
No chats yet
- ` : sorted.map(s => html` + ` + : sorted.map( + (s) => html`
this.onLoadSession?.(s.id)}> - ${this._pinnedIds.has(s.id) ? html` + ${ + this._pinnedIds.has(s.id) + ? html`
- ` : html``} + ` + : html`` + }
${s.title || "Untitled"}
${this._fmt(s.lastModified)}
@@ -118,7 +137,9 @@ export class JaeSessionSidebar extends LitElement {
- `)} + `, + ) + }
@@ -127,5 +148,5 @@ export class JaeSessionSidebar extends LitElement {
`; - } + } } diff --git a/packages/web-ui/example/src/components/terminal-panel.ts b/packages/web-ui/example/src/components/terminal-panel.ts index 22b32db..6437706 100644 --- a/packages/web-ui/example/src/components/terminal-panel.ts +++ b/packages/web-ui/example/src/components/terminal-panel.ts @@ -1,115 +1,133 @@ -import { LitElement, html } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { WebLinksAddon } from '@xterm/addon-web-links'; -import '@xterm/xterm/css/xterm.css'; +import { FitAddon } from "@xterm/addon-fit"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { Terminal } from "@xterm/xterm"; +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import "@xterm/xterm/css/xterm.css"; -@customElement('jae-terminal-panel') +@customElement("jae-terminal-panel") export class JaeTerminalPanel extends LitElement { - @state() private connected = false; - @state() private connecting = false; + @state() private connected = false; + @state() private connecting = false; - private term: Terminal | null = null; - private fitAddon: FitAddon | null = null; - private ws: WebSocket | null = null; - private container: HTMLElement | null = null; - private resizeObs: ResizeObserver | null = null; + private term: Terminal | null = null; + private fitAddon: FitAddon | null = null; + private ws: WebSocket | null = null; + private container: HTMLElement | null = null; + private resizeObs: ResizeObserver | null = null; - createRenderRoot() { return this; } + createRenderRoot() { + return this; + } - override connectedCallback() { - super.connectedCallback(); - this.style.display = 'flex'; - this.style.flexDirection = 'column'; - this.style.height = '100%'; - this.style.minHeight = '0'; - } + override connectedCallback() { + super.connectedCallback(); + this.style.display = "flex"; + this.style.flexDirection = "column"; + this.style.height = "100%"; + this.style.minHeight = "0"; + } - override disconnectedCallback() { - super.disconnectedCallback(); - this.destroyTerminal(); - } + override disconnectedCallback() { + super.disconnectedCallback(); + this.destroyTerminal(); + } - private destroyTerminal() { - this.resizeObs?.disconnect(); - this.ws?.close(); - this.term?.dispose(); - this.term = null; this.ws = null; - } + private destroyTerminal() { + this.resizeObs?.disconnect(); + this.ws?.close(); + this.term?.dispose(); + this.term = null; + this.ws = null; + } - async connect() { - if (this.connected || this.connecting) return; - this.connecting = true; - this.requestUpdate(); - await this.updateComplete; + async connect() { + if (this.connected || this.connecting) return; + this.connecting = true; + this.requestUpdate(); + await this.updateComplete; - this.container = this.querySelector('#xterm-container') as HTMLElement; - if (!this.container) { this.connecting = false; return; } + this.container = this.querySelector("#xterm-container") as HTMLElement; + if (!this.container) { + this.connecting = false; + return; + } - const isDark = document.documentElement.classList.contains('dark'); - this.term = new Terminal({ - cursorBlink: true, - fontFamily: '"Fira Code", "Cascadia Code", monospace', - fontSize: 13, - theme: isDark - ? { background: '#09090b', foreground: '#e4e4e7', cursor: '#a1a1aa' } - : { background: '#ffffff', foreground: '#18181b', cursor: '#52525b' }, - }); - this.fitAddon = new FitAddon(); - this.term.loadAddon(this.fitAddon); - this.term.loadAddon(new WebLinksAddon()); - this.term.open(this.container); - this.fitAddon.fit(); + const isDark = document.documentElement.classList.contains("dark"); + this.term = new Terminal({ + cursorBlink: true, + fontFamily: '"Fira Code", "Cascadia Code", monospace', + fontSize: 13, + theme: isDark + ? { background: "#09090b", foreground: "#e4e4e7", cursor: "#a1a1aa" } + : { background: "#ffffff", foreground: "#18181b", cursor: "#52525b" }, + }); + this.fitAddon = new FitAddon(); + this.term.loadAddon(this.fitAddon); + this.term.loadAddon(new WebLinksAddon()); + this.term.open(this.container); + this.fitAddon.fit(); - this.ws = new WebSocket('ws://localhost:7701'); - this.ws.onopen = () => { - this.connected = true; this.connecting = false; - this.requestUpdate(); - }; - this.ws.onclose = () => { - this.connected = false; this.connecting = false; - this.term?.write('\r\n\x1b[31m[disconnected]\x1b[0m\r\n'); - this.requestUpdate(); - }; - this.ws.onerror = () => { - this.connecting = false; this.connected = false; - this.term?.write('\r\n\x1b[31m[connection error - is terminal server running?]\x1b[0m\r\n'); - this.requestUpdate(); - }; - this.ws.onmessage = (e) => { - const m = JSON.parse(e.data); - if (m.type === 'data') this.term?.write(m.data); - if (m.type === 'exit') this.term?.write(`\r\n\x1b[33m[process exited: ${m.code}]\x1b[0m\r\n`); - }; - this.term.onData((d) => { this.ws?.send(JSON.stringify({ type: 'input', data: d })); }); + this.ws = new WebSocket("ws://localhost:7701"); + this.ws.onopen = () => { + this.connected = true; + this.connecting = false; + this.requestUpdate(); + }; + this.ws.onclose = () => { + this.connected = false; + this.connecting = false; + this.term?.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n"); + this.requestUpdate(); + }; + this.ws.onerror = () => { + this.connecting = false; + this.connected = false; + this.term?.write("\r\n\x1b[31m[connection error - is terminal server running?]\x1b[0m\r\n"); + this.requestUpdate(); + }; + this.ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.type === "data") this.term?.write(m.data); + if (m.type === "exit") this.term?.write(`\r\n\x1b[33m[process exited: ${m.code}]\x1b[0m\r\n`); + }; + this.term.onData((d) => { + this.ws?.send(JSON.stringify({ type: "input", data: d })); + }); - this.resizeObs = new ResizeObserver(() => { - requestAnimationFrame(() => { - this.fitAddon?.fit(); - if (this.term && this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type: 'resize', cols: this.term.cols, rows: this.term.rows })); - } - }); - }); - this.resizeObs.observe(this.container); - } + this.resizeObs = new ResizeObserver(() => { + requestAnimationFrame(() => { + this.fitAddon?.fit(); + if (this.term && this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: "resize", cols: this.term.cols, rows: this.term.rows })); + } + }); + }); + this.resizeObs.observe(this.container); + } - disconnect() { this.destroyTerminal(); this.connected = false; this.requesting = false; this.requestUpdate(); } + disconnect() { + this.destroyTerminal(); + this.connected = false; + this.requesting = false; + this.requestUpdate(); + } - override render() { - return html` + override render() { + return html`
-
+
bash
- ${!this.connected && !this.connecting - ? html`` - : html``} + ${ + !this.connected && !this.connecting + ? html`` + : html`` + } ${this.connecting ? html`Connecting...` : html``} ${this.connected ? html`` : html``}
`; - } + } } diff --git a/packages/web-ui/example/src/components/utility-toggle.ts b/packages/web-ui/example/src/components/utility-toggle.ts index bf50499..ef00d65 100644 --- a/packages/web-ui/example/src/components/utility-toggle.ts +++ b/packages/web-ui/example/src/components/utility-toggle.ts @@ -1,50 +1,62 @@ -import { LitElement, html } from "lit"; +import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; export interface UtilityVisibility { - showToolCalls: boolean; - showThinking: boolean; - showSystemMessages: boolean; - showTimestamps: boolean; + showToolCalls: boolean; + showThinking: boolean; + showSystemMessages: boolean; + showTimestamps: boolean; } @customElement("jae-utility-toggle") export class JaeUtilityToggle extends LitElement { - @property({ type: Object }) visibility: UtilityVisibility = { - showToolCalls: true, - showThinking: false, - showSystemMessages: false, - showTimestamps: true, - }; + @property({ type: Object }) visibility: UtilityVisibility = { + showToolCalls: true, + showThinking: false, + showSystemMessages: false, + showTimestamps: true, + }; - @property({ type: Boolean }) open = false; + @property({ type: Boolean }) open = false; - protected override createRenderRoot() { return this; } + protected override createRenderRoot() { + return this; + } - private _toggle(key: keyof UtilityVisibility) { - this.visibility = { ...this.visibility, [key]: !this.visibility[key] }; - this.dispatchEvent(new CustomEvent("visibility-change", { - detail: this.visibility, - bubbles: true, - composed: true, - })); - } + private _toggle(key: keyof UtilityVisibility) { + this.visibility = { ...this.visibility, [key]: !this.visibility[key] }; + this.dispatchEvent( + new CustomEvent("visibility-change", { + detail: this.visibility, + bubbles: true, + composed: true, + }), + ); + } - private _items: { key: keyof UtilityVisibility; label: string; icon: string; desc: string }[] = [ - { key: "showToolCalls", label: "Tool Calls", icon: "🔧", desc: "Show web search, image gen & other tool results" }, - { key: "showThinking", label: "Thinking", icon: "🧠", desc: "Show model reasoning / thinking blocks" }, - { key: "showSystemMessages", label: "System Messages", icon: "⚙️", desc: "Show system notifications and prompts" }, - { key: "showTimestamps", label: "Timestamps", icon: "🕐", desc: "Show message timestamps" }, - ]; + private _items: { key: keyof UtilityVisibility; label: string; icon: string; desc: string }[] = [ + { + key: "showToolCalls", + label: "Tool Calls", + icon: "🔧", + desc: "Show web search, image gen & other tool results", + }, + { key: "showThinking", label: "Thinking", icon: "🧠", desc: "Show model reasoning / thinking blocks" }, + { key: "showSystemMessages", label: "System Messages", icon: "⚙️", desc: "Show system notifications and prompts" }, + { key: "showTimestamps", label: "Timestamps", icon: "🕐", desc: "Show message timestamps" }, + ]; - override render() { - const activeCount = Object.values(this.visibility).filter(Boolean).length; - return html` + override render() { + const activeCount = Object.values(this.visibility).filter(Boolean).length; + return html`
- ${this.open ? html` + ${ + this.open + ? html`
@@ -66,14 +80,18 @@ export class JaeUtilityToggle extends LitElement {
Message Filters
Control what JAE shows you
-
- ${this._items.map(item => html` + ${this._items.map( + (item) => html`
-
-
+
+
- `)} + `, + )}
@@ -97,9 +116,14 @@ export class JaeUtilityToggle extends LitElement {
-
{ this.open = false; this.requestUpdate(); }}>
- ` : html``} +
{ + this.open = false; + this.requestUpdate(); + }}>
+ ` + : html`` + }
`; - } + } } diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 2dcd933..322aee1 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -2,50 +2,50 @@ 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, + 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 { 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"; import { JaeEmptyState } from "./components/empty-state.js"; -import { JaeUtilityToggle, type UtilityVisibility } from "./components/utility-toggle.js"; +import type { JaeUtilityToggle, UtilityVisibility } from "./components/utility-toggle.js"; import "./components/empty-state.js"; import "./components/utility-toggle.js"; -import { JaeSessionSidebar } from "./components/session-sidebar.js"; +import type { JaeSessionSidebar } from "./components/session-sidebar.js"; import "./components/session-sidebar.js"; -import { JaeTerminalPanel } from './components/terminal-panel.js'; -import { JaeBrowserPanel } from './components/browser-panel.js'; -import './components/terminal-panel.js'; -import './components/browser-panel.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(); @@ -55,17 +55,17 @@ const sessions = new SessionsStore(); const customProviders = new CustomProvidersStore(); const configs = [ - settings.getConfig(), - SessionsStore.getMetadataConfig(), - providerKeys.getConfig(), - customProviders.getConfig(), - sessions.getConfig(), + settings.getConfig(), + SessionsStore.getMetadataConfig(), + providerKeys.getConfig(), + customProviders.getConfig(), + sessions.getConfig(), ]; const backend = new IndexedDBStorageBackend({ - dbName: "jae-web-ui-example", - version: 2, - stores: configs, + dbName: "jae-web-ui-example", + version: 2, + stores: configs, }); settings.setBackend(backend); @@ -80,12 +80,12 @@ let currentSessionId: string | undefined; let currentTitle = ""; let isEditingTitle = false; let agent: Agent; -let rightPanel: 'none' | 'terminal' | 'browser' = 'none'; +let rightPanel: "none" | "terminal" | "browser" = "none"; let sidebarWidth = 220; let rightPanelWidth = 480; let hasStarted = false; let terminalPanel: JaeTerminalPanel | null = null; -let browserPanel: JaeBrowserPanel | null = null; +const browserPanel: JaeBrowserPanel | null = null; let chatPanel: ChatPanel; let agentUnsubscribe: (() => void) | undefined; @@ -95,27 +95,29 @@ 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.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 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); - } + 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); @@ -123,207 +125,362 @@ 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; - } + 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(); - } + 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")) }, - ]); + 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); + if (!agent) return; + const messages = agent.state.messages; + const title = currentTitle || "JAE Session"; + if (format === "markdown") exportSessionAsMarkdown(messages, title); + else exportSessionAsJson(messages, title); } const generateTitle = (messages: AgentMessage[]): string => { - const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments"); - if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return ""; - let text = ""; - const content = firstUserMsg.content; - if (typeof content === "string") { text = content; } - else { const textBlocks = content.filter((c: any) => c.type === "text"); text = textBlocks.map((c: any) => c.text || "").join(" "); } - text = text.trim(); - if (!text) return ""; - const sentenceEnd = text.search(/[.!?]/); - if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1); - return text.length <= 50 ? text : text.substring(0, 47) + "..."; + const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments"); + if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return ""; + let text = ""; + const content = firstUserMsg.content; + if (typeof content === "string") { + text = content; + } else { + const textBlocks = content.filter((c: any) => c.type === "text"); + text = textBlocks.map((c: any) => c.text || "").join(" "); + } + text = text.trim(); + if (!text) return ""; + const sentenceEnd = text.search(/[.!?]/); + if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1); + return text.length <= 50 ? text : text.substring(0, 47) + "..."; }; const shouldSaveSession = (messages: AgentMessage[]): boolean => { - const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments"); - const hasAssistantMsg = messages.some((m: any) => m.role === "assistant"); - return hasUserMsg && hasAssistantMsg; + const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments"); + const hasAssistantMsg = messages.some((m: any) => m.role === "assistant"); + return hasUserMsg && hasAssistantMsg; }; const saveSession = async () => { - if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return; - const state = agent.state; - if (!shouldSaveSession(state.messages)) return; - try { - 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); } + 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 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(); + 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; + 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()); + 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 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; + 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` + 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(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)" })} - ${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`
JAE${getModelLabel() ? html`${getModelLabel()}` : html``}
` - } +
+ + 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} @@ -333,8 +490,31 @@ const renderApp = () => { ${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: 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" })}
@@ -343,64 +523,125 @@ ${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick ${sidebar}
{ 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"}} + @mousedown=${(e: MouseEvent) => { + 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"; + }} >
-${!hasMessages ? html` -
- +
+
-` : html``}
${chatPanel}
-${rightPanel !== 'none' ? html` +${ + 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"}} + @mousedown=${(e: MouseEvent) => { + 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" + ? "bg-primary text-primary-foreground" + : "hover:bg-secondary text-muted-foreground" + }" @click=${() => { + rightPanel = "terminal"; + renderApp(); + requestAnimationFrame(() => { + if (!terminalPanel) terminalPanel = document.querySelector("jae-terminal-panel") as JaeTerminalPanel; + terminalPanel?.connect(); + }); + }}>Terminal + rightPanel === "browser" ? "bg-primary text-primary-foreground" : "hover:bg-secondary text-muted-foreground" + }" @click=${() => { + rightPanel = "browser"; + renderApp(); + }}>Browser
- +
-${rightPanel === 'terminal' ? html`` : html``} -${rightPanel === 'browser' ? html`` : html``} +${rightPanel === "terminal" ? html`` : html``} +${rightPanel === "browser" ? html`` : html``}
-` : html``} +` + : html`` +}
-`, app); +`, + 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(); + 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(); diff --git a/packages/web-ui/src/components/VeniceModelBrowser.ts b/packages/web-ui/src/components/VeniceModelBrowser.ts index 0491856..1c92774 100644 --- a/packages/web-ui/src/components/VeniceModelBrowser.ts +++ b/packages/web-ui/src/components/VeniceModelBrowser.ts @@ -1,110 +1,124 @@ - import { getModels } from "@jaeswift/jae-ai"; -import { html, LitElement, type TemplateResult } from "lit"; -import { customElement, state } from "lit/decorators.js"; import { Badge } from "@mariozechner/mini-lit/dist/Badge.js"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; +import { html, LitElement, type TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators.js"; const TAG_COLORS: Record = { - tools: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", - vision: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300", - reasoning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", - code: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", - "image-generation": "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300", - "video-generation": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", - tts: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300", - asr: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300", - embedding: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", - "e2ee": "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300", - "web-search": "bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300", + tools: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + vision: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300", + reasoning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + code: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + "image-generation": "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300", + "video-generation": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", + tts: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300", + asr: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300", + embedding: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", + e2ee: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300", + "web-search": "bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300", }; const CATEGORY_LABELS: Record = { - text: "💬 Text & Chat", - image: "🖼️ Image Generation", - video: "🎬 Video Generation", - audio: "🔊 Audio (TTS / ASR)", - other: "🔧 Other", + text: "💬 Text & Chat", + image: "🖼️ Image Generation", + video: "🎬 Video Generation", + audio: "🔊 Audio (TTS / ASR)", + other: "🔧 Other", }; function categoriseModel(tags: string[] = []): string { - if (tags.includes("image-generation") || tags.includes("inpainting")) return "image"; - if (tags.includes("video-generation")) return "video"; - if (tags.includes("tts") || tags.includes("asr")) return "audio"; - if (tags.includes("embedding") || tags.includes("upscaling")) return "other"; - return "text"; + if (tags.includes("image-generation") || tags.includes("inpainting")) return "image"; + if (tags.includes("video-generation")) return "video"; + if (tags.includes("tts") || tags.includes("asr")) return "audio"; + if (tags.includes("embedding") || tags.includes("upscaling")) return "other"; + return "text"; } @customElement("venice-model-browser") export class VeniceModelBrowser extends LitElement { - @state() private filter: string = "all"; - @state() private search: string = ""; + @state() private filter: string = "all"; + @state() private search: string = ""; - protected createRenderRoot() { return this; } + protected createRenderRoot() { + return this; + } - private renderTag(tag: string): TemplateResult { - const cls = TAG_COLORS[tag] ?? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"; - return html`${tag}`; - } + private renderTag(tag: string): TemplateResult { + const cls = TAG_COLORS[tag] ?? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"; + return html`${tag}`; + } - render(): TemplateResult { - let models: any[] = []; - try { - models = (getModels("venice" as any) as any[]) || []; - } catch { models = []; } + render(): TemplateResult { + let models: any[] = []; + try { + models = (getModels("venice" as any) as any[]) || []; + } catch { + models = []; + } - // Group by category - const grouped: Record = { text: [], image: [], video: [], audio: [], other: [] }; - for (const m of models) { - const cat = categoriseModel(m.tags); - if (!grouped[cat]) grouped[cat] = []; - grouped[cat].push(m); - } + // Group by category + const grouped: Record = { text: [], image: [], video: [], audio: [], other: [] }; + for (const m of models) { + const cat = categoriseModel(m.tags); + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(m); + } - const filters = [ - { id: "all", label: `All (${models.length})` }, - ...Object.entries(grouped) - .filter(([, ms]) => ms.length > 0) - .map(([cat, ms]) => ({ id: cat, label: `${CATEGORY_LABELS[cat] ?? cat} (${ms.length})` })), - ]; + const filters = [ + { id: "all", label: `All (${models.length})` }, + ...Object.entries(grouped) + .filter(([, ms]) => ms.length > 0) + .map(([cat, ms]) => ({ id: cat, label: `${CATEGORY_LABELS[cat] ?? cat} (${ms.length})` })), + ]; - const searchLower = this.search.toLowerCase(); - const activeGroups = Object.entries(grouped).filter(([cat, ms]) => { - if (this.filter !== "all" && cat !== this.filter) return false; - return ms.length > 0; - }); + const searchLower = this.search.toLowerCase(); + const activeGroups = Object.entries(grouped).filter(([cat, ms]) => { + if (this.filter !== "all" && cat !== this.filter) return false; + return ms.length > 0; + }); - return html` + return html`
- ${filters.map(f => html` + ${filters.map( + (f) => html` - `)} + `, + )} { this.search = (e.target as HTMLInputElement).value; this.requestUpdate(); }} + @input=${(e: Event) => { + this.search = (e.target as HTMLInputElement).value; + this.requestUpdate(); + }} />
${activeGroups.map(([cat, ms]) => { - const filtered = searchLower ? ms.filter((m: any) => m.id.toLowerCase().includes(searchLower)) : ms; - if (!filtered.length) return html``; - return html` + const filtered = searchLower ? ms.filter((m: any) => m.id.toLowerCase().includes(searchLower)) : ms; + if (!filtered.length) return html``; + return html`

${CATEGORY_LABELS[cat] ?? cat}

- ${filtered.map((m: any) => html` + ${filtered.map( + (m: any) => html`
${m.id} @@ -114,18 +128,23 @@ export class VeniceModelBrowser extends LitElement { ${(m.tags ?? []).map((t: string) => this.renderTag(t))}
- `)} + `, + )}
`; - })} + })} - ${models.length === 0 ? html` + ${ + models.length === 0 + ? html`
No Venice models found. Ensure jae-ai includes Venice models.
- ` : ""} + ` + : "" + }
`; - } + } } diff --git a/packages/web-ui/src/dialogs/ModelSelector.ts b/packages/web-ui/src/dialogs/ModelSelector.ts index 6b55c50..f608120 100644 --- a/packages/web-ui/src/dialogs/ModelSelector.ts +++ b/packages/web-ui/src/dialogs/ModelSelector.ts @@ -52,6 +52,7 @@ export class ModelSelector extends DialogBase { @state() searchQuery = ""; @state() filterThinking = false; @state() filterVision = false; + @state() private filterProvider = ""; @state() customProvidersLoading = false; @state() selectedIndex = 0; @state() private navigationMode: "mouse" | "keyboard" = "mouse"; @@ -274,6 +275,59 @@ export class ModelSelector extends DialogBase { }); } + private getUniqueProviders(): string[] { + const seen = new Set(); + const known = getProviders(); + for (const p of known) { + const models = getModels(p as any); + if (models.length > 0) seen.add(p); + } + for (const m of this.customProviderModels) { + if (m.provider) seen.add(m.provider); + } + if (this.allowedProviders) { + return Array.from(seen).filter((p) => this.allowedProviders!.has(p)); + } + return Array.from(seen).sort(); + } + + private renderProviderTabs() { + const providers = this.getUniqueProviders(); + if (providers.length <= 1) return html``; + return html` +
+ + ${providers.map( + (p) => html` + + `, + )} +
+ `; + } + protected override renderContent(): TemplateResult { const filteredModels = this.getFilteredModels(); diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 1eee446..952b15d 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -53,6 +53,7 @@ export { RuntimeMessageBridge } from "./components/sandbox/RuntimeMessageBridge. export { RUNTIME_MESSAGE_ROUTER } from "./components/sandbox/RuntimeMessageRouter.js"; export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js"; export { ThinkingBlock } from "./components/ThinkingBlock.js"; +export { VeniceModelBrowser } from "./components/VeniceModelBrowser.js"; export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js"; export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js"; export { CustomProviderDialog } from "./dialogs/CustomProviderDialog.js"; @@ -101,6 +102,7 @@ export { MarkdownArtifact } from "./tools/artifacts/MarkdownArtifact.js"; export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js"; export { TextArtifact } from "./tools/artifacts/TextArtifact.js"; export { createExtractDocumentTool, extractDocumentTool } from "./tools/extract-document.js"; +export { createImageGenTool, imageGenTool } from "./tools/image-gen.js"; // Tools export { getToolRenderer, registerToolRenderer, renderTool, setShowJsonMode } from "./tools/index.js"; export { createJavaScriptReplTool, javascriptReplTool } from "./tools/javascript-repl.js"; @@ -109,8 +111,13 @@ export { BashRenderer } from "./tools/renderers/BashRenderer.js"; export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js"; // Tool renderers export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js"; +export { DiffRenderer } from "./tools/renderers/DiffRenderer.js"; export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js"; +export { MermaidRenderer } from "./tools/renderers/MermaidRenderer.js"; export type { ToolRenderer, ToolRenderResult } from "./tools/types.js"; +export { createTTSTool, ttsTool } from "./tools/voice-tts.js"; +// Venice / community tools +export { createWebSearchTool, webSearchTool } from "./tools/web-search.js"; export type { Attachment } from "./utils/attachment-utils.js"; // Utils export { loadAttachment } from "./utils/attachment-utils.js"; @@ -118,12 +125,3 @@ export { clearAuthToken, getAuthToken } from "./utils/auth-token.js"; export { formatCost, formatModelCost, formatTokenCount, formatUsage } from "./utils/format.js"; 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"; diff --git a/packages/web-ui/src/tools/diff-viewer.ts b/packages/web-ui/src/tools/diff-viewer.ts index 82b9c42..0565ad2 100644 --- a/packages/web-ui/src/tools/diff-viewer.ts +++ b/packages/web-ui/src/tools/diff-viewer.ts @@ -1,98 +1,104 @@ - import type { AgentTool } from "@jaeswift/jae-agent-core"; -import { Type } from "@sinclair/typebox"; import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { Type } from "@sinclair/typebox"; 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; + original: string; + modified: string; + filename?: string; } interface DiffParams { - original: string; - modified: string; - filename?: string; + 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" })), + 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; + 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 = { - 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 }, - }; - }, + 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 { - render(params: DiffParams | undefined, result: ToolResultMessage | 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; + render(params: DiffParams | undefined, result: ToolResultMessage | 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` + return { + content: html`
${renderHeader(state, GitCompare, html`Diff: ${filename || "file"} +${adds}-${removes}`)}
- ${diffLines.map((l, i) => html` + ${diffLines.map( + (l, i) => html`
+ 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" + }"> ${i + 1} ${ - l.type === "add" ? "+ " : l.type === "remove" ? "- " : " " - }${l.line} + l.type === "add" ? "+ " : l.type === "remove" ? "- " : " " + }${l.line}
- `)} + `, + )}
`, - isCustom: false, - }; - } + isCustom: false, + }; + } } registerToolRenderer("show_diff", new DiffRenderer()); export function createDiffTool(): AgentTool { - return diffTool; + return diffTool; } diff --git a/packages/web-ui/src/tools/image-gen.ts b/packages/web-ui/src/tools/image-gen.ts index 8b6be40..54e215d 100644 --- a/packages/web-ui/src/tools/image-gen.ts +++ b/packages/web-ui/src/tools/image-gen.ts @@ -1,87 +1,95 @@ import type { AgentTool } from "@jaeswift/jae-agent-core"; -import { Type } from "@sinclair/typebox"; import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { Type } from "@sinclair/typebox"; import { html } from "lit"; import { Image } from "lucide"; -import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import { getAppStorage } from "../storage/app-storage.js"; +import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js"; const imageGenSchema = Type.Object({ - prompt: Type.String({ description: "Image generation prompt describing what to create" }), - model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })), - width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })), - height: Type.Optional(Type.Number({ description: "Height in pixels (default: 1024)" })), - steps: Type.Optional(Type.Number({ description: "Inference steps (default: 20)" })), + prompt: Type.String({ description: "Image generation prompt describing what to create" }), + model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })), + width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })), + height: Type.Optional(Type.Number({ description: "Height in pixels (default: 1024)" })), + steps: Type.Optional(Type.Number({ description: "Inference steps (default: 20)" })), }); export interface ImageGenDetails { - dataUrl?: string; - model: string; - prompt: string; - width: number; - height: number; - error?: string; + dataUrl?: string; + model: string; + prompt: string; + width: number; + height: number; + error?: string; } interface ImageGenParams { - prompt: string; - model?: string; - width?: number; - height?: number; - steps?: number; + prompt: string; + model?: string; + width?: number; + height?: number; + steps?: number; } export const imageGenTool: AgentTool = { - name: "generate_image", - label: "Generate Image", - description: "Generate an image using Venice AI image models. Displays inline in chat.", - parameters: imageGenSchema, - async execute(toolCallId, params, signal) { - const { prompt, model = "fluently-xl", width = 1024, height = 1024, steps = 20 } = params; - const apiKey = await getAppStorage().providerKeys.get("venice"); - if (!apiKey) { - return { - content: [{ type: "text", text: "Error: Venice API key not set. Add it in Settings > Providers & Models > Venice." }], - details: { model, prompt, width, height, error: "No API key" }, - }; - } - const res = await fetch("https://api.venice.ai/api/v1/image/generate", { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, - body: JSON.stringify({ model, prompt, width, height, steps, return_binary: false, safe_mode: false }), - signal: signal ?? AbortSignal.timeout(60000), - }); - if (!res.ok) { - const err = await res.text(); - return { - content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }], - details: { model, prompt, width, height, error: err }, - }; - } - const data = await res.json() as any; - const b64 = data?.images?.[0]; - if (!b64) { - return { - content: [{ type: "text", text: "No image returned from Venice API." }], - details: { model, prompt, width, height, error: "No image data" }, - }; - } - const dataUrl = `data:image/png;base64,${b64}`; - return { - content: [{ type: "text", text: `Image generated successfully. Model: ${model}, Size: ${width}x${height}` }], - details: { dataUrl, model, prompt, width, height }, - }; - }, + name: "generate_image", + label: "Generate Image", + description: "Generate an image using Venice AI image models. Displays inline in chat.", + parameters: imageGenSchema, + async execute(toolCallId, params, signal) { + const { prompt, model = "fluently-xl", width = 1024, height = 1024, steps = 20 } = params; + const apiKey = await getAppStorage().providerKeys.get("venice"); + if (!apiKey) { + return { + content: [ + { + type: "text", + text: "Error: Venice API key not set. Add it in Settings > Providers & Models > Venice.", + }, + ], + details: { model, prompt, width, height, error: "No API key" }, + }; + } + const res = await fetch("https://api.venice.ai/api/v1/image/generate", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ model, prompt, width, height, steps, return_binary: false, safe_mode: false }), + signal: signal ?? AbortSignal.timeout(60000), + }); + if (!res.ok) { + const err = await res.text(); + return { + content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }], + details: { model, prompt, width, height, error: err }, + }; + } + const data = (await res.json()) as any; + const b64 = data?.images?.[0]; + if (!b64) { + return { + content: [{ type: "text", text: "No image returned from Venice API." }], + details: { model, prompt, width, height, error: "No image data" }, + }; + } + const dataUrl = `data:image/png;base64,${b64}`; + return { + content: [{ type: "text", text: `Image generated successfully. Model: ${model}, Size: ${width}x${height}` }], + details: { dataUrl, model, prompt, width, height }, + }; + }, }; class ImageGenRenderer implements ToolRenderer { - render(params: ImageGenParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : "inprogress"; - if (result?.details?.dataUrl) { - const d = result.details; - return { - content: html` + render( + params: ImageGenParams | undefined, + result: ToolResultMessage | undefined, + ): ToolRenderResult { + const state = result ? (result.isError ? "error" : "complete") : "inprogress"; + if (result?.details?.dataUrl) { + const d = result.details; + return { + content: html`
${renderHeader(state, Image, "Image Generated")} ${d.prompt}
${d.prompt}
`, - isCustom: false, - }; - } - return { content: renderHeader(state, Image, `Generating image: ${params?.prompt?.slice(0, 50) ?? "..."}`), isCustom: false }; - } + isCustom: false, + }; + } + return { + content: renderHeader(state, Image, `Generating image: ${params?.prompt?.slice(0, 50) ?? "..."}`), + isCustom: false, + }; + } } registerToolRenderer("generate_image", new ImageGenRenderer()); export function createImageGenTool(): AgentTool { - return imageGenTool; + return imageGenTool; } diff --git a/packages/web-ui/src/tools/index.ts b/packages/web-ui/src/tools/index.ts index 4413f94..a9b727d 100644 --- a/packages/web-ui/src/tools/index.ts +++ b/packages/web-ui/src/tools/index.ts @@ -45,6 +45,6 @@ export function renderTool( export { getToolRenderer, registerToolRenderer }; -export { webSearchTool, createWebSearchTool, type WebSearchDetails, type WebSearchResult } from "./web-search.js"; -export { imageGenTool, createImageGenTool, type ImageGenDetails } from "./image-gen.js"; -export { ttsTool, createTTSTool, type TTSDetails } from "./voice-tts.js"; +export { createImageGenTool, type ImageGenDetails, imageGenTool } from "./image-gen.js"; +export { createTTSTool, type TTSDetails, ttsTool } from "./voice-tts.js"; +export { createWebSearchTool, type WebSearchDetails, type WebSearchResult, webSearchTool } from "./web-search.js"; diff --git a/packages/web-ui/src/tools/memory-tool.ts b/packages/web-ui/src/tools/memory-tool.ts index 586db4f..3223732 100644 --- a/packages/web-ui/src/tools/memory-tool.ts +++ b/packages/web-ui/src/tools/memory-tool.ts @@ -1,21 +1,20 @@ - import type { AgentTool } from "@jaeswift/jae-agent-core"; -import { Type } from "@sinclair/typebox"; import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { Type } from "@sinclair/typebox"; 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; + id: string; + content: string; + tags: string[]; + timestamp: string; } export interface MemoryStore { - entries: MemoryEntry[]; + entries: MemoryEntry[]; } const DB_NAME = "jae-memory"; @@ -25,140 +24,150 @@ const STORE_NAME = "memories"; let db: IDBDatabase | null = null; async function openDB(): Promise { - 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); - }); + 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 { - 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); - }); + 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 { - 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); - }); + 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 { - 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))); + 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 { - 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); - }); + 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" })), + content: Type.String({ description: "Information to remember" }), + tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation" })), }); export const saveMemoryTool: AgentTool = { - 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 }, - }; - }, + 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" }), + query: Type.String({ description: "Search query to find relevant memories" }), }); export const recallMemoryTool: AgentTool = { - 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 }, - }; - }, + 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` + render(params: any, result: ToolResultMessage<{ id: string; content: string }> | undefined): ToolRenderResult { + const state = result ? (result.isError ? "error" : "complete") : "inprogress"; + return { + content: html`
${renderHeader(state, Brain, `Memory saved`)} ${result?.details ? html`
${result.details.content}
` : ""}
`, - isCustom: false, - }; - } + 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` + 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`
${renderHeader(state, BrainCircuit, `Memory recall: ${params?.query || ""}`)} - ${results.length > 0 ? html` + ${ + results.length > 0 + ? html`
- ${results.map(r => html` + ${results.map( + (r) => html`
${r.timestamp.slice(0, 10)}
${r.content}
- `)} + `, + )}
- ` : ""} + ` + : "" + }
`, - isCustom: false, - }; - } + isCustom: false, + }; + } } registerToolRenderer("memory_save", new SaveMemoryRenderer()); registerToolRenderer("memory_recall", new RecallMemoryRenderer()); export function createMemoryTools() { - return [saveMemoryTool, recallMemoryTool]; + return [saveMemoryTool, recallMemoryTool]; } diff --git a/packages/web-ui/src/tools/mermaid-diagram.ts b/packages/web-ui/src/tools/mermaid-diagram.ts index cba150b..d927367 100644 --- a/packages/web-ui/src/tools/mermaid-diagram.ts +++ b/packages/web-ui/src/tools/mermaid-diagram.ts @@ -1,88 +1,90 @@ - import type { AgentTool } from "@jaeswift/jae-agent-core"; -import { Type } from "@sinclair/typebox"; import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { Type } from "@sinclair/typebox"; 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; + diagram: string; + rendered: boolean; + error?: string; } interface MermaidParams { - diagram: string; - title?: string; + 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" })), + diagram: Type.String({ description: "Mermaid diagram source code" }), + title: Type.Optional(Type.String({ description: "Optional title for the diagram" })), }); export const mermaidTool: AgentTool = { - 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 }, - }; - }, + 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 { - 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); - }); + 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 { - render(params: MermaidParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : "inprogress"; - const diagram = result?.details?.diagram || params?.diagram || ""; - const title = params?.title || "Diagram"; + render(params: MermaidParams | undefined, result: ToolResultMessage | 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 }; - } + if (!diagram) { + return { content: renderHeader(state, GitBranch, "Rendering diagram..."), isCustom: false }; + } - const containerId = `mermaid-${Math.random().toString(36).slice(2)}`; + 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 = `
Diagram error: ${err.message}
`; - } - }; + 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 = `
Diagram error: ${err.message}
`; + } + }; - return { - content: html` + return { + content: html`
${renderHeader(state, GitBranch, `Diagram: ${title}`)}
{ >
`, - isCustom: false, - }; - } + isCustom: false, + }; + } } registerToolRenderer("render_diagram", new MermaidRenderer()); export function createMermaidTool(): AgentTool { - return mermaidTool; + return mermaidTool; } diff --git a/packages/web-ui/src/tools/renderers/DiffRenderer.ts b/packages/web-ui/src/tools/renderers/DiffRenderer.ts index 44c9773..8716598 100644 --- a/packages/web-ui/src/tools/renderers/DiffRenderer.ts +++ b/packages/web-ui/src/tools/renderers/DiffRenderer.ts @@ -1,46 +1,57 @@ +import type { ToolResultMessage } from "@jaeswift/jae-ai"; import { html } from "lit"; import { FileText } from "lucide"; -import type { ToolResultMessage } from "@jaeswift/jae-ai"; -import { renderHeader } from "../renderer-registry.js"; +import { registerToolRenderer, 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"; + 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"); + const lines = diff.split("\n"); - return { - content: html` + return { + content: html`
${renderHeader(state, FileText, label)} - ${diff ? html` + ${ + diff + ? html`
${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`${line}
+						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`${line}
 `;
-              })}
+ })}
- ` : ""} + ` + : "" + }
`, - isCustom: false, - }; - } + isCustom: false, + }; + } } registerToolRenderer("diff", new DiffRenderer()); diff --git a/packages/web-ui/src/tools/renderers/MermaidRenderer.ts b/packages/web-ui/src/tools/renderers/MermaidRenderer.ts index 7ada211..f5924c0 100644 --- a/packages/web-ui/src/tools/renderers/MermaidRenderer.ts +++ b/packages/web-ui/src/tools/renderers/MermaidRenderer.ts @@ -1,25 +1,31 @@ +import type { ToolResultMessage } from "@jaeswift/jae-ai"; import { html } from "lit"; import { GitBranch } from "lucide"; -import type { ToolResultMessage } from "@jaeswift/jae-ai"; -import { renderHeader } from "../renderer-registry.js"; +import { registerToolRenderer, 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); + 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` + return { + content: html`
${renderHeader(state, GitBranch, "Diagram")} - ${diagram ? html` + ${ + diagram + ? html`
${diagram}
- ` : ""} + ` + : "" + }
`, - isCustom: false, - }; - } + isCustom: false, + }; + } } registerToolRenderer("mermaid", new MermaidRenderer()); diff --git a/packages/web-ui/src/tools/voice-tts.ts b/packages/web-ui/src/tools/voice-tts.ts index 3102b8a..98ffcca 100644 --- a/packages/web-ui/src/tools/voice-tts.ts +++ b/packages/web-ui/src/tools/voice-tts.ts @@ -1,90 +1,93 @@ import type { AgentTool } from "@jaeswift/jae-agent-core"; -import { Type } from "@sinclair/typebox"; import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { Type } from "@sinclair/typebox"; import { html } from "lit"; import { Volume2 } from "lucide"; -import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import { getAppStorage } from "../storage/app-storage.js"; +import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js"; const ttsSchema = Type.Object({ - text: Type.String({ description: "Text to convert to speech" }), - model: Type.Optional(Type.String({ description: "Venice TTS model (default: tts-kokoro)" })), - voice: Type.Optional(Type.String({ description: "Voice ID (default: af_heart)" })), + text: Type.String({ description: "Text to convert to speech" }), + model: Type.Optional(Type.String({ description: "Venice TTS model (default: tts-kokoro)" })), + voice: Type.Optional(Type.String({ description: "Voice ID (default: af_heart)" })), }); export interface TTSDetails { - audioUrl?: string; - model: string; - voice: string; - text: string; - error?: string; + audioUrl?: string; + model: string; + voice: string; + text: string; + error?: string; } interface TTSParams { - text: string; - model?: string; - voice?: string; + text: string; + model?: string; + voice?: string; } export const ttsTool: AgentTool = { - name: "text_to_speech", - label: "Text to Speech", - description: "Convert text to speech using Venice AI TTS. Audio plays inline in chat.", - parameters: ttsSchema, - async execute(toolCallId, params, signal) { - const { text, model = "tts-kokoro", voice = "af_heart" } = params; - const apiKey = await getAppStorage().providerKeys.get("venice"); - if (!apiKey) { - return { - content: [{ type: "text", text: "Error: Venice API key not set." }], - details: { model, voice, text, error: "No API key" }, - }; - } - const res = await fetch("https://api.venice.ai/api/v1/audio/speech", { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, - body: JSON.stringify({ model, input: text, voice }), - signal: signal ?? AbortSignal.timeout(30000), - }); - if (!res.ok) { - const err = await res.text(); - return { - content: [{ type: "text", text: `TTS failed (${res.status}): ${err}` }], - details: { model, voice, text, error: err }, - }; - } - const blob = await res.blob(); - const audioUrl = URL.createObjectURL(blob); - return { - content: [{ type: "text", text: `Speech generated. Model: ${model}, Voice: ${voice}` }], - details: { audioUrl, model, voice, text }, - }; - }, + name: "text_to_speech", + label: "Text to Speech", + description: "Convert text to speech using Venice AI TTS. Audio plays inline in chat.", + parameters: ttsSchema, + async execute(toolCallId, params, signal) { + const { text, model = "tts-kokoro", voice = "af_heart" } = params; + const apiKey = await getAppStorage().providerKeys.get("venice"); + if (!apiKey) { + return { + content: [{ type: "text", text: "Error: Venice API key not set." }], + details: { model, voice, text, error: "No API key" }, + }; + } + const res = await fetch("https://api.venice.ai/api/v1/audio/speech", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ model, input: text, voice }), + signal: signal ?? AbortSignal.timeout(30000), + }); + if (!res.ok) { + const err = await res.text(); + return { + content: [{ type: "text", text: `TTS failed (${res.status}): ${err}` }], + details: { model, voice, text, error: err }, + }; + } + const blob = await res.blob(); + const audioUrl = URL.createObjectURL(blob); + return { + content: [{ type: "text", text: `Speech generated. Model: ${model}, Voice: ${voice}` }], + details: { audioUrl, model, voice, text }, + }; + }, }; class TTSRenderer implements ToolRenderer { - render(params: TTSParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : "inprogress"; - if (result?.details?.audioUrl) { - const d = result.details; - return { - content: html` + render(params: TTSParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { + const state = result ? (result.isError ? "error" : "complete") : "inprogress"; + if (result?.details?.audioUrl) { + const d = result.details; + return { + content: html`
${renderHeader(state, Volume2, "Speech Generated")}
${d.model} · ${d.voice}
${d.text.length > 100 ? d.text.slice(0, 100) + "..." : d.text}
`, - isCustom: false, - }; - } - return { content: renderHeader(state, Volume2, `Speaking: ${params?.text?.slice(0, 50) ?? "..."}`), isCustom: false }; - } + isCustom: false, + }; + } + return { + content: renderHeader(state, Volume2, `Speaking: ${params?.text?.slice(0, 50) ?? "..."}`), + isCustom: false, + }; + } } registerToolRenderer("text_to_speech", new TTSRenderer()); export function createTTSTool(): AgentTool { - return ttsTool; + return ttsTool; } diff --git a/packages/web-ui/src/tools/web-search.ts b/packages/web-ui/src/tools/web-search.ts index 32497f9..2053126 100644 --- a/packages/web-ui/src/tools/web-search.ts +++ b/packages/web-ui/src/tools/web-search.ts @@ -1,106 +1,117 @@ import type { AgentTool } from "@jaeswift/jae-agent-core"; -import { Type } from "@sinclair/typebox"; import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { Type } from "@sinclair/typebox"; import { html } from "lit"; import { Globe } from "lucide"; import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js"; const webSearchSchema = Type.Object({ - query: Type.String({ description: "Search query" }), - limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })), + query: Type.String({ description: "Search query" }), + limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })), }); export interface WebSearchResult { - title: string; - url: string; - snippet: string; + title: string; + url: string; + snippet: string; } export interface WebSearchDetails { - results: WebSearchResult[]; - query: string; - error?: string; + results: WebSearchResult[]; + query: string; + error?: string; } interface WebSearchParams { - query: string; - limit?: number; + query: string; + limit?: number; } async function fetchDuckDuckGo(query: string, limit: number): Promise { - const encoded = encodeURIComponent(query); - const res = await fetch(`https://api.duckduckgo.com/?q=${encoded}&format=json&no_redirect=1&no_html=1&skip_disambig=1`); - if (!res.ok) throw new Error(`Search returned ${res.status}`); - const data = await res.json() as any; - const results: WebSearchResult[] = []; - if (data.AbstractText && data.AbstractURL) { - results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText }); - } - for (const topic of (data.RelatedTopics || [])) { - if (results.length >= limit) break; - if (topic.FirstURL && topic.Text) { - results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text }); - } else if (topic.Topics) { - for (const sub of topic.Topics) { - if (results.length >= limit) break; - if (sub.FirstURL && sub.Text) results.push({ title: sub.Text.split(" - ")[0], url: sub.FirstURL, snippet: sub.Text }); - } - } - } - for (const r of (data.Results || [])) { - if (results.length >= limit) break; - if (r.FirstURL && r.Text) results.push({ title: r.Title || r.Text, url: r.FirstURL, snippet: r.Text }); - } - return results.slice(0, limit); + const encoded = encodeURIComponent(query); + const res = await fetch( + `https://api.duckduckgo.com/?q=${encoded}&format=json&no_redirect=1&no_html=1&skip_disambig=1`, + ); + if (!res.ok) throw new Error(`Search returned ${res.status}`); + const data = (await res.json()) as any; + const results: WebSearchResult[] = []; + if (data.AbstractText && data.AbstractURL) { + results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText }); + } + for (const topic of data.RelatedTopics || []) { + if (results.length >= limit) break; + if (topic.FirstURL && topic.Text) { + results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text }); + } else if (topic.Topics) { + for (const sub of topic.Topics) { + if (results.length >= limit) break; + if (sub.FirstURL && sub.Text) + results.push({ title: sub.Text.split(" - ")[0], url: sub.FirstURL, snippet: sub.Text }); + } + } + } + for (const r of data.Results || []) { + if (results.length >= limit) break; + if (r.FirstURL && r.Text) results.push({ title: r.Title || r.Text, url: r.FirstURL, snippet: r.Text }); + } + return results.slice(0, limit); } export const webSearchTool: AgentTool = { - name: "web_search", - label: "Web Search", - description: "Search the web for current information using DuckDuckGo.", - parameters: webSearchSchema, - async execute(toolCallId, params, signal) { - const { query, limit = 5 } = params; - try { - const results = await fetchDuckDuckGo(query, limit); - const lines = results.map((r, i) => `[${i + 1}] ${r.title}` + "\n" + r.url + "\n" + r.snippet); - const text = results.length === 0 ? `No results for: ${query}` : lines.join("\n\n"); - return { content: [{ type: "text", text }], details: { results, query } }; - } catch (err: any) { - return { content: [{ type: "text", text: `Search failed: ${err.message}` }], details: { results: [], query, error: err.message } }; - } - }, + name: "web_search", + label: "Web Search", + description: "Search the web for current information using DuckDuckGo.", + parameters: webSearchSchema, + async execute(toolCallId, params, signal) { + const { query, limit = 5 } = params; + try { + const results = await fetchDuckDuckGo(query, limit); + const lines = results.map((r, i) => `[${i + 1}] ${r.title}` + "\n" + r.url + "\n" + r.snippet); + const text = results.length === 0 ? `No results for: ${query}` : lines.join("\n\n"); + return { content: [{ type: "text", text }], details: { results, query } }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Search failed: ${err.message}` }], + details: { results: [], query, error: err.message }, + }; + } + }, }; class WebSearchRenderer implements ToolRenderer { - render(params: WebSearchParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { - const state = result ? (result.isError ? "error" : "complete") : "inprogress"; - if (result?.details?.results?.length) { - const details = result.details; - return { - content: html` + render( + params: WebSearchParams | undefined, + result: ToolResultMessage | undefined, + ): ToolRenderResult { + const state = result ? (result.isError ? "error" : "complete") : "inprogress"; + if (result?.details?.results?.length) { + const details = result.details; + return { + content: html`
${renderHeader(state, Globe, `Web Search: ${details.query}`)}
- ${details.results.map((r) => html` + ${details.results.map( + (r) => html`
${r.title} ${r.url} ${r.snippet}
- `)} + `, + )}
`, - isCustom: false, - }; - } - return { content: renderHeader(state, Globe, `Searching: ${params?.query ?? "..."}`), isCustom: false }; - } + isCustom: false, + }; + } + return { content: renderHeader(state, Globe, `Searching: ${params?.query ?? "..."}`), isCustom: false }; + } } registerToolRenderer("web_search", new WebSearchRenderer()); export function createWebSearchTool(): AgentTool { - return webSearchTool; + return webSearchTool; }