feat(apps): expand platform API with objects, chat, store, cron, and webhooks
Enables apps to access the full DenchClaw platform — CRUD on workspace objects, AI chat with streaming, inter-app messaging, KV store, HTTP proxy, webhooks, cron scheduling, and widget display mode.
This commit is contained in:
parent
e92419760b
commit
ea8bab6179
63
apps/web/app/api/apps/cron/route.ts
Normal file
63
apps/web/app/api/apps/cron/route.ts
Normal file
@ -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<string, unknown> };
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/web/app/api/apps/proxy/route.ts
Normal file
69
apps/web/app/api/apps/proxy/route.ts
Normal file
@ -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<string, string>;
|
||||||
|
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<string, string> = {};
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
apps/web/app/api/apps/store/route.ts
Normal file
90
apps/web/app/api/apps/store/route.ts
Normal file
@ -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<string, unknown> {
|
||||||
|
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<string, unknown>): 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 });
|
||||||
|
}
|
||||||
82
apps/web/app/api/apps/webhooks/[...path]/route.ts
Normal file
82
apps/web/app/api/apps/webhooks/[...path]/route.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type WebhookEvent = {
|
||||||
|
method: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
receivedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_EVENTS_PER_HOOK = 100;
|
||||||
|
const webhookStore = new Map<string, WebhookEvent[]>();
|
||||||
|
|
||||||
|
// Survive HMR
|
||||||
|
const g = globalThis as unknown as { __webhookStore?: Map<string, WebhookEvent[]> };
|
||||||
|
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<string, string> = {};
|
||||||
|
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;
|
||||||
41
apps/web/app/api/workspace/execute/route.ts
Normal file
41
apps/web/app/api/workspace/execute/route.ts
Normal file
@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import type { DenchAppManifest } from "../../workspace/workspace-content";
|
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. */
|
/** Build a path-based URL for serving files from a .dench.app folder. */
|
||||||
export function appServeUrl(appPath: string, filePath: string): string {
|
export function appServeUrl(appPath: string, filePath: string): string {
|
||||||
@ -11,15 +19,35 @@ export function appServeUrl(appPath: string, filePath: string): string {
|
|||||||
type AppViewerProps = {
|
type AppViewerProps = {
|
||||||
appPath: string;
|
appPath: string;
|
||||||
manifest: DenchAppManifest;
|
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<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const appInstanceRef = useRef<AppInstance | null>(null);
|
||||||
|
const subscribedChannelsRef = useRef<Set<string>>(new Set());
|
||||||
|
const webhookPollersRef = useRef<Map<string, ReturnType<typeof setInterval>>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
const pendingToolInvocationsRef = useRef<
|
||||||
|
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
const entryFile = manifest.entry || "index.html";
|
const entryFile = manifest.entry || "index.html";
|
||||||
const appUrl = appServeUrl(appPath, entryFile);
|
const appUrl = appServeUrl(appPath, entryFile);
|
||||||
|
const permissions = manifest.permissions || [];
|
||||||
|
const appFolderName = appPath.split("/").pop() || appPath;
|
||||||
|
|
||||||
const handleReload = useCallback(() => {
|
const handleReload = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -38,70 +66,738 @@ export function AppViewer({ appPath, manifest }: AppViewerProps) {
|
|||||||
setError("Failed to load app");
|
setError("Failed to load app");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Set up postMessage bridge listener
|
|
||||||
useEffect(() => {
|
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<string, unknown>,
|
||||||
|
) {
|
||||||
|
iframe.contentWindow?.postMessage(
|
||||||
|
{ type: "dench:stream", streamId, event, data, ...extra },
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
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;
|
if (!event.data || event.data.type !== "dench:request") return;
|
||||||
|
|
||||||
const { id, method, params } = event.data;
|
const { id, method, params } = event.data;
|
||||||
const iframe = iframeRef.current;
|
|
||||||
if (!iframe?.contentWindow || event.source !== iframe.contentWindow) return;
|
|
||||||
|
|
||||||
const permissions = manifest.permissions || [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: unknown;
|
let result: unknown;
|
||||||
|
|
||||||
|
// --- App Utilities (no permission needed) ---
|
||||||
if (method === "app.getManifest") {
|
if (method === "app.getManifest") {
|
||||||
result = manifest;
|
result = manifest;
|
||||||
} else if (method === "app.getTheme") {
|
} else if (method === "app.getTheme") {
|
||||||
result = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
result = document.documentElement.classList.contains("dark")
|
||||||
} else if (method === "db.query" && permissions.includes("database")) {
|
? "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", {
|
const res = await fetch("/api/workspace/query", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ sql: params?.sql }),
|
body: JSON.stringify({ sql: params?.sql }),
|
||||||
});
|
});
|
||||||
result = await res.json();
|
result = await res.json();
|
||||||
} else if (method === "db.execute" && permissions.includes("database")) {
|
} else if (
|
||||||
const res = await fetch("/api/workspace/query", {
|
method === "db.execute" &&
|
||||||
|
hasPermission(permissions, "database:write")
|
||||||
|
) {
|
||||||
|
const res = await fetch("/api/workspace/execute", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ sql: params?.sql }),
|
body: JSON.stringify({ sql: params?.sql }),
|
||||||
});
|
});
|
||||||
result = await res.json();
|
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();
|
result = await res.json();
|
||||||
} else if (method === "files.list" && permissions.includes("files")) {
|
} else if (
|
||||||
const res = await fetch(`/api/workspace/tree?showHidden=0`);
|
method === "objects.get" &&
|
||||||
|
hasPermission(permissions, "objects")
|
||||||
|
) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/workspace/objects/${encodeURIComponent(params?.name)}/entries/${encodeURIComponent(params?.entryId)}`,
|
||||||
|
);
|
||||||
result = await res.json();
|
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 {
|
} else {
|
||||||
iframe.contentWindow.postMessage({
|
sendResponse(iframe, id, null, `Unknown method or insufficient permissions: ${method}`);
|
||||||
type: "dench:response",
|
|
||||||
id,
|
|
||||||
error: `Unknown method or insufficient permissions: ${method}`,
|
|
||||||
}, "*");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
iframe.contentWindow.postMessage({
|
sendResponse(iframe, id, result);
|
||||||
type: "dench:response",
|
|
||||||
id,
|
|
||||||
result,
|
|
||||||
}, "*");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
iframe.contentWindow?.postMessage({
|
sendResponse(
|
||||||
type: "dench:response",
|
iframe,
|
||||||
id,
|
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<string, unknown>) };
|
||||||
|
// Emit locally if subscribed
|
||||||
|
if (subscribedChannelsRef.current.has(channel)) {
|
||||||
|
_iframe.contentWindow?.postMessage(
|
||||||
|
{ type: "dench:event", channel, data: eventData },
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("message", handleMessage);
|
window.addEventListener("message", handleMessage);
|
||||||
return () => window.removeEventListener("message", handleMessage);
|
return () => {
|
||||||
}, [manifest]);
|
window.removeEventListener("message", handleMessage);
|
||||||
|
if (themeObserver) themeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [manifest, appPath, appFolderName, permissions, onToast, onNavigate]);
|
||||||
|
|
||||||
const iconIsImage = manifest.icon && (
|
const iconIsImage = manifest.icon && (
|
||||||
manifest.icon.endsWith(".png") ||
|
manifest.icon.endsWith(".png") ||
|
||||||
|
|||||||
142
apps/web/app/components/workspace/app-widget-grid.tsx
Normal file
142
apps/web/app/components/workspace/app-widget-grid.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center gap-3 h-64"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||||
|
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||||
|
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||||
|
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm">
|
||||||
|
No widget apps found. Create an app with{" "}
|
||||||
|
<code
|
||||||
|
className="px-1 py-0.5 rounded text-xs"
|
||||||
|
style={{ background: "var(--color-surface)", border: "1px solid var(--color-border)" }}
|
||||||
|
>
|
||||||
|
display: "widget"
|
||||||
|
</code>{" "}
|
||||||
|
in its manifest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid gap-4 p-6"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{apps.map((app) => {
|
||||||
|
const w = app.manifest.widget?.width || 1;
|
||||||
|
const h = app.manifest.widget?.height || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={app.appPath}
|
||||||
|
className="rounded-xl overflow-hidden"
|
||||||
|
style={{
|
||||||
|
gridColumn: `span ${Math.min(w, 4)}`,
|
||||||
|
gridRow: `span ${Math.min(h, 4)}`,
|
||||||
|
height: `${Math.min(h, 4) * CELL_HEIGHT}px`,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WidgetFrame
|
||||||
|
appPath={app.appPath}
|
||||||
|
manifest={app.manifest}
|
||||||
|
refreshInterval={app.manifest.widget?.refreshInterval}
|
||||||
|
onToast={onToast}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 flex-shrink-0"
|
||||||
|
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium truncate"
|
||||||
|
style={{ color: "var(--color-text)" }}
|
||||||
|
>
|
||||||
|
{manifest.name}
|
||||||
|
</span>
|
||||||
|
{refreshInterval && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] ml-auto"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
⟳ {refreshInterval}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<AppViewer
|
||||||
|
appPath={appPath}
|
||||||
|
manifest={manifest}
|
||||||
|
onToast={onToast}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,10 @@
|
|||||||
* Protocol:
|
* Protocol:
|
||||||
* App -> Parent: { type: "dench:request", id, method, params }
|
* App -> Parent: { type: "dench:request", id, method, params }
|
||||||
* Parent -> App: { type: "dench:response", id, result, error }
|
* 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 {
|
export function generateBridgeScript(): string {
|
||||||
@ -16,6 +20,12 @@ export function generateBridgeScript(): string {
|
|||||||
|
|
||||||
var _pendingRequests = {};
|
var _pendingRequests = {};
|
||||||
var _requestId = 0;
|
var _requestId = 0;
|
||||||
|
var _streamCallbacks = {};
|
||||||
|
var _streamId = 0;
|
||||||
|
var _eventListeners = {};
|
||||||
|
var _toolHandlers = {};
|
||||||
|
var _appMessageHandler = null;
|
||||||
|
var _webhookHandlers = {};
|
||||||
|
|
||||||
function sendRequest(method, params) {
|
function sendRequest(method, params) {
|
||||||
return new Promise(function(resolve, reject) {
|
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) {
|
window.addEventListener("message", function(event) {
|
||||||
if (!event.data || event.data.type !== "dench:response") return;
|
if (!event.data) return;
|
||||||
var pending = _pendingRequests[event.data.id];
|
var d = event.data;
|
||||||
if (!pending) return;
|
|
||||||
delete _pendingRequests[event.data.id];
|
if (d.type === "dench:response") {
|
||||||
if (event.data.error) {
|
var pending = _pendingRequests[d.id];
|
||||||
pending.reject(new Error(event.data.error));
|
if (!pending) return;
|
||||||
} else {
|
delete _pendingRequests[d.id];
|
||||||
pending.resolve(event.data.result);
|
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 }); },
|
query: function(sql) { return sendRequest("db.query", { sql: sql }); },
|
||||||
execute: function(sql) { return sendRequest("db.execute", { 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: {
|
files: {
|
||||||
read: function(path) { return sendRequest("files.read", { path: path }); },
|
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: {
|
app: {
|
||||||
getManifest: function() { return sendRequest("app.getManifest"); },
|
getManifest: function() { return sendRequest("app.getManifest"); },
|
||||||
getTheme: function() { return sendRequest("app.getTheme"); }
|
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: {
|
agent: {
|
||||||
send: function(message) { return sendRequest("agent.send", { message: message }); }
|
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 }); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
118
apps/web/lib/app-registry.ts
Normal file
118
apps/web/lib/app-registry.ts
Normal file
@ -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<string, ToolDef>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppMessageHandler = (event: {
|
||||||
|
from: string;
|
||||||
|
message: unknown;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
const g = (typeof globalThis !== "undefined" ? globalThis : window) as unknown as {
|
||||||
|
__denchAppRegistry?: Map<string, Set<AppInstance>>;
|
||||||
|
__denchAppMessageHandlers?: Set<AppMessageHandler>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ToolDef & { appName: string }> {
|
||||||
|
const result: Array<ToolDef & { appName: string }> = [];
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user