fix: provider filter tabs, model badge always visible, empty-state 4% fade, server deps (ws/concurrently)
Some checks are pending
CI / build-check-test (push) Waiting to run

This commit is contained in:
JAE 2026-03-26 21:27:24 +00:00
parent 1514fabd50
commit 63a773184c
30 changed files with 2271 additions and 1651 deletions

4
package-lock.json generated
View file

@ -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": {

View file

@ -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<typeof browserSchema>;
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<typeof browserSchema, BrowserToolDetails> = {
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 },
};
}
},
};

View file

@ -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<typeof imageGenSchema>;
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<typeof imageGenSchema, ImageGenToolDetails> = {
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 },
};
},
};

View file

@ -107,13 +107,22 @@ import { createWriteTool, createWriteToolDefinition, writeTool, writeToolDefinit
export type Tool = AgentTool<any>;
export type ToolDef = ToolDefinition<any, any>;
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<Tool
};
}
export { type BrowserToolDetails, type BrowserToolInput, browserTool } from "./browser.js";
export { type ImageGenToolDetails, type ImageGenToolInput, imageGenTool } from "./image-gen.js";
export { type MemoryToolDetails, type MemoryToolInput, memoryTool } from "./memory.js";
export { type WebFetchToolDetails, type WebFetchToolInput, webFetchTool } from "./web-fetch.js";
// ── New tools ────────────────────────────────────────────────────────────────
export { webSearchTool, type WebSearchToolInput, type WebSearchToolDetails, type WebSearchResult } from "./web-search.js";
export { webFetchTool, type WebFetchToolInput, type WebFetchToolDetails } from "./web-fetch.js";
export { imageGenTool, type ImageGenToolInput, type ImageGenToolDetails } from "./image-gen.js";
export { memoryTool, type MemoryToolInput, type MemoryToolDetails } from "./memory.js";
export { browserTool, type BrowserToolInput, type BrowserToolDetails } from "./browser.js";
export {
type WebSearchResult,
type WebSearchToolDetails,
type WebSearchToolInput,
webSearchTool,
} from "./web-search.js";

View file

@ -1,4 +1,3 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
@ -9,104 +8,126 @@ const MEMORY_DIR = join(homedir(), ".jae", "memory");
const MEMORY_FILE = join(MEMORY_DIR, "memories.json");
interface MemoryEntry {
id: string;
content: string;
tags: string[];
createdAt: string;
id: string;
content: string;
tags: string[];
createdAt: string;
}
function loadMemories(): MemoryEntry[] {
if (!existsSync(MEMORY_FILE)) return [];
try {
return JSON.parse(readFileSync(MEMORY_FILE, "utf-8")) as MemoryEntry[];
} catch { return []; }
if (!existsSync(MEMORY_FILE)) return [];
try {
return JSON.parse(readFileSync(MEMORY_FILE, "utf-8")) as MemoryEntry[];
} catch {
return [];
}
}
function saveMemories(entries: MemoryEntry[]): void {
if (!existsSync(MEMORY_DIR)) mkdirSync(MEMORY_DIR, { recursive: true });
writeFileSync(MEMORY_FILE, JSON.stringify(entries, null, 2));
if (!existsSync(MEMORY_DIR)) mkdirSync(MEMORY_DIR, { recursive: true });
writeFileSync(MEMORY_FILE, JSON.stringify(entries, null, 2));
}
function scoreMatch(entry: MemoryEntry, query: string): number {
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 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<typeof memorySchema>;
export interface MemoryToolDetails {
action: string;
count?: number;
id?: string;
action: string;
count?: number;
id?: string;
}
export const memoryTool: AgentTool<typeof memorySchema, MemoryToolDetails> = {
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 } };
},
};

View file

@ -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<typeof webFetchSchema>;
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(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
.replace(/&quot;/g, '"').replace(/&nbsp;/g, " ").replace(/&#39;/g, "'")
.replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&nbsp;/g, " ")
.replace(/&#39;/g, "'")
.replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export const webFetchTool: AgentTool<typeof webFetchSchema, WebFetchToolDetails> = {
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 },
};
}
},
};

View file

@ -29,6 +29,8 @@
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^7.1.6"
"vite": "^7.1.6",
"ws": "*",
"concurrently": "^9.0.0"
}
}

View file

@ -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`
<div class="flex items-center gap-1 px-2 py-1 border-b border-border shrink-0 bg-muted/30" style="height:40px">
<button class="p-1 rounded hover:bg-secondary" title="Back" @click=${() => this.ws?.send(JSON.stringify({ type:'back' }))}>
<button class="p-1 rounded hover:bg-secondary" title="Back" @click=${() => this.ws?.send(JSON.stringify({ type: "back" }))}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="p-1 rounded hover:bg-secondary" title="Forward" @click=${() => this.ws?.send(JSON.stringify({ type:'fwd' }))}>
<button class="p-1 rounded hover:bg-secondary" title="Forward" @click=${() => this.ws?.send(JSON.stringify({ type: "fwd" }))}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<button class="p-1 rounded hover:bg-secondary" title="Reload" @click=${() => this.ws?.send(JSON.stringify({ type:'reload' }))}>
<button class="p-1 rounded hover:bg-secondary" title="Reload" @click=${() => this.ws?.send(JSON.stringify({ type: "reload" }))}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<input
class="flex-1 text-xs border border-border rounded px-2 py-1 bg-background text-foreground outline-none focus:ring-1 focus:ring-ring"
type="text"
.value=${this.inputUrl}
@input=${(e: Event) => { 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..."
/>
<div class="w-2 h-2 rounded-full ml-1 ${this.connected ? 'bg-green-500' : 'bg-red-500'}" title="${this.connected ? 'connected' : 'disconnected'}"></div>
<div class="w-2 h-2 rounded-full ml-1 ${this.connected ? "bg-green-500" : "bg-red-500"}" title="${this.connected ? "connected" : "disconnected"}"></div>
</div>
<div class="flex-1 overflow-auto min-h-0 relative bg-white">
${this.loading ? html`
${
this.loading
? html`
<div class="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div class="text-sm text-muted-foreground">Loading...</div>
</div>
` : html``}
${this.error ? html`
`
: html``
}
${
this.error
? html`
<div class="p-4 text-sm text-red-500">${this.error}</div>
` : html``}
${this.screenshot ? html`
`
: html``
}
${
this.screenshot
? html`
<img
src=${this.screenshot}
class="w-full cursor-crosshair"
@ -114,13 +147,17 @@ export class JaeBrowserPanel extends LitElement {
@click=${this.handleImgClick}
@wheel=${this.handleScroll}
/>
` : !this.error ? html`
`
: !this.error
? html`
<div class="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
<p class="text-sm">Enter a URL above to browse</p>
</div>
` : html``}
`
: html``
}
</div>
`;
}
}
}

