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" "playwright": "^1.58.2"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.0.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^7.1.6" "vite": "^7.1.6",
"ws": "*"
} }
}, },
"packages/web-ui/node_modules/@xterm/xterm": { "packages/web-ui/node_modules/@xterm/xterm": {

View file

@ -1,32 +1,37 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@jaeswift/jae-agent-core"; import type { AgentTool } from "@jaeswift/jae-agent-core";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
const browserSchema = Type.Object({ const browserSchema = Type.Object({
action: Type.Union([ action: Type.Union(
Type.Literal("navigate"), [
Type.Literal("screenshot"), Type.Literal("navigate"),
Type.Literal("click"), Type.Literal("screenshot"),
Type.Literal("type"), Type.Literal("click"),
Type.Literal("content"), Type.Literal("type"),
Type.Literal("close"), Type.Literal("content"),
], { description: "navigate: go to URL | screenshot: capture page | click: click element | type: type into element | content: get page text | close: close browser" }), Type.Literal("close"),
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)" })), description:
wait: Type.Optional(Type.Number({ description: "Milliseconds to wait after action (default: 1000)" })), "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 type BrowserToolInput = Static<typeof browserSchema>;
export interface BrowserToolDetails { export interface BrowserToolDetails {
action: string; action: string;
url?: string; url?: string;
screenshotPath?: string; screenshotPath?: string;
error?: string; error?: string;
} }
let _playwright: any = null; let _playwright: any = null;
@ -34,93 +39,102 @@ let _browser: any = null;
let _page: any = null; let _page: any = null;
async function getPlaywright() { async function getPlaywright() {
if (!_playwright) { if (!_playwright) {
try { try {
const { chromium } = await import("playwright"); const { chromium } = await import("playwright");
_playwright = chromium; _playwright = chromium;
} catch { } catch {
throw new Error("Playwright not installed. Run: npm install -g playwright && npx playwright install chromium"); throw new Error("Playwright not installed. Run: npm install -g playwright && npx playwright install chromium");
} }
} }
return _playwright; return _playwright;
} }
async function getPage() { async function getPage() {
const pw = await getPlaywright(); const pw = await getPlaywright();
if (!_browser) _browser = await pw.launch({ headless: true }); if (!_browser) _browser = await pw.launch({ headless: true });
if (!_page) _page = await _browser.newPage(); if (!_page) _page = await _browser.newPage();
return _page; return _page;
} }
export const browserTool: AgentTool<typeof browserSchema, BrowserToolDetails> = { export const browserTool: AgentTool<typeof browserSchema, BrowserToolDetails> = {
name: "browser", name: "browser",
label: "Browser", label: "Browser",
description: "Control a headless Chromium browser. Navigate pages, take screenshots, click elements, type text, and extract page content. Requires playwright.", description:
parameters: browserSchema, "Control a headless Chromium browser. Navigate pages, take screenshots, click elements, type text, and extract page content. Requires playwright.",
async execute(toolCallId, params, signal) { parameters: browserSchema,
const { action, url, selector, text, wait = 1000 } = params; async execute(toolCallId, params, signal) {
const { action, url, selector, text, wait = 1000 } = params;
try { try {
if (action === "close") { if (action === "close") {
if (_browser) { await _browser.close(); _browser = null; _page = null; } if (_browser) {
return { content: [{ type: "text", text: "Browser closed." }], details: { action } }; 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 (action === "navigate") {
if (!url) return { content: [{ type: "text", text: "Error: url required for navigate" }], details: { action } }; if (!url)
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); return { content: [{ type: "text", text: "Error: url required for navigate" }], details: { action } };
await page.waitForTimeout(wait); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
const title = await page.title(); await page.waitForTimeout(wait);
return { const title = await page.title();
content: [{ type: "text", text: `Navigated to: ${url}\nPage title: ${title}` }], return {
details: { action, url }, content: [{ type: "text", text: `Navigated to: ${url}\nPage title: ${title}` }],
}; details: { action, url },
} };
}
if (action === "screenshot") { if (action === "screenshot") {
const dir = join(tmpdir(), "jae-browser"); const dir = join(tmpdir(), "jae-browser");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const path = join(dir, `screenshot-${Date.now()}.png`); const path = join(dir, `screenshot-${Date.now()}.png`);
await page.screenshot({ path, fullPage: false }); await page.screenshot({ path, fullPage: false });
const currentUrl = page.url(); const currentUrl = page.url();
return { return {
content: [{ type: "text", text: `Screenshot saved to: ${path}\nCurrent URL: ${currentUrl}` }], content: [{ type: "text", text: `Screenshot saved to: ${path}\nCurrent URL: ${currentUrl}` }],
details: { action, url: currentUrl, screenshotPath: path }, details: { action, url: currentUrl, screenshotPath: path },
}; };
} }
if (action === "content") { if (action === "content") {
const content = await page.evaluate(() => document.body.innerText); const content = await page.evaluate(() => document.body.innerText);
const truncated = content.slice(0, 8000); const truncated = content.slice(0, 8000);
return { return {
content: [{ type: "text", text: truncated + (content.length > 8000 ? "\n...[truncated]" : "") }], content: [{ type: "text", text: truncated + (content.length > 8000 ? "\n...[truncated]" : "") }],
details: { action, url: page.url() }, details: { action, url: page.url() },
}; };
} }
if (action === "click") { if (action === "click") {
if (!selector) return { content: [{ type: "text", text: "Error: selector required for click" }], details: { action } }; if (!selector)
await page.click(selector, { timeout: 10000 }); return { content: [{ type: "text", text: "Error: selector required for click" }], details: { action } };
await page.waitForTimeout(wait); await page.click(selector, { timeout: 10000 });
return { content: [{ type: "text", text: `Clicked: ${selector}` }], details: { action } }; await page.waitForTimeout(wait);
} return { content: [{ type: "text", text: `Clicked: ${selector}` }], details: { action } };
}
if (action === "type") { if (action === "type") {
if (!selector) return { content: [{ type: "text", text: "Error: selector required for type" }], details: { action } }; if (!selector)
if (!text) return { content: [{ type: "text", text: "Error: text required for type" }], details: { action } }; return { content: [{ type: "text", text: "Error: selector required for type" }], details: { action } };
await page.fill(selector, text); if (!text)
await page.waitForTimeout(wait); return { content: [{ type: "text", text: "Error: text required for type" }], details: { action } };
return { content: [{ type: "text", text: `Typed into: ${selector}` }], 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 } }; return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: { action } };
} catch (err: any) { } catch (err: any) {
return { return {
content: [{ type: "text", text: `Browser error: ${err.message}` }], content: [{ type: "text", text: `Browser error: ${err.message}` }],
details: { action, error: err.message }, details: { action, error: err.message },
}; };
} }
}, },
}; };

View file

@ -1,96 +1,101 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { AgentTool } from "@jaeswift/jae-agent-core"; import type { AgentTool } from "@jaeswift/jae-agent-core";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
const imageGenSchema = Type.Object({ const imageGenSchema = Type.Object({
prompt: Type.String({ description: "Image generation prompt" }), prompt: Type.String({ description: "Image generation prompt" }),
model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })), model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })),
width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })), width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })),
height: Type.Optional(Type.Number({ description: "Height 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)" })), steps: Type.Optional(Type.Number({ description: "Inference steps (default: 20)" })),
output_dir: Type.Optional(Type.String({ description: "Directory to save image (default: ./images)" })), output_dir: Type.Optional(Type.String({ description: "Directory to save image (default: ./images)" })),
}); });
export type ImageGenToolInput = Static<typeof imageGenSchema>; export type ImageGenToolInput = Static<typeof imageGenSchema>;
export interface ImageGenToolDetails { export interface ImageGenToolDetails {
path: string; path: string;
model: string; model: string;
prompt: string; prompt: string;
width: number; width: number;
height: number; height: number;
} }
export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenToolDetails> = { export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenToolDetails> = {
name: "generate_image", name: "generate_image",
label: "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.", description:
parameters: imageGenSchema, "Generate an image using Venice AI image models and save it to disk. Uses VENICE_API_KEY or OPENAI_API_KEY env var.",
async execute(toolCallId, params, signal) { parameters: imageGenSchema,
const { async execute(toolCallId, params, signal) {
prompt, const {
model = "fluently-xl", prompt,
width = 1024, model = "fluently-xl",
height = 1024, width = 1024,
steps = 20, height = 1024,
output_dir = "./images", steps = 20,
} = params; output_dir = "./images",
} = params;
const apiKey = process.env.VENICE_API_KEY || process.env.OPENAI_API_KEY; const apiKey = process.env.VENICE_API_KEY || process.env.OPENAI_API_KEY;
if (!apiKey) { if (!apiKey) {
return { return {
content: [{ type: "text", text: "Error: VENICE_API_KEY or OPENAI_API_KEY environment variable not set." }], content: [{ type: "text", text: "Error: VENICE_API_KEY or OPENAI_API_KEY environment variable not set." }],
details: { path: "", model, prompt, width, height }, details: { path: "", model, prompt, width, height },
}; };
} }
const body = JSON.stringify({ const body = JSON.stringify({
model, model,
prompt, prompt,
width, width,
height, height,
steps, steps,
return_binary: false, return_binary: false,
safe_mode: false, safe_mode: false,
}); });
const res = await fetch("https://api.venice.ai/api/v1/image/generate", { const res = await fetch("https://api.venice.ai/api/v1/image/generate", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body, body,
signal: signal ?? AbortSignal.timeout(60000), signal: signal ?? AbortSignal.timeout(60000),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const err = await res.text();
return { return {
content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }], content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }],
details: { path: "", model, prompt, width, height }, details: { path: "", model, prompt, width, height },
}; };
} }
const data = await res.json() as any; const data = (await res.json()) as any;
const b64 = data?.images?.[0]; const b64 = data?.images?.[0];
if (!b64) { if (!b64) {
return { return {
content: [{ type: "text", text: "No image returned from Venice API." }], content: [{ type: "text", text: "No image returned from Venice API." }],
details: { path: "", model, prompt, width, height }, details: { path: "", model, prompt, width, height },
}; };
} }
if (!existsSync(output_dir)) mkdirSync(output_dir, { recursive: true }); if (!existsSync(output_dir)) mkdirSync(output_dir, { recursive: true });
const filename = `gen-${Date.now()}.png`; const filename = `gen-${Date.now()}.png`;
const filepath = join(output_dir, filename); const filepath = join(output_dir, filename);
writeFileSync(filepath, Buffer.from(b64, "base64")); writeFileSync(filepath, Buffer.from(b64, "base64"));
return { return {
content: [{ type: "text", text: `Image saved to: ${filepath}\nModel: ${model}\nPrompt: ${prompt}\nSize: ${width}x${height}` }], content: [
details: { path: filepath, model, prompt, width, height }, {
}; 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 Tool = AgentTool<any>;
export type ToolDef = ToolDefinition<any, any>; export type ToolDef = ToolDefinition<any, any>;
import { browserTool } from "./browser.js";
import { webSearchTool } from "./web-search.js";
import { webFetchTool } from "./web-fetch.js";
import { imageGenTool } from "./image-gen.js"; import { imageGenTool } from "./image-gen.js";
import { memoryTool } from "./memory.js"; import { memoryTool } from "./memory.js";
import { browserTool } from "./browser.js"; import { webFetchTool } from "./web-fetch.js";
export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool, webSearchTool, webFetchTool, imageGenTool, memoryTool, browserTool]; 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 readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool];
export const allTools = { 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 ──────────────────────────────────────────────────────────────── // ── New tools ────────────────────────────────────────────────────────────────
export { webSearchTool, type WebSearchToolInput, type WebSearchToolDetails, type WebSearchResult } from "./web-search.js"; export {
export { webFetchTool, type WebFetchToolInput, type WebFetchToolDetails } from "./web-fetch.js"; type WebSearchResult,
export { imageGenTool, type ImageGenToolInput, type ImageGenToolDetails } from "./image-gen.js"; type WebSearchToolDetails,
export { memoryTool, type MemoryToolInput, type MemoryToolDetails } from "./memory.js"; type WebSearchToolInput,
export { browserTool, type BrowserToolInput, type BrowserToolDetails } from "./browser.js"; webSearchTool,
} from "./web-search.js";

View file

@ -1,4 +1,3 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@ -9,104 +8,126 @@ const MEMORY_DIR = join(homedir(), ".jae", "memory");
const MEMORY_FILE = join(MEMORY_DIR, "memories.json"); const MEMORY_FILE = join(MEMORY_DIR, "memories.json");
interface MemoryEntry { interface MemoryEntry {
id: string; id: string;
content: string; content: string;
tags: string[]; tags: string[];
createdAt: string; createdAt: string;
} }
function loadMemories(): MemoryEntry[] { function loadMemories(): MemoryEntry[] {
if (!existsSync(MEMORY_FILE)) return []; if (!existsSync(MEMORY_FILE)) return [];
try { try {
return JSON.parse(readFileSync(MEMORY_FILE, "utf-8")) as MemoryEntry[]; return JSON.parse(readFileSync(MEMORY_FILE, "utf-8")) as MemoryEntry[];
} catch { return []; } } catch {
return [];
}
} }
function saveMemories(entries: MemoryEntry[]): void { function saveMemories(entries: MemoryEntry[]): void {
if (!existsSync(MEMORY_DIR)) mkdirSync(MEMORY_DIR, { recursive: true }); if (!existsSync(MEMORY_DIR)) mkdirSync(MEMORY_DIR, { recursive: true });
writeFileSync(MEMORY_FILE, JSON.stringify(entries, null, 2)); writeFileSync(MEMORY_FILE, JSON.stringify(entries, null, 2));
} }
function scoreMatch(entry: MemoryEntry, query: string): number { function scoreMatch(entry: MemoryEntry, query: string): number {
const q = query.toLowerCase(); const q = query.toLowerCase();
const text = (entry.content + " " + entry.tags.join(" ")).toLowerCase(); const text = (entry.content + " " + entry.tags.join(" ")).toLowerCase();
const words = q.split(/\s+/); const words = q.split(/\s+/);
return words.filter(w => text.includes(w)).length / Math.max(words.length, 1); return words.filter((w) => text.includes(w)).length / Math.max(words.length, 1);
} }
const memorySchema = Type.Object({ const memorySchema = Type.Object({
action: Type.Union([ action: Type.Union([Type.Literal("save"), Type.Literal("recall"), Type.Literal("list"), Type.Literal("delete")], {
Type.Literal("save"), description: "save: store info | recall: search by query | list: show all | delete: remove by id",
Type.Literal("recall"), }),
Type.Literal("list"), content: Type.Optional(Type.String({ description: "Content to save (required for save action)" })),
Type.Literal("delete"), query: Type.Optional(Type.String({ description: "Search query (required for recall action)" })),
], { description: "save: store info | recall: search by query | list: show all | delete: remove by id" }), tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation (optional for save)" })),
content: Type.Optional(Type.String({ description: "Content to save (required for save action)" })), id: Type.Optional(Type.String({ description: "Memory ID (required for delete action)" })),
query: Type.Optional(Type.String({ description: "Search query (required for recall action)" })), limit: Type.Optional(Type.Number({ description: "Max results to return for recall (default: 5)" })),
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 type MemoryToolInput = Static<typeof memorySchema>;
export interface MemoryToolDetails { export interface MemoryToolDetails {
action: string; action: string;
count?: number; count?: number;
id?: string; id?: string;
} }
export const memoryTool: AgentTool<typeof memorySchema, MemoryToolDetails> = { export const memoryTool: AgentTool<typeof memorySchema, MemoryToolDetails> = {
name: "memory", name: "memory",
label: "Memory", label: "Memory",
description: "Persistent memory across sessions. Save facts, recall by query, list all memories, or delete by ID. Stored in ~/.jae/memory/.", description:
parameters: memorySchema, "Persistent memory across sessions. Save facts, recall by query, list all memories, or delete by ID. Stored in ~/.jae/memory/.",
async execute(toolCallId, params, signal) { parameters: memorySchema,
const { action, content, query, tags = [], id, limit = 5 } = params; async execute(toolCallId, params, signal) {
const memories = loadMemories(); const { action, content, query, tags = [], id, limit = 5 } = params;
const memories = loadMemories();
if (action === "save") { if (action === "save") {
if (!content) return { content: [{ type: "text", text: "Error: content is required for save action" }], details: { action } }; if (!content)
const entry: MemoryEntry = { return {
id: `mem-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, content: [{ type: "text", text: "Error: content is required for save action" }],
content, details: { action },
tags, };
createdAt: new Date().toISOString(), const entry: MemoryEntry = {
}; id: `mem-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
memories.push(entry); content,
saveMemories(memories); tags,
return { content: [{ type: "text", text: `Memory saved. ID: ${entry.id}` }], details: { action, id: entry.id } }; 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 (action === "recall") {
if (!query) return { content: [{ type: "text", text: "Error: query is required for recall action" }], details: { action } }; if (!query)
const scored = memories return {
.map(m => ({ m, score: scoreMatch(m, query) })) content: [{ type: "text", text: "Error: query is required for recall action" }],
.filter(x => x.score > 0) details: { action },
.sort((a, b) => b.score - a.score) };
.slice(0, limit) const scored = memories
.map(x => x.m); .map((m) => ({ m, score: scoreMatch(m, query) }))
const text = scored.length === 0 .filter((x) => x.score > 0)
? `No memories found for: ${query}` .sort((a, b) => b.score - a.score)
: scored.map(m => `[${m.id}] ${m.content}${m.tags.length ? ` (tags: ${m.tags.join(", ")})` : ""}`).join("\n\n"); .slice(0, limit)
return { content: [{ type: "text", text }], details: { action, count: scored.length } }; .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") { if (action === "list") {
const text = memories.length === 0 const text =
? "No memories stored." memories.length === 0
: memories.map(m => `[${m.id}] ${m.content.slice(0, 100)}${m.content.length > 100 ? "..." : ""}`).join("\n"); ? "No memories stored."
return { content: [{ type: "text", text }], details: { action, count: memories.length } }; : 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 (action === "delete") {
if (!id) return { content: [{ type: "text", text: "Error: id is required for delete action" }], details: { action } }; if (!id)
const filtered = memories.filter(m => m.id !== id); return {
if (filtered.length === memories.length) return { content: [{ type: "text", text: `No memory found with ID: ${id}` }], details: { action } }; content: [{ type: "text", text: "Error: id is required for delete action" }],
saveMemories(filtered); details: { action },
return { content: [{ type: "text", text: `Memory ${id} deleted.` }], details: { action, id } }; };
} 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 { AgentTool } from "@jaeswift/jae-agent-core";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
const webFetchSchema = Type.Object({ const webFetchSchema = Type.Object({
url: Type.String({ description: "URL to fetch" }), url: Type.String({ description: "URL to fetch" }),
selector: Type.Optional(Type.String({ description: "CSS selector to extract specific content (optional)" })), selector: Type.Optional(Type.String({ description: "CSS selector to extract specific content (optional)" })),
}); });
export type WebFetchToolInput = Static<typeof webFetchSchema>; export type WebFetchToolInput = Static<typeof webFetchSchema>;
export interface WebFetchToolDetails { export interface WebFetchToolDetails {
url: string; url: string;
status: number; status: number;
contentType: string; contentType: string;
truncated: boolean; truncated: boolean;
} }
const MAX_CHARS = 20000; const MAX_CHARS = 20000;
function htmlToText(html: string): string { function htmlToText(html: string): string {
return html return html
.replace(/<script[\s\S]*?<\/script>/gi, "") .replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "") .replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ") .replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">") .replace(/&amp;/g, "&")
.replace(/&quot;/g, '"').replace(/&nbsp;/g, " ").replace(/&#39;/g, "'") .replace(/&lt;/g, "<")
.replace(/[ \t]+/g, " ") .replace(/&gt;/g, ">")
.replace(/\n{3,}/g, "\n\n") .replace(/&quot;/g, '"')
.trim(); .replace(/&nbsp;/g, " ")
.replace(/&#39;/g, "'")
.replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
} }
export const webFetchTool: AgentTool<typeof webFetchSchema, WebFetchToolDetails> = { export const webFetchTool: AgentTool<typeof webFetchSchema, WebFetchToolDetails> = {
name: "web_fetch", name: "web_fetch",
label: "Web Fetch", label: "Web Fetch",
description: "Fetch and read the text content of any web page or URL. Strips HTML to plain text.", description: "Fetch and read the text content of any web page or URL. Strips HTML to plain text.",
parameters: webFetchSchema, parameters: webFetchSchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const { url } = params; const { url } = params;
try { try {
const res = await fetch(url, { const res = await fetch(url, {
headers: { "User-Agent": "JAE-Agent/1.0", "Accept": "text/html,application/xhtml+xml,text/plain,*/*" }, headers: { "User-Agent": "JAE-Agent/1.0", Accept: "text/html,application/xhtml+xml,text/plain,*/*" },
signal: signal ?? AbortSignal.timeout(15000), signal: signal ?? AbortSignal.timeout(15000),
redirect: "follow", redirect: "follow",
}); });
const contentType = res.headers.get("content-type") || ""; const contentType = res.headers.get("content-type") || "";
let body = await res.text(); const body = await res.text();
let text: string; let text: string;
if (contentType.includes("html")) { if (contentType.includes("html")) {
text = htmlToText(body); text = htmlToText(body);
} else { } else {
text = body; text = body;
} }
const truncated = text.length > MAX_CHARS; const truncated = text.length > MAX_CHARS;
if (truncated) text = text.slice(0, MAX_CHARS) + "\n... [truncated]"; if (truncated) text = text.slice(0, MAX_CHARS) + "\n... [truncated]";
return { return {
content: [{ type: "text", text: `URL: ${url}\nStatus: ${res.status}\n\n${text}` }], content: [{ type: "text", text: `URL: ${url}\nStatus: ${res.status}\n\n${text}` }],
details: { url, status: res.status, contentType, truncated }, details: { url, status: res.status, contentType, truncated },
}; };
} catch (err: any) { } catch (err: any) {
return { return {
content: [{ type: "text", text: `Fetch failed for ${url}: ${err.message}` }], content: [{ type: "text", text: `Fetch failed for ${url}: ${err.message}` }],
details: { url, status: 0, contentType: "", truncated: false }, details: { url, status: 0, contentType: "", truncated: false },
}; };
} }
}, },
}; };

View file

@ -29,6 +29,8 @@
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.3", "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 { html, LitElement } from "lit";
import { customElement, state } from 'lit/decorators.js'; import { customElement, state } from "lit/decorators.js";
@customElement('jae-browser-panel') @customElement("jae-browser-panel")
export class JaeBrowserPanel extends LitElement { export class JaeBrowserPanel extends LitElement {
@state() private url = ''; @state() private url = "";
@state() private inputUrl = ''; @state() private inputUrl = "";
@state() private screenshot = ''; @state() private screenshot = "";
@state() private loading = false; @state() private loading = false;
@state() private connected = false; @state() private connected = false;
@state() private error = ''; @state() private error = "";
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private imgEl: HTMLImageElement | null = null; private imgEl: HTMLImageElement | null = null;
createRenderRoot() { return this; } createRenderRoot() {
return this;
}
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.style.display = 'flex'; this.style.display = "flex";
this.style.flexDirection = 'column'; this.style.flexDirection = "column";
this.style.height = '100%'; this.style.height = "100%";
this.style.minHeight = '0'; this.style.minHeight = "0";
this.connect(); this.connect();
} }
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.ws?.close(); this.ws?.close();
} }
connect() { connect() {
this.ws = new WebSocket('ws://localhost:7702'); this.ws = new WebSocket("ws://localhost:7702");
this.ws.onopen = () => { this.connected = true; this.requestUpdate(); }; this.ws.onopen = () => {
this.ws.onclose = () => { this.connected = false; this.requestUpdate(); }; this.connected = true;
this.ws.onerror = () => { this.connected = false; this.error = 'Browser server not running.'; this.requestUpdate(); }; this.requestUpdate();
this.ws.onmessage = (e) => { };
const m = JSON.parse(e.data); this.ws.onclose = () => {
if (m.type === 'screenshot') { this.connected = false;
this.loading = false; this.requestUpdate();
this.screenshot = `data:image/jpeg;base64,${m.data}`; };
this.url = m.url; this.ws.onerror = () => {
this.inputUrl = m.url; this.connected = false;
this.error = ''; this.error = "Browser server not running.";
this.requestUpdate(); this.requestUpdate();
} };
if (m.type === 'loading') { this.loading = true; this.requestUpdate(); } this.ws.onmessage = (e) => {
if (m.type === 'error') { this.loading = false; this.error = m.msg; this.requestUpdate(); } 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) { navigate(url?: string) {
const target = url || this.inputUrl; const target = url || this.inputUrl;
if (!target) return; if (!target) return;
this.ws?.send(JSON.stringify({ type: 'navigate', url: target })); this.ws?.send(JSON.stringify({ type: "navigate", url: target }));
this.loading = true; this.loading = true;
this.requestUpdate(); this.requestUpdate();
} }
private handleImgClick(e: MouseEvent) { private handleImgClick(e: MouseEvent) {
const img = e.currentTarget as HTMLImageElement; const img = e.currentTarget as HTMLImageElement;
const rect = img.getBoundingClientRect(); const rect = img.getBoundingClientRect();
const scaleX = 1280 / rect.width; const scaleX = 1280 / rect.width;
const scaleY = 800 / rect.height; const scaleY = 800 / rect.height;
const x = (e.clientX - rect.left) * scaleX; const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY; const y = (e.clientY - rect.top) * scaleY;
this.ws?.send(JSON.stringify({ type: 'click', x, y })); this.ws?.send(JSON.stringify({ type: "click", x, y }));
this.loading = true; this.loading = true;
this.requestUpdate(); this.requestUpdate();
} }
private handleScroll(e: WheelEvent) { private handleScroll(e: WheelEvent) {
e.preventDefault(); e.preventDefault();
this.ws?.send(JSON.stringify({ type: 'scroll', dy: e.deltaY })); this.ws?.send(JSON.stringify({ type: "scroll", dy: e.deltaY }));
} }
override render() { override render() {
return html` 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"> <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> <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>
<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> <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>
<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> <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> </button>
<input <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" 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" type="text"
.value=${this.inputUrl} .value=${this.inputUrl}
@input=${(e: Event) => { this.inputUrl = (e.target as HTMLInputElement).value; }} @input=${(e: Event) => {
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this.navigate(); }} this.inputUrl = (e.target as HTMLInputElement).value;
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") this.navigate();
}}
placeholder="Enter URL and press Enter..." 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>
<div class="flex-1 overflow-auto min-h-0 relative bg-white"> <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="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div class="text-sm text-muted-foreground">Loading...</div> <div class="text-sm text-muted-foreground">Loading...</div>
</div> </div>
` : html``} `
${this.error ? html` : html``
}
${
this.error
? html`
<div class="p-4 text-sm text-red-500">${this.error}</div> <div class="p-4 text-sm text-red-500">${this.error}</div>
` : html``} `
${this.screenshot ? html` : html``
}
${
this.screenshot
? html`
<img <img
src=${this.screenshot} src=${this.screenshot}
class="w-full cursor-crosshair" class="w-full cursor-crosshair"
@ -114,13 +147,17 @@ export class JaeBrowserPanel extends LitElement {
@click=${this.handleImgClick} @click=${this.handleImgClick}
@wheel=${this.handleScroll} @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"> <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> <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> <p class="text-sm">Enter a URL above to browse</p>
</div> </div>
` : html``} `
: html``
}
</div> </div>
`; `;
} }
} }

View file

@ -1,82 +1,86 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
export interface Command { export interface Command {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
icon?: string; icon?: string;
shortcut?: string; shortcut?: string;
action: () => void; action: () => void;
keywords?: string[]; keywords?: string[];
} }
@customElement("command-palette") @customElement("command-palette")
export class CommandPalette extends LitElement { export class CommandPalette extends LitElement {
@state() private open = false; @state() private open = false;
@state() private query = ""; @state() private query = "";
@state() private selectedIndex = 0; @state() private selectedIndex = 0;
private commands: Command[] = []; private commands: Command[] = [];
protected override createRenderRoot() { return this; } protected override createRenderRoot() {
return this;
}
setCommands(commands: Command[]) { setCommands(commands: Command[]) {
this.commands = commands; this.commands = commands;
} }
show() { show() {
this.open = true; this.open = true;
this.query = ""; this.query = "";
this.selectedIndex = 0; this.selectedIndex = 0;
this.requestUpdate(); this.requestUpdate();
requestAnimationFrame(() => { requestAnimationFrame(() => {
const input = this.querySelector("input") as HTMLInputElement; const input = this.querySelector("input") as HTMLInputElement;
if (input) input.focus(); if (input) input.focus();
}); });
} }
hide() { hide() {
this.open = false; this.open = false;
this.requestUpdate(); this.requestUpdate();
} }
get filteredCommands(): Command[] { get filteredCommands(): Command[] {
if (!this.query) return this.commands; if (!this.query) return this.commands;
const q = this.query.toLowerCase(); const q = this.query.toLowerCase();
return this.commands.filter(c => return this.commands.filter(
c.label.toLowerCase().includes(q) || (c) =>
c.description?.toLowerCase().includes(q) || c.label.toLowerCase().includes(q) ||
c.keywords?.some(k => k.toLowerCase().includes(q)) c.description?.toLowerCase().includes(q) ||
); c.keywords?.some((k) => k.toLowerCase().includes(q)),
} );
}
private handleKeyDown(e: KeyboardEvent) { private handleKeyDown(e: KeyboardEvent) {
const cmds = this.filteredCommands; const cmds = this.filteredCommands;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, cmds.length - 1); this.selectedIndex = Math.min(this.selectedIndex + 1, cmds.length - 1);
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0); this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (cmds[this.selectedIndex]) { if (cmds[this.selectedIndex]) {
cmds[this.selectedIndex].action(); cmds[this.selectedIndex].action();
this.hide(); this.hide();
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
this.hide(); this.hide();
} }
} }
override render() { override render() {
if (!this.open) return html``; if (!this.open) return html``;
const cmds = this.filteredCommands; const cmds = this.filteredCommands;
return html` 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="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="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"> <div class="flex items-center gap-3 px-4 py-3 border-b border-border">
<span class="text-muted-foreground text-sm">&#x2318;</span> <span class="text-muted-foreground text-sm">&#x2318;</span>
@ -85,20 +89,29 @@ export class CommandPalette extends LitElement {
placeholder="Type a command..." placeholder="Type a command..."
class="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" class="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
.value=${this.query} .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} @keydown=${this.handleKeyDown}
/> />
<kbd class="text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5">ESC</kbd> <kbd class="text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5">ESC</kbd>
</div> </div>
<div class="max-h-80 overflow-y-auto py-1"> <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.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 <button
class="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-secondary transition-colors text-left ${ 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" : "" i === this.selectedIndex ? "bg-secondary" : ""
}" }"
@click=${() => { cmd.action(); this.hide(); }} @click=${() => {
@mouseover=${() => { this.selectedIndex = i; }} cmd.action();
this.hide();
}}
@mouseover=${() => {
this.selectedIndex = i;
}}
> >
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="font-medium">${cmd.label}</span> <span class="font-medium">${cmd.label}</span>
@ -106,7 +119,8 @@ export class CommandPalette extends LitElement {
</div> </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>` : ""} ${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> </button>
`)} `,
)}
</div> </div>
<div class="px-4 py-2 border-t border-border text-xs text-muted-foreground flex items-center gap-4"> <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> <span>&#x2191;&#x2193; Navigate</span>
@ -116,5 +130,5 @@ export class CommandPalette extends LitElement {
</div> </div>
</div> </div>
`; `;
} }
} }

View file

@ -1,79 +1,89 @@
import type { Agent } from "@jaeswift/jae-agent-core";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import type { Agent } from "@jaeswift/jae-agent-core";
export interface UsageSnapshot { export interface UsageSnapshot {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
totalTokens: number; totalTokens: number;
estimatedCost: number; estimatedCost: number;
model: string; model: string;
requestCount: number; requestCount: number;
} }
// Very rough cost estimates per 1M tokens for common models // Very rough cost estimates per 1M tokens for common models
const MODEL_COSTS: Record<string, { input: number; output: number }> = { 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 { function estimateCost(model: string, input: number, output: number): number {
const costs = MODEL_COSTS[model] || MODEL_COSTS.default; const costs = MODEL_COSTS[model] || MODEL_COSTS.default;
return (input / 1_000_000) * costs.input + (output / 1_000_000) * costs.output; return (input / 1_000_000) * costs.input + (output / 1_000_000) * costs.output;
} }
@customElement("cost-tracker") @customElement("cost-tracker")
export class CostTracker extends LitElement { export class CostTracker extends LitElement {
@state() private inputTokens = 0; @state() private inputTokens = 0;
@state() private outputTokens = 0; @state() private outputTokens = 0;
@state() private requestCount = 0; @state() private requestCount = 0;
@state() private modelId = ""; @state() private modelId = "";
@state() private expanded = false; @state() private expanded = false;
private unsubscribe?: () => void; private unsubscribe?: () => void;
protected override createRenderRoot() { return this; } protected override createRenderRoot() {
return this;
}
bindAgent(agent: Agent) { bindAgent(agent: Agent) {
if (this.unsubscribe) this.unsubscribe(); if (this.unsubscribe) this.unsubscribe();
this.inputTokens = 0; this.inputTokens = 0;
this.outputTokens = 0; this.outputTokens = 0;
this.requestCount = 0; this.requestCount = 0;
this.modelId = agent.state.model?.id || ""; this.modelId = agent.state.model?.id || "";
this.unsubscribe = agent.subscribe((event) => { this.unsubscribe = agent.subscribe((event) => {
if (event.type === "message" && event.message.role === "assistant") { if (event.type === "message" && event.message.role === "assistant") {
const msg = event.message as any; const msg = event.message as any;
if (msg.usage) { if (msg.usage) {
this.inputTokens += msg.usage.inputTokens || 0; this.inputTokens += msg.usage.inputTokens || 0;
this.outputTokens += msg.usage.outputTokens || 0; this.outputTokens += msg.usage.outputTokens || 0;
this.requestCount += 1; this.requestCount += 1;
} }
} }
}); });
} }
get totalTokens() { return this.inputTokens + this.outputTokens; } get totalTokens() {
get estimatedCost() { return estimateCost(this.modelId, this.inputTokens, this.outputTokens); } return this.inputTokens + this.outputTokens;
}
get estimatedCost() {
return estimateCost(this.modelId, this.inputTokens, this.outputTokens);
}
reset() { reset() {
this.inputTokens = 0; this.inputTokens = 0;
this.outputTokens = 0; this.outputTokens = 0;
this.requestCount = 0; this.requestCount = 0;
} }
override render() { override render() {
const cost = this.estimatedCost; const cost = this.estimatedCost;
return html` return html`
<button <button
class="flex items-center gap-1.5 px-2 py-1 text-xs rounded hover:bg-secondary transition-colors text-muted-foreground" 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" title="Token usage & cost"
> >
<span class="font-mono">${this.totalTokens > 0 ? this.totalTokens.toLocaleString() : "0"} tok</span> <span class="font-mono">${this.totalTokens > 0 ? this.totalTokens.toLocaleString() : "0"} tok</span>
<span class="text-muted-foreground/50">|</span> <span class="text-muted-foreground/50">|</span>
<span class="font-mono">$${cost.toFixed(4)}</span> <span class="font-mono">$${cost.toFixed(4)}</span>
</button> </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="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"> <div class="font-semibold text-sm mb-3 flex items-center justify-between">
<span>Token Usage</span> <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 class="flex justify-between"><span class="text-muted-foreground">Requests</span><span class="font-mono">${this.requestCount}</span></div>
</div> </div>
</div> </div>
` : ""} `
: ""
}
`; `;
} }
} }
export function createCostTracker(): CostTracker { 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"; import { customElement, property } from "lit/decorators.js";
@customElement("jae-empty-state") @customElement("jae-empty-state")
export class JaeEmptyState extends LitElement { 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 = [ private _suggestions = [
{ icon: "💻", text: "Write me a TypeScript function that debounces API calls" }, { 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: "Search the web for the latest news on AI coding agents" },
{ icon: "🖼️", text: "Generate an image of a black dragon breathing fire" }, { icon: "🖼️", text: "Generate an image of a black dragon breathing fire" },
{ icon: "📝", text: "Explain how async/await works in JavaScript" }, { icon: "📝", text: "Explain how async/await works in JavaScript" },
{ icon: "🔧", text: "Help me debug this code and explain the issue" }, { icon: "🔧", text: "Help me debug this code and explain the issue" },
{ icon: "📊", text: "Create a Mermaid diagram of a REST API flow" }, { icon: "📊", text: "Create a Mermaid diagram of a REST API flow" },
]; ];
override render() { override render() {
if (!this.visible) return html``; if (!this.visible) return html``;
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"> <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"> <div class="relative mb-2 group">
<img <img
src="/mascot/jae-default.png" 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" class="w-40 h-auto drop-shadow-2xl transition-transform duration-300 group-hover:scale-105"
/> />
</div> </div>
<!-- Title -->
<h1 class="text-2xl font-bold text-foreground mb-1">Hey, I'm JAE</h1> <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"> <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. Your AI coding agent. I can write code, search the web, generate images, and a whole lot more.
</p> </p>
<!-- Suggestion chips -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full max-w-xl"> <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 <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" 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 }))} @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-lg shrink-0">${s.icon}</span>
<span class="text-muted-foreground group-hover:text-foreground transition-colors leading-tight">${s.text}</span> <span class="text-muted-foreground group-hover:text-foreground transition-colors leading-tight">${s.text}</span>
</button> </button>
`)} `,
)}
</div> </div>
</div> </div>
`; `;
} }
} }

View file

@ -1,60 +1,85 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
@customElement("keyboard-shortcuts") @customElement("keyboard-shortcuts")
export class KeyboardShortcuts extends LitElement { 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(); } show() {
hide() { this.open = false; this.requestUpdate(); } this.open = true;
toggle() { this.open = !this.open; this.requestUpdate(); } this.requestUpdate();
}
hide() {
this.open = false;
this.requestUpdate();
}
toggle() {
this.open = !this.open;
this.requestUpdate();
}
private readonly shortcuts = [ private readonly shortcuts = [
{ group: "General", items: [ {
{ key: "Cmd+K", desc: "Open command palette" }, group: "General",
{ key: "?", desc: "Show keyboard shortcuts" }, items: [
{ key: "Ctrl+L", desc: "Open model selector" }, { key: "Cmd+K", desc: "Open command palette" },
{ key: "Esc", desc: "Close dialogs / abort generation" }, { key: "?", desc: "Show keyboard shortcuts" },
]}, { key: "Ctrl+L", desc: "Open model selector" },
{ group: "Sessions", items: [ { key: "Esc", desc: "Close dialogs / abort generation" },
{ key: "Ctrl+N", desc: "New session" }, ],
{ key: "Ctrl+H", desc: "Session history" }, },
{ key: "Ctrl+E", desc: "Export session" }, {
]}, group: "Sessions",
{ group: "Tools & Features", items: [ items: [
{ key: "/memory", desc: "Open memory manager" }, { key: "Ctrl+N", desc: "New session" },
{ key: "/clear", desc: "Clear conversation" }, { key: "Ctrl+H", desc: "Session history" },
{ key: "/model", desc: "Switch model" }, { 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() { override render() {
if (!this.open) return html``; if (!this.open) return html``;
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="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="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"> <div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-lg">Keyboard Shortcuts</h2> <h2 class="font-semibold text-lg">Keyboard Shortcuts</h2>
<button class="text-muted-foreground hover:text-foreground" @click=${() => this.hide()}>&#x2715;</button> <button class="text-muted-foreground hover:text-foreground" @click=${() => this.hide()}>&#x2715;</button>
</div> </div>
${this.shortcuts.map(group => html` ${this.shortcuts.map(
(group) => html`
<div class="mb-4"> <div class="mb-4">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">${group.group}</div> <div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">${group.group}</div>
<div class="flex flex-col gap-1"> <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"> <div class="flex items-center justify-between py-1">
<span class="text-sm">${item.desc}</span> <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> <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> </div>
`)} `,
)}
</div> </div>
</div> </div>
`; `;
} }
} }

View file

@ -1,12 +1,11 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
export interface MemoryEntry { export interface MemoryEntry {
id: string; id: string;
content: string; content: string;
tags: string[]; tags: string[];
timestamp: string; timestamp: string;
} }
const DB_NAME = "jae-memory"; const DB_NAME = "jae-memory";
@ -15,91 +14,106 @@ const STORE_NAME = "memories";
let _db: IDBDatabase | null = null; let _db: IDBDatabase | null = null;
async function openDB(): Promise<IDBDatabase> { async function openDB(): Promise<IDBDatabase> {
if (_db) return _db; if (_db) return _db;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION); const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME, { keyPath: "id" }); req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME, { keyPath: "id" });
req.onsuccess = () => { _db = req.result; resolve(_db); }; req.onsuccess = () => {
req.onerror = () => reject(req.error); _db = req.result;
}); resolve(_db);
};
req.onerror = () => reject(req.error);
});
} }
export async function memoryLoad(): Promise<MemoryEntry[]> { export async function memoryLoad(): Promise<MemoryEntry[]> {
const db = await openDB(); const db = await openDB();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly"); const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).getAll(); const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result || []); req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error); req.onerror = () => reject(req.error);
}); });
} }
export async function memorySave(content: string, tags: string[] = []): Promise<string> { export async function memorySave(content: string, tags: string[] = []): Promise<string> {
const db = await openDB(); const db = await openDB();
const entry: MemoryEntry = { id: crypto.randomUUID(), content, tags, timestamp: new Date().toISOString() }; const entry: MemoryEntry = { id: crypto.randomUUID(), content, tags, timestamp: new Date().toISOString() };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite"); const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(entry); tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve(entry.id); tx.oncomplete = () => resolve(entry.id);
tx.onerror = () => reject(tx.error); tx.onerror = () => reject(tx.error);
}); });
} }
export async function memoryDelete(id: string): Promise<void> { export async function memoryDelete(id: string): Promise<void> {
const db = await openDB(); const db = await openDB();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite"); const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(id); tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve(); tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error); tx.onerror = () => reject(tx.error);
}); });
} }
@customElement("memory-manager") @customElement("memory-manager")
export class MemoryManager extends LitElement { export class MemoryManager extends LitElement {
@state() private open = false; @state() private open = false;
@state() private entries: MemoryEntry[] = []; @state() private entries: MemoryEntry[] = [];
@state() private loading = false; @state() private loading = false;
@state() private newContent = ""; @state() private newContent = "";
@state() private newTags = ""; @state() private newTags = "";
@state() private filter = ""; @state() private filter = "";
protected override createRenderRoot() { return this; } protected override createRenderRoot() {
return this;
}
async show() { async show() {
this.open = true; this.open = true;
this.loading = true; this.loading = true;
this.requestUpdate(); this.requestUpdate();
this.entries = await memoryLoad(); this.entries = await memoryLoad();
this.loading = false; this.loading = false;
this.requestUpdate(); this.requestUpdate();
} }
hide() { this.open = false; this.requestUpdate(); } hide() {
this.open = false;
this.requestUpdate();
}
get filtered() { get filtered() {
if (!this.filter) return this.entries; if (!this.filter) return this.entries;
const q = this.filter.toLowerCase(); const q = this.filter.toLowerCase();
return this.entries.filter(e => e.content.toLowerCase().includes(q) || e.tags.some(t => t.toLowerCase().includes(q))); return this.entries.filter(
} (e) => e.content.toLowerCase().includes(q) || e.tags.some((t) => t.toLowerCase().includes(q)),
);
}
async deleteEntry(id: string) { async deleteEntry(id: string) {
await memoryDelete(id); await memoryDelete(id);
this.entries = this.entries.filter(e => e.id !== id); this.entries = this.entries.filter((e) => e.id !== id);
this.requestUpdate(); this.requestUpdate();
} }
async addEntry() { async addEntry() {
if (!this.newContent.trim()) return; if (!this.newContent.trim()) return;
const tags = this.newTags.split(",").map(t => t.trim()).filter(Boolean); const tags = this.newTags
await memorySave(this.newContent.trim(), tags); .split(",")
this.newContent = ""; .map((t) => t.trim())
this.newTags = ""; .filter(Boolean);
this.entries = await memoryLoad(); await memorySave(this.newContent.trim(), tags);
this.requestUpdate(); this.newContent = "";
} this.newTags = "";
this.entries = await memoryLoad();
this.requestUpdate();
}
override render() { override render() {
if (!this.open) return html``; if (!this.open) return html``;
const entries = this.filtered; const entries = this.filtered;
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="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="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"> <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> <h2 class="font-semibold text-lg">&#x1F9E0; Memory Manager</h2>
@ -107,35 +121,43 @@ export class MemoryManager extends LitElement {
</div> </div>
<div class="px-6 py-3 border-b border-border shrink-0"> <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" <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>
<div class="flex-1 overflow-y-auto px-6 py-4"> <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 ? 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>` : ""} ${!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"> <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 gap-3 p-3 rounded-lg border border-border bg-background group">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-sm">${entry.content}</div> <div class="text-sm">${entry.content}</div>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<span class="text-xs text-muted-foreground">${entry.timestamp.slice(0, 10)}</span> <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>
</div> </div>
<button class="shrink-0 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity" <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> @click=${() => this.deleteEntry(entry.id)} title="Delete">&#x1F5D1;</button>
</div> </div>
`)} `,
)}
</div> </div>
</div> </div>
<div class="px-6 py-4 border-t border-border shrink-0"> <div class="px-6 py-4 border-t border-border shrink-0">
<div class="flex flex-col gap-2"> <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" <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} 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"> <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" <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" <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> @click=${() => this.addEntry()}>Save</button>
</div> </div>
@ -144,5 +166,5 @@ export class MemoryManager extends LitElement {
</div> </div>
</div> </div>
`; `;
} }
} }

View file

@ -1,45 +1,47 @@
import type { AgentMessage } from "@jaeswift/jae-agent-core"; import type { AgentMessage } from "@jaeswift/jae-agent-core";
export function exportSessionAsMarkdown(messages: AgentMessage[], title: string): void { export function exportSessionAsMarkdown(messages: AgentMessage[], title: string): void {
const lines: string[] = [ const lines: string[] = [
`# ${title || "JAE Session Export"}`, `# ${title || "JAE Session Export"}`,
``, ``,
`*Exported: ${new Date().toLocaleString()}*`, `*Exported: ${new Date().toLocaleString()}*`,
``, ``,
`---`, `---`,
``, ``,
]; ];
for (const msg of messages) { for (const msg of messages) {
if (msg.role === "user") { if (msg.role === "user") {
const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content); const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
lines.push(`## 👤 User`, ``, content, ``, `---`, ``); lines.push(`## 👤 User`, ``, content, ``, `---`, ``);
} else if (msg.role === "assistant") { } else if (msg.role === "assistant") {
const m = msg as any; const m = msg as any;
const textBlocks = Array.isArray(m.content) const textBlocks = Array.isArray(m.content)
? m.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("\n") ? m.content
: m.content || ""; .filter((b: any) => b.type === "text")
lines.push(`## 🤖 Assistant`, ``, textBlocks, ``, `---`, ``); .map((b: any) => b.text)
} .join("\n")
} : m.content || "";
lines.push(`## 🤖 Assistant`, ``, textBlocks, ``, `---`, ``);
}
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `jae-session-${Date.now()}.md`; a.download = `jae-session-${Date.now()}.md`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
export function exportSessionAsJson(messages: AgentMessage[], title: string): void { export function exportSessionAsJson(messages: AgentMessage[], title: string): void {
const data = { title, exportedAt: new Date().toISOString(), messages }; const data = { title, exportedAt: new Date().toISOString(), messages };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `jae-session-${Date.now()}.json`; a.download = `jae-session-${Date.now()}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); 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 type { SessionMetadata } from "@jaeswift/jae-web-ui";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("jae-session-sidebar") @customElement("jae-session-sidebar")
export class JaeSessionSidebar extends LitElement { export class JaeSessionSidebar extends LitElement {
@property({ type: Boolean }) collapsed = false; @property({ type: Boolean }) collapsed = false;
@property({ type: String }) currentSessionId: string | undefined = undefined; @property({ type: String }) currentSessionId: string | undefined = undefined;
@property({ attribute: false }) onLoadSession?: (id: string) => void; @property({ attribute: false }) onLoadSession?: (id: string) => void;
@property({ attribute: false }) onNewSession?: () => void; @property({ attribute: false }) onNewSession?: () => void;
@state() private _sessions: SessionMetadata[] = []; @state() private _sessions: SessionMetadata[] = [];
@state() private _pinnedIds: Set<string> = new Set(); @state() private _pinnedIds: Set<string> = new Set();
@state() private _confirmDelete: string | null = null; @state() private _confirmDelete: string | null = null;
protected override createRenderRoot() { return this; } protected override createRenderRoot() {
return this;
}
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
const raw = localStorage.getItem("jae-pinned-sessions"); const raw = localStorage.getItem("jae-pinned-sessions");
if (raw) { try { this._pinnedIds = new Set(JSON.parse(raw)); } catch {} } if (raw) {
} try {
this._pinnedIds = new Set(JSON.parse(raw));
} catch {}
}
}
setSessions(sessions: SessionMetadata[]) { setSessions(sessions: SessionMetadata[]) {
this._sessions = [...sessions]; this._sessions = [...sessions];
this.requestUpdate(); this.requestUpdate();
} }
private _togglePin(e: Event, id: string) { private _togglePin(e: Event, id: string) {
e.stopPropagation(); e.stopPropagation();
const s = new Set(this._pinnedIds); const s = new Set(this._pinnedIds);
s.has(id) ? s.delete(id) : s.add(id); s.has(id) ? s.delete(id) : s.add(id);
this._pinnedIds = s; this._pinnedIds = s;
localStorage.setItem("jae-pinned-sessions", JSON.stringify([...s])); localStorage.setItem("jae-pinned-sessions", JSON.stringify([...s]));
this.requestUpdate(); this.requestUpdate();
} }
private _deleteSession(e: Event, id: string) { private _deleteSession(e: Event, id: string) {
e.stopPropagation(); e.stopPropagation();
if (this._confirmDelete === id) { if (this._confirmDelete === id) {
this._confirmDelete = null; this._confirmDelete = null;
this.dispatchEvent(new CustomEvent("delete-session", { detail: id, bubbles: true, composed: true })); this.dispatchEvent(new CustomEvent("delete-session", { detail: id, bubbles: true, composed: true }));
} else { } else {
this._confirmDelete = id; this._confirmDelete = id;
this.requestUpdate(); this.requestUpdate();
setTimeout(() => { this._confirmDelete = null; this.requestUpdate(); }, 3000); setTimeout(() => {
} this._confirmDelete = null;
} this.requestUpdate();
}, 3000);
}
}
private _fmt(iso: string) { private _fmt(iso: string) {
const ms = Date.now() - new Date(iso).getTime(); const ms = Date.now() - new Date(iso).getTime();
if (ms < 60000) return "just now"; if (ms < 60000) return "just now";
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"; if (ms < 3600000) return Math.floor(ms / 60000) + "m ago";
if (ms < 86400000) return Math.floor(ms / 3600000) + "h ago"; if (ms < 86400000) return Math.floor(ms / 3600000) + "h ago";
if (ms < 604800000) return Math.floor(ms / 86400000) + "d ago"; if (ms < 604800000) return Math.floor(ms / 86400000) + "d ago";
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString();
} }
override render() { override render() {
if (this.collapsed) return html``; if (this.collapsed) return html``;
const pinned = this._sessions.filter(s => this._pinnedIds.has(s.id)) const pinned = this._sessions
.sort((a, b) => b.lastModified.localeCompare(a.lastModified)); .filter((s) => this._pinnedIds.has(s.id))
const rest = this._sessions.filter(s => !this._pinnedIds.has(s.id)) .sort((a, b) => b.lastModified.localeCompare(a.lastModified));
.sort((a, b) => b.lastModified.localeCompare(a.lastModified)); const rest = this._sessions
const sorted = [...pinned, ...rest]; .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 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"> <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> <span class="text-[11px] font-semibold text-muted-foreground uppercase tracking-widest">Chats</span>
@ -78,18 +89,26 @@ export class JaeSessionSidebar extends LitElement {
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto py-1"> <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="px-4 py-10 text-center">
<div class="text-3xl mb-2">💬</div> <div class="text-3xl mb-2">💬</div>
<div class="text-xs text-muted-foreground">No chats yet</div> <div class="text-xs text-muted-foreground">No chats yet</div>
</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 <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"}" ${s.id === this.currentSessionId ? "bg-secondary" : "hover:bg-secondary/50"}"
@click=${() => this.onLoadSession?.(s.id)}> @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> <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="flex-1 min-w-0 pl-1">
<div class="text-xs font-medium truncate" style="color: inherit">${s.title || "Untitled"}</div> <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> <div class="text-[10px] text-muted-foreground leading-tight">${this._fmt(s.lastModified)}</div>
@ -118,7 +137,9 @@ export class JaeSessionSidebar extends LitElement {
</button> </button>
</div> </div>
</div> </div>
`)} `,
)
}
</div> </div>
<div class="px-3 py-1.5 border-t border-border shrink-0"> <div class="px-3 py-1.5 border-t border-border shrink-0">
<div class="text-[10px] text-muted-foreground text-center"> <div class="text-[10px] text-muted-foreground text-center">
@ -127,5 +148,5 @@ export class JaeSessionSidebar extends LitElement {
</div> </div>
</div> </div>
`; `;
} }
} }

View file

@ -1,115 +1,133 @@
import { LitElement, html } from 'lit'; import { FitAddon } from "@xterm/addon-fit";
import { customElement, state } from 'lit/decorators.js'; import { WebLinksAddon } from "@xterm/addon-web-links";
import { Terminal } from '@xterm/xterm'; import { Terminal } from "@xterm/xterm";
import { FitAddon } from '@xterm/addon-fit'; import { html, LitElement } from "lit";
import { WebLinksAddon } from '@xterm/addon-web-links'; import { customElement, state } from "lit/decorators.js";
import '@xterm/xterm/css/xterm.css'; import "@xterm/xterm/css/xterm.css";
@customElement('jae-terminal-panel') @customElement("jae-terminal-panel")
export class JaeTerminalPanel extends LitElement { export class JaeTerminalPanel extends LitElement {
@state() private connected = false; @state() private connected = false;
@state() private connecting = false; @state() private connecting = false;
private term: Terminal | null = null; private term: Terminal | null = null;
private fitAddon: FitAddon | null = null; private fitAddon: FitAddon | null = null;
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private resizeObs: ResizeObserver | null = null; private resizeObs: ResizeObserver | null = null;
createRenderRoot() { return this; } createRenderRoot() {
return this;
}
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.style.display = 'flex'; this.style.display = "flex";
this.style.flexDirection = 'column'; this.style.flexDirection = "column";
this.style.height = '100%'; this.style.height = "100%";
this.style.minHeight = '0'; this.style.minHeight = "0";
} }
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.destroyTerminal(); this.destroyTerminal();
} }
private destroyTerminal() { private destroyTerminal() {
this.resizeObs?.disconnect(); this.resizeObs?.disconnect();
this.ws?.close(); this.ws?.close();
this.term?.dispose(); this.term?.dispose();
this.term = null; this.ws = null; this.term = null;
} this.ws = null;
}
async connect() { async connect() {
if (this.connected || this.connecting) return; if (this.connected || this.connecting) return;
this.connecting = true; this.connecting = true;
this.requestUpdate(); this.requestUpdate();
await this.updateComplete; await this.updateComplete;
this.container = this.querySelector('#xterm-container') as HTMLElement; this.container = this.querySelector("#xterm-container") as HTMLElement;
if (!this.container) { this.connecting = false; return; } if (!this.container) {
this.connecting = false;
return;
}
const isDark = document.documentElement.classList.contains('dark'); const isDark = document.documentElement.classList.contains("dark");
this.term = new Terminal({ this.term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontFamily: '"Fira Code", "Cascadia Code", monospace', fontFamily: '"Fira Code", "Cascadia Code", monospace',
fontSize: 13, fontSize: 13,
theme: isDark theme: isDark
? { background: '#09090b', foreground: '#e4e4e7', cursor: '#a1a1aa' } ? { background: "#09090b", foreground: "#e4e4e7", cursor: "#a1a1aa" }
: { background: '#ffffff', foreground: '#18181b', cursor: '#52525b' }, : { background: "#ffffff", foreground: "#18181b", cursor: "#52525b" },
}); });
this.fitAddon = new FitAddon(); this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon); this.term.loadAddon(this.fitAddon);
this.term.loadAddon(new WebLinksAddon()); this.term.loadAddon(new WebLinksAddon());
this.term.open(this.container); this.term.open(this.container);
this.fitAddon.fit(); this.fitAddon.fit();
this.ws = new WebSocket('ws://localhost:7701'); this.ws = new WebSocket("ws://localhost:7701");
this.ws.onopen = () => { this.ws.onopen = () => {
this.connected = true; this.connecting = false; this.connected = true;
this.requestUpdate(); this.connecting = false;
}; this.requestUpdate();
this.ws.onclose = () => { };
this.connected = false; this.connecting = false; this.ws.onclose = () => {
this.term?.write('\r\n\x1b[31m[disconnected]\x1b[0m\r\n'); this.connected = false;
this.requestUpdate(); this.connecting = false;
}; this.term?.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
this.ws.onerror = () => { this.requestUpdate();
this.connecting = false; this.connected = false; };
this.term?.write('\r\n\x1b[31m[connection error - is terminal server running?]\x1b[0m\r\n'); this.ws.onerror = () => {
this.requestUpdate(); this.connecting = false;
}; this.connected = false;
this.ws.onmessage = (e) => { this.term?.write("\r\n\x1b[31m[connection error - is terminal server running?]\x1b[0m\r\n");
const m = JSON.parse(e.data); this.requestUpdate();
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.ws.onmessage = (e) => {
}; const m = JSON.parse(e.data);
this.term.onData((d) => { this.ws?.send(JSON.stringify({ type: 'input', data: d })); }); 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(() => { this.resizeObs = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.fitAddon?.fit(); this.fitAddon?.fit();
if (this.term && this.ws?.readyState === WebSocket.OPEN) { if (this.term && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'resize', cols: this.term.cols, rows: this.term.rows })); this.ws.send(JSON.stringify({ type: "resize", cols: this.term.cols, rows: this.term.rows }));
} }
}); });
}); });
this.resizeObs.observe(this.container); 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() { override render() {
return html` 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="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> <span class="text-xs font-mono text-muted-foreground">bash</span>
<div class="flex-1"></div> <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>` !this.connected && !this.connecting
: html``} ? 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.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``} ${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>
<div id="xterm-container" class="flex-1 overflow-hidden" style="min-height:0;background:#09090b"></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"; import { customElement, property } from "lit/decorators.js";
export interface UtilityVisibility { export interface UtilityVisibility {
showToolCalls: boolean; showToolCalls: boolean;
showThinking: boolean; showThinking: boolean;
showSystemMessages: boolean; showSystemMessages: boolean;
showTimestamps: boolean; showTimestamps: boolean;
} }
@customElement("jae-utility-toggle") @customElement("jae-utility-toggle")
export class JaeUtilityToggle extends LitElement { export class JaeUtilityToggle extends LitElement {
@property({ type: Object }) visibility: UtilityVisibility = { @property({ type: Object }) visibility: UtilityVisibility = {
showToolCalls: true, showToolCalls: true,
showThinking: false, showThinking: false,
showSystemMessages: false, showSystemMessages: false,
showTimestamps: true, 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) { private _toggle(key: keyof UtilityVisibility) {
this.visibility = { ...this.visibility, [key]: !this.visibility[key] }; this.visibility = { ...this.visibility, [key]: !this.visibility[key] };
this.dispatchEvent(new CustomEvent("visibility-change", { this.dispatchEvent(
detail: this.visibility, new CustomEvent("visibility-change", {
bubbles: true, detail: this.visibility,
composed: true, bubbles: true,
})); composed: true,
} }),
);
}
private _items: { key: keyof UtilityVisibility; label: string; icon: string; desc: string }[] = [ 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: "showToolCalls",
{ key: "showSystemMessages", label: "System Messages", icon: "⚙️", desc: "Show system notifications and prompts" }, label: "Tool Calls",
{ key: "showTimestamps", label: "Timestamps", icon: "🕐", desc: "Show message timestamps" }, 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() { override render() {
const activeCount = Object.values(this.visibility).filter(Boolean).length; const activeCount = Object.values(this.visibility).filter(Boolean).length;
return html` return html`
<div class="relative"> <div class="relative">
<!-- Toggle button --> <!-- Toggle button -->
<button <button
title="Message filters" 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" 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"> <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> </button>
<!-- Dropdown panel --> <!-- 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"> <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 --> <!-- Header -->
<div class="flex items-center gap-2 px-4 py-3 border-b border-border bg-secondary/30"> <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-sm font-semibold text-foreground">Message Filters</div>
<div class="text-xs text-muted-foreground">Control what JAE shows you</div> <div class="text-xs text-muted-foreground">Control what JAE shows you</div>
</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> <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> </button>
</div> </div>
<!-- Items --> <!-- Items -->
<div class="p-2"> <div class="p-2">
${this._items.map(item => html` ${this._items.map(
(item) => html`
<button <button
class="w-full flex items-start gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/60 transition-colors text-left" 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)} @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 class="text-xs text-muted-foreground leading-tight">${item.desc}</div>
</div> </div>
<!-- Toggle switch --> <!-- 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="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="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> </div>
</button> </button>
`)} `,
)}
</div> </div>
<!-- Footer --> <!-- Footer -->
@ -97,9 +116,14 @@ export class JaeUtilityToggle extends LitElement {
</div> </div>
</div> </div>
<!-- Click-outside overlay --> <!-- Click-outside overlay -->
<div class="fixed inset-0 z-40" @click=${() => { this.open = false; this.requestUpdate(); }}></div> <div class="fixed inset-0 z-40" @click=${() => {
` : html``} this.open = false;
this.requestUpdate();
}}></div>
`
: html``
}
</div> </div>
`; `;
} }
} }

View file

@ -2,50 +2,50 @@ import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core"; import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core";
import { getModel } from "@jaeswift/jae-ai"; import { getModel } from "@jaeswift/jae-ai";
import { import {
type AgentState, type AgentState,
ApiKeyPromptDialog, ApiKeyPromptDialog,
AppStorage, AppStorage,
ChatPanel, ChatPanel,
CustomProvidersStore, CustomProvidersStore,
createJavaScriptReplTool, createJavaScriptReplTool,
IndexedDBStorageBackend, IndexedDBStorageBackend,
ProviderKeysStore, ProviderKeysStore,
ProvidersModelsTab, ProvidersModelsTab,
ProxyTab, ProxyTab,
SessionListDialog, SessionListDialog,
SessionsStore, SessionsStore,
SettingsDialog, SettingsDialog,
SettingsStore, SettingsStore,
setAppStorage, setAppStorage,
} from "@jaeswift/jae-web-ui"; } from "@jaeswift/jae-web-ui";
import { html, render } from "lit"; import { html, render } from "lit";
import { Brain, Download, History, Keyboard, Plus, Settings } from "lucide"; import { Brain, Download, History, Keyboard, Plus, Settings } from "lucide";
import "./app.css"; import "./app.css";
import { createImageGenTool, createTTSTool, createWebSearchTool } from "@jaeswift/jae-web-ui";
import { icon } from "@mariozechner/mini-lit"; import { icon } from "@mariozechner/mini-lit";
import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js"; import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import 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 { 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/command-palette.js";
import "./components/keyboard-shortcuts.js"; import "./components/keyboard-shortcuts.js";
import "./components/memory-manager.js"; import "./components/memory-manager.js";
import "./components/cost-tracker.js"; import "./components/cost-tracker.js";
import { JaeEmptyState } from "./components/empty-state.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/empty-state.js";
import "./components/utility-toggle.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 "./components/session-sidebar.js";
import { JaeTerminalPanel } from './components/terminal-panel.js'; import type { JaeBrowserPanel } from "./components/browser-panel.js";
import { JaeBrowserPanel } from './components/browser-panel.js'; import type { JaeTerminalPanel } from "./components/terminal-panel.js";
import './components/terminal-panel.js'; import "./components/terminal-panel.js";
import './components/browser-panel.js'; import "./components/browser-panel.js";
registerCustomMessageRenderers(); registerCustomMessageRenderers();
@ -55,17 +55,17 @@ const sessions = new SessionsStore();
const customProviders = new CustomProvidersStore(); const customProviders = new CustomProvidersStore();
const configs = [ const configs = [
settings.getConfig(), settings.getConfig(),
SessionsStore.getMetadataConfig(), SessionsStore.getMetadataConfig(),
providerKeys.getConfig(), providerKeys.getConfig(),
customProviders.getConfig(), customProviders.getConfig(),
sessions.getConfig(), sessions.getConfig(),
]; ];
const backend = new IndexedDBStorageBackend({ const backend = new IndexedDBStorageBackend({
dbName: "jae-web-ui-example", dbName: "jae-web-ui-example",
version: 2, version: 2,
stores: configs, stores: configs,
}); });
settings.setBackend(backend); settings.setBackend(backend);
@ -80,12 +80,12 @@ let currentSessionId: string | undefined;
let currentTitle = ""; let currentTitle = "";
let isEditingTitle = false; let isEditingTitle = false;
let agent: Agent; let agent: Agent;
let rightPanel: 'none' | 'terminal' | 'browser' = 'none'; let rightPanel: "none" | "terminal" | "browser" = "none";
let sidebarWidth = 220; let sidebarWidth = 220;
let rightPanelWidth = 480; let rightPanelWidth = 480;
let hasStarted = false; let hasStarted = false;
let terminalPanel: JaeTerminalPanel | null = null; let terminalPanel: JaeTerminalPanel | null = null;
let browserPanel: JaeBrowserPanel | null = null; const browserPanel: JaeBrowserPanel | null = null;
let chatPanel: ChatPanel; let chatPanel: ChatPanel;
let agentUnsubscribe: (() => void) | undefined; 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 costTracker = document.createElement("cost-tracker") as CostTracker;
const sidebar = document.createElement("jae-session-sidebar") as JaeSessionSidebar; 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.onNewSession = () => newSession();
sidebar.addEventListener("delete-session", async (e: Event) => { sidebar.addEventListener("delete-session", async (e: Event) => {
const id = (e as CustomEvent<string>).detail; const id = (e as CustomEvent<string>).detail;
if (storage.sessions) { if (storage.sessions) {
await storage.sessions.delete(id); await storage.sessions.delete(id);
if (id === currentSessionId) newSession(); if (id === currentSessionId) newSession();
await refreshSidebar(); await refreshSidebar();
} }
}); });
const utilityToggle = document.createElement("jae-utility-toggle") as JaeUtilityToggle; const utilityToggle = document.createElement("jae-utility-toggle") as JaeUtilityToggle;
utilityToggle.addEventListener("visibility-change", (e: Event) => { utilityToggle.addEventListener("visibility-change", (e: Event) => {
const vis = (e as CustomEvent<UtilityVisibility>).detail; const vis = (e as CustomEvent<UtilityVisibility>).detail;
const chatEl = document.getElementById("chat-wrapper"); const chatEl = document.getElementById("chat-wrapper");
if (chatEl) { if (chatEl) {
chatEl.classList.toggle("hide-tool-calls", !vis.showToolCalls); chatEl.classList.toggle("hide-tool-calls", !vis.showToolCalls);
chatEl.classList.toggle("hide-thinking", !vis.showThinking); chatEl.classList.toggle("hide-thinking", !vis.showThinking);
chatEl.classList.toggle("hide-system-msgs", !vis.showSystemMessages); chatEl.classList.toggle("hide-system-msgs", !vis.showSystemMessages);
chatEl.classList.toggle("hide-timestamps", !vis.showTimestamps); chatEl.classList.toggle("hide-timestamps", !vis.showTimestamps);
} }
}); });
document.body.appendChild(commandPalette); document.body.appendChild(commandPalette);
@ -123,207 +125,362 @@ document.body.appendChild(keyboardShortcuts);
document.body.appendChild(memoryManager); document.body.appendChild(memoryManager);
const refreshSidebar = async () => { const refreshSidebar = async () => {
if (storage.sessions) { if (storage.sessions) {
const all = await storage.sessions.getAllMetadata(); const all = await storage.sessions.getAllMetadata();
sidebar.setSessions(all); sidebar.setSessions(all);
sidebar.currentSessionId = currentSessionId; sidebar.currentSessionId = currentSessionId;
} }
}; };
window.addEventListener("keydown", (e: KeyboardEvent) => { window.addEventListener("keydown", (e: KeyboardEvent) => {
const meta = e.metaKey || e.ctrlKey; const meta = e.metaKey || e.ctrlKey;
if (meta && e.key === "k") { e.preventDefault(); commandPalette.show(); return; } if (meta && e.key === "k") {
if (meta && e.key === "e") { e.preventDefault(); handleExport(); return; } e.preventDefault();
if (meta && e.key === "n") { e.preventDefault(); newSession(); return; } commandPalette.show();
if (e.key === "?" && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) { return;
keyboardShortcuts.toggle(); }
} 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() { function setupCommands() {
commandPalette.setCommands([ 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: "new-session",
{ id: "export-md", label: "Export as Markdown", description: "Download current session as .md", shortcut: "Ctrl+E", keywords: ["export", "download", "markdown"], action: () => handleExport("markdown") }, label: "New Session",
{ id: "export-json", label: "Export as JSON", description: "Download current session as .json", keywords: ["export", "download", "json"], action: () => handleExport("json") }, description: "Start a fresh conversation",
{ id: "memory", label: "Memory Manager", description: "Browse and manage stored memories", keywords: ["memory", "remember", "recall"], action: () => memoryManager.show() }, shortcut: "Ctrl+N",
{ id: "settings", label: "Settings", description: "Configure providers and models", keywords: ["settings", "config", "provider", "api", "model"], action: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]) }, keywords: ["new", "fresh", "start"],
{ id: "shortcuts", label: "Keyboard Shortcuts", description: "View all keyboard shortcuts", shortcut: "?", keywords: ["keyboard", "shortcuts", "help"], action: () => keyboardShortcuts.show() }, action: newSession,
{ id: "cost", label: "Token Usage & Cost", description: "View API usage stats for this session", keywords: ["tokens", "cost", "usage"], action: () => costTracker.dispatchEvent(new MouseEvent("click")) }, },
]); {
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") { function handleExport(format: "markdown" | "json" = "markdown") {
if (!agent) return; if (!agent) return;
const messages = agent.state.messages; const messages = agent.state.messages;
const title = currentTitle || "JAE Session"; const title = currentTitle || "JAE Session";
if (format === "markdown") exportSessionAsMarkdown(messages, title); if (format === "markdown") exportSessionAsMarkdown(messages, title);
else exportSessionAsJson(messages, title); else exportSessionAsJson(messages, title);
} }
const generateTitle = (messages: AgentMessage[]): string => { const generateTitle = (messages: AgentMessage[]): string => {
const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments"); const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments");
if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return ""; if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return "";
let text = ""; let text = "";
const content = firstUserMsg.content; const content = firstUserMsg.content;
if (typeof content === "string") { text = content; } if (typeof content === "string") {
else { const textBlocks = content.filter((c: any) => c.type === "text"); text = textBlocks.map((c: any) => c.text || "").join(" "); } text = content;
text = text.trim(); } else {
if (!text) return ""; const textBlocks = content.filter((c: any) => c.type === "text");
const sentenceEnd = text.search(/[.!?]/); text = textBlocks.map((c: any) => c.text || "").join(" ");
if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1); }
return text.length <= 50 ? text : text.substring(0, 47) + "..."; text = text.trim();
if (!text) return "";
const sentenceEnd = text.search(/[.!?]/);
if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1);
return text.length <= 50 ? text : text.substring(0, 47) + "...";
}; };
const shouldSaveSession = (messages: AgentMessage[]): boolean => { const shouldSaveSession = (messages: AgentMessage[]): boolean => {
const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments"); const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments");
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant"); const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
return hasUserMsg && hasAssistantMsg; return hasUserMsg && hasAssistantMsg;
}; };
const saveSession = async () => { const saveSession = async () => {
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return; if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
const state = agent.state; const state = agent.state;
if (!shouldSaveSession(state.messages)) return; if (!shouldSaveSession(state.messages)) return;
try { try {
const sessionData = { const sessionData = {
id: currentSessionId, title: currentTitle, model: state.model!, id: currentSessionId,
thinkingLevel: state.thinkingLevel, messages: state.messages, title: currentTitle,
createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), model: state.model!,
}; thinkingLevel: state.thinkingLevel,
const metadata = { messages: state.messages,
id: currentSessionId, title: currentTitle, createdAt: new Date().toISOString(),
createdAt: sessionData.createdAt, lastModified: sessionData.lastModified, lastModified: new Date().toISOString(),
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 } }, const metadata = {
modelId: state.model?.id || null, thinkingLevel: state.thinkingLevel, id: currentSessionId,
preview: generateTitle(state.messages), title: currentTitle,
}; createdAt: sessionData.createdAt,
await storage.sessions.save(sessionData, metadata); lastModified: sessionData.lastModified,
await refreshSidebar(); messageCount: state.messages.length,
} catch (err) { console.error("Failed to save session:", err); } 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 updateUrl = (sessionId: string) => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("session", sessionId); url.searchParams.set("session", sessionId);
window.history.replaceState({}, "", url); window.history.replaceState({}, "", url);
}; };
const createAgent = async (initialState?: Partial<AgentState>) => { const createAgent = async (initialState?: Partial<AgentState>) => {
if (agentUnsubscribe) agentUnsubscribe(); if (agentUnsubscribe) agentUnsubscribe();
agent = new Agent({ agent = new Agent({
initialState: initialState || { 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.", systemPrompt:
model: getModel("venice", "llama-3.3-70b"), "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.",
thinkingLevel: "off", model: getModel("venice", "llama-3.3-70b"),
messages: [], thinkingLevel: "off",
tools: [], messages: [],
}, tools: [],
convertToLlm: customConvertToLlm, },
onApiKeyRequired: async (provider: string) => { convertToLlm: customConvertToLlm,
const key = await ApiKeyPromptDialog.prompt(provider); onApiKeyRequired: async (provider: string) => {
if (key) await providerKeys.set(provider, key); const key = await ApiKeyPromptDialog.prompt(provider);
return key; if (key) await providerKeys.set(provider, key);
}, return key;
getProviderApiKey: async (provider: string) => providerKeys.get(provider), },
onStateChange: async (state: AgentState, prevState: AgentState | undefined) => { getProviderApiKey: async (provider: string) => providerKeys.get(provider),
if (state.messages.length > 0) hasStarted = true; onStateChange: async (state: AgentState, prevState: AgentState | undefined) => {
if (prevState?.messages.length !== state.messages.length) { if (state.messages.length > 0) hasStarted = true;
if (!currentTitle) { if (prevState?.messages.length !== state.messages.length) {
const generated = generateTitle(state.messages); if (!currentTitle) {
if (generated) { const generated = generateTitle(state.messages);
currentTitle = generated; if (generated) {
if (!currentSessionId) currentSessionId = crypto.randomUUID(); currentTitle = generated;
updateUrl(currentSessionId); if (!currentSessionId) currentSessionId = crypto.randomUUID();
} updateUrl(currentSessionId);
} }
await saveSession(); }
} await saveSession();
renderApp(); }
}, renderApp();
createTools: async (runtimeProvidersFactory: any) => { },
const replTool = createJavaScriptReplTool(); createTools: async (runtimeProvidersFactory: any) => {
replTool.runtimeProvidersFactory = runtimeProvidersFactory; const replTool = createJavaScriptReplTool();
return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()]; replTool.runtimeProvidersFactory = runtimeProvidersFactory;
}, return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()];
}); },
costTracker.bindAgent(agent); });
chatPanel?.setAgent(agent); costTracker.bindAgent(agent);
if (!currentSessionId) currentSessionId = crypto.randomUUID(); chatPanel?.setAgent(agent);
if (!currentSessionId) currentSessionId = crypto.randomUUID();
}; };
const loadSession = async (sessionId: string): Promise<boolean> => { const loadSession = async (sessionId: string): Promise<boolean> => {
if (!storage.sessions) return false; if (!storage.sessions) return false;
const sessionData = await storage.sessions.get(sessionId); const sessionData = await storage.sessions.get(sessionId);
if (!sessionData) return false; if (!sessionData) return false;
currentSessionId = sessionId; currentSessionId = sessionId;
hasStarted = sessionData.messages.length > 0; hasStarted = sessionData.messages.length > 0;
const metadata = await storage.sessions.getMetadata(sessionId); const metadata = await storage.sessions.getMetadata(sessionId);
currentTitle = metadata?.title || ""; currentTitle = metadata?.title || "";
await createAgent({ await createAgent({
model: sessionData.model, thinkingLevel: sessionData.thinkingLevel, model: sessionData.model,
messages: sessionData.messages, tools: [], thinkingLevel: sessionData.thinkingLevel,
}); messages: sessionData.messages,
sidebar.currentSessionId = currentSessionId; tools: [],
updateUrl(sessionId); });
renderApp(); sidebar.currentSessionId = currentSessionId;
return true; updateUrl(sessionId);
renderApp();
return true;
}; };
const newSession = () => { const newSession = () => {
currentSessionId = undefined; currentSessionId = undefined;
currentTitle = ""; currentTitle = "";
isEditingTitle = false; isEditingTitle = false;
hasStarted = false; hasStarted = false;
createAgent().then(() => renderApp()); createAgent().then(() => renderApp());
}; };
const handleSuggestion = (e: Event) => { const handleSuggestion = (e: Event) => {
const text = (e as CustomEvent<string>).detail; const text = (e as CustomEvent<string>).detail;
if (!text) return; if (!text) return;
// Try ChatPanel.agentInterface.setInput first // Try ChatPanel.agentInterface.setInput first
if (chatPanel?.agentInterface) { if (chatPanel?.agentInterface) {
chatPanel.agentInterface.setInput(text); chatPanel.agentInterface.setInput(text);
// Focus the textarea after injection // Focus the textarea after injection
requestAnimationFrame(() => { requestAnimationFrame(() => {
const ta = document.querySelector("message-editor textarea") as HTMLTextAreaElement const ta =
|| document.querySelector("textarea") as HTMLTextAreaElement; (document.querySelector("message-editor textarea") as HTMLTextAreaElement) ||
if (ta) ta.focus(); (document.querySelector("textarea") as HTMLTextAreaElement);
}); if (ta) ta.focus();
} else { });
const ta = document.querySelector("message-editor textarea") as HTMLTextAreaElement } else {
|| document.querySelector("textarea") as HTMLTextAreaElement; const ta =
if (ta) { (document.querySelector("message-editor textarea") as HTMLTextAreaElement) ||
ta.value = text; (document.querySelector("textarea") as HTMLTextAreaElement);
ta.dispatchEvent(new Event("input", { bubbles: true })); if (ta) {
ta.focus(); ta.value = text;
} ta.dispatchEvent(new Event("input", { bubbles: true }));
} ta.focus();
}
}
}; };
const getModelLabel = (): string | null => { const getModelLabel = (): string | null => {
if (!agent?.state?.model) return null; if (!agent?.state?.model) return null;
const m = agent.state.model as any; const m = agent.state.model as any;
return m.name || m.id || null; return m.name || m.id || null;
}; };
const renderApp = () => { const renderApp = () => {
const app = document.getElementById("app"); const app = document.getElementById("app");
if (!app) return; if (!app) return;
const hasMessages = hasStarted || !!(agent?.state?.messages?.length); const hasMessages = hasStarted || !!agent?.state?.messages?.length;
render(html` render(
html`
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden"> <div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
<div class="flex items-center justify-between border-b border-border shrink-0" style="height:44px"> <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"> <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)" })} ${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session (Ctrl+N)" })}
${currentTitle <div class="flex items-center gap-2">
? isEditingTitle <img src="/mascot/jae-default.png" alt="JAE" class="w-7 h-auto header-logo cursor-pointer" />
? 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>` <span class="text-base font-semibold text-foreground">JAE</span>
: 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>` ${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``}
: 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>
} ${
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>
<div class="flex items-center gap-1 px-2"> <div class="flex items-center gap-1 px-2">
${costTracker} ${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)" })} ${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} ${utilityToggle}
<theme-toggle></theme-toggle> <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({
${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" })} 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" })} ${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), title: "Settings" })}
</div> </div>
</div> </div>
@ -343,64 +523,125 @@ ${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick
${sidebar} ${sidebar}
</div> </div>
<div id="sb-resize" style="width:5px;cursor:col-resize;flex-shrink:0;background:transparent;z-index:10;transition:background 0.15s" <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); }} @mousedown=${(e: MouseEvent) => {
@mouseenter=${(e: Event)=>{(e.currentTarget as HTMLElement).style.background="rgba(128,128,128,0.4)"}} e.preventDefault();
@mouseleave=${(e: Event)=>{(e.currentTarget as HTMLElement).style.background="transparent"}} 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>
<div class="flex flex-col flex-1 min-w-0 min-h-0 relative"> <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" style="bottom:130px;pointer-events:${hasMessages ? "none" : "auto"}" @suggestion=${handleSuggestion}>
<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 .faded=${hasMessages} style="display:flex;flex-direction:column;flex:1;width:100%;min-height:0"></jae-empty-state>
<jae-empty-state style="display:flex;flex-direction:column;flex:1;width:100%;min-height:0"></jae-empty-state>
</div> </div>
` : html``}
<div id="chat-wrapper" class="flex flex-col flex-1 min-h-0" > <div id="chat-wrapper" class="flex flex-col flex-1 min-h-0" >
${chatPanel} ${chatPanel}
</div> </div>
</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" <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); }} @mousedown=${(e: MouseEvent) => {
@mouseenter=${(e: Event)=>{(e.currentTarget as HTMLElement).style.background="rgba(128,128,128,0.4)"}} e.preventDefault();
@mouseleave=${(e: Event)=>{(e.currentTarget as HTMLElement).style.background="transparent"}} 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>
<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 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"> <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 ${ <button class="text-xs px-2 py-1 rounded ${
rightPanel === 'terminal' rightPanel === "terminal"
? 'bg-primary text-primary-foreground' ? "bg-primary text-primary-foreground"
: 'hover:bg-secondary text-muted-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> }" @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 ${ <button class="text-xs px-2 py-1 rounded ${
rightPanel === 'browser' rightPanel === "browser" ? "bg-primary text-primary-foreground" : "hover:bg-secondary text-muted-foreground"
? 'bg-primary text-primary-foreground' }" @click=${() => {
: 'hover:bg-secondary text-muted-foreground' rightPanel = "browser";
}" @click=${() => { rightPanel = 'browser'; renderApp(); }}>Browser</button> renderApp();
}}>Browser</button>
<div class="flex-1"></div> <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> </div>
${rightPanel === 'terminal' ? html`<jae-terminal-panel class="flex-1 min-h-0"></jae-terminal-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``} ${rightPanel === "browser" ? html`<jae-browser-panel class="flex-1 min-h-0"></jae-browser-panel>` : html``}
</div> </div>
` : html``} `
: html``
}
</div> </div>
`, app); `,
app,
);
}; };
async function initApp() { async function initApp() {
const app = document.getElementById("app"); const app = document.getElementById("app");
if (!app) throw new Error("App container not found"); if (!app) throw new Error("App container not found");
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); render(
chatPanel = new ChatPanel(); html`<div class="w-full h-screen flex items-center justify-center bg-background text-foreground"><div class="text-muted-foreground">Loading...</div></div>`,
setupCommands(); app,
await refreshSidebar(); );
const sessionIdFromUrl = new URLSearchParams(window.location.search).get("session"); chatPanel = new ChatPanel();
if (sessionIdFromUrl) { setupCommands();
const loaded = await loadSession(sessionIdFromUrl); await refreshSidebar();
if (!loaded) { newSession(); return; } const sessionIdFromUrl = new URLSearchParams(window.location.search).get("session");
} else { if (sessionIdFromUrl) {
await createAgent(); const loaded = await loadSession(sessionIdFromUrl);
} if (!loaded) {
renderApp(); newSession();
return;
}
} else {
await createAgent();
}
renderApp();
} }
initApp(); initApp();

View file

@ -1,110 +1,124 @@
import { getModels } from "@jaeswift/jae-ai"; 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 { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
import { Button } from "@mariozechner/mini-lit/dist/Button.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> = { const TAG_COLORS: Record<string, string> = {
tools: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-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", 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", 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", 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", "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", "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", 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", 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", 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", 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", "web-search": "bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300",
}; };
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
text: "💬 Text & Chat", text: "💬 Text & Chat",
image: "🖼️ Image Generation", image: "🖼️ Image Generation",
video: "🎬 Video Generation", video: "🎬 Video Generation",
audio: "🔊 Audio (TTS / ASR)", audio: "🔊 Audio (TTS / ASR)",
other: "🔧 Other", other: "🔧 Other",
}; };
function categoriseModel(tags: string[] = []): string { function categoriseModel(tags: string[] = []): string {
if (tags.includes("image-generation") || tags.includes("inpainting")) return "image"; if (tags.includes("image-generation") || tags.includes("inpainting")) return "image";
if (tags.includes("video-generation")) return "video"; if (tags.includes("video-generation")) return "video";
if (tags.includes("tts") || tags.includes("asr")) return "audio"; if (tags.includes("tts") || tags.includes("asr")) return "audio";
if (tags.includes("embedding") || tags.includes("upscaling")) return "other"; if (tags.includes("embedding") || tags.includes("upscaling")) return "other";
return "text"; return "text";
} }
@customElement("venice-model-browser") @customElement("venice-model-browser")
export class VeniceModelBrowser extends LitElement { export class VeniceModelBrowser extends LitElement {
@state() private filter: string = "all"; @state() private filter: string = "all";
@state() private search: string = ""; @state() private search: string = "";
protected createRenderRoot() { return this; } protected createRenderRoot() {
return this;
}
private renderTag(tag: string): TemplateResult { private renderTag(tag: string): TemplateResult {
const cls = TAG_COLORS[tag] ?? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"; 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>`; return html`<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${cls}">${tag}</span>`;
} }
render(): TemplateResult { render(): TemplateResult {
let models: any[] = []; let models: any[] = [];
try { try {
models = (getModels("venice" as any) as any[]) || []; models = (getModels("venice" as any) as any[]) || [];
} catch { models = []; } } catch {
models = [];
}
// Group by category // Group by category
const grouped: Record<string, any[]> = { text: [], image: [], video: [], audio: [], other: [] }; const grouped: Record<string, any[]> = { text: [], image: [], video: [], audio: [], other: [] };
for (const m of models) { for (const m of models) {
const cat = categoriseModel(m.tags); const cat = categoriseModel(m.tags);
if (!grouped[cat]) grouped[cat] = []; if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(m); grouped[cat].push(m);
} }
const filters = [ const filters = [
{ id: "all", label: `All (${models.length})` }, { id: "all", label: `All (${models.length})` },
...Object.entries(grouped) ...Object.entries(grouped)
.filter(([, ms]) => ms.length > 0) .filter(([, ms]) => ms.length > 0)
.map(([cat, ms]) => ({ id: cat, label: `${CATEGORY_LABELS[cat] ?? cat} (${ms.length})` })), .map(([cat, ms]) => ({ id: cat, label: `${CATEGORY_LABELS[cat] ?? cat} (${ms.length})` })),
]; ];
const searchLower = this.search.toLowerCase(); const searchLower = this.search.toLowerCase();
const activeGroups = Object.entries(grouped).filter(([cat, ms]) => { const activeGroups = Object.entries(grouped).filter(([cat, ms]) => {
if (this.filter !== "all" && cat !== this.filter) return false; if (this.filter !== "all" && cat !== this.filter) return false;
return ms.length > 0; return ms.length > 0;
}); });
return html` return html`
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<!-- Filter bar --> <!-- Filter bar -->
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
${filters.map(f => html` ${filters.map(
(f) => html`
<button <button
class="px-3 py-1 rounded-full text-xs font-medium border transition-colors 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" this.filter === f.id
: "border-border text-muted-foreground hover:bg-secondary"}" ? "bg-primary text-primary-foreground border-primary"
@click=${() => { this.filter = f.id; this.requestUpdate(); }} : "border-border text-muted-foreground hover:bg-secondary"
}"
@click=${() => {
this.filter = f.id;
this.requestUpdate();
}}
>${f.label}</button> >${f.label}</button>
`)} `,
)}
<input <input
type="search" type="search"
placeholder="Search models..." placeholder="Search models..."
class="ml-auto px-3 py-1 text-sm rounded border border-border bg-background text-foreground w-48" class="ml-auto px-3 py-1 text-sm rounded border border-border bg-background text-foreground w-48"
.value=${this.search} .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> </div>
<!-- Model groups --> <!-- Model groups -->
${activeGroups.map(([cat, ms]) => { ${activeGroups.map(([cat, ms]) => {
const filtered = searchLower ? ms.filter((m: any) => m.id.toLowerCase().includes(searchLower)) : ms; const filtered = searchLower ? ms.filter((m: any) => m.id.toLowerCase().includes(searchLower)) : ms;
if (!filtered.length) return html``; if (!filtered.length) return html``;
return html` return html`
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">${CATEGORY_LABELS[cat] ?? cat}</h4> <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"> <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 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"> <div class="flex flex-col gap-1 min-w-0">
<span class="text-sm font-mono font-medium text-foreground truncate">${m.id}</span> <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))} ${(m.tags ?? []).map((t: string) => this.renderTag(t))}
</div> </div>
</div> </div>
`)} `,
)}
</div> </div>
</div> </div>
`; `;
})} })}
${models.length === 0 ? html` ${
models.length === 0
? html`
<div class="text-sm text-muted-foreground text-center py-4"> <div class="text-sm text-muted-foreground text-center py-4">
No Venice models found. Ensure jae-ai includes Venice models. No Venice models found. Ensure jae-ai includes Venice models.
</div> </div>
` : ""} `
: ""
}
</div> </div>
`; `;
} }
} }

