From 29574c7c86613b45d1f815c308a2b50276f6f37d Mon Sep 17 00:00:00 2001 From: JAE Date: Wed, 25 Mar 2026 18:32:10 +0000 Subject: [PATCH] feat: add web-search, image-gen, voice-tts tools to web-ui - web-search: DuckDuckGo search with inline result cards - image-gen: Venice AI image generation with inline preview + download - voice-tts: Venice AI TTS with inline audio player - All use correct ToolRenderer class pattern matching jae-web-ui API --- packages/web-ui/src/tools/image-gen.ts | 108 ++++++++++++++++++++++++ packages/web-ui/src/tools/voice-tts.ts | 90 ++++++++++++++++++++ packages/web-ui/src/tools/web-search.ts | 106 +++++++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 packages/web-ui/src/tools/image-gen.ts create mode 100644 packages/web-ui/src/tools/voice-tts.ts create mode 100644 packages/web-ui/src/tools/web-search.ts diff --git a/packages/web-ui/src/tools/image-gen.ts b/packages/web-ui/src/tools/image-gen.ts new file mode 100644 index 0000000..8b6be40 --- /dev/null +++ b/packages/web-ui/src/tools/image-gen.ts @@ -0,0 +1,108 @@ +import type { AgentTool } from "@jaeswift/jae-agent-core"; +import { Type } from "@sinclair/typebox"; +import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { html } from "lit"; +import { Image } from "lucide"; +import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; +import { getAppStorage } from "../storage/app-storage.js"; +import type { ToolRenderer, ToolRenderResult } from "./types.js"; + +const imageGenSchema = Type.Object({ + prompt: Type.String({ description: "Image generation prompt describing what to create" }), + model: Type.Optional(Type.String({ description: "Venice image model (default: fluently-xl)" })), + width: Type.Optional(Type.Number({ description: "Width in pixels (default: 1024)" })), + height: Type.Optional(Type.Number({ description: "Height in pixels (default: 1024)" })), + steps: Type.Optional(Type.Number({ description: "Inference steps (default: 20)" })), +}); + +export interface ImageGenDetails { + dataUrl?: string; + model: string; + prompt: string; + width: number; + height: number; + error?: string; +} + +interface ImageGenParams { + prompt: string; + model?: string; + width?: number; + height?: number; + steps?: number; +} + +export const imageGenTool: AgentTool = { + name: "generate_image", + label: "Generate Image", + description: "Generate an image using Venice AI image models. Displays inline in chat.", + parameters: imageGenSchema, + async execute(toolCallId, params, signal) { + const { prompt, model = "fluently-xl", width = 1024, height = 1024, steps = 20 } = params; + const apiKey = await getAppStorage().providerKeys.get("venice"); + if (!apiKey) { + return { + content: [{ type: "text", text: "Error: Venice API key not set. Add it in Settings > Providers & Models > Venice." }], + details: { model, prompt, width, height, error: "No API key" }, + }; + } + const res = await fetch("https://api.venice.ai/api/v1/image/generate", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ model, prompt, width, height, steps, return_binary: false, safe_mode: false }), + signal: signal ?? AbortSignal.timeout(60000), + }); + if (!res.ok) { + const err = await res.text(); + return { + content: [{ type: "text", text: `Image generation failed (${res.status}): ${err}` }], + details: { model, prompt, width, height, error: err }, + }; + } + const data = await res.json() as any; + const b64 = data?.images?.[0]; + if (!b64) { + return { + content: [{ type: "text", text: "No image returned from Venice API." }], + details: { model, prompt, width, height, error: "No image data" }, + }; + } + const dataUrl = `data:image/png;base64,${b64}`; + return { + content: [{ type: "text", text: `Image generated successfully. Model: ${model}, Size: ${width}x${height}` }], + details: { dataUrl, model, prompt, width, height }, + }; + }, +}; + +class ImageGenRenderer implements ToolRenderer { + render(params: ImageGenParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { + const state = result ? (result.isError ? "error" : "complete") : "inprogress"; + if (result?.details?.dataUrl) { + const d = result.details; + return { + content: html` +
+ ${renderHeader(state, Image, "Image Generated")} + ${d.prompt} +
+ ${d.model} · ${d.width}×${d.height} + ⬇ Download +
+
${d.prompt}
+
`, + isCustom: false, + }; + } + return { content: renderHeader(state, Image, `Generating image: ${params?.prompt?.slice(0, 50) ?? "..."}`), isCustom: false }; + } +} + +registerToolRenderer("generate_image", new ImageGenRenderer()); + +export function createImageGenTool(): AgentTool { + return imageGenTool; +} diff --git a/packages/web-ui/src/tools/voice-tts.ts b/packages/web-ui/src/tools/voice-tts.ts new file mode 100644 index 0000000..3102b8a --- /dev/null +++ b/packages/web-ui/src/tools/voice-tts.ts @@ -0,0 +1,90 @@ +import type { AgentTool } from "@jaeswift/jae-agent-core"; +import { Type } from "@sinclair/typebox"; +import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { html } from "lit"; +import { Volume2 } from "lucide"; +import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; +import { getAppStorage } from "../storage/app-storage.js"; +import type { ToolRenderer, ToolRenderResult } from "./types.js"; + +const ttsSchema = Type.Object({ + text: Type.String({ description: "Text to convert to speech" }), + model: Type.Optional(Type.String({ description: "Venice TTS model (default: tts-kokoro)" })), + voice: Type.Optional(Type.String({ description: "Voice ID (default: af_heart)" })), +}); + +export interface TTSDetails { + audioUrl?: string; + model: string; + voice: string; + text: string; + error?: string; +} + +interface TTSParams { + text: string; + model?: string; + voice?: string; +} + +export const ttsTool: AgentTool = { + name: "text_to_speech", + label: "Text to Speech", + description: "Convert text to speech using Venice AI TTS. Audio plays inline in chat.", + parameters: ttsSchema, + async execute(toolCallId, params, signal) { + const { text, model = "tts-kokoro", voice = "af_heart" } = params; + const apiKey = await getAppStorage().providerKeys.get("venice"); + if (!apiKey) { + return { + content: [{ type: "text", text: "Error: Venice API key not set." }], + details: { model, voice, text, error: "No API key" }, + }; + } + const res = await fetch("https://api.venice.ai/api/v1/audio/speech", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ model, input: text, voice }), + signal: signal ?? AbortSignal.timeout(30000), + }); + if (!res.ok) { + const err = await res.text(); + return { + content: [{ type: "text", text: `TTS failed (${res.status}): ${err}` }], + details: { model, voice, text, error: err }, + }; + } + const blob = await res.blob(); + const audioUrl = URL.createObjectURL(blob); + return { + content: [{ type: "text", text: `Speech generated. Model: ${model}, Voice: ${voice}` }], + details: { audioUrl, model, voice, text }, + }; + }, +}; + +class TTSRenderer implements ToolRenderer { + render(params: TTSParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { + const state = result ? (result.isError ? "error" : "complete") : "inprogress"; + if (result?.details?.audioUrl) { + const d = result.details; + return { + content: html` +
+ ${renderHeader(state, Volume2, "Speech Generated")} + +
${d.model} · ${d.voice}
+
${d.text.length > 100 ? d.text.slice(0, 100) + "..." : d.text}
+
`, + isCustom: false, + }; + } + return { content: renderHeader(state, Volume2, `Speaking: ${params?.text?.slice(0, 50) ?? "..."}`), isCustom: false }; + } +} + +registerToolRenderer("text_to_speech", new TTSRenderer()); + +export function createTTSTool(): AgentTool { + return ttsTool; +} diff --git a/packages/web-ui/src/tools/web-search.ts b/packages/web-ui/src/tools/web-search.ts new file mode 100644 index 0000000..32497f9 --- /dev/null +++ b/packages/web-ui/src/tools/web-search.ts @@ -0,0 +1,106 @@ +import type { AgentTool } from "@jaeswift/jae-agent-core"; +import { Type } from "@sinclair/typebox"; +import type { ToolResultMessage } from "@jaeswift/jae-ai"; +import { html } from "lit"; +import { Globe } from "lucide"; +import { registerToolRenderer, renderHeader } from "./renderer-registry.js"; +import type { ToolRenderer, ToolRenderResult } from "./types.js"; + +const webSearchSchema = Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })), +}); + +export interface WebSearchResult { + title: string; + url: string; + snippet: string; +} + +export interface WebSearchDetails { + results: WebSearchResult[]; + query: string; + error?: string; +} + +interface WebSearchParams { + query: string; + limit?: number; +} + +async function fetchDuckDuckGo(query: string, limit: number): Promise { + const encoded = encodeURIComponent(query); + const res = await fetch(`https://api.duckduckgo.com/?q=${encoded}&format=json&no_redirect=1&no_html=1&skip_disambig=1`); + if (!res.ok) throw new Error(`Search returned ${res.status}`); + const data = await res.json() as any; + const results: WebSearchResult[] = []; + if (data.AbstractText && data.AbstractURL) { + results.push({ title: data.Heading || query, url: data.AbstractURL, snippet: data.AbstractText }); + } + for (const topic of (data.RelatedTopics || [])) { + if (results.length >= limit) break; + if (topic.FirstURL && topic.Text) { + results.push({ title: topic.Text.split(" - ")[0], url: topic.FirstURL, snippet: topic.Text }); + } else if (topic.Topics) { + for (const sub of topic.Topics) { + if (results.length >= limit) break; + if (sub.FirstURL && sub.Text) results.push({ title: sub.Text.split(" - ")[0], url: sub.FirstURL, snippet: sub.Text }); + } + } + } + for (const r of (data.Results || [])) { + if (results.length >= limit) break; + if (r.FirstURL && r.Text) results.push({ title: r.Title || r.Text, url: r.FirstURL, snippet: r.Text }); + } + return results.slice(0, limit); +} + +export const webSearchTool: AgentTool = { + name: "web_search", + label: "Web Search", + description: "Search the web for current information using DuckDuckGo.", + parameters: webSearchSchema, + async execute(toolCallId, params, signal) { + const { query, limit = 5 } = params; + try { + const results = await fetchDuckDuckGo(query, limit); + const lines = results.map((r, i) => `[${i + 1}] ${r.title}` + "\n" + r.url + "\n" + r.snippet); + const text = results.length === 0 ? `No results for: ${query}` : lines.join("\n\n"); + return { content: [{ type: "text", text }], details: { results, query } }; + } catch (err: any) { + return { content: [{ type: "text", text: `Search failed: ${err.message}` }], details: { results: [], query, error: err.message } }; + } + }, +}; + +class WebSearchRenderer implements ToolRenderer { + render(params: WebSearchParams | undefined, result: ToolResultMessage | undefined): ToolRenderResult { + const state = result ? (result.isError ? "error" : "complete") : "inprogress"; + if (result?.details?.results?.length) { + const details = result.details; + return { + content: html` +
+ ${renderHeader(state, Globe, `Web Search: ${details.query}`)} +
+ ${details.results.map((r) => html` +
+ ${r.title} + ${r.url} + ${r.snippet} +
+ `)} +
+
`, + isCustom: false, + }; + } + return { content: renderHeader(state, Globe, `Searching: ${params?.query ?? "..."}`), isCustom: false }; + } +} + +registerToolRenderer("web_search", new WebSearchRenderer()); + +export function createWebSearchTool(): AgentTool { + return webSearchTool; +}