View file

@ -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`
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20" @click=${(e: Event) => { if (e.target === e.currentTarget) this.hide(); }}>
return html`
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20" @click=${(e: Event) => {
if (e.target === e.currentTarget) this.hide();
}}>
<div class="w-full max-w-xl bg-popover border border-border rounded-xl shadow-2xl overflow-hidden">
<div class="flex items-center gap-3 px-4 py-3 border-b border-border">
<span class="text-muted-foreground text-sm">&#x2318;</span>
@ -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}
/>
<kbd class="text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5">ESC</kbd>
</div>
<div class="max-h-80 overflow-y-auto py-1">
${cmds.length === 0 ? html`<div class="px-4 py-6 text-center text-sm text-muted-foreground">No commands found</div>` : ""}
${cmds.map((cmd, i) => html`
${cmds.map(
(cmd, i) => html`
<button
class="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-secondary transition-colors text-left ${
i === this.selectedIndex ? "bg-secondary" : ""
}"
@click=${() => { cmd.action(); this.hide(); }}
@mouseover=${() => { this.selectedIndex = i; }}
i === this.selectedIndex ? "bg-secondary" : ""
}"
@click=${() => {
cmd.action();
this.hide();
}}
@mouseover=${() => {
this.selectedIndex = i;
}}
>
<div class="flex flex-col gap-0.5">
<span class="font-medium">${cmd.label}</span>
@ -106,7 +119,8 @@ export class CommandPalette extends LitElement {
</div>
${cmd.shortcut ? html`<kbd class="text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5 shrink-0">${cmd.shortcut}</kbd>` : ""}
</button>
`)}
`,
)}
</div>
<div class="px-4 py-2 border-t border-border text-xs text-muted-foreground flex items-center gap-4">
<span>&#x2191;&#x2193; Navigate</span>
@ -116,5 +130,5 @@ export class CommandPalette extends LitElement {
</div>
</div>
`;
}
}
}

View file

@ -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<string, { input: number; output: number }> = {
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`
<button
class="flex items-center gap-1.5 px-2 py-1 text-xs rounded hover:bg-secondary transition-colors text-muted-foreground"
@click=${() => { this.expanded = !this.expanded; this.requestUpdate(); }}
@click=${() => {
this.expanded = !this.expanded;
this.requestUpdate();
}}
title="Token usage & cost"
>
<span class="font-mono">${this.totalTokens > 0 ? this.totalTokens.toLocaleString() : "0"} tok</span>
<span class="text-muted-foreground/50">|</span>
<span class="font-mono">$${cost.toFixed(4)}</span>
</button>
${this.expanded ? html`
${
this.expanded
? html`
<div class="absolute top-12 right-2 z-50 bg-popover border border-border rounded-lg shadow-xl p-4 min-w-52">
<div class="font-semibold text-sm mb-3 flex items-center justify-between">
<span>Token Usage</span>
@ -87,11 +97,13 @@ export class CostTracker extends LitElement {
<div class="flex justify-between"><span class="text-muted-foreground">Requests</span><span class="font-mono">${this.requestCount}</span></div>
</div>
</div>
` : ""}
`
: ""
}
`;
}
}
}
export function createCostTracker(): CostTracker {
return document.createElement("cost-tracker") as CostTracker;
return document.createElement("cost-tracker") as CostTracker;
}

View file

@ -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`
<div class="flex flex-col items-center justify-center h-full select-none"
style="pointer-events:none">
<img src="/mascot/jae-default.png" alt="JAE"
style="width:10rem;height:auto;opacity:0.04;filter:grayscale(40%)" />
</div>
`;
}
return html`
<div class="flex flex-col items-center justify-center h-full min-h-[60vh] px-4 pb-8 select-none">
<!-- Mascot -->
<div class="relative mb-2 group">
<img
src="/mascot/jae-default.png"
@ -28,16 +41,13 @@ export class JaeEmptyState extends LitElement {
class="w-40 h-auto drop-shadow-2xl transition-transform duration-300 group-hover:scale-105"
/>
</div>
<!-- Title -->
<h1 class="text-2xl font-bold text-foreground mb-1">Hey, I'm JAE</h1>
<p class="text-muted-foreground text-sm mb-8 text-center max-w-sm">
Your AI coding agent. I can write code, search the web, generate images, and a whole lot more.
</p>
<!-- Suggestion chips -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full max-w-xl">
${this._suggestions.map(s => html`
${this._suggestions.map(
(s) => html`
<button
class="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-secondary/50 hover:bg-secondary hover:border-primary/40 transition-all text-left text-sm group"
@click=${() => this.dispatchEvent(new CustomEvent("suggestion", { detail: s.text, bubbles: true, composed: true }))}
@ -45,9 +55,10 @@ export class JaeEmptyState extends LitElement {
<span class="text-lg shrink-0">${s.icon}</span>
<span class="text-muted-foreground group-hover:text-foreground transition-colors leading-tight">${s.text}</span>
</button>
`)}
`,
)}
</div>
</div>
`;
}
}
}

View file

@ -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`
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click=${(e: Event) => { if (e.target === e.currentTarget) this.hide(); }}>
override render() {
if (!this.open) return html``;
return html`
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click=${(e: Event) => {
if (e.target === e.currentTarget) this.hide();
}}>
<div class="bg-popover border border-border rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-lg">Keyboard Shortcuts</h2>
<button class="text-muted-foreground hover:text-foreground" @click=${() => this.hide()}>&#x2715;</button>
</div>
${this.shortcuts.map(group => html`
${this.shortcuts.map(
(group) => html`
<div class="mb-4">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">${group.group}</div>
<div class="flex flex-col gap-1">
${group.items.map(item => html`
${group.items.map(
(item) => html`
<div class="flex items-center justify-between py-1">
<span class="text-sm">${item.desc}</span>
<kbd class="text-xs bg-secondary border border-border rounded px-2 py-0.5 font-mono">${item.key}</kbd>
</div>
`)}
`,
)}
</div>
</div>
`)}
`,
)}
</div>
</div>
`;
}
}
}

View file

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

View file

@ -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);
}