View file

@ -52,6 +52,7 @@ export class ModelSelector extends DialogBase {
@state() searchQuery = ""; @state() searchQuery = "";
@state() filterThinking = false; @state() filterThinking = false;
@state() filterVision = false; @state() filterVision = false;
@state() private filterProvider = "";
@state() customProvidersLoading = false; @state() customProvidersLoading = false;
@state() selectedIndex = 0; @state() selectedIndex = 0;
@state() private navigationMode: "mouse" | "keyboard" = "mouse"; @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 { protected override renderContent(): TemplateResult {
const filteredModels = this.getFilteredModels(); 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 { RUNTIME_MESSAGE_ROUTER } from "./components/sandbox/RuntimeMessageRouter.js";
export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js"; export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
export { ThinkingBlock } from "./components/ThinkingBlock.js"; export { ThinkingBlock } from "./components/ThinkingBlock.js";
export { VeniceModelBrowser } from "./components/VeniceModelBrowser.js";
export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js"; export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js"; export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
export { CustomProviderDialog } from "./dialogs/CustomProviderDialog.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 { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
export { TextArtifact } from "./tools/artifacts/TextArtifact.js"; export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
export { createExtractDocumentTool, extractDocumentTool } from "./tools/extract-document.js"; export { createExtractDocumentTool, extractDocumentTool } from "./tools/extract-document.js";
export { createImageGenTool, imageGenTool } from "./tools/image-gen.js";
// Tools // Tools
export { getToolRenderer, registerToolRenderer, renderTool, setShowJsonMode } from "./tools/index.js"; export { getToolRenderer, registerToolRenderer, renderTool, setShowJsonMode } from "./tools/index.js";
export { createJavaScriptReplTool, javascriptReplTool } from "./tools/javascript-repl.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"; export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
// Tool renderers // Tool renderers
export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js"; export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js";
export { DiffRenderer } from "./tools/renderers/DiffRenderer.js";
export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js"; export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js";
export { MermaidRenderer } from "./tools/renderers/MermaidRenderer.js";
export type { ToolRenderer, ToolRenderResult } from "./tools/types.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"; export type { Attachment } from "./utils/attachment-utils.js";
// Utils // Utils
export { loadAttachment } from "./utils/attachment-utils.js"; 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 { formatCost, formatModelCost, formatTokenCount, formatUsage } from "./utils/format.js";
export { i18n, setLanguage, translations } from "./utils/i18n.js"; export { i18n, setLanguage, translations } from "./utils/i18n.js";
export { applyProxyIfNeeded, createStreamFn, isCorsError, shouldUseProxyForProvider } from "./utils/proxy-utils.js"; export { applyProxyIfNeeded, createStreamFn, isCorsError, shouldUseProxyForProvider } from "./utils/proxy-utils.js";
export { VeniceModelBrowser } from "./components/VeniceModelBrowser.js";
// 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 { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai"; import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { Type } from "@sinclair/typebox";
import { html } from "lit"; import { html } from "lit";
import { GitCompare } from "lucide"; import { GitCompare } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js";
export interface DiffDetails { export interface DiffDetails {
original: string; original: string;
modified: string; modified: string;
filename?: string; filename?: string;
} }
interface DiffParams { interface DiffParams {
original: string; original: string;
modified: string; modified: string;
filename?: string; filename?: string;
} }
const diffSchema = Type.Object({ const diffSchema = Type.Object({
original: Type.String({ description: "Original file content" }), original: Type.String({ description: "Original file content" }),
modified: Type.String({ description: "Modified file content" }), modified: Type.String({ description: "Modified file content" }),
filename: Type.Optional(Type.String({ description: "Filename for display" })), filename: Type.Optional(Type.String({ description: "Filename for display" })),
}); });
function computeLineDiff(original: string, modified: string): Array<{ type: "add" | "remove" | "same"; line: string }> { function computeLineDiff(original: string, modified: string): Array<{ type: "add" | "remove" | "same"; line: string }> {
const oldLines = original.split("\n"); const oldLines = original.split("\n");
const newLines = modified.split("\n"); const newLines = modified.split("\n");
const result: Array<{ type: "add" | "remove" | "same"; line: string }> = []; const result: Array<{ type: "add" | "remove" | "same"; line: string }> = [];
const maxLen = Math.max(oldLines.length, newLines.length); const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) { for (let i = 0; i < maxLen; i++) {
if (i >= oldLines.length) { result.push({ type: "add", line: newLines[i] }); } if (i >= oldLines.length) {
else if (i >= newLines.length) { result.push({ type: "remove", line: oldLines[i] }); } result.push({ type: "add", line: newLines[i] });
else if (oldLines[i] === newLines[i]) { result.push({ type: "same", line: oldLines[i] }); } } else if (i >= newLines.length) {
else { result.push({ type: "remove", line: oldLines[i] });
result.push({ type: "remove", line: oldLines[i] }); } else if (oldLines[i] === newLines[i]) {
result.push({ type: "add", line: newLines[i] }); result.push({ type: "same", line: oldLines[i] });
} } else {
} result.push({ type: "remove", line: oldLines[i] });
return result; result.push({ type: "add", line: newLines[i] });
}
}
return result;
} }
export const diffTool: AgentTool<typeof diffSchema, DiffDetails> = { export const diffTool: AgentTool<typeof diffSchema, DiffDetails> = {
name: "show_diff", name: "show_diff",
label: "Show Diff", label: "Show Diff",
description: "Show a diff between two versions of code or text", description: "Show a diff between two versions of code or text",
parameters: diffSchema, parameters: diffSchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
return { return {
content: [{ type: "text", text: `Diff shown for: ${params.filename || "file"}` }], content: [{ type: "text", text: `Diff shown for: ${params.filename || "file"}` }],
details: { original: params.original, modified: params.modified, filename: params.filename }, details: { original: params.original, modified: params.modified, filename: params.filename },
}; };
}, },
}; };
class DiffRenderer implements ToolRenderer<DiffParams, DiffDetails> { class DiffRenderer implements ToolRenderer<DiffParams, DiffDetails> {
render(params: DiffParams | undefined, result: ToolResultMessage<DiffDetails> | undefined): ToolRenderResult { render(params: DiffParams | undefined, result: ToolResultMessage<DiffDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (!result?.details) { if (!result?.details) {
return { content: renderHeader(state, GitCompare, `Diff: ${params?.filename || "file"}`), isCustom: false }; return { content: renderHeader(state, GitCompare, `Diff: ${params?.filename || "file"}`), isCustom: false };
} }
const { original, modified, filename } = result.details; const { original, modified, filename } = result.details;
const diffLines = computeLineDiff(original, modified); const diffLines = computeLineDiff(original, modified);
const adds = diffLines.filter(l => l.type === "add").length; const adds = diffLines.filter((l) => l.type === "add").length;
const removes = diffLines.filter(l => l.type === "remove").length; const removes = diffLines.filter((l) => l.type === "remove").length;
return { return {
content: html` content: html`
<div class="flex flex-col gap-3"> <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>`)} ${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"> <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 ${ <div class="flex gap-0 ${
l.type === "add" ? "bg-green-500/10 text-green-700 dark:text-green-400" : l.type === "add"
l.type === "remove" ? "bg-red-500/10 text-red-700 dark:text-red-400" : ? "bg-green-500/10 text-green-700 dark:text-green-400"
"text-muted-foreground" : 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="w-6 text-center shrink-0 select-none border-r border-border px-1">${i + 1}</span>
<span class="px-2 whitespace-pre">${ <span class="px-2 whitespace-pre">${
l.type === "add" ? "+ " : l.type === "remove" ? "- " : " " l.type === "add" ? "+ " : l.type === "remove" ? "- " : " "
}${l.line}</span> }${l.line}</span>
</div> </div>
`)} `,
)}
</div> </div>
</div> </div>
`, `,
isCustom: false, isCustom: false,
}; };
} }
} }
registerToolRenderer("show_diff", new DiffRenderer()); registerToolRenderer("show_diff", new DiffRenderer());
export function createDiffTool(): AgentTool<typeof diffSchema, DiffDetails> { 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 { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai"; import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { Type } from "@sinclair/typebox";
import { html } from "lit"; import { html } from "lit";
import { Image } from "lucide"; import { Image } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import { getAppStorage } from "../storage/app-storage.js"; import { getAppStorage } from "../storage/app-storage.js";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js";
const imageGenSchema = Type.Object({ const imageGenSchema = Type.Object({
prompt: Type.String({ description: "Image generation prompt describing what to create" }), prompt: Type.String({ description: "Image generation prompt describing what to create" }),
model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })), model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })),
width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })), width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })),
height: Type.Optional(Type.Number({ description: "Height 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)" })), steps: Type.Optional(Type.Number({ description: "Inference steps (default: 20)" })),
}); });
export interface ImageGenDetails { export interface ImageGenDetails {
dataUrl?: string; dataUrl?: string;
model: string; model: string;
prompt: string; prompt: string;
width: number; width: number;
height: number; height: number;
error?: string; error?: string;
} }
interface ImageGenParams { interface ImageGenParams {
prompt: string; prompt: string;
model?: string; model?: string;
width?: number; width?: number;
height?: number; height?: number;
steps?: number; steps?: number;
} }
export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenDetails> = { export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenDetails> = {
name: "generate_image", name: "generate_image",
label: "Generate Image", label: "Generate Image",
description: "Generate an image using Venice AI image models. Displays inline in chat.", description: "Generate an image using Venice AI image models. Displays inline in chat.",
parameters: imageGenSchema, parameters: imageGenSchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const { prompt, model = "fluently-xl", width = 1024, height = 1024, steps = 20 } = params; const { prompt, model = "fluently-xl", width = 1024, height = 1024, steps = 20 } = params;
const apiKey = await getAppStorage().providerKeys.get("venice"); const apiKey = await getAppStorage().providerKeys.get("venice");
if (!apiKey) { if (!apiKey) {
return { return {
content: [{ type: "text", text: "Error: Venice API key not set. Add it in Settings > Providers & Models > Venice." }], content: [
details: { model, prompt, width, height, error: "No API key" }, {
}; type: "text",
} text: "Error: Venice API key not set. Add it in Settings > Providers & Models > Venice.",
const res = await fetch("https://api.venice.ai/api/v1/image/generate", { },
method: "POST", ],
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, details: { model, prompt, width, height, error: "No API key" },
body: JSON.stringify({ model, prompt, width, height, steps, return_binary: false, safe_mode: false }), };
signal: signal ?? AbortSignal.timeout(60000), }
}); const res = await fetch("https://api.venice.ai/api/v1/image/generate", {
if (!res.ok) { method: "POST",
const err = await res.text(); headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
return { body: JSON.stringify({ model, prompt, width, height, steps, return_binary: false, safe_mode: false }),
content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }], signal: signal ?? AbortSignal.timeout(60000),
details: { model, prompt, width, height, error: err }, });
}; if (!res.ok) {
} const err = await res.text();
const data = await res.json() as any; return {
const b64 = data?.images?.[0]; content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }],
if (!b64) { details: { model, prompt, width, height, error: err },
return { };
content: [{ type: "text", text: "No image returned from Venice API." }], }
details: { model, prompt, width, height, error: "No image data" }, const data = (await res.json()) as any;
}; const b64 = data?.images?.[0];
} if (!b64) {
const dataUrl = `data:image/png;base64,${b64}`; return {
return { content: [{ type: "text", text: "No image returned from Venice API." }],
content: [{ type: "text", text: `Image generated successfully. Model: ${model}, Size: ${width}x${height}` }], details: { model, prompt, width, height, error: "No image data" },
details: { dataUrl, model, prompt, width, height }, };
}; }
}, 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> { class ImageGenRenderer implements ToolRenderer<ImageGenParams, ImageGenDetails> {
render(params: ImageGenParams | undefined, result: ToolResultMessage<ImageGenDetails> | undefined): ToolRenderResult { render(
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; params: ImageGenParams | undefined,
if (result?.details?.dataUrl) { result: ToolResultMessage<ImageGenDetails> | undefined,
const d = result.details; ): ToolRenderResult {
return { const state = result ? (result.isError ? "error" : "complete") : "inprogress";
content: html` if (result?.details?.dataUrl) {
const d = result.details;
return {
content: html`
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
${renderHeader(state, Image, "Image Generated")} ${renderHeader(state, Image, "Image Generated")}
<img src=${d.dataUrl} alt=${d.prompt} <img src=${d.dataUrl} alt=${d.prompt}
@ -94,15 +102,18 @@ class ImageGenRenderer implements ToolRenderer<ImageGenParams, ImageGenDetails>
</div> </div>
<div class="text-xs text-foreground italic">${d.prompt}</div> <div class="text-xs text-foreground italic">${d.prompt}</div>
</div>`, </div>`,
isCustom: false, isCustom: false,
}; };
} }
return { content: renderHeader(state, Image, `Generating image: ${params?.prompt?.slice(0, 50) ?? "..."}`), isCustom: false }; return {
} content: renderHeader(state, Image, `Generating image: ${params?.prompt?.slice(0, 50) ?? "..."}`),
isCustom: false,
};
}
} }
registerToolRenderer("generate_image", new ImageGenRenderer()); registerToolRenderer("generate_image", new ImageGenRenderer());
export function createImageGenTool(): AgentTool<typeof imageGenSchema, ImageGenDetails> { export function createImageGenTool(): AgentTool<typeof imageGenSchema, ImageGenDetails> {
return imageGenTool; return imageGenTool;
} }

View file

@ -45,6 +45,6 @@ export function renderTool(
export { getToolRenderer, registerToolRenderer }; export { getToolRenderer, registerToolRenderer };
export { webSearchTool, createWebSearchTool, type WebSearchDetails, type WebSearchResult } from "./web-search.js"; export { createImageGenTool, type ImageGenDetails, imageGenTool } from "./image-gen.js";
export { imageGenTool, createImageGenTool, type ImageGenDetails } from "./image-gen.js"; export { createTTSTool, type TTSDetails, ttsTool } from "./voice-tts.js";
export { ttsTool, createTTSTool, type TTSDetails } 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 { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai"; import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { Type } from "@sinclair/typebox";
import { html } from "lit"; import { html } from "lit";
import { Brain, BrainCircuit, Trash2 } from "lucide"; import { Brain, BrainCircuit, Trash2 } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js";
export interface MemoryEntry { export interface MemoryEntry {
id: string; id: string;
content: string; content: string;
tags: string[]; tags: string[];
timestamp: string; timestamp: string;
} }
export interface MemoryStore { export interface MemoryStore {
entries: MemoryEntry[]; entries: MemoryEntry[];
} }
const DB_NAME = "jae-memory"; const DB_NAME = "jae-memory";
@ -25,140 +24,150 @@ const STORE_NAME = "memories";
let db: IDBDatabase | null = null; let db: IDBDatabase | null = null;
async function openDB(): Promise<IDBDatabase> { async function openDB(): Promise<IDBDatabase> {
if (db) return db; if (db) return db;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION); const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => { req.onupgradeneeded = () => {
req.result.createObjectStore(STORE_NAME, { keyPath: "id" }); req.result.createObjectStore(STORE_NAME, { keyPath: "id" });
}; };
req.onsuccess = () => { db = req.result; resolve(db); }; req.onsuccess = () => {
req.onerror = () => reject(req.error); db = req.result;
}); resolve(db);
};
req.onerror = () => reject(req.error);
});
} }
export async function memorySave(content: string, tags: string[] = []): Promise<string> { export async function memorySave(content: string, tags: string[] = []): Promise<string> {
const db = await openDB(); const db = await openDB();
const entry: MemoryEntry = { id: crypto.randomUUID(), content, tags, timestamp: new Date().toISOString() }; const entry: MemoryEntry = { id: crypto.randomUUID(), content, tags, timestamp: new Date().toISOString() };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite"); const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(entry); tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve(entry.id); tx.oncomplete = () => resolve(entry.id);
tx.onerror = () => reject(tx.error); tx.onerror = () => reject(tx.error);
}); });
} }
export async function memoryLoad(): Promise<MemoryEntry[]> { export async function memoryLoad(): Promise<MemoryEntry[]> {
const db = await openDB(); const db = await openDB();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly"); const tx = db.transaction(STORE_NAME, "readonly");
const req = tx.objectStore(STORE_NAME).getAll(); const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result || []); req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error); req.onerror = () => reject(req.error);
}); });
} }
export async function memorySearch(query: string): Promise<MemoryEntry[]> { export async function memorySearch(query: string): Promise<MemoryEntry[]> {
const all = await memoryLoad(); const all = await memoryLoad();
const q = query.toLowerCase(); const q = query.toLowerCase();
return all.filter(e => e.content.toLowerCase().includes(q) || e.tags.some(t => t.toLowerCase().includes(q))); 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> { export async function memoryDelete(id: string): Promise<void> {
const db = await openDB(); const db = await openDB();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite"); const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(id); tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve(); tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error); tx.onerror = () => reject(tx.error);
}); });
} }
// --- Save Memory Tool --- // --- Save Memory Tool ---
const saveMemorySchema = Type.Object({ const saveMemorySchema = Type.Object({
content: Type.String({ description: "Information to remember" }), content: Type.String({ description: "Information to remember" }),
tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation" })), tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation" })),
}); });
export const saveMemoryTool: AgentTool<typeof saveMemorySchema, { id: string; content: string }> = { export const saveMemoryTool: AgentTool<typeof saveMemorySchema, { id: string; content: string }> = {
name: "memory_save", name: "memory_save",
label: "Save Memory", label: "Save Memory",
description: "Save a piece of information to long-term memory for future sessions.", description: "Save a piece of information to long-term memory for future sessions.",
parameters: saveMemorySchema, parameters: saveMemorySchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const id = await memorySave(params.content, params.tags || []); const id = await memorySave(params.content, params.tags || []);
return { return {
content: [{ type: "text", text: `Memory saved with ID: ${id}` }], content: [{ type: "text", text: `Memory saved with ID: ${id}` }],
details: { id, content: params.content }, details: { id, content: params.content },
}; };
}, },
}; };
// --- Recall Memory Tool --- // --- Recall Memory Tool ---
const recallMemorySchema = Type.Object({ 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[] }> = { export const recallMemoryTool: AgentTool<typeof recallMemorySchema, { results: MemoryEntry[] }> = {
name: "memory_recall", name: "memory_recall",
label: "Recall Memory", label: "Recall Memory",
description: "Search long-term memory for relevant information.", description: "Search long-term memory for relevant information.",
parameters: recallMemorySchema, parameters: recallMemorySchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const results = await memorySearch(params.query); const results = await memorySearch(params.query);
const text = results.length === 0 const text =
? `No memories found for: ${params.query}` results.length === 0
: results.map(r => `[${r.timestamp.slice(0, 10)}] ${r.content}`).join("\n\n"); ? `No memories found for: ${params.query}`
return { : results.map((r) => `[${r.timestamp.slice(0, 10)}] ${r.content}`).join("\n\n");
content: [{ type: "text", text }], return {
details: { results }, content: [{ type: "text", text }],
}; details: { results },
}, };
},
}; };
// --- Renderers --- // --- Renderers ---
class SaveMemoryRenderer implements ToolRenderer { class SaveMemoryRenderer implements ToolRenderer {
render(params: any, result: ToolResultMessage<{ id: string; content: string }> | undefined): ToolRenderResult { render(params: any, result: ToolResultMessage<{ id: string; content: string }> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
return { return {
content: html` content: html`
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
${renderHeader(state, Brain, `Memory saved`)} ${renderHeader(state, Brain, `Memory saved`)}
${result?.details ? html`<div class="text-xs text-muted-foreground truncate">${result.details.content}</div>` : ""} ${result?.details ? html`<div class="text-xs text-muted-foreground truncate">${result.details.content}</div>` : ""}
</div> </div>
`, `,
isCustom: false, isCustom: false,
}; };
} }
} }
class RecallMemoryRenderer implements ToolRenderer { class RecallMemoryRenderer implements ToolRenderer {
render(params: any, result: ToolResultMessage<{ results: MemoryEntry[] }> | undefined): ToolRenderResult { render(params: any, result: ToolResultMessage<{ results: MemoryEntry[] }> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const results = result?.details?.results || []; const results = result?.details?.results || [];
return { return {
content: html` content: html`
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
${renderHeader(state, BrainCircuit, `Memory recall: ${params?.query || ""}`)} ${renderHeader(state, BrainCircuit, `Memory recall: ${params?.query || ""}`)}
${results.length > 0 ? html` ${
results.length > 0
? html`
<div class="flex flex-col gap-1"> <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-xs p-2 rounded border border-border">
<div class="text-muted-foreground">${r.timestamp.slice(0, 10)}</div> <div class="text-muted-foreground">${r.timestamp.slice(0, 10)}</div>
<div>${r.content}</div> <div>${r.content}</div>
</div> </div>
`)} `,
)}
</div> </div>
` : ""} `
: ""
}
</div> </div>
`, `,
isCustom: false, isCustom: false,
}; };
} }
} }
registerToolRenderer("memory_save", new SaveMemoryRenderer()); registerToolRenderer("memory_save", new SaveMemoryRenderer());
registerToolRenderer("memory_recall", new RecallMemoryRenderer()); registerToolRenderer("memory_recall", new RecallMemoryRenderer());
export function createMemoryTools() { 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 { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai"; import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { Type } from "@sinclair/typebox";
import { html } from "lit"; import { html } from "lit";
import { GitBranch } from "lucide"; import { GitBranch } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js";
export interface MermaidDetails { export interface MermaidDetails {
diagram: string; diagram: string;
rendered: boolean; rendered: boolean;
error?: string; error?: string;
} }
interface MermaidParams { interface MermaidParams {
diagram: string; diagram: string;
title?: string; title?: string;
} }
const mermaidSchema = Type.Object({ const mermaidSchema = Type.Object({
diagram: Type.String({ description: "Mermaid diagram source code" }), diagram: Type.String({ description: "Mermaid diagram source code" }),
title: Type.Optional(Type.String({ description: "Optional title for the diagram" })), title: Type.Optional(Type.String({ description: "Optional title for the diagram" })),
}); });
export const mermaidTool: AgentTool<typeof mermaidSchema, MermaidDetails> = { export const mermaidTool: AgentTool<typeof mermaidSchema, MermaidDetails> = {
name: "render_diagram", name: "render_diagram",
label: "Render Diagram", label: "Render Diagram",
description: "Render a Mermaid diagram (flowchart, sequence, gantt, class diagram, etc.)", description: "Render a Mermaid diagram (flowchart, sequence, gantt, class diagram, etc.)",
parameters: mermaidSchema, parameters: mermaidSchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
return { return {
content: [{ type: "text", text: `Diagram rendered: ${params.title || "Untitled"}` }], content: [{ type: "text", text: `Diagram rendered: ${params.title || "Untitled"}` }],
details: { diagram: params.diagram, rendered: true }, details: { diagram: params.diagram, rendered: true },
}; };
}, },
}; };
let mermaidLoaded = false; let mermaidLoaded = false;
async function loadMermaid(): Promise<any> { async function loadMermaid(): Promise<any> {
if ((window as any).mermaid) return (window as any).mermaid; if ((window as any).mermaid) return (window as any).mermaid;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"; script.src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
script.onload = () => { script.onload = () => {
const m = (window as any).mermaid; const m = (window as any).mermaid;
m.initialize({ startOnLoad: false, theme: document.documentElement.classList.contains("dark") ? "dark" : "default" }); m.initialize({
mermaidLoaded = true; startOnLoad: false,
resolve(m); theme: document.documentElement.classList.contains("dark") ? "dark" : "default",
}; });
script.onerror = reject; mermaidLoaded = true;
document.head.appendChild(script); resolve(m);
}); };
script.onerror = reject;
document.head.appendChild(script);
});
} }
class MermaidRenderer implements ToolRenderer<MermaidParams, MermaidDetails> { class MermaidRenderer implements ToolRenderer<MermaidParams, MermaidDetails> {
render(params: MermaidParams | undefined, result: ToolResultMessage<MermaidDetails> | undefined): ToolRenderResult { render(params: MermaidParams | undefined, result: ToolResultMessage<MermaidDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const diagram = result?.details?.diagram || params?.diagram || ""; const diagram = result?.details?.diagram || params?.diagram || "";
const title = params?.title || "Diagram"; const title = params?.title || "Diagram";
if (!diagram) { if (!diagram) {
return { content: renderHeader(state, GitBranch, "Rendering diagram..."), isCustom: false }; 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) => { const renderDiagram = async (container: HTMLElement) => {
try { try {
const mermaid = await loadMermaid(); const mermaid = await loadMermaid();
const { svg } = await mermaid.render(containerId + "-svg", diagram); const { svg } = await mermaid.render(containerId + "-svg", diagram);
container.innerHTML = svg; container.innerHTML = svg;
container.style.maxWidth = "100%"; container.style.maxWidth = "100%";
const svgEl = container.querySelector("svg"); const svgEl = container.querySelector("svg");
if (svgEl) { if (svgEl) {
svgEl.style.maxWidth = "100%"; svgEl.style.maxWidth = "100%";
svgEl.style.height = "auto"; svgEl.style.height = "auto";
} }
} catch (err: any) { } catch (err: any) {
container.innerHTML = `<div class="text-destructive text-sm p-2">Diagram error: ${err.message}</div>`; container.innerHTML = `<div class="text-destructive text-sm p-2">Diagram error: ${err.message}</div>`;
} }
}; };
return { return {
content: html` content: html`
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
${renderHeader(state, GitBranch, `Diagram: ${title}`)} ${renderHeader(state, GitBranch, `Diagram: ${title}`)}
<div <div
@ -91,13 +93,13 @@ class MermaidRenderer implements ToolRenderer<MermaidParams, MermaidDetails> {
></div> ></div>
</div> </div>
`, `,
isCustom: false, isCustom: false,
}; };
} }
} }
registerToolRenderer("render_diagram", new MermaidRenderer()); registerToolRenderer("render_diagram", new MermaidRenderer());
export function createMermaidTool(): AgentTool<typeof mermaidSchema, MermaidDetails> { 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 { html } from "lit";
import { FileText } from "lucide"; import { FileText } from "lucide";
import type { ToolResultMessage } from "@jaeswift/jae-ai"; import { registerToolRenderer, renderHeader } from "../renderer-registry.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js"; import type { ToolRenderer, ToolRenderResult } from "../types.js";
import { registerToolRenderer } from "../renderer-registry.js";
export class DiffRenderer implements ToolRenderer { export class DiffRenderer implements ToolRenderer {
render(params: any, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult { render(params: any, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult {
const rawContent = result?.content; const rawContent = result?.content;
const resultText = Array.isArray(rawContent) const resultText = Array.isArray(rawContent)
? rawContent.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n") ? rawContent
: typeof rawContent === "string" ? rawContent : ""; .filter((c: any) => c.type === "text")
const diff = params?.diff || params?.patch || resultText || ""; .map((c: any) => c.text)
const filename = params?.file || params?.filename || ""; .join("\n")
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; : typeof rawContent === "string"
const label = filename ? "Diff: " + filename : "File Diff"; ? 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 { return {
content: html` content: html`
<div class="space-y-3"> <div class="space-y-3">
${renderHeader(state, FileText, label)} ${renderHeader(state, FileText, label)}
${diff ? html` ${
diff
? html`
<div class="overflow-auto max-h-96 rounded-lg border border-border"> <div class="overflow-auto max-h-96 rounded-lg border border-border">
<pre class="text-xs font-mono p-4">${lines.map((line: string) => { <pre class="text-xs font-mono p-4">${lines.map((line: string) => {
let cls = "block"; let cls = "block";
if (line.startsWith("+") && !line.startsWith("+++")) cls = "text-green-500 bg-green-500/10 block px-1"; if (line.startsWith("+") && !line.startsWith("+++"))
else if (line.startsWith("-") && !line.startsWith("---")) cls = "text-red-500 bg-red-500/10 block px-1"; cls = "text-green-500 bg-green-500/10 block px-1";
else if (line.startsWith("@@")) cls = "text-blue-400 block px-1"; else if (line.startsWith("-") && !line.startsWith("---"))
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "text-muted-foreground block px-1"; cls = "text-red-500 bg-red-500/10 block px-1";
else cls = "block px-1"; else if (line.startsWith("@@")) cls = "text-blue-400 block px-1";
return html`<span class=${cls}>${line}</span> 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>
` : ""} `
: ""
}
</div> </div>
`, `,
isCustom: false, isCustom: false,
}; };
} }
} }
registerToolRenderer("diff", new DiffRenderer()); registerToolRenderer("diff", new DiffRenderer());

