Spaces:
Sleeping
Sleeping
| // spacecall — call any Gradio Space through the beacon | |
| // Handles the full Gradio pattern: upload → call → poll → result | |
| // Requires HF_TOKEN env var for authenticated access (ZeroGPU quota, private spaces) | |
| const HF_TOKEN = process.env.HF_TOKEN | |
| const authHeader = HF_TOKEN ? { Authorization: `Bearer ${HF_TOKEN}` } : {} | |
| function spaceUrl(owner, name) { | |
| const slug = s => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') | |
| return `https://${slug(owner)}-${slug(name)}.hf.space` | |
| } | |
| // Upload a file (URL or local path) to a Space's /gradio_api/upload | |
| // Returns the Gradio FileData object ready to use as input | |
| async function uploadToSpace(base, fileUrl, filename) { | |
| // Fetch the source file | |
| const fileRes = await fetch(fileUrl) | |
| if (!fileRes.ok) throw new Error(`Could not fetch file: ${fileUrl}`) | |
| const blob = await fileRes.blob() | |
| const form = new FormData() | |
| form.append('files', blob, filename || 'upload') | |
| const r = await fetch(`${base}/gradio_api/upload`, { | |
| method: 'POST', | |
| headers: { ...authHeader }, | |
| body: form, | |
| }) | |
| if (!r.ok) throw new Error(`Upload failed: ${r.status} ${await r.text()}`) | |
| const paths = await r.json() // [ "/tmp/gradio/.../filename" ] | |
| const path = paths[0] | |
| return { | |
| path, | |
| meta: { _type: 'gradio.FileData' }, | |
| orig_name: filename || path.split('/').pop(), | |
| } | |
| } | |
| // Resolve inputs — any string that looks like a URL gets uploaded first | |
| async function resolveInputs(base, inputs) { | |
| const resolved = {} | |
| for (const [key, val] of Object.entries(inputs)) { | |
| if (typeof val === 'string' && (val.startsWith('http://') || val.startsWith('https://'))) { | |
| const filename = val.split('/').pop().split('?')[0] || key | |
| resolved[key] = await uploadToSpace(base, val, filename) | |
| } else { | |
| resolved[key] = val | |
| } | |
| } | |
| return resolved | |
| } | |
| // Poll a Gradio SSE stream until complete, return parsed result | |
| async function pollResult(url, timeoutMs = 120000) { | |
| const start = Date.now() | |
| const r = await fetch(url, { headers: { ...authHeader } }) | |
| if (!r.ok) throw new Error(`Poll failed: ${r.status}`) | |
| const reader = r.body.getReader() | |
| const decoder = new TextDecoder() | |
| let buffer = '' | |
| while (Date.now() - start < timeoutMs) { | |
| const { done, value } = await reader.read() | |
| if (done) break | |
| buffer += decoder.decode(value, { stream: true }) | |
| const lines = buffer.split('\n') | |
| buffer = lines.pop() | |
| for (const line of lines) { | |
| if (line.startsWith('event: complete')) { | |
| // next line will be data | |
| continue | |
| } | |
| if (line.startsWith('data:') && buffer.includes('event: complete') || lines.some(l => l.includes('event: complete'))) { | |
| try { | |
| const data = JSON.parse(line.replace('data: ', '')) | |
| return data | |
| } catch {} | |
| } | |
| if (line.startsWith('data:')) { | |
| try { | |
| const data = JSON.parse(line.replace('data: ', '')) | |
| // If this is a final result (array, not a status object) | |
| if (Array.isArray(data)) return data | |
| } catch {} | |
| } | |
| if (line.includes('event: error')) { | |
| throw new Error(`Space returned error during generation`) | |
| } | |
| } | |
| } | |
| throw new Error('Timed out waiting for Space result') | |
| } | |
| // Fetch the Gradio schema for a Space | |
| async function fetchSpaceSchema(base) { | |
| const url = `${base}/gradio_api/info` | |
| let r | |
| try { | |
| r = await fetch(url, { signal: AbortSignal.timeout(10000) }) | |
| } catch (err) { | |
| throw new Error(`Schema fetch failed (${url}): ${err.message}`) | |
| } | |
| if (!r.ok) throw new Error(`Schema fetch ${r.status} (${url})`) | |
| return r.json() | |
| } | |
| // Use the LLM to map a natural language prompt → { endpoint, inputs } | |
| // given the Space's Gradio schema | |
| async function resolveWithLLM(schema, userPrompt, { chat }) { | |
| const endpoints = Object.entries(schema?.named_endpoints ?? {}) | |
| .map(([name, def]) => { | |
| const params = (def.parameters ?? []) | |
| .map(p => ` ${p.label} (${p.python_type?.type ?? p.type ?? 'any'}): ${p.component ?? ''}`) | |
| .join('\n') | |
| return `Endpoint "${name}":\n${params}` | |
| }) | |
| .join('\n\n') | |
| const messages = [ | |
| { | |
| role: 'system', | |
| content: `You are an API parameter resolver. Given a Gradio Space's available endpoints and a user's natural language request, return ONLY a JSON object with two fields: | |
| - "endpoint": the best matching endpoint name (string) | |
| - "inputs": an object mapping each required parameter name to its value | |
| Rules: | |
| - For image/file parameters: use the URL directly as the string value (the caller will handle upload) | |
| - For missing optional parameters: omit them | |
| - Return ONLY valid JSON, no explanation, no markdown` | |
| }, | |
| { | |
| role: 'user', | |
| content: `Available endpoints:\n${endpoints}\n\nUser request: ${userPrompt}\n\nReturn JSON only:` | |
| } | |
| ] | |
| const reply = await chat(messages) | |
| // Strip markdown code fences if LLM added them | |
| const clean = reply.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim() | |
| return JSON.parse(clean) | |
| } | |
| // Main entrypoint — call any Gradio Space endpoint | |
| // space: "owner/name" or { owner, name } | |
| // endpoint: named endpoint string e.g. "predict" or "/predict" | |
| // inputs: object of named parameters | |
| // returns: { outputs, output_urls, raw } | |
| export async function callSpace({ space, endpoint = 'predict', inputs = {}, timeout = 120000 }) { | |
| const [owner, name] = typeof space === 'string' ? space.split('/') : [space.owner, space.name] | |
| if (!owner || !name) throw new Error('space must be "owner/name"') | |
| const base = spaceUrl(owner, name) | |
| const ep = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint | |
| // Resolve file inputs | |
| const resolvedInputs = await resolveInputs(base, inputs) | |
| // Submit | |
| const callRes = await fetch(`${base}/gradio_api/call/v2/${ep}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', ...authHeader }, | |
| body: JSON.stringify(resolvedInputs), | |
| }) | |
| if (!callRes.ok) { | |
| const err = await callRes.text() | |
| throw new Error(`Space call failed ${callRes.status}: ${err}`) | |
| } | |
| const { event_id } = await callRes.json() | |
| if (!event_id) throw new Error('No event_id returned from Space') | |
| // Poll | |
| const pollUrl = `${base}/gradio_api/call/${ep}/${event_id}` | |
| const raw = await pollResult(pollUrl, timeout) | |
| // Extract output URLs (Gradio returns FileData objects or plain values) | |
| const output_urls = [] | |
| const walk = (val) => { | |
| if (!val) return | |
| if (typeof val === 'string' && val.startsWith('/tmp/')) { | |
| output_urls.push(`${base}/gradio_api/file=${val}`) | |
| } else if (val?.path) { | |
| output_urls.push(`${base}/gradio_api/file=${val.path}`) | |
| } else if (Array.isArray(val)) { | |
| val.forEach(walk) | |
| } else if (typeof val === 'object') { | |
| Object.values(val).forEach(walk) | |
| } | |
| } | |
| walk(raw) | |
| return { outputs: raw, output_urls, space: `${owner}/${name}`, endpoint: ep, event_id } | |
| } | |
| // Build a Gradio-compatible schema object from a KNOWN_MANIFESTS entry | |
| // so we never need a live /gradio_api/info fetch for known spaces | |
| function manifestToSchema(manifest) { | |
| const params = (manifest.inputs ?? []).map(i => ({ | |
| label: i.name, | |
| python_type: { type: i.type ?? 'string' }, | |
| component: i.type ?? 'string', | |
| })) | |
| return { named_endpoints: { predict: { parameters: params } } } | |
| } | |
| async function getSchema(space) { | |
| const { getSpaceManifest } = await import('./curlycue.js') | |
| const manifest = getSpaceManifest(space) | |
| if (manifest) return manifestToSchema(manifest) | |
| const [owner, name] = space.split('/') | |
| return fetchSpaceSchema(spaceUrl(owner, name)) | |
| } | |
| // Preview only — LLM resolves parameters but does NOT execute the Space call | |
| // Used by the UI to show curl preview before committing | |
| export async function previewSpaceCall({ space, prompt }) { | |
| const { chat } = await import('./llm.js') | |
| const schema = await getSchema(space) | |
| const resolved = await resolveWithLLM(schema, prompt, { chat }) | |
| return { | |
| space, | |
| resolved, | |
| curl: `curl -X POST https://acecalisto3-beacon.hf.space/space/ask \\\n -H "Content-Type: application/json" \\\n -d '${JSON.stringify({ space, prompt }, null, 2)}'`, | |
| structured: { space, endpoint: resolved.endpoint, inputs: resolved.inputs }, | |
| } | |
| } | |
| // Natural language entrypoint — user just describes what they want | |
| // The LLM reads the Space schema and fills in the parameters | |
| export async function callSpaceFromPrompt({ space, prompt, timeout = 120000 }) { | |
| const { chat } = await import('./llm.js') | |
| const schema = await getSchema(space) | |
| const resolved = await resolveWithLLM(schema, prompt, { chat }) | |
| if (!resolved?.endpoint || !resolved?.inputs) { | |
| throw new Error('LLM could not resolve Space parameters from prompt') | |
| } | |
| const result = await callSpace({ space, endpoint: resolved.endpoint, inputs: resolved.inputs, timeout }) | |
| return { ...result, resolved_from_prompt: prompt, llm_resolved: resolved } | |
| } | |