View file

@ -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<string> = new Set();
@state() private _confirmDelete: string | null = null;
@state() private _sessions: SessionMetadata[] = [];
@state() private _pinnedIds: Set<string> = 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`
<div class="flex flex-col h-full border-r border-border bg-background shrink-0" style="width:100%">
<div class="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<span class="text-[11px] font-semibold text-muted-foreground uppercase tracking-widest">Chats</span>
@ -78,18 +89,26 @@ export class JaeSessionSidebar extends LitElement {
</button>
</div>
<div class="flex-1 overflow-y-auto py-1">
${sorted.length === 0 ? html`
${
sorted.length === 0
? html`
<div class="px-4 py-10 text-center">
<div class="text-3xl mb-2">💬</div>
<div class="text-xs text-muted-foreground">No chats yet</div>
</div>
` : sorted.map(s => html`
`
: sorted.map(
(s) => html`
<div class="group relative flex items-center gap-1 px-2 py-1.5 mx-1 my-0.5 rounded-lg cursor-pointer transition-colors select-none
${s.id === this.currentSessionId ? "bg-secondary" : "hover:bg-secondary/50"}"
@click=${() => this.onLoadSession?.(s.id)}>
${this._pinnedIds.has(s.id) ? html`
${
this._pinnedIds.has(s.id)
? html`
<div class="absolute left-0.5 top-1/2 -translate-y-1/2 w-1 h-4 rounded-full bg-primary/60"></div>
` : html``}
`
: html``
}
<div class="flex-1 min-w-0 pl-1">
<div class="text-xs font-medium truncate" style="color: inherit">${s.title || "Untitled"}</div>
<div class="text-[10px] text-muted-foreground leading-tight">${this._fmt(s.lastModified)}</div>
@ -118,7 +137,9 @@ export class JaeSessionSidebar extends LitElement {
</button>
</div>
</div>
`)}
`,
)
}
</div>
<div class="px-3 py-1.5 border-t border-border shrink-0">
<div class="text-[10px] text-muted-foreground text-center">
@ -127,5 +148,5 @@ export class JaeSessionSidebar extends LitElement {
</div>
</div>
`;
}
}
}

View file

