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,19 +1,24 @@
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("navigate"),
Type.Literal("screenshot"), Type.Literal("screenshot"),
Type.Literal("click"), Type.Literal("click"),
Type.Literal("type"), Type.Literal("type"),
Type.Literal("content"), Type.Literal("content"),
Type.Literal("close"), Type.Literal("close"),
], { description: "navigate: go to URL | screenshot: capture page | click: click element | type: type into element | content: get page text | close: close browser" }), ],
{
description:
"navigate: go to URL | screenshot: capture page | click: click element | type: type into element | content: get page text | close: close browser",
},
),
url: Type.Optional(Type.String({ description: "URL to navigate to (for navigate action)" })), 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)" })), selector: Type.Optional(Type.String({ description: "CSS selector (for click/type actions)" })),
text: Type.Optional(Type.String({ description: "Text to type (for type action)" })), text: Type.Optional(Type.String({ description: "Text to type (for type action)" })),
@ -55,21 +60,27 @@ async function getPage() {
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:
"Control a headless Chromium browser. Navigate pages, take screenshots, click elements, type text, and extract page content. Requires playwright.",
parameters: browserSchema, parameters: browserSchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const { action, url, selector, text, wait = 1000 } = params; 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) {
await _browser.close();
_browser = null;
_page = null;
}
return { content: [{ type: "text", text: "Browser closed." }], details: { action } }; 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)
return { content: [{ type: "text", text: "Error: url required for navigate" }], details: { action } };
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(wait); await page.waitForTimeout(wait);
const title = await page.title(); const title = await page.title();
@ -101,15 +112,18 @@ export const browserTool: AgentTool<typeof browserSchema, BrowserToolDetails> =
} }
if (action === "click") { if (action === "click") {
if (!selector) return { content: [{ type: "text", text: "Error: selector required for click" }], details: { action } }; if (!selector)
return { content: [{ type: "text", text: "Error: selector required for click" }], details: { action } };
await page.click(selector, { timeout: 10000 }); await page.click(selector, { timeout: 10000 });
await page.waitForTimeout(wait); await page.waitForTimeout(wait);
return { content: [{ type: "text", text: `Clicked: ${selector}` }], details: { action } }; 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 } };
if (!text)
return { content: [{ type: "text", text: "Error: text required for type" }], details: { action } };
await page.fill(selector, text); await page.fill(selector, text);
await page.waitForTimeout(wait); await page.waitForTimeout(wait);
return { content: [{ type: "text", text: `Typed into: ${selector}` }], details: { action } }; return { content: [{ type: "text", text: `Typed into: ${selector}` }], details: { action } };

View file

