Spaces:
Sleeping
Sleeping
| import Fastify from 'fastify' | |
| import cors from '@fastify/cors' | |
| import { readFileSync } from 'fs' | |
| import { resolve, dirname } from 'path' | |
| import { fileURLToPath } from 'url' | |
| import { getContext, closeContext } from './browser.js' | |
| import { extractContent } from './extractor.js' | |
| import { pageToRSS } from './feeds.js' | |
| import { chat } from './llm.js' | |
| import { resolveManifest, resolveDatasetManifest, getKnownSpaces } from './curlycue.js' | |
| import { callSpace, callSpaceFromPrompt, previewSpaceCall } from './spacecall.js' | |
| const __dirname = dirname(fileURLToPath(import.meta.url)) | |
| const AGENTS_MD = readFileSync(resolve(__dirname, '../AGENTS.md'), 'utf8') | |
| const UI_HTML = readFileSync(resolve(__dirname, './ui.html'), 'utf8') | |
| const INJECT_SRC = resolve(__dirname, './inject.browser.js') | |
| const PORT = parseInt(process.env.BEACON_PORT ?? '3700') | |
| const PUBLIC_BASE = process.env.BEACON_PUBLIC_URL || `http://localhost:${PORT}` | |
| const app = Fastify({ logger: { level: 'warn' } }) | |
| await app.register(cors) | |
| // ── Manifest ──────────────────────────────────────────────────────────────── | |
| app.get('/', async (req, reply) => { | |
| const accept = req.headers.accept ?? '' | |
| if (accept.includes('text/html')) { | |
| reply.type('text/html').send(UI_HTML) | |
| } else { | |
| reply.type('text/markdown').send(AGENTS_MD) | |
| } | |
| }) | |
| app.get('/ui', async (_, reply) => { | |
| reply.type('text/html').send(UI_HTML) | |
| }) | |
| // /agents.md alias — standard path for the curlyCue spec | |
| app.get('/agents.md', async (_, reply) => { | |
| reply.type('text/markdown').send(AGENTS_MD) | |
| }) | |
| app.get('/health', async () => ({ status: 'ok', ts: Date.now(), port: PORT })) | |
| // ── Space caller — call any Gradio Space through the beacon ───────────────── | |
| // POST /space/call { space: "owner/name", endpoint: "predict", inputs: {...} } | |
| // File inputs that are URLs are uploaded automatically. | |
| // Returns { outputs, output_urls, space, endpoint, event_id } | |
| app.post('/space/call', async (req, reply) => { | |
| const { space, endpoint, inputs, timeout } = req.body ?? {} | |
| if (!space) return reply.code(400).send({ error: 'space required (e.g. "microsoft/TRELLIS.2")' }) | |
| try { | |
| const result = await callSpace({ space, endpoint, inputs, timeout }) | |
| return result | |
| } catch (err) { | |
| reply.code(502).send({ error: err.message }) | |
| } | |
| }) | |
| // Natural language → Space call | |
| // POST /space/ask { space: "owner/name", prompt: "make a 3d model from this image: https://..." } | |
| // LLM reads the Space schema and fills in endpoint + inputs automatically | |
| app.post('/space/ask', async (req, reply) => { | |
| const { space, prompt, timeout } = req.body ?? {} | |
| if (!space) return reply.code(400).send({ error: 'space required' }) | |
| if (!prompt) return reply.code(400).send({ error: 'prompt required' }) | |
| try { | |
| const result = await callSpaceFromPrompt({ space, prompt, timeout }) | |
| return result | |
| } catch (err) { | |
| reply.code(502).send({ error: err.message }) | |
| } | |
| }) | |
| // List all known spaces — powers the sidebar cards in /ui | |
| app.get('/space/list', async () => getKnownSpaces()) | |
| // Dry-run: LLM resolves parameters but does NOT call the Space | |
| // Returns { curl, structured, resolved } for the UI preview panel | |
| app.post('/space/preview', async (req, reply) => { | |
| const { space, prompt } = req.body ?? {} | |
| if (!space) return reply.code(400).send({ error: 'space required' }) | |
| if (!prompt) return reply.code(400).send({ error: 'prompt required' }) | |
| try { | |
| const result = await previewSpaceCall({ space, prompt }) | |
| return result | |
| } catch (err) { | |
| reply.code(502).send({ error: err.message }) | |
| } | |
| }) | |
| // ── curlyCue — AGENTS.md for any HF Space or dataset ──────────────────────── | |
| // curl https://acecalisto3-beacon.hf.space/spaces/owner/name/agents.md | |
| app.get('/spaces/:owner/:name/agents.md', async (req, reply) => { | |
| const { owner, name } = req.params | |
| const result = await resolveManifest(owner, name) | |
| if (!result) return reply.code(404).send(`# AGENTS.md not found\n\n${owner}/${name} has no discoverable manifest.`) | |
| reply.type('text/markdown').header('X-CurlyCue-Source', result.source).send(result.content) | |
| }) | |
| // curl https://acecalisto3-beacon.hf.space/datasets/owner/name/agents.md | |
| app.get('/datasets/:owner/:name/agents.md', async (req, reply) => { | |
| const { owner, name } = req.params | |
| try { | |
| const content = await resolveDatasetManifest(owner, name) | |
| reply.type('text/markdown').header('X-CurlyCue-Source', 'hf-dataset').send(content) | |
| } catch (err) { | |
| reply.code(502).send({ error: err.message }) | |
| } | |
| }) | |
| // ── Core fetch helpers ─────────────────────────────────────────────────────── | |
| async function withPage(url, fn, reply) { | |
| if (!url) return reply.code(400).send({ error: 'url param required' }) | |
| const ctx = await getContext() | |
| const page = await ctx.newPage() | |
| try { | |
| await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }) | |
| return await fn(page) | |
| } catch (err) { | |
| reply.code(502).send({ error: err.message }) | |
| } finally { | |
| await page.close() | |
| } | |
| } | |
| // ── Endpoints ──────────────────────────────────────────────────────────────── | |
| // Clean readable text — the workhorse | |
| app.get('/raw', async (req, reply) => { | |
| const result = await withPage(req.query.url, page => extractContent(page, 'text'), reply) | |
| if (result) reply.type('text/plain').send(result) | |
| }) | |
| // Structured article JSON | |
| app.get('/json', async (req, reply) => { | |
| const result = await withPage(req.query.url, page => extractContent(page, 'json'), reply) | |
| if (result) reply.send(result) | |
| }) | |
| // RSS feed from any URL | |
| app.get('/feed', async (req, reply) => { | |
| const { url } = req.query | |
| const result = await withPage(url, page => pageToRSS(page, url), reply) | |
| if (result) reply.type('application/rss+xml').send(result) | |
| }) | |
| // Screenshot | |
| app.get('/screenshot', async (req, reply) => { | |
| const result = await withPage( | |
| req.query.url, | |
| page => page.screenshot({ fullPage: false, type: 'png' }), | |
| reply | |
| ) | |
| if (result) reply.type('image/png').send(result) | |
| }) | |
| // Inject script — served as JS so any page can load it | |
| // Bookmarklet: javascript:(function(){var s=document.createElement('script');s.src='BASE/inject.js';document.head.appendChild(s)})() | |
| app.get('/inject.js', async (_, reply) => { | |
| const script = readFileSync(INJECT_SRC, 'utf8').replaceAll('__BEACON_BASE__', PUBLIC_BASE) | |
| reply.type('application/javascript').send(script) | |
| }) | |
| // Chat — natural language in, LLM reply out | |
| // Body: { message: string, context?: string (URL) } | |
| // The current page is fetched through the beacon and injected as context | |
| app.post('/chat', async (req, reply) => { | |
| const { message, context: contextUrl } = req.body ?? {} | |
| if (!message) return reply.code(400).send({ error: 'message required' }) | |
| let pageContent = '' | |
| if (contextUrl) { | |
| try { | |
| const ctx = await getContext() | |
| const page = await ctx.newPage() | |
| try { | |
| await page.goto(contextUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }) | |
| const raw = await extractContent(page, 'json') | |
| pageContent = `Title: ${raw.title}\nURL: ${raw.url}\n\n${(raw.content || raw.text || '').slice(0, 5000)}` | |
| } finally { | |
| await page.close() | |
| } | |
| } catch {} | |
| } | |
| const messages = [ | |
| { | |
| role: 'system', | |
| content: `You are a browsing agent with authenticated access to the web. | |
| You are embedded in the user's browser via the beacon console interface. | |
| Respond concisely and directly — this is a DevTools console, not a chat UI. | |
| ${pageContent ? 'Current page content is provided below.' : 'No page context provided.'}`, | |
| }, | |
| ...(pageContent ? [{ | |
| role: 'user', | |
| content: `Page content:\n${pageContent}`, | |
| }, { | |
| role: 'assistant', | |
| content: 'I have the page content. What would you like to know?', | |
| }] : []), | |
| { role: 'user', content: message }, | |
| ] | |
| try { | |
| const reply_text = await chat(messages) | |
| return { reply: reply_text } | |
| } catch (err) { | |
| return reply.code(502).send({ error: err.message }) | |
| } | |
| }) | |
| // Save session snapshot for a named site (call after you've logged in via CDP) | |
| app.post('/session/:name', async (req, reply) => { | |
| const { name } = req.params | |
| const ctx = await getContext() | |
| try { | |
| const path = resolve(__dirname, `../sessions/snapshots/${name}.json`) | |
| await ctx.storageState({ path }) | |
| return { saved: path } | |
| } catch (err) { | |
| reply.code(500).send({ error: err.message }) | |
| } | |
| }) | |
| // ── Start ──────────────────────────────────────────────────────────────────── | |
| process.on('SIGINT', async () => { | |
| await closeContext() | |
| process.exit(0) | |
| }) | |
| await app.listen({ port: PORT, host: '0.0.0.0' }) | |
| console.log(` | |
| ╔══════════════════════════════════════════════════════╗ | |
| ║ beacon :${PORT} ║ | |
| ╠══════════════════════════════════════════════════════╣ | |
| ║ GET /ui → beacon dashboard ║ | |
| ║ GET /space/list → known spaces (JSON) ║ | |
| ║ POST /space/preview → resolve params, no call ║ | |
| ║ POST /space/ask → NL → Space call ║ | |
| ║ POST /space/call → direct Space call ║ | |
| ╠══════════════════════════════════════════════════════╣ | |
| ║ GET /raw?url=... → clean text ║ | |
| ║ GET /json?url=... → article JSON ║ | |
| ║ GET /feed?url=... → RSS feed ║ | |
| ║ GET /screenshot?url=... → PNG ║ | |
| ║ GET /inject.js → browser console bridge ║ | |
| ║ POST /chat → LLM chat w/ page context ║ | |
| ║ POST /session/:name → save auth state ║ | |
| ╠══════════════════════════════════════════════════════╣ | |
| ║ Console bookmarklet: ║ | |
| ║ javascript:(function(){var s=document.createElement ║ | |
| ║ ('script');s.src='${PUBLIC_BASE}/inject.js'; ║ | |
| ║ document.head.appendChild(s)})() ║ | |
| ╚══════════════════════════════════════════════════════╝ | |
| `) | |