@ -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`
<div class="flex items-center gap-2 px-2 py-1 border-b border-border shrink-0 bg-muted/30" style="height:34px">
<div class="w-2 h-2 rounded-full ${this.connected ? 'bg-green-500' : 'bg-red-500'}"></div>
<div class="w-2 h-2 rounded-full ${this.connected ? "bg-green-500" : "bg-red-500"}"></div>
<span class="text-xs font-mono text-muted-foreground">bash</span>
<div class="flex-1"></div>
${!this.connected && !this.connecting
? html`<button class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:opacity-90" @click=${() => this.connect()}>Connect</button>`
: html``}
${
!this.connected && !this.connecting
? html`<button class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:opacity-90" @click=${() => this.connect()}>Connect</button>`
: html``
}
${this.connecting ? html`<span class="text-xs text-muted-foreground">Connecting...</span>` : html``}
${this.connected ? html`<button class="text-xs px-2 py-0.5 rounded bg-secondary text-secondary-foreground hover:opacity-90" @click=${() => this.disconnect()}>Kill</button>` : html``}
</div>
<div id="xterm-container" class="flex-1 overflow-hidden" style="min-height:0;background:#09090b"></div>
`;
}
}
}

View file

@ -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`
<div class="relative">
<!-- Toggle button -->
<button
title="Message filters"
@click=${() => { this.open = !this.open; this.requestUpdate(); }}
@click=${() => {
this.open = !this.open;
this.requestUpdate();
}}
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs border border-border bg-secondary/60 hover:bg-secondary hover:border-primary/40 transition-all text-muted-foreground hover:text-foreground"
>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -57,7 +69,9 @@ export class JaeUtilityToggle extends LitElement {
</button>
<!-- Dropdown panel -->
${this.open ? html`
${
this.open
? html`
<div class="absolute top-full right-0 mt-2 w-72 rounded-xl border border-border bg-background shadow-2xl z-50 overflow-hidden">
<!-- Header -->
<div class="flex items-center gap-2 px-4 py-3 border-b border-border bg-secondary/30">
@ -66,14 +80,18 @@ export class JaeUtilityToggle extends LitElement {
<div class="text-sm font-semibold text-foreground">Message Filters</div>
<div class="text-xs text-muted-foreground">Control what JAE shows you</div>
</div>
<button @click=${() => { this.open = false; this.requestUpdate(); }} class="ml-auto text-muted-foreground hover:text-foreground transition-colors">
<button @click=${() => {
this.open = false;
this.requestUpdate();
}} class="ml-auto text-muted-foreground hover:text-foreground transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Items -->
<div class="p-2">
${this._items.map(item => html`
${this._items.map(
(item) => html`
<button
class="w-full flex items-start gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/60 transition-colors text-left"
@click=${() => this._toggle(item.key)}
@ -84,11 +102,12 @@ export class JaeUtilityToggle extends LitElement {
<div class="text-xs text-muted-foreground leading-tight">${item.desc}</div>
</div>
<!-- Toggle switch -->
<div class="shrink-0 mt-0.5 w-9 h-5 rounded-full transition-colors duration-200 flex items-center px-0.5 ${this.visibility[item.key] ? 'bg-primary' : 'bg-muted'}">
<div class="w-4 h-4 rounded-full bg-white shadow transition-transform duration-200 ${this.visibility[item.key] ? 'translate-x-4' : 'translate-x-0'}"></div>
<div class="shrink-0 mt-0.5 w-9 h-5 rounded-full transition-colors duration-200 flex items-center px-0.5 ${this.visibility[item.key] ? "bg-primary" : "bg-muted"}">
<div class="w-4 h-4 rounded-full bg-white shadow transition-transform duration-200 ${this.visibility[item.key] ? "translate-x-4" : "translate-x-0"}"></div>
</div>
</button>
`)}
`,
)}
</div>
<!-- Footer -->
@ -97,9 +116,14 @@ export class JaeUtilityToggle extends LitElement {
</div>
</div>
<!-- Click-outside overlay -->
<div class="fixed inset-0 z-40" @click=${() => { this.open = false; this.requestUpdate(); }}></div>
` : html``}
<div class="fixed inset-0 z-40" @click=${() => {
this.open = false;
this.requestUpdate();
}}></div>
`
: html``
}
</div>
`;
}
}
}

View file

@ -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<string>).detail;
if (storage.sessions) {
await storage.sessions.delete(id);
if (id === currentSessionId) newSession();
await refreshSidebar();
}
const id = (e as CustomEvent<string>).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<UtilityVisibility>).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<UtilityVisibility>).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<AgentState>) => {
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<boolean> => {
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<string>).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<string>).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`
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
<div class="flex items-center justify-between border-b border-border shrink-0" style="height:44px">
<div class="flex items-center gap-1 px-2">
${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`<div class="flex items-center gap-2">${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(); } } })}</div>`
: html`<button class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors max-w-xs truncate" @click=${() => { isEditingTitle = true; renderApp(); requestAnimationFrame(() => { const inp = app.querySelector('input[type="text"]') as HTMLInputElement; if (inp) { inp.focus(); inp.select(); } }); }} title="Click to edit">${currentTitle}</button>`
: html`<div class="flex items-center gap-2"><img src="/mascot/jae-default.png" alt="JAE" class="w-7 h-auto header-logo cursor-pointer" /><span class="text-base font-semibold text-foreground">JAE</span>${getModelLabel() ? html`<span class="ml-1 text-[11px] text-muted-foreground bg-muted/80 px-1.5 py-0.5 rounded font-mono truncate max-w-[180px]" title="${getModelLabel()}">${getModelLabel()}</span>` : html``}</div>`
}
<div class="flex items-center gap-2">
<img src="/mascot/jae-default.png" alt="JAE" class="w-7 h-auto header-logo cursor-pointer" />
<span class="text-base font-semibold text-foreground">JAE</span>
${getModelLabel() ? html`<span class="ml-1 text-[11px] text-muted-foreground bg-muted/80 px-1.5 py-0.5 rounded font-mono truncate max-w-[180px] cursor-pointer hover:bg-muted" title="${getModelLabel()}">${getModelLabel()}</span>` : html``}
</div>
${
currentTitle
? isEditingTitle
? html`<div class="flex items-center gap-2">${Input({
type: "text",
value: currentTitle,
className: "text-sm w-64",
onChange: async (e: Event) => {
const 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();
}
},
})}</div>`
: html`<button class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors max-w-xs truncate" @click=${() => {
isEditingTitle = true;
renderApp();
requestAnimationFrame(() => {
const inp = app.querySelector('input[type="text"]') as HTMLInputElement;
if (inp) {
inp.focus();
inp.select();
}
});
}} title="Click to edit">${currentTitle}</button>`
: html``
}
</div>
<div class="flex items-center gap-1 px-2">
${costTracker}
@ -333,8 +490,31 @@ const renderApp = () => {
${Button({ variant: "ghost", size: "sm", children: html`<span class="text-xs font-mono px-1">&#x2318;K</span>`, onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })}
${utilityToggle}
<theme-toggle></theme-toggle>
${Button({ variant: "ghost", size: "sm", children: html`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`, 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`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`, onClick: () => { rightPanel = rightPanel === 'browser' ? 'none' : 'browser'; renderApp(); }, title: "Toggle Browser" })}
${Button({
variant: "ghost",
size: "sm",
children: html`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
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`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`,
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" })}
</div>
</div>
@ -343,64 +523,125 @@ ${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick
${sidebar}
</div>
<div id="sb-resize" style="width:5px;cursor:col-resize;flex-shrink:0;background:transparent;z-index:10;transition:background 0.15s"
@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"}}
@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";
}}
></div>
<div class="flex flex-col flex-1 min-w-0 min-h-0 relative">
${!hasMessages ? html`
<div class="absolute inset-x-0 top-0 z-10 flex flex-col overflow-y-auto bg-background" style="bottom:130px" @suggestion=${handleSuggestion}>
<jae-empty-state style="display:flex;flex-direction:column;flex:1;width:100%;min-height:0"></jae-empty-state>
<div class="absolute inset-x-0 top-0 z-10 flex flex-col" style="bottom:130px;pointer-events:${hasMessages ? "none" : "auto"}" @suggestion=${handleSuggestion}>
<jae-empty-state .faded=${hasMessages} style="display:flex;flex-direction:column;flex:1;width:100%;min-height:0"></jae-empty-state>
</div>
` : html``}
<div id="chat-wrapper" class="flex flex-col flex-1 min-h-0" >
${chatPanel}
</div>
</div>
${rightPanel !== 'none' ? html`
${
rightPanel !== "none"
? html`
<div id="rp-resize" style="width:5px;cursor:col-resize;flex-shrink:0;background:transparent;z-index:10;transition:background 0.15s"
@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"}}
@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";
}}
></div>
<div id="right-panel" class="flex flex-col border-l border-border" style="width:${rightPanelWidth}px;min-width:280px;max-width:800px;flex-shrink:0">
<div class="flex items-center gap-1 px-2 shrink-0 border-b border-border bg-muted/20" style="height:36px">
<button class="text-xs px-2 py-1 rounded ${
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</button>
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</button>
<button class="text-xs px-2 py-1 rounded ${
rightPanel === 'browser'
? 'bg-primary text-primary-foreground'
: 'hover:bg-secondary text-muted-foreground'
}" @click=${() => { rightPanel = 'browser'; renderApp(); }}>Browser</button>
rightPanel === "browser" ? "bg-primary text-primary-foreground" : "hover:bg-secondary text-muted-foreground"
}" @click=${() => {
rightPanel = "browser";
renderApp();
}}>Browser</button>
<div class="flex-1"></div>
<button class="text-xs px-2 py-1 rounded hover:bg-secondary text-muted-foreground" @click=${() => { rightPanel = 'none'; renderApp(); }} title="Close panel"></button>
<button class="text-xs px-2 py-1 rounded hover:bg-secondary text-muted-foreground" @click=${() => {
rightPanel = "none";
renderApp();
}} title="Close panel"></button>
</div>
${rightPanel === 'terminal' ? html`<jae-terminal-panel class="flex-1 min-h-0"></jae-terminal-panel>` : html``}
${rightPanel === 'browser' ? html`<jae-browser-panel class="flex-1 min-h-0"></jae-browser-panel>` : html``}
${rightPanel === "terminal" ? html`<jae-terminal-panel class="flex-1 min-h-0"></jae-terminal-panel>` : html``}
${rightPanel === "browser" ? html`<jae-browser-panel class="flex-1 min-h-0"></jae-browser-panel>` : html``}
</div>
` : html``}
`
: html``
}
</div>
`, app);
`,
app,
);
};
async function initApp() {
const app = document.getElementById("app");
if (!app) throw new Error("App container not found");
render(html`<div class="w-full h-screen flex items-center justify-center bg-background text-foreground"><div class="text-muted-foreground">Loading...</div></div>`, app);
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`<div class="w-full h-screen flex items-center justify-center bg-background text-foreground"><div class="text-muted-foreground">Loading...</div></div>`,
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();

View file

@ -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<string, string> = {
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<string, string> = {
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`<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${cls}">${tag}</span>`;
}
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`<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${cls}">${tag}</span>`;
}
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<string, any[]> = { 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<string, any[]> = { 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`
<div class="flex flex-col gap-4">
<!-- Filter bar -->
<div class="flex flex-wrap gap-2 items-center">
${filters.map(f => html`
${filters.map(
(f) => html`
<button
class="px-3 py-1 rounded-full text-xs font-medium border transition-colors
${this.filter === f.id
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:bg-secondary"}"
@click=${() => { this.filter = f.id; this.requestUpdate(); }}
${
this.filter === f.id
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:bg-secondary"
}"
@click=${() => {
this.filter = f.id;
this.requestUpdate();
}}
>${f.label}</button>
`)}
`,
)}
<input
type="search"
placeholder="Search models..."
class="ml-auto px-3 py-1 text-sm rounded border border-border bg-background text-foreground w-48"
.value=${this.search}
@input=${(e: Event) => { this.search = (e.target as HTMLInputElement).value; this.requestUpdate(); }}
@input=${(e: Event) => {
this.search = (e.target as HTMLInputElement).value;
this.requestUpdate();
}}
/>
</div>
<!-- Model groups -->
${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`
<div class="flex flex-col gap-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">${CATEGORY_LABELS[cat] ?? cat}</h4>
<div class="grid grid-cols-1 gap-2">
${filtered.map((m: any) => html`
${filtered.map(
(m: any) => html`
<div class="flex items-start justify-between p-3 rounded-lg border border-border bg-card hover:bg-secondary/40 transition-colors">
<div class="flex flex-col gap-1 min-w-0">
<span class="text-sm font-mono font-medium text-foreground truncate">${m.id}</span>
@ -114,18 +128,23 @@ export class VeniceModelBrowser extends LitElement {
${(m.tags ?? []).map((t: string) => this.renderTag(t))}
</div>
</div>
`)}
`,
)}
</div>
</div>
`;
})}
})}
${models.length === 0 ? html`
${
models.length === 0
? html`
<div class="text-sm text-muted-foreground text-center py-4">
No Venice models found. Ensure jae-ai includes Venice models.
</div>
` : ""}
`
: ""
}
</div>
`;
}
}
}

View file

@ -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<string>();
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`
<div class="flex gap-1 flex-wrap">
<button
class="text-xs px-2 py-1 rounded-full border transition-colors ${
this.filterProvider === ""
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40"
}"
@click=${() => {
this.filterProvider = "";
this.selectedIndex = 0;
if (this.scrollContainerRef.value) this.scrollContainerRef.value.scrollTop = 0;
}}
>All</button>
${providers.map(
(p) => html`
<button
class="text-xs px-2 py-1 rounded-full border transition-colors capitalize ${
this.filterProvider === p
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40"
}"
@click=${() => {
this.filterProvider = p;
this.selectedIndex = 0;
if (this.scrollContainerRef.value) this.scrollContainerRef.value.scrollTop = 0;
}}
>${p}</button>
`,
)}
</div>
`;
}
protected override renderContent(): TemplateResult {
const filteredModels = this.getFilteredModels();

View file

@ -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";

View file

@ -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<typeof diffSchema, DiffDetails> = {
name: "show_diff",
label: "Show Diff",
description: "Show a diff between two versions of code or text",
parameters: diffSchema,
async execute(toolCallId, params, signal) {
return {
content: [{ type: "text", text: `Diff shown for: ${params.filename || "file"}` }],
details: { original: params.original, modified: params.modified, filename: params.filename },
};
},
name: "show_diff",
label: "Show Diff",
description: "Show a diff between two versions of code or text",
parameters: diffSchema,
async execute(toolCallId, params, signal) {
return {
content: [{ type: "text", text: `Diff shown for: ${params.filename || "file"}` }],
details: { original: params.original, modified: params.modified, filename: params.filename },
};
},
};
class DiffRenderer implements ToolRenderer<DiffParams, DiffDetails> {
render(params: DiffParams | undefined, result: ToolResultMessage<DiffDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (!result?.details) {
return { content: renderHeader(state, GitCompare, `Diff: ${params?.filename || "file"}`), isCustom: false };
}
const { original, modified, filename } = result.details;
const diffLines = computeLineDiff(original, modified);
const adds = diffLines.filter(l => l.type === "add").length;
const removes = diffLines.filter(l => l.type === "remove").length;
render(params: DiffParams | undefined, result: ToolResultMessage<DiffDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (!result?.details) {
return { content: renderHeader(state, GitCompare, `Diff: ${params?.filename || "file"}`), isCustom: false };
}
const { original, modified, filename } = result.details;
const diffLines = computeLineDiff(original, modified);
const adds = diffLines.filter((l) => l.type === "add").length;
const removes = diffLines.filter((l) => l.type === "remove").length;
return {
content: html`
return {
content: html`
<div class="flex flex-col gap-3">
${renderHeader(state, GitCompare, html`Diff: ${filename || "file"} <span class="text-green-500 ml-2">+${adds}</span><span class="text-red-500 ml-1">-${removes}</span>`)}
<div class="rounded border border-border overflow-auto max-h-96 text-xs font-mono">
${diffLines.map((l, i) => html`
${diffLines.map(
(l, i) => html`
<div class="flex gap-0 ${
l.type === "add" ? "bg-green-500/10 text-green-700 dark:text-green-400" :
l.type === "remove" ? "bg-red-500/10 text-red-700 dark:text-red-400" :
"text-muted-foreground"
}">
l.type === "add"
? "bg-green-500/10 text-green-700 dark:text-green-400"
: l.type === "remove"
? "bg-red-500/10 text-red-700 dark:text-red-400"
: "text-muted-foreground"
}">
<span class="w-6 text-center shrink-0 select-none border-r border-border px-1">${i + 1}</span>
<span class="px-2 whitespace-pre">${
l.type === "add" ? "+ " : l.type === "remove" ? "- " : " "
}${l.line}</span>
l.type === "add" ? "+ " : l.type === "remove" ? "- " : " "
}${l.line}</span>
</div>
`)}
`,
)}
</div>
</div>
`,
isCustom: false,
};
}
isCustom: false,
};
}
}
registerToolRenderer("show_diff", new DiffRenderer());
export function createDiffTool(): AgentTool<typeof diffSchema, DiffDetails> {
return diffTool;
return diffTool;
}

View file

@ -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<typeof imageGenSchema, ImageGenDetails> = {
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<ImageGenParams, ImageGenDetails> {
render(params: ImageGenParams | undefined, result: ToolResultMessage<ImageGenDetails> | 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<ImageGenDetails> | undefined,
): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (result?.details?.dataUrl) {
const d = result.details;
return {
content: html`
<div class="flex flex-col gap-3">
${renderHeader(state, Image, "Image Generated")}
<img src=${d.dataUrl} alt=${d.prompt}
@ -94,15 +102,18 @@ class ImageGenRenderer implements ToolRenderer<ImageGenParams, ImageGenDetails>
</div>
<div class="text-xs text-foreground italic">${d.prompt}</div>
</div>`,
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<typeof imageGenSchema, ImageGenDetails> {
return imageGenTool;
return imageGenTool;
}

View file

@ -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";

View file

@ -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<IDBDatabase> {
if (db) return db;
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE_NAME, { keyPath: "id" });
};
req.onsuccess = () => { db = req.result; resolve(db); };
req.onerror = () => reject(req.error);
});
if (db) return db;
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE_NAME, { keyPath: "id" });
};
req.onsuccess = () => {
db = req.result;
resolve(db);
};
req.onerror = () => reject(req.error);
});
}
export async function memorySave(content: string, tags: string[] = []): Promise<string> {
const db = await openDB();
const entry: MemoryEntry = { id: crypto.randomUUID(), content, tags, timestamp: new Date().toISOString() };
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve(entry.id);
tx.onerror = () => reject(tx.error);
});
const db = await openDB();
const entry: MemoryEntry = { id: crypto.randomUUID(), content, tags, timestamp: new Date().toISOString() };
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve(entry.id);
tx.onerror = () => reject(tx.error);
});
}
export async function memoryLoad(): Promise<MemoryEntry[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
export async function memorySearch(query: string): Promise<MemoryEntry[]> {
const all = await memoryLoad();
const q = query.toLowerCase();
return all.filter(e => e.content.toLowerCase().includes(q) || e.tags.some(t => t.toLowerCase().includes(q)));
const all = await memoryLoad();
const q = query.toLowerCase();
return all.filter((e) => e.content.toLowerCase().includes(q) || e.tags.some((t) => t.toLowerCase().includes(q)));
}
export async function memoryDelete(id: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
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<typeof saveMemorySchema, { id: string; content: string }> = {
name: "memory_save",
label: "Save Memory",
description: "Save a piece of information to long-term memory for future sessions.",
parameters: saveMemorySchema,
async execute(toolCallId, params, signal) {
const id = await memorySave(params.content, params.tags || []);
return {
content: [{ type: "text", text: `Memory saved with ID: ${id}` }],
details: { id, content: params.content },
};
},
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<typeof recallMemorySchema, { results: MemoryEntry[] }> = {
name: "memory_recall",
label: "Recall Memory",
description: "Search long-term memory for relevant information.",
parameters: recallMemorySchema,
async execute(toolCallId, params, signal) {
const results = await memorySearch(params.query);
const text = results.length === 0
? `No memories found for: ${params.query}`
: results.map(r => `[${r.timestamp.slice(0, 10)}] ${r.content}`).join("\n\n");
return {
content: [{ type: "text", text }],
details: { results },
};
},
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`
<div class="flex flex-col gap-2">
${renderHeader(state, Brain, `Memory saved`)}
${result?.details ? html`<div class="text-xs text-muted-foreground truncate">${result.details.content}</div>` : ""}
</div>
`,
isCustom: false,
};
}
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`
<div class="flex flex-col gap-3">
${renderHeader(state, BrainCircuit, `Memory recall: ${params?.query || ""}`)}
${results.length > 0 ? html`
${
results.length > 0
? html`
<div class="flex flex-col gap-1">
${results.map(r => html`
${results.map(
(r) => html`
<div class="text-xs p-2 rounded border border-border">
<div class="text-muted-foreground">${r.timestamp.slice(0, 10)}</div>
<div>${r.content}</div>
</div>
`)}
`,
)}
</div>
` : ""}
`
: ""
}
</div>
`,
isCustom: false,
};
}
isCustom: false,
};
}
}
registerToolRenderer("memory_save", new SaveMemoryRenderer());
registerToolRenderer("memory_recall", new RecallMemoryRenderer());
export function createMemoryTools() {
return [saveMemoryTool, recallMemoryTool];
return [saveMemoryTool, recallMemoryTool];
}

View file

@ -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<typeof mermaidSchema, MermaidDetails> = {
name: "render_diagram",
label: "Render Diagram",
description: "Render a Mermaid diagram (flowchart, sequence, gantt, class diagram, etc.)",
parameters: mermaidSchema,
async execute(toolCallId, params, signal) {
return {
content: [{ type: "text", text: `Diagram rendered: ${params.title || "Untitled"}` }],
details: { diagram: params.diagram, rendered: true },
};
},
name: "render_diagram",
label: "Render Diagram",
description: "Render a Mermaid diagram (flowchart, sequence, gantt, class diagram, etc.)",
parameters: mermaidSchema,
async execute(toolCallId, params, signal) {
return {
content: [{ type: "text", text: `Diagram rendered: ${params.title || "Untitled"}` }],
details: { diagram: params.diagram, rendered: true },
};
},
};
let mermaidLoaded = false;
async function loadMermaid(): Promise<any> {
if ((window as any).mermaid) return (window as any).mermaid;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
script.onload = () => {
const m = (window as any).mermaid;
m.initialize({ startOnLoad: false, theme: document.documentElement.classList.contains("dark") ? "dark" : "default" });
mermaidLoaded = true;
resolve(m);
};
script.onerror = reject;
document.head.appendChild(script);
});
if ((window as any).mermaid) return (window as any).mermaid;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
script.onload = () => {
const m = (window as any).mermaid;
m.initialize({
startOnLoad: false,
theme: document.documentElement.classList.contains("dark") ? "dark" : "default",
});
mermaidLoaded = true;
resolve(m);
};
script.onerror = reject;
document.head.appendChild(script);
});
}
class MermaidRenderer implements ToolRenderer<MermaidParams, MermaidDetails> {
render(params: MermaidParams | undefined, result: ToolResultMessage<MermaidDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const diagram = result?.details?.diagram || params?.diagram || "";
const title = params?.title || "Diagram";
render(params: MermaidParams | undefined, result: ToolResultMessage<MermaidDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const diagram = result?.details?.diagram || params?.diagram || "";
const title = params?.title || "Diagram";
if (!diagram) {
return { content: renderHeader(state, GitBranch, "Rendering diagram..."), isCustom: false };
}
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 = `<div class="text-destructive text-sm p-2">Diagram error: ${err.message}</div>`;
}
};
const renderDiagram = async (container: HTMLElement) => {
try {
const mermaid = await loadMermaid();
const { svg } = await mermaid.render(containerId + "-svg", diagram);
container.innerHTML = svg;
container.style.maxWidth = "100%";
const svgEl = container.querySelector("svg");
if (svgEl) {
svgEl.style.maxWidth = "100%";
svgEl.style.height = "auto";
}
} catch (err: any) {
container.innerHTML = `<div class="text-destructive text-sm p-2">Diagram error: ${err.message}</div>`;
}
};
return {
content: html`
return {
content: html`
<div class="flex flex-col gap-3">
${renderHeader(state, GitBranch, `Diagram: ${title}`)}
<div
@ -91,13 +93,13 @@ class MermaidRenderer implements ToolRenderer<MermaidParams, MermaidDetails> {
></div>
</div>
`,
isCustom: false,
};
}
isCustom: false,
};
}
}
registerToolRenderer("render_diagram", new MermaidRenderer());
export function createMermaidTool(): AgentTool<typeof mermaidSchema, MermaidDetails> {
return mermaidTool;
return mermaidTool;
}

View file

@ -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`
<div class="space-y-3">
${renderHeader(state, FileText, label)}
${diff ? html`
${
diff
? html`
<div class="overflow-auto max-h-96 rounded-lg border border-border">
<pre class="text-xs font-mono p-4">${lines.map((line: string) => {
let cls = "block";
if (line.startsWith("+") && !line.startsWith("+++")) cls = "text-green-500 bg-green-500/10 block px-1";
else if (line.startsWith("-") && !line.startsWith("---")) cls = "text-red-500 bg-red-500/10 block px-1";
else if (line.startsWith("@@")) cls = "text-blue-400 block px-1";
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "text-muted-foreground block px-1";
else cls = "block px-1";
return html`<span class=${cls}>${line}</span>
let cls = "block";
if (line.startsWith("+") && !line.startsWith("+++"))
cls = "text-green-500 bg-green-500/10 block px-1";
else if (line.startsWith("-") && !line.startsWith("---"))
cls = "text-red-500 bg-red-500/10 block px-1";
else if (line.startsWith("@@")) cls = "text-blue-400 block px-1";
else if (line.startsWith("diff ") || line.startsWith("index "))
cls = "text-muted-foreground block px-1";
else cls = "block px-1";
return html`<span class=${cls}>${line}</span>
`;
})}</pre>
})}</pre>
</div>
` : ""}
`
: ""
}
</div>
`,
isCustom: false,
};
}
isCustom: false,
};
}
}
registerToolRenderer("diff", new DiffRenderer());

View file

@ -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`
<div class="space-y-3">
${renderHeader(state, GitBranch, "Diagram")}
${diagram ? html`
${
diagram
? html`
<div class="p-4 bg-background rounded-lg border border-border overflow-auto">
<div .id=${id} class="mermaid">${diagram}</div>
<script>
@ -41,12 +47,14 @@ export class MermaidRenderer implements ToolRenderer {
})();
</script>
</div>
` : ""}
`
: ""
}
</div>
`,
isCustom: false,
};
}
isCustom: false,
};
}
}
registerToolRenderer("mermaid", new MermaidRenderer());

View file

@ -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<typeof ttsSchema, TTSDetails> = {
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<TTSParams, TTSDetails> {
render(params: TTSParams | undefined, result: ToolResultMessage<TTSDetails> | 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<TTSDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (result?.details?.audioUrl) {
const d = result.details;
return {
content: html`
<div class="flex flex-col gap-2">
${renderHeader(state, Volume2, "Speech Generated")}
<audio controls src=${d.audioUrl} class="w-full"></audio>
<div class="text-xs text-muted-foreground">${d.model} · ${d.voice}</div>
<div class="text-xs text-foreground italic">${d.text.length > 100 ? d.text.slice(0, 100) + "..." : d.text}</div>
</div>`,
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<typeof ttsSchema, TTSDetails> {
return ttsTool;
return ttsTool;
}

View file

@ -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<WebSearchResult[]> {
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<typeof webSearchSchema, WebSearchDetails> = {
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<WebSearchParams, WebSearchDetails> {
render(params: WebSearchParams | undefined, result: ToolResultMessage<WebSearchDetails> | 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<WebSearchDetails> | undefined,
): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (result?.details?.results?.length) {
const details = result.details;
return {
content: html`
<div class="flex flex-col gap-3">
${renderHeader(state, Globe, `Web Search: ${details.query}`)}
<div class="flex flex-col gap-2">
${details.results.map((r) => html`
${details.results.map(
(r) => html`
<div class="flex flex-col gap-0.5 p-2 rounded border border-border bg-background">
<a href=${r.url} target="_blank" rel="noopener" class="text-sm font-medium text-primary hover:underline">${r.title}</a>
<span class="text-xs text-muted-foreground truncate">${r.url}</span>
<span class="text-xs text-foreground mt-1">${r.snippet}</span>
</div>
`)}
`,
)}
</div>
</div>`,
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<typeof webSearchSchema, WebSearchDetails> {
return webSearchTool;
return webSearchTool;
}