@ -1,5 +1,4 @@
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";
@ -26,7 +25,8 @@ export interface ImageGenToolDetails {
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:
"Generate an image using Venice AI image models and save it to disk. Uses VENICE_API_KEY or OPENAI_API_KEY env var.",
parameters: imageGenSchema, parameters: imageGenSchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const { const {
@ -60,7 +60,7 @@ export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenToolDetails>
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),
@ -74,7 +74,7 @@ export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenToolDetails>
}; };
} }
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 {
@ -89,7 +89,12 @@ export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenToolDetails>
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: [
{
type: "text",
text: `Image saved to: ${filepath}\nModel: ${model}\nPrompt: ${prompt}\nSize: ${width}x${height}`,
},
],
details: { path: filepath, model, prompt, width, 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";
@ -19,7 +18,9 @@ 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 {
@ -31,16 +32,13 @@ 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"),
Type.Literal("delete"),
], { description: "save: store info | recall: search by query | list: show all | delete: remove by id" }),
content: Type.Optional(Type.String({ description: "Content to save (required for save action)" })), content: Type.Optional(Type.String({ description: "Content to save (required for save action)" })),
query: Type.Optional(Type.String({ description: "Search query (required for recall action)" })), query: Type.Optional(Type.String({ description: "Search query (required for recall action)" })),
tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation (optional for save)" })), tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation (optional for save)" })),
@ -59,14 +57,19 @@ export interface MemoryToolDetails {
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:
"Persistent memory across sessions. Save facts, recall by query, list all memories, or delete by ID. Stored in ~/.jae/memory/.",
parameters: memorySchema, parameters: memorySchema,
async execute(toolCallId, params, signal) { async execute(toolCallId, params, signal) {
const { action, content, query, tags = [], id, limit = 5 } = params; const { action, content, query, tags = [], id, limit = 5 } = params;
const memories = loadMemories(); 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)
return {
content: [{ type: "text", text: "Error: content is required for save action" }],
details: { action },
};
const entry: MemoryEntry = { const entry: MemoryEntry = {
id: `mem-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `mem-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
content, content,
@ -75,34 +78,52 @@ export const memoryTool: AgentTool<typeof memorySchema, MemoryToolDetails> = {
}; };
memories.push(entry); memories.push(entry);
saveMemories(memories); saveMemories(memories);
return { content: [{ type: "text", text: `Memory saved. ID: ${entry.id}` }], details: { action, id: entry.id } }; 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)
return {
content: [{ type: "text", text: "Error: query is required for recall action" }],
details: { action },
};
const scored = memories const scored = memories
.map(m => ({ m, score: scoreMatch(m, query) })) .map((m) => ({ m, score: scoreMatch(m, query) }))
.filter(x => x.score > 0) .filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.slice(0, limit) .slice(0, limit)
.map(x => x.m); .map((x) => x.m);
const text = scored.length === 0 const text =
scored.length === 0
? `No memories found for: ${query}` ? `No memories found for: ${query}`
: scored.map(m => `[${m.id}] ${m.content}${m.tags.length ? ` (tags: ${m.tags.join(", ")})` : ""}`).join("\n\n"); : 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 } }; return { content: [{ type: "text", text }], details: { action, count: scored.length } };
} }
if (action === "list") { if (action === "list") {
const text = memories.length === 0 const text =
memories.length === 0
? "No memories stored." ? "No memories stored."
: memories.map(m => `[${m.id}] ${m.content.slice(0, 100)}${m.content.length > 100 ? "..." : ""}`).join("\n"); : 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 } }; 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" }],
details: { action },
};
const filtered = memories.filter((m) => m.id !== id);
if (filtered.length === memories.length)
return { content: [{ type: "text", text: `No memory found with ID: ${id}` }], details: { action } };
saveMemories(filtered); saveMemories(filtered);
return { content: [{ type: "text", text: `Memory ${id} deleted.` }], details: { action, id } }; return { content: [{ type: "text", text: `Memory ${id} deleted.` }], details: { action, id } };
} }

View file

@ -1,4 +1,3 @@
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";
@ -23,8 +22,12 @@ function htmlToText(html: string): string {
.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(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&nbsp;/g, " ")
.replace(/&#39;/g, "'")
.replace(/[ \t]+/g, " ") .replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n") .replace(/\n{3,}/g, "\n\n")
.trim(); .trim();
@ -39,12 +42,12 @@ export const webFetchTool: AgentTool<typeof webFetchSchema, WebFetchToolDetails>
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);

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,26 +1,28 @@
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();
} }
@ -30,29 +32,46 @@ export class JaeBrowserPanel extends LitElement {
} }
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.onclose = () => {
this.connected = false;
this.requestUpdate();
};
this.ws.onerror = () => {
this.connected = false;
this.error = "Browser server not running.";
this.requestUpdate();
};
this.ws.onmessage = (e) => { this.ws.onmessage = (e) => {
const m = JSON.parse(e.data); const m = JSON.parse(e.data);
if (m.type === 'screenshot') { if (m.type === "screenshot") {
this.loading = false; this.loading = false;
this.screenshot = `data:image/jpeg;base64,${m.data}`; this.screenshot = `data:image/jpeg;base64,${m.data}`;
this.url = m.url; this.url = m.url;
this.inputUrl = m.url; this.inputUrl = m.url;
this.error = ''; 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(); 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();
} }
@ -64,48 +83,62 @@ export class JaeBrowserPanel extends LitElement {
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,12 +147,16 @@ 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,4 +1,3 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
@ -20,7 +19,9 @@ export class CommandPalette extends LitElement {
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;
@ -45,10 +46,11 @@ export class CommandPalette extends LitElement {
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) =>
c.label.toLowerCase().includes(q) || c.label.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q) || c.description?.toLowerCase().includes(q) ||
c.keywords?.some(k => k.toLowerCase().includes(q)) c.keywords?.some((k) => k.toLowerCase().includes(q)),
); );
} }
@ -76,7 +78,9 @@ export class CommandPalette extends LitElement {
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>

View file

@ -1,7 +1,6 @@
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;
@ -32,7 +31,9 @@ export class CostTracker extends LitElement {
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();
@ -52,8 +53,12 @@ export class CostTracker extends LitElement {
}); });
} }
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;
@ -66,14 +71,19 @@ export class CostTracker extends LitElement {
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,7 +97,9 @@ 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>
` : ""} `
: ""
}
`; `;
} }
} }