View file

@ -1,25 +1,31 @@
import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { html } from "lit"; import { html } from "lit";
import { GitBranch } from "lucide"; import { GitBranch } from "lucide";
import type { ToolResultMessage } from "@jaeswift/jae-ai"; import { registerToolRenderer, renderHeader } from "../renderer-registry.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js"; import type { ToolRenderer, ToolRenderResult } from "../types.js";
import { registerToolRenderer } from "../renderer-registry.js";
export class MermaidRenderer implements ToolRenderer { export class MermaidRenderer implements ToolRenderer {
render(params: any, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult { render(params: any, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult {
const rawContent = result?.content; const rawContent = result?.content;
const resultText = Array.isArray(rawContent) const resultText = Array.isArray(rawContent)
? rawContent.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n") ? rawContent
: typeof rawContent === "string" ? rawContent : ""; .filter((c: any) => c.type === "text")
const diagram = params?.diagram || params?.code || resultText || ""; .map((c: any) => c.text)
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; .join("\n")
const id = "mermaid-" + Math.random().toString(36).slice(2); : 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 { return {
content: html` content: html`
<div class="space-y-3"> <div class="space-y-3">
${renderHeader(state, GitBranch, "Diagram")} ${renderHeader(state, GitBranch, "Diagram")}
${diagram ? html` ${
diagram
? html`
<div class="p-4 bg-background rounded-lg border border-border overflow-auto"> <div class="p-4 bg-background rounded-lg border border-border overflow-auto">
<div .id=${id} class="mermaid">${diagram}</div> <div .id=${id} class="mermaid">${diagram}</div>
<script> <script>
@ -41,12 +47,14 @@ export class MermaidRenderer implements ToolRenderer {
})(); })();
</script> </script>
</div> </div>
` : ""} `
: ""
}
</div> </div>
`, `,
isCustom: false, isCustom: false,
}; };
} }
} }
registerToolRenderer("mermaid", new MermaidRenderer()); registerToolRenderer("mermaid", new MermaidRenderer());

