diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 35adf84..399e72d 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -126,7 +126,7 @@ export function getEnvApiKey(provider: any): string | undefined { opencode: "OPENCODE_API_KEY", "opencode-go": "OPENCODE_API_KEY", "kimi-coding": "KIMI_API_KEY", - venice: "VENICE_API_KEY", + venice: "VENICE_API_KEY", }; const envVar = envMap[provider]; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 522d691..eac07a0 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -40,7 +40,7 @@ export type KnownProvider = | "opencode" | "opencode-go" | "kimi-coding" -| "venice"; + | "venice"; export type Provider = KnownProvider | string; export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -90,8 +90,8 @@ export interface StreamOptions { * Not supported by all providers (e.g., AWS Bedrock uses SDK auth). */ headers?: Record; - /** Capability tags (e.g. 'tools', 'vision', 'reasoning', 'code', 'image-generation', etc.) */ - tags?: string[]; + /** Capability tags (e.g. 'tools', 'vision', 'reasoning', 'code', 'image-generation', etc.) */ + tags?: string[]; /** * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. * If the server's requested delay exceeds this value, the request fails immediately @@ -331,8 +331,8 @@ export interface Model { contextWindow: number; maxTokens: number; headers?: Record; - /** Capability tags (e.g. 'tools', 'vision', 'reasoning', 'code', 'image-generation', etc.) */ - tags?: string[]; + /** Capability tags (e.g. 'tools', 'vision', 'reasoning', 'code', 'image-generation', etc.) */ + tags?: string[]; /** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ compat?: TApi extends "openai-completions" ? OpenAICompletionsCompat diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 503786f..f5712f1 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -25,10 +25,9 @@ export { antigravityOAuthProvider, loginAntigravity, refreshAntigravityToken } f export { geminiCliOAuthProvider, loginGeminiCli, refreshGoogleCloudToken } from "./google-gemini-cli.js"; // OpenAI Codex (ChatGPT OAuth) export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.js"; -// Venice AI (API Key) -export { loginVenice, veniceOAuthProvider, refreshVeniceToken } from "./venice.js"; - export * from "./types.js"; +// Venice AI (API Key) +export { loginVenice, refreshVeniceToken, veniceOAuthProvider } from "./venice.js"; // ============================================================================ // Provider Registry @@ -39,8 +38,8 @@ import { githubCopilotOAuthProvider } from "./github-copilot.js"; import { antigravityOAuthProvider } from "./google-antigravity.js"; import { geminiCliOAuthProvider } from "./google-gemini-cli.js"; import { openaiCodexOAuthProvider } from "./openai-codex.js"; -import { veniceOAuthProvider } from "./venice.js"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.js"; +import { veniceOAuthProvider } from "./venice.js"; const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ anthropicOAuthProvider, @@ -48,7 +47,7 @@ const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ geminiCliOAuthProvider, antigravityOAuthProvider, openaiCodexOAuthProvider, - veniceOAuthProvider, + veniceOAuthProvider, ]; const oauthProviderRegistry = new Map( diff --git a/packages/ai/src/utils/oauth/venice.ts b/packages/ai/src/utils/oauth/venice.ts index b4c152c..c9f01ff 100644 --- a/packages/ai/src/utils/oauth/venice.ts +++ b/packages/ai/src/utils/oauth/venice.ts @@ -9,46 +9,46 @@ import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } fr * Get your API key at: https://venice.ai/settings/api */ export async function loginVenice(callbacks: OAuthLoginCallbacks): Promise { - callbacks.onProgress?.("To get an API key, visit https://venice.ai/settings/api"); + callbacks.onProgress?.("To get an API key, visit https://venice.ai/settings/api"); - const apiKey = await callbacks.onPrompt({ - message: "Enter your Venice AI API key:", - placeholder: "e.g. your_venice_api_key", - }); + const apiKey = await callbacks.onPrompt({ + message: "Enter your Venice AI API key:", + placeholder: "e.g. your_venice_api_key", + }); - if (!apiKey || !apiKey.trim()) { - throw new Error("No API key provided"); - } + if (!apiKey || !apiKey.trim()) { + throw new Error("No API key provided"); + } - return { - access: apiKey.trim(), - refresh: "", - // API keys don't expire — set a 10-year window so the refresh path is never hit - expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10, - }; + return { + access: apiKey.trim(), + refresh: "", + // API keys don't expire — set a 10-year window so the refresh path is never hit + expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10, + }; } export async function refreshVeniceToken(credentials: OAuthCredentials): Promise { - // API keys don't expire, just bump the expiry forward - return { - ...credentials, - expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10, - }; + // API keys don't expire, just bump the expiry forward + return { + ...credentials, + expires: Date.now() + 1000 * 60 * 60 * 24 * 365 * 10, + }; } export const veniceOAuthProvider: OAuthProviderInterface = { - id: "venice", - name: "Venice AI (API Key)", + id: "venice", + name: "Venice AI (API Key)", - async login(callbacks: OAuthLoginCallbacks): Promise { - return loginVenice(callbacks); - }, + async login(callbacks: OAuthLoginCallbacks): Promise { + return loginVenice(callbacks); + }, - async refreshToken(credentials: OAuthCredentials): Promise { - return refreshVeniceToken(credentials); - }, + async refreshToken(credentials: OAuthCredentials): Promise { + return refreshVeniceToken(credentials); + }, - getApiKey(credentials: OAuthCredentials): string { - return credentials.access; - }, + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, }; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b1e8567..1cb2517 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -15,14 +15,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; -import type { - Agent, - AgentEvent, - AgentMessage, - AgentState, - AgentTool, - ThinkingLevel, -} from "@jaeswift/jae-agent-core"; +import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@jaeswift/jae-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@jaeswift/jae-ai"; import { isContextOverflow, modelsAreEqual, resetApiProviders, supportsXhigh } from "@jaeswift/jae-ai"; import { getDocsPath } from "../config.js"; diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 470e269..e97bbc5 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -6,12 +6,7 @@ * try to refresh tokens simultaneously. */ -import { - getEnvApiKey, - type OAuthCredentials, - type OAuthLoginCallbacks, - type OAuthProviderId, -} from "@jaeswift/jae-ai"; +import { getEnvApiKey, type OAuthCredentials, type OAuthLoginCallbacks, type OAuthProviderId } from "@jaeswift/jae-ai"; import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@jaeswift/jae-ai/oauth"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index b19beab..0c061a8 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -9,12 +9,12 @@ import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { createJiti } from "@mariozechner/jiti"; import * as _bundledPiAgentCore from "@jaeswift/jae-agent-core"; import * as _bundledPiAi from "@jaeswift/jae-ai"; import * as _bundledPiAiOauth from "@jaeswift/jae-ai/oauth"; import type { KeyId } from "@jaeswift/jae-tui"; import * as _bundledPiTui from "@jaeswift/jae-tui"; +import { createJiti } from "@mariozechner/jiti"; // Static imports of packages that extensions may use. // These MUST be static so Bun bundles them into the compiled binary. // The virtualModules option then makes them available to extensions. diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 0fac23e..52af23f 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -8,12 +8,7 @@ * - Interact with the user via UI primitives */ -import type { - AgentMessage, - AgentToolResult, - AgentToolUpdateCallback, - ThinkingLevel, -} from "@jaeswift/jae-agent-core"; +import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@jaeswift/jae-agent-core"; import type { Api, AssistantMessageEvent, diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index a94ee52..fb90eec 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -35,7 +35,7 @@ export const defaultModelPerProvider: Record = { opencode: "claude-opus-4-6", "opencode-go": "kimi-k2.5", "kimi-coding": "kimi-k2-thinking", - venice: "llama-3.3-70b", + venice: "llama-3.3-70b", }; export interface ScopedModel { diff --git a/packages/coding-agent/src/core/tools/browser.ts b/packages/coding-agent/src/core/tools/browser.ts new file mode 100644 index 0000000..7ffc023 --- /dev/null +++ b/packages/coding-agent/src/core/tools/browser.ts @@ -0,0 +1,126 @@ + +import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { AgentTool } from "@jaeswift/jae-agent-core"; +import { type Static, Type } from "@sinclair/typebox"; + +const browserSchema = Type.Object({ + action: Type.Union([ + Type.Literal("navigate"), + Type.Literal("screenshot"), + Type.Literal("click"), + Type.Literal("type"), + Type.Literal("content"), + Type.Literal("close"), + ], { description: "navigate: go to URL | screenshot: capture page | click: click element | type: type into element | content: get page text | close: close browser" }), + url: Type.Optional(Type.String({ description: "URL to navigate to (for navigate action)" })), + selector: Type.Optional(Type.String({ description: "CSS selector (for click/type actions)" })), + text: Type.Optional(Type.String({ description: "Text to type (for type action)" })), + wait: Type.Optional(Type.Number({ description: "Milliseconds to wait after action (default: 1000)" })), +}); + +export type BrowserToolInput = Static; + +export interface BrowserToolDetails { + action: string; + url?: string; + screenshotPath?: string; + error?: string; +} + +let _playwright: any = null; +let _browser: any = null; +let _page: any = null; + +async function getPlaywright() { + if (!_playwright) { + try { + const { chromium } = await import("playwright"); + _playwright = chromium; + } catch { + throw new Error("Playwright not installed. Run: npm install -g playwright && npx playwright install chromium"); + } + } + return _playwright; +} + +async function getPage() { + const pw = await getPlaywright(); + if (!_browser) _browser = await pw.launch({ headless: true }); + if (!_page) _page = await _browser.newPage(); + return _page; +} + +export const browserTool: AgentTool = { + name: "browser", + label: "Browser", + description: "Control a headless Chromium browser. Navigate pages, take screenshots, click elements, type text, and extract page content. Requires playwright.", + parameters: browserSchema, + async execute(toolCallId, params, signal) { + const { action, url, selector, text, wait = 1000 } = params; + + try { + if (action === "close") { + if (_browser) { await _browser.close(); _browser = null; _page = null; } + return { content: [{ type: "text", text: "Browser closed." }], details: { action } }; + } + + const page = await getPage(); + + if (action === "navigate") { + if (!url) return { content: [{ type: "text", text: "Error: url required for navigate" }], details: { action } }; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(wait); + const title = await page.title(); + return { + content: [{ type: "text", text: `Navigated to: ${url}\nPage title: ${title}` }], + details: { action, url }, + }; + } + + if (action === "screenshot") { + const dir = join(tmpdir(), "jae-browser"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const path = join(dir, `screenshot-${Date.now()}.png`); + await page.screenshot({ path, fullPage: false }); + const currentUrl = page.url(); + return { + content: [{ type: "text", text: `Screenshot saved to: ${path}\nCurrent URL: ${currentUrl}` }], + details: { action, url: currentUrl, screenshotPath: path }, + }; + } + + if (action === "content") { + const content = await page.evaluate(() => document.body.innerText); + const truncated = content.slice(0, 8000); + return { + content: [{ type: "text", text: truncated + (content.length > 8000 ? "\n...[truncated]" : "") }], + details: { action, url: page.url() }, + }; + } + + if (action === "click") { + if (!selector) return { content: [{ type: "text", text: "Error: selector required for click" }], details: { action } }; + await page.click(selector, { timeout: 10000 }); + await page.waitForTimeout(wait); + return { content: [{ type: "text", text: `Clicked: ${selector}` }], details: { action } }; + } + + if (action === "type") { + if (!selector) return { content: [{ type: "text", text: "Error: selector required for type" }], details: { action } }; + if (!text) return { content: [{ type: "text", text: "Error: text required for type" }], details: { action } }; + await page.fill(selector, text); + await page.waitForTimeout(wait); + return { content: [{ type: "text", text: `Typed into: ${selector}` }], details: { action } }; + } + + return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: { action } }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Browser error: ${err.message}` }], + details: { action, error: err.message }, + }; + } + }, +}; diff --git a/packages/coding-agent/src/core/tools/image-gen.ts b/packages/coding-agent/src/core/tools/image-gen.ts new file mode 100644 index 0000000..a88872e --- /dev/null +++ b/packages/coding-agent/src/core/tools/image-gen.ts @@ -0,0 +1,96 @@ + +import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { AgentTool } from "@jaeswift/jae-agent-core"; +import { type Static, Type } from "@sinclair/typebox"; + +const imageGenSchema = Type.Object({ + prompt: Type.String({ description: "Image generation prompt" }), + model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })), + width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })), + height: Type.Optional(Type.Number({ description: "Height in pixels (default: 1024)" })), + steps: Type.Optional(Type.Number({ description: "Inference steps (default: 20)" })), + output_dir: Type.Optional(Type.String({ description: "Directory to save image (default: ./images)" })), +}); + +export type ImageGenToolInput = Static; + +export interface ImageGenToolDetails { + path: string; + model: string; + prompt: string; + width: number; + height: number; +} + +export const imageGenTool: AgentTool = { + name: "generate_image", + label: "Generate Image", + description: "Generate an image using Venice AI image models and save it to disk. Uses VENICE_API_KEY or OPENAI_API_KEY env var.", + parameters: imageGenSchema, + async execute(toolCallId, params, signal) { + const { + prompt, + model = "fluently-xl", + width = 1024, + height = 1024, + steps = 20, + output_dir = "./images", + } = params; + + const apiKey = process.env.VENICE_API_KEY || process.env.OPENAI_API_KEY; + if (!apiKey) { + return { + content: [{ type: "text", text: "Error: VENICE_API_KEY or OPENAI_API_KEY environment variable not set." }], + details: { path: "", model, prompt, width, height }, + }; + } + + const body = JSON.stringify({ + model, + prompt, + width, + height, + steps, + return_binary: false, + safe_mode: false, + }); + + const res = await fetch("https://api.venice.ai/api/v1/image/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body, + signal: signal ?? AbortSignal.timeout(60000), + }); + + if (!res.ok) { + const err = await res.text(); + return { + content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }], + details: { path: "", model, prompt, width, height }, + }; + } + + const data = await res.json() as any; + const b64 = data?.images?.[0]; + if (!b64) { + return { + content: [{ type: "text", text: "No image returned from Venice API." }], + details: { path: "", model, prompt, width, height }, + }; + } + + if (!existsSync(output_dir)) mkdirSync(output_dir, { recursive: true }); + const filename = `gen-${Date.now()}.png`; + const filepath = join(output_dir, filename); + writeFileSync(filepath, Buffer.from(b64, "base64")); + + return { + content: [{ type: "text", text: `Image saved to: ${filepath}\nModel: ${model}\nPrompt: ${prompt}\nSize: ${width}x${height}` }], + details: { path: filepath, model, prompt, width, height }, + }; + }, +}; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index c5f38d2..69662e7 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -107,7 +107,13 @@ import { createWriteTool, createWriteToolDefinition, writeTool, writeToolDefinit export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool]; + +import { webSearchTool } from "./web-search.js"; +import { webFetchTool } from "./web-fetch.js"; +import { imageGenTool } from "./image-gen.js"; +import { memoryTool } from "./memory.js"; +import { browserTool } from "./browser.js"; +export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool, webSearchTool, webFetchTool, imageGenTool, memoryTool, browserTool]; export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool]; export const allTools = { @@ -191,3 +197,10 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record text.includes(w)).length / Math.max(words.length, 1); +} + +const memorySchema = Type.Object({ + action: Type.Union([ + Type.Literal("save"), + Type.Literal("recall"), + Type.Literal("list"), + Type.Literal("delete"), + ], { description: "save: store info | recall: search by query | list: show all | delete: remove by id" }), + content: Type.Optional(Type.String({ description: "Content to save (required for save action)" })), + query: Type.Optional(Type.String({ description: "Search query (required for recall action)" })), + tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorisation (optional for save)" })), + id: Type.Optional(Type.String({ description: "Memory ID (required for delete action)" })), + limit: Type.Optional(Type.Number({ description: "Max results to return for recall (default: 5)" })), +}); + +export type MemoryToolInput = Static; + +export interface MemoryToolDetails { + action: string; + count?: number; + id?: string; +} + +export const memoryTool: AgentTool = { + name: "memory", + label: "Memory", + description: "Persistent memory across sessions. Save facts, recall by query, list all memories, or delete by ID. Stored in ~/.jae/memory/.", + parameters: memorySchema, + async execute(toolCallId, params, signal) { + const { action, content, query, tags = [], id, limit = 5 } = params; + const memories = loadMemories(); + + if (action === "save") { + if (!content) return { content: [{ type: "text", text: "Error: content is required for save action" }], details: { action } }; + const entry: MemoryEntry = { + id: `mem-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + content, + tags, + createdAt: new Date().toISOString(), + }; + memories.push(entry); + saveMemories(memories); + return { content: [{ type: "text", text: `Memory saved. ID: ${entry.id}` }], details: { action, id: entry.id } }; + } + + if (action === "recall") { + if (!query) return { content: [{ type: "text", text: "Error: query is required for recall action" }], details: { action } }; + const scored = memories + .map(m => ({ m, score: scoreMatch(m, query) })) + .filter(x => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(x => x.m); + const text = scored.length === 0 + ? `No memories found for: ${query}` + : scored.map(m => `[${m.id}] ${m.content}${m.tags.length ? ` (tags: ${m.tags.join(", ")})` : ""}`).join("\n\n"); + return { content: [{ type: "text", text }], details: { action, count: scored.length } }; + } + + if (action === "list") { + const text = memories.length === 0 + ? "No memories stored." + : memories.map(m => `[${m.id}] ${m.content.slice(0, 100)}${m.content.length > 100 ? "..." : ""}`).join("\n"); + return { content: [{ type: "text", text }], details: { action, count: memories.length } }; + } + + if (action === "delete") { + if (!id) return { content: [{ type: "text", text: "Error: id is required for delete action" }], details: { action } }; + const filtered = memories.filter(m => m.id !== id); + if (filtered.length === memories.length) return { content: [{ type: "text", text: `No memory found with ID: ${id}` }], details: { action } }; + saveMemories(filtered); + return { content: [{ type: "text", text: `Memory ${id} deleted.` }], details: { action, id } }; + } + + return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: { action } }; + }, +}; diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts new file mode 100644 index 0000000..91154fb --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -0,0 +1,67 @@ + +import type { AgentTool } from "@jaeswift/jae-agent-core"; +import { type Static, Type } from "@sinclair/typebox"; + +const webFetchSchema = Type.Object({ + url: Type.String({ description: "URL to fetch" }), + selector: Type.Optional(Type.String({ description: "CSS selector to extract specific content (optional)" })), +}); + +export type WebFetchToolInput = Static; + +export interface WebFetchToolDetails { + url: string; + status: number; + contentType: string; + truncated: boolean; +} + +const MAX_CHARS = 20000; + +function htmlToText(html: string): string { + return html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") + .replace(/"/g, '"').replace(/ /g, " ").replace(/'/g, "'") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export const webFetchTool: AgentTool = { + name: "web_fetch", + label: "Web Fetch", + description: "Fetch and read the text content of any web page or URL. Strips HTML to plain text.", + parameters: webFetchSchema, + async execute(toolCallId, params, signal) { + const { url } = params; + try { + const res = await fetch(url, { + headers: { "User-Agent": "JAE-Agent/1.0", "Accept": "text/html,application/xhtml+xml,text/plain,*/*" }, + signal: signal ?? AbortSignal.timeout(15000), + redirect: "follow", + }); + const contentType = res.headers.get("content-type") || ""; + let body = await res.text(); + let text: string; + if (contentType.includes("html")) { + text = htmlToText(body); + } else { + text = body; + } + const truncated = text.length > MAX_CHARS; + if (truncated) text = text.slice(0, MAX_CHARS) + "\n... [truncated]"; + return { + content: [{ type: "text", text: `URL: ${url}\nStatus: ${res.status}\n\n${text}` }], + details: { url, status: res.status, contentType, truncated }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Fetch failed for ${url}: ${err.message}` }], + details: { url, status: 0, contentType: "", truncated: false }, + }; + } + }, +}; diff --git a/packages/coding-agent/src/core/tools/web-search.ts b/packages/coding-agent/src/core/tools/web-search.ts new file mode 100644 index 0000000..d254d0b --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-search.ts @@ -0,0 +1,99 @@ + +import type { AgentTool } from "@jaeswift/jae-agent-core"; +import { Text } from "@jaeswift/jae-tui"; +import { type Static, Type } from "@sinclair/typebox"; +import { theme } from "../../modes/interactive/theme/theme.js"; +import type { ToolRenderResultOptions } from "../extensions/types.js"; +import { getTextOutput } from "./render-utils.js"; + +const webSearchSchema = Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional(Type.Number({ description: "Max results to return (default: 5)" })), +}); + +export type WebSearchToolInput = Static; + +export interface WebSearchResult { + title: string; + url: string; + snippet: string; +} + +export interface WebSearchToolDetails { + results: WebSearchResult[]; + query: string; + error?: string; +} + +async function fetchDuckDuckGo(query: string, limit: number): Promise { + const encoded = encodeURIComponent(query); + const url = `https://api.duckduckgo.com/?q=${encoded}&format=json&no_redirect=1&no_html=1&skip_disambig=1`; + const res = await fetch(url, { + headers: { "User-Agent": "JAE-Agent/1.0" }, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`DuckDuckGo returned ${res.status}`); + const data = await res.json() as any; + + const results: WebSearchResult[] = []; + + // Instant answer + if (data.AbstractText && data.AbstractURL) { + results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText }); + } + + // Related topics + for (const topic of (data.RelatedTopics || [])) { + if (results.length >= limit) break; + if (topic.FirstURL && topic.Text) { + results.push({ title: topic.Text.split(" - ")[0] || topic.Text, url: topic.FirstURL, snippet: topic.Text }); + } else if (topic.Topics) { + for (const sub of topic.Topics) { + if (results.length >= limit) break; + if (sub.FirstURL && sub.Text) { + results.push({ title: sub.Text.split(" - ")[0] || sub.Text, url: sub.FirstURL, snippet: sub.Text }); + } + } + } + } + + // Results section + for (const r of (data.Results || [])) { + if (results.length >= limit) break; + if (r.FirstURL && r.Text) { + results.push({ title: r.Title || r.Text, url: r.FirstURL, snippet: r.Text }); + } + } + + return results.slice(0, limit); +} + +export const webSearchTool: AgentTool = { + name: "web_search", + label: "Web Search", + description: "Search the web for current information using DuckDuckGo. Returns titles, URLs, and snippets.", + parameters: webSearchSchema, + async execute(toolCallId, params, signal) { + const { query, limit = 5 } = params; + try { + const results = await fetchDuckDuckGo(query, limit); + const text = results.length === 0 + ? `No results found for: ${query}` + : results.map((r, i) => `[${i + 1}] ${r.title} + URL: ${r.url} + ${r.snippet}`).join(" + +"); + return { + content: [{ type: "text", text }], + details: { results, query }, + }; + } catch (err: any) { + const msg = `Web search failed: ${err.message}`; + return { + content: [{ type: "text", text: msg }], + details: { results: [], query, error: err.message }, + }; + } + }, +}; diff --git a/packages/web-ui/example/src/custom-messages.ts b/packages/web-ui/example/src/custom-messages.ts index 1e06242..11dff20 100644 --- a/packages/web-ui/example/src/custom-messages.ts +++ b/packages/web-ui/example/src/custom-messages.ts @@ -1,7 +1,7 @@ -import { Alert } from "@mariozechner/mini-lit/dist/Alert.js"; import type { Message } from "@jaeswift/jae-ai"; import type { AgentMessage, MessageRenderer } from "@jaeswift/jae-web-ui"; import { defaultConvertToLlm, registerMessageRenderer } from "@jaeswift/jae-web-ui"; +import { Alert } from "@mariozechner/mini-lit/dist/Alert.js"; import { html } from "lit"; // ============================================================================ diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 3fe7d8d..f1d6cdc 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -210,7 +210,7 @@ Feel free to use these tools when needed to provide accurate and helpful respons // Create javascript_repl tool with access to attachments + artifacts const replTool = createJavaScriptReplTool(); replTool.runtimeProvidersFactory = runtimeProvidersFactory; - return [replTool]; + return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()]; }, }); }; diff --git a/packages/web-ui/src/components/MessageEditor.ts b/packages/web-ui/src/components/MessageEditor.ts index d704d0b..f37b43b 100644 --- a/packages/web-ui/src/components/MessageEditor.ts +++ b/packages/web-ui/src/components/MessageEditor.ts @@ -1,7 +1,7 @@ +import type { Model } from "@jaeswift/jae-ai"; import { icon } from "@mariozechner/mini-lit"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { Select, type SelectOption } from "@mariozechner/mini-lit/dist/Select.js"; -import type { Model } from "@jaeswift/jae-ai"; import { html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref } from "lit/directives/ref.js"; diff --git a/packages/web-ui/src/components/ProviderKeyInput.ts b/packages/web-ui/src/components/ProviderKeyInput.ts index 0e5c643..1d9b322 100644 --- a/packages/web-ui/src/components/ProviderKeyInput.ts +++ b/packages/web-ui/src/components/ProviderKeyInput.ts @@ -1,7 +1,7 @@ +import { type Context, complete, getModel } from "@jaeswift/jae-ai"; import { i18n } from "@mariozechner/mini-lit"; import { Badge } from "@mariozechner/mini-lit/dist/Badge.js"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { type Context, complete, getModel } from "@jaeswift/jae-ai"; import { html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { getAppStorage } from "../storage/app-storage.js"; diff --git a/packages/web-ui/src/components/VeniceModelBrowser.ts b/packages/web-ui/src/components/VeniceModelBrowser.ts new file mode 100644 index 0000000..0491856 --- /dev/null +++ b/packages/web-ui/src/components/VeniceModelBrowser.ts @@ -0,0 +1,131 @@ + +import { getModels } from "@jaeswift/jae-ai"; +import { html, LitElement, type TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { Badge } from "@mariozechner/mini-lit/dist/Badge.js"; +import { Button } from "@mariozechner/mini-lit/dist/Button.js"; + +const TAG_COLORS: Record = { + tools: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + vision: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300", + reasoning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + code: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + "image-generation": "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300", + "video-generation": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", + tts: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300", + asr: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300", + embedding: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", + "e2ee": "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300", + "web-search": "bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300", +}; + +const CATEGORY_LABELS: Record = { + text: "💬 Text & Chat", + image: "🖼️ Image Generation", + video: "🎬 Video Generation", + audio: "🔊 Audio (TTS / ASR)", + other: "🔧 Other", +}; + +function categoriseModel(tags: string[] = []): string { + if (tags.includes("image-generation") || tags.includes("inpainting")) return "image"; + if (tags.includes("video-generation")) return "video"; + if (tags.includes("tts") || tags.includes("asr")) return "audio"; + if (tags.includes("embedding") || tags.includes("upscaling")) return "other"; + return "text"; +} + +@customElement("venice-model-browser") +export class VeniceModelBrowser extends LitElement { + @state() private filter: string = "all"; + @state() private search: string = ""; + + protected createRenderRoot() { return this; } + + private renderTag(tag: string): TemplateResult { + const cls = TAG_COLORS[tag] ?? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"; + return html`${tag}`; + } + + render(): TemplateResult { + let models: any[] = []; + try { + models = (getModels("venice" as any) as any[]) || []; + } catch { models = []; } + + // Group by category + const grouped: Record = { text: [], image: [], video: [], audio: [], other: [] }; + for (const m of models) { + const cat = categoriseModel(m.tags); + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(m); + } + + const filters = [ + { id: "all", label: `All (${models.length})` }, + ...Object.entries(grouped) + .filter(([, ms]) => ms.length > 0) + .map(([cat, ms]) => ({ id: cat, label: `${CATEGORY_LABELS[cat] ?? cat} (${ms.length})` })), + ]; + + const searchLower = this.search.toLowerCase(); + const activeGroups = Object.entries(grouped).filter(([cat, ms]) => { + if (this.filter !== "all" && cat !== this.filter) return false; + return ms.length > 0; + }); + + return html` +
+ +
+ ${filters.map(f => html` + + `)} + { this.search = (e.target as HTMLInputElement).value; this.requestUpdate(); }} + /> +
+ + + ${activeGroups.map(([cat, ms]) => { + const filtered = searchLower ? ms.filter((m: any) => m.id.toLowerCase().includes(searchLower)) : ms; + if (!filtered.length) return html``; + return html` +
+

${CATEGORY_LABELS[cat] ?? cat}

+
+ ${filtered.map((m: any) => html` +
+
+ ${m.id} + ${m.contextWindow ? html`${(m.contextWindow / 1000).toFixed(0)}k ctx` : ""} +
+
+ ${(m.tags ?? []).map((t: string) => this.renderTag(t))} +
+
+ `)} +
+
+ `; + })} + + ${models.length === 0 ? html` +
+ No Venice models found. Ensure jae-ai includes Venice models. +
+ ` : ""} +
+ `; + } +} diff --git a/packages/web-ui/src/dialogs/CustomProviderDialog.ts b/packages/web-ui/src/dialogs/CustomProviderDialog.ts index f238d43..e178073 100644 --- a/packages/web-ui/src/dialogs/CustomProviderDialog.ts +++ b/packages/web-ui/src/dialogs/CustomProviderDialog.ts @@ -1,10 +1,10 @@ +import type { Model } from "@jaeswift/jae-ai"; import { i18n } from "@mariozechner/mini-lit"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js"; import { Input } from "@mariozechner/mini-lit/dist/Input.js"; import { Label } from "@mariozechner/mini-lit/dist/Label.js"; import { Select } from "@mariozechner/mini-lit/dist/Select.js"; -import type { Model } from "@jaeswift/jae-ai"; import { html, type TemplateResult } from "lit"; import { state } from "lit/decorators.js"; import { getAppStorage } from "../storage/app-storage.js"; diff --git a/packages/web-ui/src/dialogs/ModelSelector.ts b/packages/web-ui/src/dialogs/ModelSelector.ts index 6ade92d..6b55c50 100644 --- a/packages/web-ui/src/dialogs/ModelSelector.ts +++ b/packages/web-ui/src/dialogs/ModelSelector.ts @@ -1,9 +1,9 @@ +import { getModels, getProviders, type Model, modelsAreEqual } from "@jaeswift/jae-ai"; import { icon } from "@mariozechner/mini-lit"; import { Badge } from "@mariozechner/mini-lit/dist/Badge.js"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js"; import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js"; -import { getModels, getProviders, type Model, modelsAreEqual } from "@jaeswift/jae-ai"; import { html, type PropertyValues, type TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { createRef, ref } from "lit/directives/ref.js"; diff --git a/packages/web-ui/src/dialogs/ProvidersModelsTab.ts b/packages/web-ui/src/dialogs/ProvidersModelsTab.ts index 1612ce3..369203a 100644 --- a/packages/web-ui/src/dialogs/ProvidersModelsTab.ts +++ b/packages/web-ui/src/dialogs/ProvidersModelsTab.ts @@ -1,9 +1,10 @@ +import { getProviders } from "@jaeswift/jae-ai"; import { i18n } from "@mariozechner/mini-lit"; import { Select } from "@mariozechner/mini-lit/dist/Select.js"; -import { getProviders } from "@jaeswift/jae-ai"; import { html, type TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import "../components/CustomProviderCard.js"; +import "../components/VeniceModelBrowser.js"; import "../components/ProviderKeyInput.js"; import { getAppStorage } from "../storage/app-storage.js"; import type { diff --git a/packages/web-ui/src/dialogs/SettingsDialog.ts b/packages/web-ui/src/dialogs/SettingsDialog.ts index 578551f..e65b33a 100644 --- a/packages/web-ui/src/dialogs/SettingsDialog.ts +++ b/packages/web-ui/src/dialogs/SettingsDialog.ts @@ -1,9 +1,9 @@ +import { getProviders } from "@jaeswift/jae-ai"; import { i18n } from "@mariozechner/mini-lit"; import { Dialog, DialogContent, DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js"; import { Input } from "@mariozechner/mini-lit/dist/Input.js"; import { Label } from "@mariozechner/mini-lit/dist/Label.js"; import { Switch } from "@mariozechner/mini-lit/dist/Switch.js"; -import { getProviders } from "@jaeswift/jae-ai"; import { html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import "../components/ProviderKeyInput.js"; diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index e24f219..136d98a 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -118,3 +118,5 @@ export { clearAuthToken, getAuthToken } from "./utils/auth-token.js"; export { formatCost, formatModelCost, formatTokenCount, formatUsage } from "./utils/format.js"; export { i18n, setLanguage, translations } from "./utils/i18n.js"; export { applyProxyIfNeeded, createStreamFn, isCorsError, shouldUseProxyForProvider } from "./utils/proxy-utils.js"; + +export { VeniceModelBrowser } from "./components/VeniceModelBrowser.js"; diff --git a/packages/web-ui/src/tools/artifacts/artifacts.ts b/packages/web-ui/src/tools/artifacts/artifacts.ts index b2d5e67..9c6cb92 100644 --- a/packages/web-ui/src/tools/artifacts/artifacts.ts +++ b/packages/web-ui/src/tools/artifacts/artifacts.ts @@ -1,8 +1,8 @@ import { icon } from "@mariozechner/mini-lit"; import "@mariozechner/mini-lit/dist/MarkdownBlock.js"; -import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import type { Agent, AgentMessage, AgentTool } from "@jaeswift/jae-agent-core"; import { StringEnum, type ToolCall } from "@jaeswift/jae-ai"; +import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { type Static, Type } from "@sinclair/typebox"; import { html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; diff --git a/packages/web-ui/src/tools/index.ts b/packages/web-ui/src/tools/index.ts index 2b10118..4413f94 100644 --- a/packages/web-ui/src/tools/index.ts +++ b/packages/web-ui/src/tools/index.ts @@ -44,3 +44,7 @@ export function renderTool( } export { getToolRenderer, registerToolRenderer }; + +export { webSearchTool, createWebSearchTool, type WebSearchDetails, type WebSearchResult } from "./web-search.js"; +export { imageGenTool, createImageGenTool, type ImageGenDetails } from "./image-gen.js"; +export { ttsTool, createTTSTool, type TTSDetails } from "./voice-tts.js"; diff --git a/packages/web-ui/src/tools/javascript-repl.ts b/packages/web-ui/src/tools/javascript-repl.ts index 3717248..e32f6f2 100644 --- a/packages/web-ui/src/tools/javascript-repl.ts +++ b/packages/web-ui/src/tools/javascript-repl.ts @@ -1,6 +1,6 @@ -import { i18n } from "@mariozechner/mini-lit"; import type { AgentTool } from "@jaeswift/jae-agent-core"; import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { i18n } from "@mariozechner/mini-lit"; import { type Static, Type } from "@sinclair/typebox"; import { html } from "lit"; import { createRef, ref } from "lit/directives/ref.js"; diff --git a/packages/web-ui/src/utils/format.ts b/packages/web-ui/src/utils/format.ts index fd5bc18..4fecde9 100644 --- a/packages/web-ui/src/utils/format.ts +++ b/packages/web-ui/src/utils/format.ts @@ -1,5 +1,5 @@ -import { i18n } from "@mariozechner/mini-lit"; import type { Usage } from "@jaeswift/jae-ai"; +import { i18n } from "@mariozechner/mini-lit"; export function formatCost(cost: number): string { return `$${cost.toFixed(4)}`; diff --git a/packages/web-ui/src/utils/model-discovery.ts b/packages/web-ui/src/utils/model-discovery.ts index ad7cdbb..5594dd9 100644 --- a/packages/web-ui/src/utils/model-discovery.ts +++ b/packages/web-ui/src/utils/model-discovery.ts @@ -1,5 +1,5 @@ -import { LMStudioClient } from "@lmstudio/sdk"; import type { Model } from "@jaeswift/jae-ai"; +import { LMStudioClient } from "@lmstudio/sdk"; import { Ollama } from "ollama/browser"; /**