View file

@ -1,11 +1,14 @@
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" },
@ -18,9 +21,19 @@ export class JaeEmptyState extends LitElement {
override render() { override render() {
if (!this.visible) return html``; if (!this.visible) return html``;
if (this.faded) {
return html`
<div class="flex flex-col items-center justify-center h-full select-none"
style="pointer-events:none">
<img src="/mascot/jae-default.png" alt="JAE"
style="width:10rem;height:auto;opacity:0.04;filter:grayscale(40%)" />
</div>
`;
}
return html` 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,7 +55,8 @@ 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,4 +1,3 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
@ -6,53 +5,79 @@ import { customElement, state } from "lit/decorators.js";
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: [ {
group: "General",
items: [
{ key: "Cmd+K", desc: "Open command palette" }, { key: "Cmd+K", desc: "Open command palette" },
{ key: "?", desc: "Show keyboard shortcuts" }, { key: "?", desc: "Show keyboard shortcuts" },
{ key: "Ctrl+L", desc: "Open model selector" }, { key: "Ctrl+L", desc: "Open model selector" },
{ key: "Esc", desc: "Close dialogs / abort generation" }, { key: "Esc", desc: "Close dialogs / abort generation" },
]}, ],
{ group: "Sessions", items: [ },
{
group: "Sessions",
items: [
{ key: "Ctrl+N", desc: "New session" }, { key: "Ctrl+N", desc: "New session" },
{ key: "Ctrl+H", desc: "Session history" }, { key: "Ctrl+H", desc: "Session history" },
{ key: "Ctrl+E", desc: "Export session" }, { key: "Ctrl+E", desc: "Export session" },
]}, ],
{ group: "Tools & Features", items: [ },
{
group: "Tools & Features",
items: [
{ key: "/memory", desc: "Open memory manager" }, { key: "/memory", desc: "Open memory manager" },
{ key: "/clear", desc: "Clear conversation" }, { key: "/clear", desc: "Clear conversation" },
{ key: "/model", desc: "Switch model" }, { 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,4 +1,3 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
@ -19,7 +18,10 @@ async function openDB(): Promise<IDBDatabase> {
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 = () => {
_db = req.result;
resolve(_db);
};
req.onerror = () => reject(req.error); req.onerror = () => reject(req.error);
}); });
} }
@ -61,7 +63,9 @@ export class MemoryManager extends LitElement {
@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;
@ -71,23 +75,31 @@ export class MemoryManager extends LitElement {
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
.split(",")
.map((t) => t.trim())
.filter(Boolean);
await memorySave(this.newContent.trim(), tags); await memorySave(this.newContent.trim(), tags);
this.newContent = ""; this.newContent = "";
this.newTags = ""; this.newTags = "";
@ -99,7 +111,9 @@ export class MemoryManager extends LitElement {
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>

View file

@ -1,4 +1,3 @@
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 {
@ -18,7 +17,10 @@ export function exportSessionAsMarkdown(messages: AgentMessage[], title: string)
} 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
.filter((b: any) => b.type === "text")
.map((b: any) => b.text)
.join("\n")
: m.content || ""; : m.content || "";
lines.push(`## 🤖 Assistant`, ``, textBlocks, ``, `---`, ``); lines.push(`## 🤖 Assistant`, ``, textBlocks, ``, `---`, ``);
} }

View file

@ -1,6 +1,6 @@
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 {
@ -13,12 +13,18 @@ export class JaeSessionSidebar extends LitElement {
@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[]) {
@ -43,7 +49,10 @@ export class JaeSessionSidebar extends LitElement {
} 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);
} }
} }
@ -58,9 +67,11 @@ export class JaeSessionSidebar extends LitElement {
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
.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.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 sorted = [...pinned, ...rest]; const sorted = [...pinned, ...rest];
@ -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">

View file

@ -1,11 +1,11 @@
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;
@ -16,14 +16,16 @@ export class JaeTerminalPanel extends LitElement {
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() {
@ -35,7 +37,8 @@ export class JaeTerminalPanel extends LitElement {
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() {
@ -44,17 +47,20 @@ export class JaeTerminalPanel extends LitElement {
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);
@ -62,50 +68,62 @@ export class JaeTerminalPanel extends LitElement {
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.connecting = false;
this.requestUpdate(); this.requestUpdate();
}; };
this.ws.onclose = () => { this.ws.onclose = () => {
this.connected = false; this.connecting = false; this.connected = false;
this.term?.write('\r\n\x1b[31m[disconnected]\x1b[0m\r\n'); this.connecting = false;
this.term?.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
this.requestUpdate(); this.requestUpdate();
}; };
this.ws.onerror = () => { this.ws.onerror = () => {
this.connecting = false; this.connected = false; this.connecting = false;
this.term?.write('\r\n\x1b[31m[connection error - is terminal server running?]\x1b[0m\r\n'); this.connected = false;
this.term?.write("\r\n\x1b[31m[connection error - is terminal server running?]\x1b[0m\r\n");
this.requestUpdate(); this.requestUpdate();
}; };
this.ws.onmessage = (e) => { this.ws.onmessage = (e) => {
const m = JSON.parse(e.data); const m = JSON.parse(e.data);
if (m.type === 'data') this.term?.write(m.data); if (m.type === "data") this.term?.write(m.data);
if (m.type === 'exit') this.term?.write(`\r\n\x1b[33m[process exited: ${m.code}]\x1b[0m\r\n`); 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.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 ${
!this.connected && !this.connecting
? html`<button class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:opacity-90" @click=${() => this.connect()}>Connect</button>` ? html`<button class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:opacity-90" @click=${() => this.connect()}>Connect</button>`
: html``} : 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>

View file

@ -1,4 +1,4 @@
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 {
@ -19,19 +19,28 @@ export class JaeUtilityToggle extends LitElement {
@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(
new CustomEvent("visibility-change", {
detail: this.visibility, detail: this.visibility,
bubbles: true, bubbles: true,
composed: 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: "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: "showThinking", label: "Thinking", icon: "🧠", desc: "Show model reasoning / thinking blocks" },
{ key: "showSystemMessages", label: "System Messages", icon: "⚙️", desc: "Show system notifications and prompts" }, { key: "showSystemMessages", label: "System Messages", icon: "⚙️", desc: "Show system notifications and prompts" },
{ key: "showTimestamps", label: "Timestamps", icon: "🕐", desc: "Show message timestamps" }, { key: "showTimestamps", label: "Timestamps", icon: "🕐", desc: "Show message timestamps" },
@ -44,7 +53,10 @@ export class JaeUtilityToggle extends LitElement {
<!-- 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,8 +116,13 @@ 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

@ -21,31 +21,31 @@ import {
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();
@ -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,7 +95,9 @@ 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;
@ -132,9 +134,21 @@ const refreshSidebar = async () => {
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();
return;
}
if (meta && e.key === "e") {
e.preventDefault();
handleExport();
return;
}
if (meta && e.key === "n") {
e.preventDefault();
newSession();
return;
}
if (e.key === "?" && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) { if (e.key === "?" && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
keyboardShortcuts.toggle(); keyboardShortcuts.toggle();
} }
@ -142,14 +156,72 @@ window.addEventListener("keydown", (e: KeyboardEvent) => {
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")),
},
]); ]);
} }
@ -166,8 +238,12 @@ const generateTitle = (messages: AgentMessage[]): string => {
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;
} else {
const textBlocks = content.filter((c: any) => c.type === "text");
text = textBlocks.map((c: any) => c.text || "").join(" ");
}
text = text.trim(); text = text.trim();
if (!text) return ""; if (!text) return "";
const sentenceEnd = text.search(/[.!?]/); const sentenceEnd = text.search(/[.!?]/);
@ -187,21 +263,37 @@ const saveSession = async () => {
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,
messages: state.messages,
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
}; };
const metadata = { const metadata = {
id: currentSessionId, title: currentTitle, id: currentSessionId,
createdAt: sessionData.createdAt, lastModified: sessionData.lastModified, title: currentTitle,
createdAt: sessionData.createdAt,
lastModified: sessionData.lastModified,
messageCount: state.messages.length, 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 } }, usage: {
modelId: state.model?.id || null, thinkingLevel: state.thinkingLevel, 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), preview: generateTitle(state.messages),
}; };
await storage.sessions.save(sessionData, metadata); await storage.sessions.save(sessionData, metadata);
await refreshSidebar(); await refreshSidebar();
} catch (err) { console.error("Failed to save session:", err); } } catch (err) {
console.error("Failed to save session:", err);
}
}; };
const updateUrl = (sessionId: string) => { const updateUrl = (sessionId: string) => {
@ -214,7 +306,8 @@ 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:
"You are JAE, a helpful AI assistant and coding agent with access to tools including web search, image generation, JavaScript REPL, text-to-speech, and artifact creation. Use these tools whenever helpful.",
model: getModel("venice", "llama-3.3-70b"), model: getModel("venice", "llama-3.3-70b"),
thinkingLevel: "off", thinkingLevel: "off",
messages: [], messages: [],
@ -262,8 +355,10 @@ const loadSession = async (sessionId: string): Promise<boolean> => {
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,
tools: [],
}); });
sidebar.currentSessionId = currentSessionId; sidebar.currentSessionId = currentSessionId;
updateUrl(sessionId); updateUrl(sessionId);
@ -287,13 +382,15 @@ const handleSuggestion = (e: Event) => {
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) ||
(document.querySelector("textarea") as HTMLTextAreaElement);
if (ta) ta.focus(); if (ta) ta.focus();
}); });
} else { } else {
const ta = document.querySelector("message-editor textarea") as HTMLTextAreaElement const ta =
|| document.querySelector("textarea") as HTMLTextAreaElement; (document.querySelector("message-editor textarea") as HTMLTextAreaElement) ||
(document.querySelector("textarea") as HTMLTextAreaElement);
if (ta) { if (ta) {
ta.value = text; ta.value = text;
ta.dispatchEvent(new Event("input", { bubbles: true })); ta.dispatchEvent(new Event("input", { bubbles: true }));
@ -311,18 +408,78 @@ 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">
<img src="/mascot/jae-default.png" alt="JAE" class="w-7 h-auto header-logo cursor-pointer" />
<span class="text-base font-semibold text-foreground">JAE</span>
${getModelLabel() ? html`<span class="ml-1 text-[11px] text-muted-foreground bg-muted/80 px-1.5 py-0.5 rounded font-mono truncate max-w-[180px] cursor-pointer hover:bg-muted" title="${getModelLabel()}">${getModelLabel()}</span>` : html``}
</div>
${
currentTitle
? isEditingTitle ? 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`<div class="flex items-center gap-2">${Input({
: 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>` type: "text",
: 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>` 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">
@ -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,60 +523,121 @@ ${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(
html`<div class="w-full h-screen flex items-center justify-center bg-background text-foreground"><div class="text-muted-foreground">Loading...</div></div>`,
app,
);
chatPanel = new ChatPanel(); chatPanel = new ChatPanel();
setupCommands(); setupCommands();
await refreshSidebar(); await refreshSidebar();
const sessionIdFromUrl = new URLSearchParams(window.location.search).get("session"); const sessionIdFromUrl = new URLSearchParams(window.location.search).get("session");
if (sessionIdFromUrl) { if (sessionIdFromUrl) {
const loaded = await loadSession(sessionIdFromUrl); const loaded = await loadSession(sessionIdFromUrl);
if (!loaded) { newSession(); return; } if (!loaded) {
newSession();
return;
}
} else { } else {
await createAgent(); await createAgent();
} }

View file

@ -1,9 +1,8 @@
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",
@ -15,7 +14,7 @@ const TAG_COLORS: Record<string, string> = {
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",
}; };
@ -40,7 +39,9 @@ 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";
@ -51,7 +52,9 @@ export class VeniceModelBrowser extends LitElement {
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: [] };
@ -78,21 +81,31 @@ export class VeniceModelBrowser extends LitElement {
<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 ${
this.filter === f.id
? "bg-primary text-primary-foreground border-primary" ? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:bg-secondary"}" : "border-border text-muted-foreground hover:bg-secondary"
@click=${() => { this.filter = f.id; this.requestUpdate(); }} }"
@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>
@ -104,7 +117,8 @@ export class VeniceModelBrowser extends LitElement {
<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,17 +128,22 @@ 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,7 +1,6 @@
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";
@ -31,10 +30,13 @@ function computeLineDiff(original: string, modified: string): Array<{ type: "add
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] });
} else if (oldLines[i] === newLines[i]) {
result.push({ type: "same", line: oldLines[i] });
} else {
result.push({ type: "remove", line: oldLines[i] }); result.push({ type: "remove", line: oldLines[i] });
result.push({ type: "add", line: newLines[i] }); result.push({ type: "add", line: newLines[i] });
} }
@ -63,26 +65,30 @@ class DiffRenderer implements ToolRenderer<DiffParams, DiffDetails> {
} }
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>
`, `,

View file

@ -1,10 +1,10 @@
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({
@ -42,7 +42,12 @@ export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenDetails> = {
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: [
{
type: "text",
text: "Error: Venice API key not set. Add it in Settings > Providers & Models > Venice.",
},
],
details: { model, prompt, width, height, error: "No API key" }, details: { model, prompt, width, height, error: "No API key" },
}; };
} }
@ -59,7 +64,7 @@ export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenDetails> = {
details: { model, prompt, width, height, error: err }, details: { model, prompt, width, height, error: err },
}; };
} }
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 {
@ -76,7 +81,10 @@ export const imageGenTool: AgentTool<typeof imageGenSchema, ImageGenDetails> = {
}; };
class ImageGenRenderer implements ToolRenderer<ImageGenParams, ImageGenDetails> { class ImageGenRenderer implements ToolRenderer<ImageGenParams, ImageGenDetails> {
render(params: ImageGenParams | undefined, result: ToolResultMessage<ImageGenDetails> | undefined): ToolRenderResult { render(
params: ImageGenParams | undefined,
result: ToolResultMessage<ImageGenDetails> | undefined,
): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (result?.details?.dataUrl) { if (result?.details?.dataUrl) {
const d = result.details; const d = result.details;
@ -97,7 +105,10 @@ class ImageGenRenderer implements ToolRenderer<ImageGenParams, ImageGenDetails>
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,
};
} }
} }

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,7 +1,6 @@
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";
@ -31,7 +30,10 @@ async function openDB(): Promise<IDBDatabase> {
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 = () => {
db = req.result;
resolve(db);
};
req.onerror = () => reject(req.error); req.onerror = () => reject(req.error);
}); });
} }
@ -60,7 +62,7 @@ export async function memoryLoad(): Promise<MemoryEntry[]> {
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> {
@ -105,9 +107,10 @@ export const recallMemoryTool: AgentTool<typeof recallMemorySchema, { results: M
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 =
results.length === 0
? `No memories found for: ${params.query}` ? `No memories found for: ${params.query}`
: results.map(r => `[${r.timestamp.slice(0, 10)}] ${r.content}`).join("\n\n"); : results.map((r) => `[${r.timestamp.slice(0, 10)}] ${r.content}`).join("\n\n");
return { return {
content: [{ type: "text", text }], content: [{ type: "text", text }],
details: { results }, details: { results },
@ -139,16 +142,22 @@ class RecallMemoryRenderer implements ToolRenderer {
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,

View file

@ -1,7 +1,6 @@
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";
@ -44,7 +43,10 @@ async function loadMermaid(): Promise<any> {
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({
startOnLoad: false,
theme: document.documentElement.classList.contains("dark") ? "dark" : "default",
});
mermaidLoaded = true; mermaidLoaded = true;
resolve(m); resolve(m);
}; };

View file

@ -1,16 +1,20 @@
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")
.map((c: any) => c.text)
.join("\n")
: typeof rawContent === "string"
? rawContent
: "";
const diff = params?.diff || params?.patch || resultText || ""; const diff = params?.diff || params?.patch || resultText || "";
const filename = params?.file || params?.filename || ""; const filename = params?.file || params?.filename || "";
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
@ -22,20 +26,27 @@ export class DiffRenderer implements ToolRenderer {
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("-") && !line.startsWith("---"))
cls = "text-red-500 bg-red-500/10 block px-1";
else if (line.startsWith("@@")) cls = "text-blue-400 block px-1"; else if (line.startsWith("@@")) cls = "text-blue-400 block px-1";
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "text-muted-foreground block px-1"; else if (line.startsWith("diff ") || line.startsWith("index "))
cls = "text-muted-foreground block px-1";
else cls = "block px-1"; else cls = "block px-1";
return html`<span class=${cls}>${line}</span> return html`<span class=${cls}>${line}</span>
`; `;
})}</pre> })}</pre>
</div> </div>
` : ""} `
: ""
}
</div> </div>
`, `,
isCustom: false, isCustom: false,

View file

@ -1,16 +1,20 @@
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")
.map((c: any) => c.text)
.join("\n")
: typeof rawContent === "string"
? rawContent
: "";
const diagram = params?.diagram || params?.code || resultText || ""; const diagram = params?.diagram || params?.code || resultText || "";
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
const id = "mermaid-" + Math.random().toString(36).slice(2); const id = "mermaid-" + Math.random().toString(36).slice(2);
@ -19,7 +23,9 @@ export class MermaidRenderer implements ToolRenderer {
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,7 +47,9 @@ export class MermaidRenderer implements ToolRenderer {
})(); })();
</script> </script>
</div> </div>
` : ""} `
: ""
}
</div> </div>
`, `,
isCustom: false, isCustom: false,

View file

@ -1,10 +1,10 @@
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({
@ -79,7 +79,10 @@ class TTSRenderer implements ToolRenderer<TTSParams, TTSDetails> {
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,
};
} }
} }

View file

@ -1,6 +1,6 @@
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";
@ -30,25 +30,28 @@ interface WebSearchParams {
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(
`https://api.duckduckgo.com/?q=${encoded}&format=json&no_redirect=1&no_html=1&skip_disambig=1`,
);
if (!res.ok) throw new Error(`Search returned ${res.status}`); if (!res.ok) throw new Error(`Search returned ${res.status}`);
const data = await res.json() as any; const data = (await res.json()) as any;
const results: WebSearchResult[] = []; const results: WebSearchResult[] = [];
if (data.AbstractText && data.AbstractURL) { if (data.AbstractText && data.AbstractURL) {
results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText }); results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText });
} }
for (const topic of (data.RelatedTopics || [])) { for (const topic of data.RelatedTopics || []) {
if (results.length >= limit) break; if (results.length >= limit) break;
if (topic.FirstURL && topic.Text) { if (topic.FirstURL && topic.Text) {
results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text }); results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text });
} else if (topic.Topics) { } else if (topic.Topics) {
for (const sub of topic.Topics) { for (const sub of topic.Topics) {
if (results.length >= limit) break; if (results.length >= limit) break;
if (sub.FirstURL && sub.Text) results.push({ title: sub.Text.split(" - ")[0], url: sub.FirstURL, snippet: sub.Text }); if (sub.FirstURL && sub.Text)
results.push({ title: sub.Text.split(" - ")[0], url: sub.FirstURL, snippet: sub.Text });
} }
} }
} }
for (const r of (data.Results || [])) { for (const r of data.Results || []) {
if (results.length >= limit) break; if (results.length >= limit) break;
if (r.FirstURL && r.Text) results.push({ title: r.Title || r.Text, url: r.FirstURL, snippet: r.Text }); if (r.FirstURL && r.Text) results.push({ title: r.Title || r.Text, url: r.FirstURL, snippet: r.Text });
} }
@ -68,13 +71,19 @@ export const webSearchTool: AgentTool<typeof webSearchSchema, WebSearchDetails>
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(
params: WebSearchParams | undefined,
result: ToolResultMessage<WebSearchDetails> | undefined,
): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress"; const state = result ? (result.isError ? "error" : "complete") : "inprogress";
if (result?.details?.results?.length) { if (result?.details?.results?.length) {
const details = result.details; const details = result.details;
@ -83,13 +92,15 @@ class WebSearchRenderer implements ToolRenderer<WebSearchParams, WebSearchDetail
<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,