View file

@ -1,90 +1,93 @@
import type { AgentTool } from "@jaeswift/jae-agent-core"; import type { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai"; import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { Type } from "@sinclair/typebox";
import { html } from "lit"; import { html } from "lit";
import { Volume2 } from "lucide"; import { Volume2 } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import { getAppStorage } from "../storage/app-storage.js"; import { getAppStorage } from "../storage/app-storage.js";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js";
const ttsSchema = Type.Object({ const ttsSchema = Type.Object({
text: Type.String({ description: "Text to convert to speech" }), text: Type.String({ description: "Text to convert to speech" }),
model: Type.Optional(Type.String({ description: "Venice TTS model (default: tts-kokoro)" })), model: Type.Optional(Type.String({ description: "Venice TTS model (default: tts-kokoro)" })),
voice: Type.Optional(Type.String({ description: "Voice ID (default: af_heart)" })), voice: Type.Optional(Type.String({ description: "Voice ID (default: af_heart)" })),
}); });
export interface TTSDetails { export interface TTSDetails {
audioUrl?: string; audioUrl?: string;
model: string; model: string;
voice: string; voice: string;
text: string; text: string;
error?: string; error?: string;
} }
interface TTSParams { interface TTSParams {
text: string; text: string;
model?: string; model?: string;
voice?: string; voice?: string;
} }
export const ttsTool: AgentTool<typeof ttsSchema, TTSDetails> = { export const ttsTool: AgentTool<typeof ttsSchema, TTSDetails> = {
name: "text_to_speech", name: "text_to_speech",
label: "Text to Speech", label: "Text to Speech",
description: "Convert text to speech using Venice AI TTS. Audio plays inline in chat.", description: "Convert text to speech using Venice AI TTS. Audio plays inline in chat.",
parameters: ttsSchema, parameters: ttsSchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const { text, model = "tts-kokoro", voice = "af_heart" } = params; const { text, model = "tts-kokoro", voice = "af_heart" } = params;
const apiKey = await getAppStorage().providerKeys.get("venice"); const apiKey = await getAppStorage().providerKeys.get("venice");
if (!apiKey) { if (!apiKey) {
return { return {
content: [{ type: "text", text: "Error: Venice API key not set." }], content: [{ type: "text", text: "Error: Venice API key not set." }],
details: { model, voice, text, error: "No API key" }, details: { model, voice, text, error: "No API key" },
}; };
} }
const res = await fetch("https://api.venice.ai/api/v1/audio/speech", { const res = await fetch("https://api.venice.ai/api/v1/audio/speech", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model, input: text, voice }), body: JSON.stringify({ model, input: text, voice }),
signal: signal ?? AbortSignal.timeout(30000), signal: signal ?? AbortSignal.timeout(30000),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const err = await res.text();
return { return {
content: [{ type: "text", text: `TTS failed (${res.status}): ${err}` }], content: [{ type: "text", text: `TTS failed (${res.status}): ${err}` }],
details: { model, voice, text, error: err }, details: { model, voice, text, error: err },
}; };
} }
const blob = await res.blob(); const blob = await res.blob();
const audioUrl = URL.createObjectURL(blob); const audioUrl = URL.createObjectURL(blob);
return { return {
content: [{ type: "text", text: `Speech generated. Model: ${model}, Voice: ${voice}` }], content: [{ type: "text", text: `Speech generated. Model: ${model}, Voice: ${voice}` }],
details: { audioUrl, model, voice, text }, details: { audioUrl, model, voice, text },
}; };
}, },
}; };
class TTSRenderer implements ToolRenderer<TTSParams, TTSDetails> { class TTSRenderer implements ToolRenderer<TTSParams, TTSDetails> {
render(params: TTSParams | undefined, result: ToolResultMessage<TTSDetails> | undefined): ToolRenderResult { render(params: TTSParams | undefined, result: ToolResultMessage<TTSDetails> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (result?.details?.audioUrl) { if (result?.details?.audioUrl) {
const d = result.details; const d = result.details;
return { return {
content: html` content: html`
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
${renderHeader(state, Volume2, "Speech Generated")} ${renderHeader(state, Volume2, "Speech Generated")}
<audio controls src=${d.audioUrl} class="w-full"></audio> <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-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 class="text-xs text-foreground italic">${d.text.length > 100 ? d.text.slice(0, 100) + "..." : d.text}</div>
</div>`, </div>`,
isCustom: false, isCustom: false,
}; };
} }
return { content: renderHeader(state, Volume2, `Speaking: ${params?.text?.slice(0, 50) ?? "..."}`), isCustom: false }; return {
} content: renderHeader(state, Volume2, `Speaking: ${params?.text?.slice(0, 50) ?? "..."}`),
isCustom: false,
};
}
} }
registerToolRenderer("text_to_speech", new TTSRenderer()); registerToolRenderer("text_to_speech", new TTSRenderer());
export function createTTSTool(): AgentTool<typeof ttsSchema, TTSDetails> { 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 { AgentTool } from "@jaeswift/jae-agent-core";
import { Type } from "@sinclair/typebox";
import type { ToolResultMessage } from "@jaeswift/jae-ai"; import type { ToolResultMessage } from "@jaeswift/jae-ai";
import { Type } from "@sinclair/typebox";
import { html } from "lit"; import { html } from "lit";
import { Globe } from "lucide"; import { Globe } from "lucide";
import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; import { registerToolRenderer, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js"; import type { ToolRenderer, ToolRenderResult } from "./types.js";
const webSearchSchema = Type.Object({ const webSearchSchema = Type.Object({
query: Type.String({ description: "Search query" }), query: Type.String({ description: "Search query" }),
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })), limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
}); });
export interface WebSearchResult { export interface WebSearchResult {
title: string; title: string;
url: string; url: string;
snippet: string; snippet: string;
} }
export interface WebSearchDetails { export interface WebSearchDetails {
results: WebSearchResult[]; results: WebSearchResult[];
query: string; query: string;
error?: string; error?: string;
} }
interface WebSearchParams { interface WebSearchParams {
query: string; query: string;
limit?: number; limit?: number;
} }
async function fetchDuckDuckGo(query: string, limit: number): Promise<WebSearchResult[]> { async function fetchDuckDuckGo(query: string, limit: number): Promise<WebSearchResult[]> {
const encoded = encodeURIComponent(query); 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`); const res = await fetch(
if (!res.ok) throw new Error(`Search returned ${res.status}`); `https://api.duckduckgo.com/?q=${encoded}&format=json&no_redirect=1&no_html=1&skip_disambig=1`,
const data = await res.json() as any; );
const results: WebSearchResult[] = []; if (!res.ok) throw new Error(`Search returned ${res.status}`);
if (data.AbstractText && data.AbstractURL) { const data = (await res.json()) as any;
results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText }); const results: WebSearchResult[] = [];
} if (data.AbstractText && data.AbstractURL) {
for (const topic of (data.RelatedTopics || [])) { results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText });
if (results.length >= limit) break; }
if (topic.FirstURL && topic.Text) { for (const topic of data.RelatedTopics || []) {
results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text }); if (results.length >= limit) break;
} else if (topic.Topics) { if (topic.FirstURL && topic.Text) {
for (const sub of topic.Topics) { results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text });
if (results.length >= limit) break; } else if (topic.Topics) {
if (sub.FirstURL && sub.Text) results.push({ title: sub.Text.split(" - ")[0], url: sub.FirstURL, snippet: sub.Text }); 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 }); }
} for (const r of data.Results || []) {
return results.slice(0, limit); 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> = { export const webSearchTool: AgentTool<typeof webSearchSchema, WebSearchDetails> = {
name: "web_search", name: "web_search",
label: "Web Search", label: "Web Search",
description: "Search the web for current information using DuckDuckGo.", description: "Search the web for current information using DuckDuckGo.",
parameters: webSearchSchema, parameters: webSearchSchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const { query, limit = 5 } = params; const { query, limit = 5 } = params;
try { try {
const results = await fetchDuckDuckGo(query, limit); const results = await fetchDuckDuckGo(query, limit);
const lines = results.map((r, i) => `[${i + 1}] ${r.title}` + "\n" + r.url + "\n" + r.snippet); 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"); const text = results.length === 0 ? `No results for: ${query}` : lines.join("\n\n");
return { content: [{ type: "text", text }], details: { results, query } }; return { content: [{ type: "text", text }], details: { results, query } };
} catch (err: any) { } catch (err: any) {
return { content: [{ type: "text", text: `Search failed: ${err.message}` }], details: { results: [], query, error: err.message } }; return {
} content: [{ type: "text", text: `Search failed: ${err.message}` }],
}, details: { results: [], query, error: err.message },
};
}
},
}; };
class WebSearchRenderer implements ToolRenderer<WebSearchParams, WebSearchDetails> { class WebSearchRenderer implements ToolRenderer<WebSearchParams, WebSearchDetails> {
render(params: WebSearchParams | undefined, result: ToolResultMessage<WebSearchDetails> | undefined): ToolRenderResult { render(
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; params: WebSearchParams | undefined,
if (result?.details?.results?.length) { result: ToolResultMessage<WebSearchDetails> | undefined,
const details = result.details; ): ToolRenderResult {
return { const state = result ? (result.isError ? "error" : "complete") : "inprogress";
content: html` if (result?.details?.results?.length) {
const details = result.details;
return {
content: html`
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
${renderHeader(state, Globe, `Web Search: ${details.query}`)} ${renderHeader(state, Globe, `Web Search: ${details.query}`)}
<div class="flex flex-col gap-2"> <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"> <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> <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-muted-foreground truncate">${r.url}</span>
<span class="text-xs text-foreground mt-1">${r.snippet}</span> <span class="text-xs text-foreground mt-1">${r.snippet}</span>
</div> </div>
`)} `,
)}
</div> </div>
</div>`, </div>`,
isCustom: false, isCustom: false,
}; };
} }
return { content: renderHeader(state, Globe, `Searching: ${params?.query ?? "..."}`), isCustom: false }; return { content: renderHeader(state, Globe, `Searching: ${params?.query ?? "..."}`), isCustom: false };
} }
} }
registerToolRenderer("web_search", new WebSearchRenderer()); registerToolRenderer("web_search", new WebSearchRenderer());
export function createWebSearchTool(): AgentTool<typeof webSearchSchema, WebSearchDetails> { export function createWebSearchTool(): AgentTool<typeof webSearchSchema, WebSearchDetails> {
return webSearchTool; return webSearchTool;
} }