Beacon / src /server.js
Ig0tU
feat: beacon dashboard — space cards, live preview, output viewer
bb92ce1
Raw
History Blame Contribute Delete
11.2 kB
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)})() ║
╚══════════════════════════════════════════════════════╝
`)