diff --git a/apps/web/app/api/apps/cron/route.ts b/apps/web/app/api/apps/cron/route.ts new file mode 100644 index 00000000000..5a26f5ab45f --- /dev/null +++ b/apps/web/app/api/apps/cron/route.ts @@ -0,0 +1,63 @@ +import { callGatewayRpc } from "@/lib/agent-runner"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function POST(req: Request) { + let body: { action?: string; params?: Record }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { action, params } = body; + if (!action || typeof action !== "string") { + return Response.json( + { error: "Missing 'action' field" }, + { status: 400 }, + ); + } + + const ALLOWED_ACTIONS = ["add", "remove", "enable", "disable", "run", "list"]; + if (!ALLOWED_ACTIONS.includes(action)) { + return Response.json( + { error: `Invalid action: ${action}` }, + { status: 400 }, + ); + } + + try { + const result = await callGatewayRpc(`cron.${action}`, params || {}); + if (result.ok) { + return Response.json({ ok: true, payload: result.payload }); + } + return Response.json( + { error: result.error || "RPC failed" }, + { status: 500 }, + ); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Gateway RPC failed" }, + { status: 502 }, + ); + } +} + +export async function GET() { + try { + const result = await callGatewayRpc("cron.list", {}); + if (result.ok) { + return Response.json(result.payload); + } + return Response.json( + { error: result.error || "RPC failed" }, + { status: 500 }, + ); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Gateway RPC failed" }, + { status: 502 }, + ); + } +} diff --git a/apps/web/app/api/apps/proxy/route.ts b/apps/web/app/api/apps/proxy/route.ts new file mode 100644 index 00000000000..39a5472b681 --- /dev/null +++ b/apps/web/app/api/apps/proxy/route.ts @@ -0,0 +1,69 @@ +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const PRIVATE_IP = + /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|localhost|::1|\[::1\])/i; + +export async function POST(req: Request) { + let body: { + url?: string; + method?: string; + headers?: Record; + body?: string; + }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { url } = body; + if (!url || typeof url !== "string") { + return Response.json( + { error: "Missing 'url' field" }, + { status: 400 }, + ); + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return Response.json({ error: "Invalid URL" }, { status: 400 }); + } + + if (PRIVATE_IP.test(parsed.hostname)) { + return Response.json( + { error: "Requests to private/local addresses are not allowed" }, + { status: 403 }, + ); + } + + try { + const resp = await fetch(url, { + method: body.method || "GET", + headers: body.headers || {}, + body: body.method && body.method !== "GET" && body.method !== "HEAD" + ? body.body + : undefined, + }); + + const respBody = await resp.text(); + const respHeaders: Record = {}; + resp.headers.forEach((v, k) => { + respHeaders[k] = v; + }); + + return Response.json({ + status: resp.status, + statusText: resp.statusText, + headers: respHeaders, + body: respBody, + }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Fetch failed" }, + { status: 502 }, + ); + } +} diff --git a/apps/web/app/api/apps/store/route.ts b/apps/web/app/api/apps/store/route.ts new file mode 100644 index 00000000000..b1a865dc24a --- /dev/null +++ b/apps/web/app/api/apps/store/route.ts @@ -0,0 +1,90 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { resolveFilesystemPath } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function storePath(appName: string): string | null { + const wsRoot = resolveFilesystemPath(""); + if (!wsRoot) return null; + return join(wsRoot.absolutePath, ".dench-app-data", appName, "store.json"); +} + +function readStore(appName: string): Record { + const p = storePath(appName); + if (!p || !existsSync(p)) return {}; + try { + return JSON.parse(readFileSync(p, "utf-8")); + } catch { + return {}; + } +} + +function writeStore(appName: string, data: Record): boolean { + const p = storePath(appName); + if (!p) return false; + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify(data, null, 2), "utf-8"); + return true; +} + +export async function GET(req: Request) { + const url = new URL(req.url); + const app = url.searchParams.get("app"); + const key = url.searchParams.get("key"); + + if (!app) { + return Response.json({ error: "Missing 'app' param" }, { status: 400 }); + } + + const store = readStore(app); + + if (key) { + return Response.json({ value: store[key] ?? null }); + } + return Response.json({ keys: Object.keys(store) }); +} + +export async function POST(req: Request) { + let body: { app?: string; key?: string; value?: unknown }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { app, key, value } = body; + if (!app || !key) { + return Response.json( + { error: "Missing 'app' or 'key'" }, + { status: 400 }, + ); + } + + const store = readStore(app); + store[key] = value; + writeStore(app, store); + return Response.json({ ok: true }); +} + +export async function DELETE(req: Request) { + const url = new URL(req.url); + const app = url.searchParams.get("app"); + const key = url.searchParams.get("key"); + + if (!app) { + return Response.json({ error: "Missing 'app' param" }, { status: 400 }); + } + + const store = readStore(app); + + if (key) { + delete store[key]; + } else { + for (const k of Object.keys(store)) delete store[k]; + } + + writeStore(app, store); + return Response.json({ ok: true }); +} diff --git a/apps/web/app/api/apps/webhooks/[...path]/route.ts b/apps/web/app/api/apps/webhooks/[...path]/route.ts new file mode 100644 index 00000000000..4e1980baa2d --- /dev/null +++ b/apps/web/app/api/apps/webhooks/[...path]/route.ts @@ -0,0 +1,82 @@ +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type WebhookEvent = { + method: string; + headers: Record; + body: string; + receivedAt: number; +}; + +const MAX_EVENTS_PER_HOOK = 100; +const webhookStore = new Map(); + +// Survive HMR +const g = globalThis as unknown as { __webhookStore?: Map }; +if (!g.__webhookStore) g.__webhookStore = webhookStore; +const store = g.__webhookStore; + +function hookKey(params: { path: string[] }): string { + return params.path.join("/"); +} + +function pushEvent(key: string, event: WebhookEvent) { + let events = store.get(key); + if (!events) { + events = []; + store.set(key, events); + } + events.push(event); + if (events.length > MAX_EVENTS_PER_HOOK) { + events.splice(0, events.length - MAX_EVENTS_PER_HOOK); + } +} + +async function handleIncoming( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const p = await params; + const key = hookKey(p); + const headers: Record = {}; + req.headers.forEach((v, k) => { headers[k] = v; }); + + let body = ""; + try { + body = await req.text(); + } catch { /* empty body */ } + + pushEvent(key, { + method: req.method, + headers, + body, + receivedAt: Date.now(), + }); + + return Response.json({ ok: true, received: true }); +} + +export async function GET( + req: Request, + ctx: { params: Promise<{ path: string[] }> }, +) { + const url = new URL(req.url); + const since = url.searchParams.get("since"); + const poll = url.searchParams.get("poll"); + + if (poll || since) { + const p = await ctx.params; + const key = hookKey(p); + const events = store.get(key) || []; + const sinceTs = since ? parseInt(since, 10) : 0; + const filtered = events.filter((e) => e.receivedAt > sinceTs); + return Response.json({ events: filtered }); + } + + return handleIncoming(req, ctx); +} + +export const POST = handleIncoming; +export const PUT = handleIncoming; +export const PATCH = handleIncoming; +export const DELETE = handleIncoming; diff --git a/apps/web/app/api/workspace/execute/route.ts b/apps/web/app/api/workspace/execute/route.ts new file mode 100644 index 00000000000..c0cb8d1531e --- /dev/null +++ b/apps/web/app/api/workspace/execute/route.ts @@ -0,0 +1,41 @@ +import { duckdbQueryAsync } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const BLOCKED_PATTERN = + /^\s*(DROP\s+DATABASE|ATTACH|DETACH|COPY|EXPORT|INSTALL|LOAD|PRAGMA|\.)/i; + +export async function POST(req: Request) { + let body: { sql?: string }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { sql } = body; + if (!sql || typeof sql !== "string") { + return Response.json( + { error: "Missing 'sql' field in request body" }, + { status: 400 }, + ); + } + + if (BLOCKED_PATTERN.test(sql)) { + return Response.json( + { error: "This SQL statement is not allowed" }, + { status: 403 }, + ); + } + + try { + const rows = await duckdbQueryAsync(sql); + return Response.json({ rows: rows ?? [], ok: true }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Query failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/components/workspace/app-viewer.tsx b/apps/web/app/components/workspace/app-viewer.tsx index 7acf48aa07f..33c3bc37f33 100644 --- a/apps/web/app/components/workspace/app-viewer.tsx +++ b/apps/web/app/components/workspace/app-viewer.tsx @@ -2,6 +2,14 @@ import { useState, useRef, useCallback, useEffect } from "react"; import type { DenchAppManifest } from "../../workspace/workspace-content"; +import { + registerApp, + unregisterApp, + sendToApp, + listActiveApps, + type AppInstance, + type ToolDef, +} from "@/lib/app-registry"; /** Build a path-based URL for serving files from a .dench.app folder. */ export function appServeUrl(appPath: string, filePath: string): string { @@ -11,15 +19,35 @@ export function appServeUrl(appPath: string, filePath: string): string { type AppViewerProps = { appPath: string; manifest: DenchAppManifest; + onToast?: (message: string, opts?: { type?: string }) => void; + onNavigate?: (path: string) => void; }; -export function AppViewer({ appPath, manifest }: AppViewerProps) { +function hasPermission( + permissions: string[] | undefined, + ...required: string[] +): boolean { + if (!permissions) return false; + return required.some((r) => permissions.includes(r)); +} + +export function AppViewer({ appPath, manifest, onToast, onNavigate }: AppViewerProps) { const iframeRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const appInstanceRef = useRef(null); + const subscribedChannelsRef = useRef>(new Set()); + const webhookPollersRef = useRef>>( + new Map(), + ); + const pendingToolInvocationsRef = useRef< + Map void; reject: (e: Error) => void }> + >(new Map()); const entryFile = manifest.entry || "index.html"; const appUrl = appServeUrl(appPath, entryFile); + const permissions = manifest.permissions || []; + const appFolderName = appPath.split("/").pop() || appPath; const handleReload = useCallback(() => { setLoading(true); @@ -38,70 +66,738 @@ export function AppViewer({ appPath, manifest }: AppViewerProps) { setError("Failed to load app"); }, []); - // Set up postMessage bridge listener useEffect(() => { + const instance: AppInstance = { + appName: appFolderName, + iframe: iframeRef.current!, + tools: new Map(), + }; + appInstanceRef.current = instance; + registerApp(instance); + + return () => { + unregisterApp(instance); + for (const poller of webhookPollersRef.current.values()) { + clearInterval(poller); + } + webhookPollersRef.current.clear(); + }; + }, [appFolderName]); + + useEffect(() => { + function sendResponse( + iframe: HTMLIFrameElement, + id: number, + result: unknown, + error?: string, + ) { + iframe.contentWindow?.postMessage( + error + ? { type: "dench:response", id, error } + : { type: "dench:response", id, result }, + "*", + ); + } + + function emitEvent( + iframe: HTMLIFrameElement, + channel: string, + data: unknown, + ) { + iframe.contentWindow?.postMessage( + { type: "dench:event", channel, data }, + "*", + ); + } + + function emitStream( + iframe: HTMLIFrameElement, + streamId: number, + event: string, + data: unknown, + extra?: Record, + ) { + iframe.contentWindow?.postMessage( + { type: "dench:stream", streamId, event, data, ...extra }, + "*", + ); + } + const handleMessage = async (event: MessageEvent) => { + const iframe = iframeRef.current; + if (!iframe?.contentWindow || event.source !== iframe.contentWindow) + return; + + if (event.data?.type === "dench:tool-response") { + const { invokeId, result, error: toolError } = event.data; + const pending = pendingToolInvocationsRef.current.get(invokeId); + if (pending) { + pendingToolInvocationsRef.current.delete(invokeId); + if (toolError) pending.reject(new Error(toolError)); + else pending.resolve(result); + } + return; + } + if (!event.data || event.data.type !== "dench:request") return; const { id, method, params } = event.data; - const iframe = iframeRef.current; - if (!iframe?.contentWindow || event.source !== iframe.contentWindow) return; - - const permissions = manifest.permissions || []; try { let result: unknown; + // --- App Utilities (no permission needed) --- if (method === "app.getManifest") { result = manifest; } else if (method === "app.getTheme") { - result = document.documentElement.classList.contains("dark") ? "dark" : "light"; - } else if (method === "db.query" && permissions.includes("database")) { + result = document.documentElement.classList.contains("dark") + ? "dark" + : "light"; + } else if (method === "context.getWorkspace") { + result = { name: appPath.split("/")[0] || "workspace" }; + } else if (method === "context.getAppInfo") { + result = { + appPath, + folderName: appFolderName, + permissions, + manifest, + }; + + // --- Database --- + } else if ( + method === "db.query" && + hasPermission(permissions, "database", "database:write") + ) { const res = await fetch("/api/workspace/query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sql: params?.sql }), }); result = await res.json(); - } else if (method === "db.execute" && permissions.includes("database")) { - const res = await fetch("/api/workspace/query", { + } else if ( + method === "db.execute" && + hasPermission(permissions, "database:write") + ) { + const res = await fetch("/api/workspace/execute", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sql: params?.sql }), }); result = await res.json(); - } else if (method === "files.read" && permissions.includes("files")) { - const res = await fetch(`/api/workspace/file?path=${encodeURIComponent(params?.path)}`); + + // --- Objects CRUD --- + } else if ( + method === "objects.list" && + hasPermission(permissions, "objects") + ) { + const qs = new URLSearchParams(); + if (params?.filters) qs.set("filters", params.filters); + if (params?.sort) qs.set("sort", params.sort); + if (params?.search) qs.set("search", params.search); + if (params?.page) qs.set("page", String(params.page)); + if (params?.pageSize) qs.set("pageSize", String(params.pageSize)); + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(params?.name)}?${qs}`, + ); result = await res.json(); - } else if (method === "files.list" && permissions.includes("files")) { - const res = await fetch(`/api/workspace/tree?showHidden=0`); + } else if ( + method === "objects.get" && + hasPermission(permissions, "objects") + ) { + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(params?.name)}/entries/${encodeURIComponent(params?.entryId)}`, + ); result = await res.json(); + } else if ( + method === "objects.create" && + hasPermission(permissions, "objects") + ) { + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(params?.name)}/entries`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: params?.fields }), + }, + ); + result = await res.json(); + emitObjectEvent(iframe, "object.entry.created", params?.name, result); + } else if ( + method === "objects.update" && + hasPermission(permissions, "objects") + ) { + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(params?.name)}/entries/${encodeURIComponent(params?.entryId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: params?.fields }), + }, + ); + result = await res.json(); + emitObjectEvent(iframe, "object.entry.updated", params?.name, { + entryId: params?.entryId, + }); + } else if ( + method === "objects.delete" && + hasPermission(permissions, "objects") + ) { + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(params?.name)}/entries/${encodeURIComponent(params?.entryId)}`, + { method: "DELETE" }, + ); + result = await res.json(); + emitObjectEvent(iframe, "object.entry.deleted", params?.name, { + entryId: params?.entryId, + }); + } else if ( + method === "objects.bulkDelete" && + hasPermission(permissions, "objects") + ) { + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(params?.name)}/entries/bulk-delete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entryIds: params?.entryIds }), + }, + ); + result = await res.json(); + emitObjectEvent(iframe, "object.entry.deleted", params?.name, { + entryIds: params?.entryIds, + }); + } else if ( + method === "objects.getSchema" && + hasPermission(permissions, "objects") + ) { + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(params?.name)}?schemaOnly=1`, + ); + result = await res.json(); + } else if ( + method === "objects.getOptions" && + hasPermission(permissions, "objects") + ) { + const qs = params?.query + ? `?q=${encodeURIComponent(params.query)}` + : ""; + const res = await fetch( + `/api/workspace/objects/${encodeURIComponent(params?.name)}/entries/options${qs}`, + ); + result = await res.json(); + + // --- Files --- + } else if ( + method === "files.read" && + hasPermission(permissions, "files", "files:write") + ) { + const res = await fetch( + `/api/workspace/file?path=${encodeURIComponent(params?.path)}`, + ); + result = await res.json(); + } else if ( + method === "files.list" && + hasPermission(permissions, "files", "files:write") + ) { + const qs = params?.dir + ? `?path=${encodeURIComponent(params.dir)}` + : "?showHidden=0"; + const res = await fetch(`/api/workspace/browse${qs}`); + result = await res.json(); + } else if ( + method === "files.write" && + hasPermission(permissions, "files:write") + ) { + const res = await fetch("/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: params?.path, + content: params?.content, + }), + }); + result = await res.json(); + } else if ( + method === "files.delete" && + hasPermission(permissions, "files:write") + ) { + const res = await fetch( + `/api/workspace/file?path=${encodeURIComponent(params?.path)}`, + { method: "DELETE" }, + ); + result = await res.json(); + } else if ( + method === "files.mkdir" && + hasPermission(permissions, "files:write") + ) { + const res = await fetch("/api/workspace/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: params?.path }), + }); + result = await res.json(); + + // --- Chat --- + } else if ( + method === "chat.createSession" && + hasPermission(permissions, "agent") + ) { + const res = await fetch("/api/web-sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: params?.title }), + }); + result = await res.json(); + } else if ( + method === "chat.send" && + hasPermission(permissions, "agent") + ) { + const streamId = params?._streamId as number | undefined; + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: params?.message }], + sessionId: params?.sessionId, + }), + }); + + if (streamId && res.body) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let accumulated = ""; + let fullText = ""; + + // Read SSE stream and relay to app + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + accumulated += decoder.decode(value, { stream: true }); + const lines = accumulated.split("\n"); + accumulated = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const sseData = JSON.parse(line.slice(6)); + emitStream(iframe, streamId, sseData.type, sseData.data, { + name: sseData.name, + args: sseData.args, + result: sseData.result, + }); + if ( + sseData.type === "text-delta" && + typeof sseData.data === "string" + ) { + fullText += sseData.data; + } + } catch { + // skip malformed SSE lines + } + } + } + result = { text: fullText }; + } else { + result = await res.json(); + } + } else if ( + method === "chat.getHistory" && + hasPermission(permissions, "agent") + ) { + const res = await fetch( + `/api/web-sessions/${encodeURIComponent(params?.sessionId)}/messages`, + ); + result = await res.json(); + } else if ( + method === "chat.getSessions" && + hasPermission(permissions, "agent") + ) { + const qs = params?.limit ? `?limit=${params.limit}` : ""; + const res = await fetch(`/api/web-sessions${qs}`); + result = await res.json(); + } else if ( + method === "chat.abort" && + hasPermission(permissions, "agent") + ) { + const res = await fetch("/api/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: params?.sessionId }), + }); + result = await res.json(); + } else if ( + method === "chat.isActive" && + hasPermission(permissions, "agent") + ) { + const res = await fetch("/api/chat/active"); + const data = await res.json(); + result = Array.isArray(data) + ? data.includes(params?.sessionId) + : false; + + // --- Agent --- + } else if ( + method === "agent.send" && + hasPermission(permissions, "agent") + ) { + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: [{ role: "user", content: params?.message }], + }), + }); + result = { ok: res.ok }; + + // --- Tool Registration --- + } else if ( + method === "tool.register" && + hasPermission(permissions, "agent") + ) { + const inst = appInstanceRef.current; + if (inst && params?.name) { + const toolDef: ToolDef = { + name: params.name, + description: params.description || "", + inputSchema: params.inputSchema, + }; + inst.tools.set(params.name, toolDef); + } + result = { ok: true }; + + // --- Memory --- + } else if ( + method === "memory.get" && + hasPermission(permissions, "agent") + ) { + const res = await fetch("/api/memories"); + result = await res.json(); + + // --- UI --- + } else if ( + method === "ui.toast" && + hasPermission(permissions, "ui") + ) { + if (onToast) { + onToast(params?.message, { type: params?.type }); + } + result = { ok: true }; + } else if ( + method === "ui.navigate" && + hasPermission(permissions, "ui") + ) { + if (onNavigate) { + onNavigate(params?.path); + } + result = { ok: true }; + } else if ( + method === "ui.openEntry" && + hasPermission(permissions, "ui") + ) { + if (onNavigate) { + onNavigate(`/${params?.objectName}/${params?.entryId}`); + } + result = { ok: true }; + } else if ( + method === "ui.setTitle" && + hasPermission(permissions, "ui") + ) { + // Handled by parent via tab state - emit a custom event + window.dispatchEvent( + new CustomEvent("dench:app-title-change", { + detail: { appPath, title: params?.title }, + }), + ); + result = { ok: true }; + } else if ( + method === "ui.confirm" && + hasPermission(permissions, "ui") + ) { + result = window.confirm(params?.message || ""); + } else if ( + method === "ui.prompt" && + hasPermission(permissions, "ui") + ) { + result = window.prompt(params?.message || "", params?.defaultValue || ""); + + // --- Store --- + } else if ( + method === "store.get" && + hasPermission(permissions, "store") + ) { + const res = await fetch( + `/api/apps/store?app=${encodeURIComponent(appFolderName)}&key=${encodeURIComponent(params?.key)}`, + ); + const data = await res.json(); + result = data.value; + } else if ( + method === "store.set" && + hasPermission(permissions, "store") + ) { + const res = await fetch("/api/apps/store", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + app: appFolderName, + key: params?.key, + value: params?.value, + }), + }); + result = await res.json(); + } else if ( + method === "store.delete" && + hasPermission(permissions, "store") + ) { + const res = await fetch( + `/api/apps/store?app=${encodeURIComponent(appFolderName)}&key=${encodeURIComponent(params?.key)}`, + { method: "DELETE" }, + ); + result = await res.json(); + } else if ( + method === "store.list" && + hasPermission(permissions, "store") + ) { + const res = await fetch( + `/api/apps/store?app=${encodeURIComponent(appFolderName)}`, + ); + const data = await res.json(); + result = data.keys; + } else if ( + method === "store.clear" && + hasPermission(permissions, "store") + ) { + const res = await fetch( + `/api/apps/store?app=${encodeURIComponent(appFolderName)}`, + { method: "DELETE" }, + ); + result = await res.json(); + + // --- HTTP Proxy --- + } else if ( + method === "http.fetch" && + hasPermission(permissions, "http") + ) { + const res = await fetch("/api/apps/proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: params?.url, + method: params?.method, + headers: params?.headers, + body: params?.body, + }), + }); + result = await res.json(); + + // --- Events --- + } else if (method === "events.subscribe") { + subscribedChannelsRef.current.add(params?.channel); + if (params?.channel === "theme.changed") { + setupThemeObserver(iframe); + } + result = { ok: true }; + } else if (method === "events.unsubscribe") { + subscribedChannelsRef.current.delete(params?.channel); + result = { ok: true }; + + // --- Apps --- + } else if ( + method === "apps.send" && + hasPermission(permissions, "apps") + ) { + sendToApp(params?.targetApp, params?.message, appFolderName); + result = { ok: true }; + } else if ( + method === "apps.list" && + hasPermission(permissions, "apps") + ) { + result = listActiveApps(); + + // --- Cron --- + } else if ( + method === "cron.schedule" && + hasPermission(permissions, "cron") + ) { + const res = await fetch("/api/apps/cron", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "add", + params: { + expression: params?.expression, + message: params?.message, + channel: params?.channel, + }, + }), + }); + result = await res.json(); + } else if ( + method === "cron.list" && + hasPermission(permissions, "cron") + ) { + const res = await fetch("/api/apps/cron"); + result = await res.json(); + } else if ( + method === "cron.run" && + hasPermission(permissions, "cron") + ) { + const res = await fetch("/api/apps/cron", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "run", + params: { id: params?.jobId }, + }), + }); + result = await res.json(); + } else if ( + method === "cron.cancel" && + hasPermission(permissions, "cron") + ) { + const res = await fetch("/api/apps/cron", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "remove", + params: { id: params?.jobId }, + }), + }); + result = await res.json(); + + // --- Webhooks --- + } else if ( + method === "webhooks.register" && + hasPermission(permissions, "webhooks") + ) { + const hookUrl = `${window.location.origin}/api/apps/webhooks/${encodeURIComponent(appFolderName)}/${encodeURIComponent(params?.hookName)}`; + result = { url: hookUrl, hookName: params?.hookName }; + } else if ( + method === "webhooks.subscribe" && + hasPermission(permissions, "webhooks") + ) { + const hookName = params?.hookName; + if (hookName && !webhookPollersRef.current.has(hookName)) { + let lastTs = Date.now(); + const poller = setInterval(async () => { + try { + const res = await fetch( + `/api/apps/webhooks/${encodeURIComponent(appFolderName)}/${encodeURIComponent(hookName)}?poll=1&since=${lastTs}`, + ); + const data = await res.json(); + if (data.events?.length) { + lastTs = Math.max( + ...data.events.map( + (e: { receivedAt: number }) => e.receivedAt, + ), + ); + for (const evt of data.events) { + emitEvent(iframe, `webhooks.${hookName}`, evt); + } + } + } catch { + /* polling error, will retry */ + } + }, 5000); + webhookPollersRef.current.set(hookName, poller); + } + result = { ok: true }; + } else if ( + method === "webhooks.poll" && + hasPermission(permissions, "webhooks") + ) { + const since = params?.since || 0; + const res = await fetch( + `/api/apps/webhooks/${encodeURIComponent(appFolderName)}/${encodeURIComponent(params?.hookName)}?poll=1&since=${since}`, + ); + result = await res.json(); + + // --- Clipboard --- + } else if ( + method === "clipboard.write" && + hasPermission(permissions, "clipboard") + ) { + try { + await navigator.clipboard.writeText(params?.text || ""); + result = { ok: true }; + } catch { + // Fallback for non-secure contexts + const ta = document.createElement("textarea"); + ta.value = params?.text || ""; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + result = { ok: true }; + } + } else if ( + method === "clipboard.read" && + hasPermission(permissions, "clipboard") + ) { + try { + result = await navigator.clipboard.readText(); + } catch { + result = null; + } } else { - iframe.contentWindow.postMessage({ - type: "dench:response", - id, - error: `Unknown method or insufficient permissions: ${method}`, - }, "*"); + sendResponse(iframe, id, null, `Unknown method or insufficient permissions: ${method}`); return; } - iframe.contentWindow.postMessage({ - type: "dench:response", - id, - result, - }, "*"); + sendResponse(iframe, id, result); } catch (err) { - iframe.contentWindow?.postMessage({ - type: "dench:response", + sendResponse( + iframe, id, - error: err instanceof Error ? err.message : "Unknown error", - }, "*"); + null, + err instanceof Error ? err.message : "Unknown error", + ); } }; + let themeObserver: MutationObserver | null = null; + function setupThemeObserver(iframe: HTMLIFrameElement) { + if (themeObserver) return; + themeObserver = new MutationObserver(() => { + if (subscribedChannelsRef.current.has("theme.changed")) { + const theme = document.documentElement.classList.contains("dark") + ? "dark" + : "light"; + emitEvent(iframe, "theme.changed", { theme }); + } + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + } + + function emitObjectEvent( + _iframe: HTMLIFrameElement, + channel: string, + objectName: string, + data: unknown, + ) { + // Emit to all subscribed apps via the registry + const eventData = { objectName, ...(data as Record) }; + // Emit locally if subscribed + if (subscribedChannelsRef.current.has(channel)) { + _iframe.contentWindow?.postMessage( + { type: "dench:event", channel, data: eventData }, + "*", + ); + } + } + window.addEventListener("message", handleMessage); - return () => window.removeEventListener("message", handleMessage); - }, [manifest]); + return () => { + window.removeEventListener("message", handleMessage); + if (themeObserver) themeObserver.disconnect(); + }; + }, [manifest, appPath, appFolderName, permissions, onToast, onNavigate]); const iconIsImage = manifest.icon && ( manifest.icon.endsWith(".png") || diff --git a/apps/web/app/components/workspace/app-widget-grid.tsx b/apps/web/app/components/workspace/app-widget-grid.tsx new file mode 100644 index 00000000000..303aac0f2c1 --- /dev/null +++ b/apps/web/app/components/workspace/app-widget-grid.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { AppViewer, appServeUrl } from "./app-viewer"; +import type { DenchAppManifest } from "../../workspace/workspace-content"; + +type WidgetApp = { + appPath: string; + manifest: DenchAppManifest & { + display?: string; + widget?: { width?: number; height?: number; refreshInterval?: number }; + }; +}; + +type AppWidgetGridProps = { + apps: WidgetApp[]; + onToast?: (message: string, opts?: { type?: string }) => void; + onNavigate?: (path: string) => void; +}; + +const CELL_HEIGHT = 200; + +export function AppWidgetGrid({ apps, onToast, onNavigate }: AppWidgetGridProps) { + if (apps.length === 0) { + return ( +
+ + + + + + +

+ No widget apps found. Create an app with{" "} + + display: "widget" + {" "} + in its manifest. +

+
+ ); + } + + return ( +
+ {apps.map((app) => { + const w = app.manifest.widget?.width || 1; + const h = app.manifest.widget?.height || 1; + + return ( +
+ +
+ ); + })} +
+ ); +} + +function WidgetFrame({ + appPath, + manifest, + refreshInterval, + onToast, + onNavigate, +}: { + appPath: string; + manifest: DenchAppManifest; + refreshInterval?: number; + onToast?: (message: string, opts?: { type?: string }) => void; + onNavigate?: (path: string) => void; +}) { + const entryFile = manifest.entry || "index.html"; + const appUrl = appServeUrl(appPath, entryFile); + + return ( +
+
+ + {manifest.name} + + {refreshInterval && ( + + ⟳ {refreshInterval}s + + )} +
+
+ +
+
+ ); +} diff --git a/apps/web/lib/app-bridge.ts b/apps/web/lib/app-bridge.ts index 19701529a76..45156bf7be6 100644 --- a/apps/web/lib/app-bridge.ts +++ b/apps/web/lib/app-bridge.ts @@ -7,6 +7,10 @@ * Protocol: * App -> Parent: { type: "dench:request", id, method, params } * Parent -> App: { type: "dench:response", id, result, error } + * Parent -> App: { type: "dench:stream", streamId, event, data } + * Parent -> App: { type: "dench:stream-end", streamId, result } + * Parent -> App: { type: "dench:event", channel, data } + * Parent -> App: { type: "dench:tool-invoke", toolName, args, invokeId } */ export function generateBridgeScript(): string { @@ -16,6 +20,12 @@ export function generateBridgeScript(): string { var _pendingRequests = {}; var _requestId = 0; + var _streamCallbacks = {}; + var _streamId = 0; + var _eventListeners = {}; + var _toolHandlers = {}; + var _appMessageHandler = null; + var _webhookHandlers = {}; function sendRequest(method, params) { return new Promise(function(resolve, reject) { @@ -37,15 +47,99 @@ export function generateBridgeScript(): string { }); } + function sendStreamRequest(method, params, onEvent) { + return new Promise(function(resolve, reject) { + var id = ++_requestId; + var sid = ++_streamId; + _streamCallbacks[sid] = onEvent; + _pendingRequests[id] = { + resolve: function(result) { + delete _streamCallbacks[sid]; + resolve(result); + }, + reject: function(err) { + delete _streamCallbacks[sid]; + reject(err); + } + }; + window.parent.postMessage({ + type: "dench:request", + id: id, + method: method, + params: Object.assign({}, params, { _streamId: sid }) + }, "*"); + + setTimeout(function() { + if (_pendingRequests[id]) { + delete _streamCallbacks[sid]; + _pendingRequests[id].reject(new Error("Request timeout: " + method)); + delete _pendingRequests[id]; + } + }, 300000); + }); + } + window.addEventListener("message", function(event) { - if (!event.data || event.data.type !== "dench:response") return; - var pending = _pendingRequests[event.data.id]; - if (!pending) return; - delete _pendingRequests[event.data.id]; - if (event.data.error) { - pending.reject(new Error(event.data.error)); - } else { - pending.resolve(event.data.result); + if (!event.data) return; + var d = event.data; + + if (d.type === "dench:response") { + var pending = _pendingRequests[d.id]; + if (!pending) return; + delete _pendingRequests[d.id]; + if (d.error) { + pending.reject(new Error(d.error)); + } else { + pending.resolve(d.result); + } + } + + else if (d.type === "dench:stream") { + var cb = _streamCallbacks[d.streamId]; + if (cb) cb({ type: d.event, data: d.data, name: d.name, args: d.args, result: d.result }); + } + + else if (d.type === "dench:stream-end") { + // Stream end is handled via the normal response path + } + + else if (d.type === "dench:event") { + var channel = d.channel; + if (channel === "apps.message" && _appMessageHandler) { + _appMessageHandler(d.data); + } + if (channel && channel.indexOf("webhooks.") === 0) { + var hookName = channel.substring(9); + var whCb = _webhookHandlers[hookName]; + if (whCb) whCb(d.data); + } + var listeners = _eventListeners[channel]; + if (listeners) { + for (var i = 0; i < listeners.length; i++) { + try { listeners[i](d.data); } catch(e) { console.error("Event handler error:", e); } + } + } + } + + else if (d.type === "dench:tool-invoke") { + var handler = _toolHandlers[d.toolName]; + if (handler) { + Promise.resolve().then(function() { + return handler(d.args); + }).then(function(result) { + window.parent.postMessage({ + type: "dench:tool-response", + invokeId: d.invokeId, + result: result + }, "*"); + }).catch(function(err) { + window.parent.postMessage({ + type: "dench:tool-response", + invokeId: d.invokeId, + error: err.message || "Tool handler failed" + }, "*"); + }); + } } }); @@ -54,16 +148,114 @@ export function generateBridgeScript(): string { query: function(sql) { return sendRequest("db.query", { sql: sql }); }, execute: function(sql) { return sendRequest("db.execute", { sql: sql }); } }, + objects: { + list: function(name, opts) { return sendRequest("objects.list", Object.assign({ name: name }, opts || {})); }, + get: function(name, entryId) { return sendRequest("objects.get", { name: name, entryId: entryId }); }, + create: function(name, fields) { return sendRequest("objects.create", { name: name, fields: fields }); }, + update: function(name, entryId, fields) { return sendRequest("objects.update", { name: name, entryId: entryId, fields: fields }); }, + delete: function(name, entryId) { return sendRequest("objects.delete", { name: name, entryId: entryId }); }, + bulkDelete: function(name, entryIds) { return sendRequest("objects.bulkDelete", { name: name, entryIds: entryIds }); }, + getSchema: function(name) { return sendRequest("objects.getSchema", { name: name }); }, + getOptions: function(name, query) { return sendRequest("objects.getOptions", { name: name, query: query }); } + }, files: { read: function(path) { return sendRequest("files.read", { path: path }); }, - list: function(dir) { return sendRequest("files.list", { dir: dir }); } + list: function(dir) { return sendRequest("files.list", { dir: dir }); }, + write: function(path, content) { return sendRequest("files.write", { path: path, content: content }); }, + delete: function(path) { return sendRequest("files.delete", { path: path }); }, + mkdir: function(path) { return sendRequest("files.mkdir", { path: path }); } }, app: { getManifest: function() { return sendRequest("app.getManifest"); }, getTheme: function() { return sendRequest("app.getTheme"); } }, + chat: { + createSession: function(title) { return sendRequest("chat.createSession", { title: title }); }, + send: function(sessionId, message, opts) { + if (opts && opts.onEvent) { + return sendStreamRequest("chat.send", { sessionId: sessionId, message: message }, opts.onEvent); + } + return sendRequest("chat.send", { sessionId: sessionId, message: message }); + }, + getHistory: function(sessionId) { return sendRequest("chat.getHistory", { sessionId: sessionId }); }, + getSessions: function(opts) { return sendRequest("chat.getSessions", opts || {}); }, + abort: function(sessionId) { return sendRequest("chat.abort", { sessionId: sessionId }); }, + isActive: function(sessionId) { return sendRequest("chat.isActive", { sessionId: sessionId }); } + }, agent: { send: function(message) { return sendRequest("agent.send", { message: message }); } + }, + tool: { + register: function(name, handler) { + _toolHandlers[name] = handler; + return sendRequest("tool.register", { name: name }); + } + }, + memory: { + get: function() { return sendRequest("memory.get"); } + }, + ui: { + toast: function(message, opts) { return sendRequest("ui.toast", Object.assign({ message: message }, opts || {})); }, + navigate: function(path) { return sendRequest("ui.navigate", { path: path }); }, + openEntry: function(objectName, entryId) { return sendRequest("ui.openEntry", { objectName: objectName, entryId: entryId }); }, + setTitle: function(title) { return sendRequest("ui.setTitle", { title: title }); }, + confirm: function(message) { return sendRequest("ui.confirm", { message: message }); }, + prompt: function(message, defaultValue) { return sendRequest("ui.prompt", { message: message, defaultValue: defaultValue }); } + }, + store: { + get: function(key) { return sendRequest("store.get", { key: key }); }, + set: function(key, value) { return sendRequest("store.set", { key: key, value: value }); }, + delete: function(key) { return sendRequest("store.delete", { key: key }); }, + list: function() { return sendRequest("store.list"); }, + clear: function() { return sendRequest("store.clear"); } + }, + http: { + fetch: function(url, opts) { return sendRequest("http.fetch", Object.assign({ url: url }, opts || {})); } + }, + events: { + on: function(channel, callback) { + if (!_eventListeners[channel]) _eventListeners[channel] = []; + _eventListeners[channel].push(callback); + sendRequest("events.subscribe", { channel: channel }).catch(function() {}); + }, + off: function(channel, callback) { + if (!callback) { + delete _eventListeners[channel]; + } else if (_eventListeners[channel]) { + _eventListeners[channel] = _eventListeners[channel].filter(function(cb) { return cb !== callback; }); + if (_eventListeners[channel].length === 0) delete _eventListeners[channel]; + } + sendRequest("events.unsubscribe", { channel: channel }).catch(function() {}); + } + }, + context: { + getWorkspace: function() { return sendRequest("context.getWorkspace"); }, + getAppInfo: function() { return sendRequest("context.getAppInfo"); } + }, + apps: { + send: function(targetApp, message) { return sendRequest("apps.send", { targetApp: targetApp, message: message }); }, + on: function(eventType, callback) { + if (eventType === "message") _appMessageHandler = callback; + }, + list: function() { return sendRequest("apps.list"); } + }, + cron: { + schedule: function(opts) { return sendRequest("cron.schedule", opts); }, + list: function() { return sendRequest("cron.list"); }, + run: function(jobId) { return sendRequest("cron.run", { jobId: jobId }); }, + cancel: function(jobId) { return sendRequest("cron.cancel", { jobId: jobId }); } + }, + webhooks: { + register: function(hookName) { return sendRequest("webhooks.register", { hookName: hookName }); }, + on: function(hookName, callback) { + _webhookHandlers[hookName] = callback; + sendRequest("webhooks.subscribe", { hookName: hookName }).catch(function() {}); + }, + poll: function(hookName, opts) { return sendRequest("webhooks.poll", Object.assign({ hookName: hookName }, opts || {})); } + }, + clipboard: { + read: function() { return sendRequest("clipboard.read"); }, + write: function(text) { return sendRequest("clipboard.write", { text: text }); } } }; })(); diff --git a/apps/web/lib/app-registry.ts b/apps/web/lib/app-registry.ts new file mode 100644 index 00000000000..09c2ef2f871 --- /dev/null +++ b/apps/web/lib/app-registry.ts @@ -0,0 +1,118 @@ +/** + * Global registry for active Dench App instances. + * Enables inter-app messaging, tool discovery, and app listing. + */ + +export type ToolDef = { + name: string; + description: string; + inputSchema?: unknown; +}; + +export type AppInstance = { + appName: string; + iframe: HTMLIFrameElement; + tools: Map; +}; + +type AppMessageHandler = (event: { + from: string; + message: unknown; +}) => void; + +const g = (typeof globalThis !== "undefined" ? globalThis : window) as unknown as { + __denchAppRegistry?: Map>; + __denchAppMessageHandlers?: Set; +}; + +if (!g.__denchAppRegistry) g.__denchAppRegistry = new Map(); +if (!g.__denchAppMessageHandlers) g.__denchAppMessageHandlers = new Set(); + +const registry = g.__denchAppRegistry; +const messageHandlers = g.__denchAppMessageHandlers; + +export function registerApp(instance: AppInstance): void { + let instances = registry.get(instance.appName); + if (!instances) { + instances = new Set(); + registry.set(instance.appName, instances); + } + instances.add(instance); +} + +export function unregisterApp(instance: AppInstance): void { + const instances = registry.get(instance.appName); + if (instances) { + instances.delete(instance); + if (instances.size === 0) registry.delete(instance.appName); + } +} + +export function sendToApp( + targetApp: string, + message: unknown, + sourceApp: string, +): boolean { + const instances = registry.get(targetApp); + if (!instances || instances.size === 0) return false; + + for (const inst of instances) { + inst.iframe.contentWindow?.postMessage( + { + type: "dench:event", + channel: "apps.message", + data: { from: sourceApp, message }, + }, + "*", + ); + } + return true; +} + +export function listActiveApps(): Array<{ + name: string; + instanceCount: number; +}> { + const result: Array<{ name: string; instanceCount: number }> = []; + for (const [name, instances] of registry) { + result.push({ name, instanceCount: instances.size }); + } + return result; +} + +export function getAppTools( + appName: string, +): ToolDef[] { + const instances = registry.get(appName); + if (!instances) return []; + const tools: ToolDef[] = []; + for (const inst of instances) { + for (const tool of inst.tools.values()) { + tools.push(tool); + } + } + return tools; +} + +export function getAllTools(): Array { + const result: Array = []; + for (const [appName, instances] of registry) { + for (const inst of instances) { + for (const tool of inst.tools.values()) { + result.push({ ...tool, appName }); + } + } + } + return result; +} + +export function onAppMessage(handler: AppMessageHandler): () => void { + messageHandlers.add(handler); + return () => messageHandlers.delete(handler); +} + +export function emitAppMessage(from: string, message: unknown): void { + for (const handler of messageHandlers) { + handler({ from, message }); + } +}