PromptPool MVP: token-donation marketplace for vibe-coding projects

Vetted vision (VISION.md) + working implementation:
- FastAPI + SQLite server: accounts, project marketplace, supports,
  AI Donor leaderboards, batch queue, node pairing/claim protocol
- OpenAI-compatible /v1/chat/completions realtime proxy (incl. SSE)
  backed by volunteer nodes; model preference + fallback matching
- Stdlib-only node connector: pairing-code onboarding, auto-detected
  runners (claude/codex/gemini/cursor CLIs, direct Anthropic/OpenAI
  APIs, echo test runner); provider keys never leave the node
- Zero-build vanilla JS web UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stephen M AbbottandClaude Fable 5 committed 2026-07-23 10:28:56 -07:00
commit 82d4a37af1
12 files changed
+2167

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "promptpool",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "uvicorn", "server.main:app", "--port", "8377"],
"port": 8377
}
]
}
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
data/
.venv/
+99
View File
@@ -0,0 +1,99 @@
# 🪙 PromptPool
**Some devs have tokens and no ideas. Some have ideas and no tokens.**
PromptPool is a marketplace of vibe-coding projects and a pool of donated AI
compute. Project owners get a queue and an OpenAI-compatible endpoint backed
by volunteers. Donors point their idle AI subscriptions / API keys at projects
they believe in and get credited as **AI Donors** on the project page —
GoFundMe, but the currency is tokens.
> Read [VISION.md](VISION.md) for the full vetting: what's strong, what's
> hard (trust, ToS, privacy, cold start), and how the design answers it.
```
idea-rich, token-poor token-rich, idea-poor
┌──────────────────────┐ ┌──────────────────────┐
│ Hermes / OpenClaw / │ /v1/chat/… ┌────┐ │ node_connector.py │
│ aider / curl / any ├─────────────►│ │◄┤ polls for work │
│ OpenAI-style client │◄─────────────┤pool│─┤ runs claude/codex/ │
└──────────────────────┘ answer │ │ │ gemini/API runners │
┌──────────────────────┐ │ │ │ KEYS STAY LOCAL │
│ project batch queue ├─────────────►│ │ └──────────────────────┘
│ (web UI, public feed)│ └────┘ credited as AI Donor
└──────────────────────┘ FastAPI+SQLite, one process
```
## Run the server
```bash
pip install -r server/requirements.txt
uvicorn server.main:app --port 8377
```
Open http://localhost:8377 — create an account (you get an API key, shown
once, stored hashed), create a project (you get an inference key for its
realtime endpoint), and browse the marketplace.
## Connect a node (become a donor)
On any machine that has an AI CLI (`claude`, `codex`, `gemini`,
`cursor-agent`) **or** a metered provider key in the environment
(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`):
1. Web UI → **Account** → *Generate pairing code*
2. ```bash
python connector/node_connector.py --server http://localhost:8377 --code XXXX-XXXX
```
3. **Support** projects in the marketplace. Your node serves only queues you
opted into, and every completed job credits you on the project page.
The connector is stdlib-only Python — nothing to install. It re-runs with no
arguments after the first pairing (`~/.promptpool/node.json` holds the node
token; never your provider keys). A built-in `echo` runner
(`--runners echo`) lets you test the full loop without spending a token.
## Use the pool from your tools
Any OpenAI-compatible client works — set the base URL and use the project's
inference key:
```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8377/v1", api_key="ppi_...")
r = client.chat.completions.create(
model="default", # or override the project's model preference
messages=[{"role": "user", "content": "Plan the refactor of miner.py"}],
)
print(r.choices[0].message.content)
```
Jobs carry the project's **model preference + fallback**, **temperature**,
and **max_tokens**; nodes only claim jobs whose model they declared they can
serve. CLI runners apply what their CLI supports; the direct-API runners
honor temperature/max_tokens exactly.
## Safety model (short version)
- **Keys never leave the node.** The server stores only salted-hash
credentials it issued itself; provider keys live in node env vars.
- **Everything is public by default.** Job prompts and outputs are visible on
the project page — sunlight is the anti-abuse mechanism. Never submit secrets.
- **Nodes opt in per project.** Nothing is served that you didn't choose.
- **CLI runners execute in an empty scratch dir** with agentic turns capped;
API runners have no tool access at all.
- **Donation-only.** No payments, no token resale. Node operators are
responsible for their own provider terms; metered API keys (or, soon, local
models via ollama) are the recommended way to donate.
## Repo map
| Path | What |
|---|---|
| `server/` | FastAPI app + SQLite (`server.main:app`) |
| `web/` | zero-build vanilla JS UI served at `/` |
| `connector/node_connector.py` | the node agent (stdlib only) |
| `VISION.md` | the vetted vision & roadmap |
Roadmap highlights (see VISION.md): karma credits, GitHub `issue` jobs that
open PRs, pledge scheduling, trusted-donor tiers, ollama runner.
+190
View File
@@ -0,0 +1,190 @@
# PromptPool — Vision & Vetting
> **Some devs have tokens and no ideas. Some have ideas and no tokens.**
> PromptPool is the place where they trade — a marketplace of vibe-coding
> projects, and a pool of donated AI compute that keeps them moving.
This document is the honest vetting of the idea: what's genuinely strong,
what's genuinely hard, and what we added to the vision to make it something
every vibe coder would actually want to use.
---
## 1. What's strong about the original idea
**The pain is real and it is daily.** Every serious AI-assisted developer has
hit "you're out of usage until 7pm" mid-flow, and every one of them has also
had nights where a Max subscription or an API budget sat completely idle.
Supply and demand exist *inside the same community*, often inside the same
person on different days. That's the best possible market condition.
**The GoFundMe framing beats the "GPU rental" framing.** Paid compute
marketplaces (vast.ai, RunPod) already exist and are commodity businesses.
Nobody has built the *patronage* version for LLM work: "I believe in your
open-source project, so my idle agent works your issue queue tonight."
Donation sidesteps payments, pricing, and most legal weight — and it matches
how open source actually runs (sponsors, stars, contributors).
**The OpenAI-compatible proxy is the killer feature.** If submitting work is
`POST /v1/chat/completions` with a project key, then *every existing tool* —
Hermes Desktop, OpenClaw, Cline, aider, LangChain, a raw `openai` client —
can use the pool by changing one base URL. Zero integration cost is what
makes adoption plausible.
**GitHub-centricity gives the work provenance.** A donated answer in a chat
window is ephemeral. A donated answer that becomes a branch and a PR on the
project's repo is a *contribution* — reviewable, attributable, and visible on
a contributor graph. That's the difference between charity and community.
**Keys stay on the node.** Never custodying API keys is the single most
important architectural decision in the original idea, and it's correct.
The service moves *prompts and answers*, never credentials.
---
## 2. The hard problems (and what we do about them)
An idea this social has failure modes that are social, not technical.
Ignoring them is how you build a spam funnel with a leaderboard. Facing them:
### 2.1 Trust runs in both directions
*A node operator is executing text written by a stranger.* If the runner has
tool access, a hostile prompt can read the operator's filesystem or exfiltrate
env vars — including the very API keys we promised never to move.
*A submitter is accepting text written by a stranger's model.* It can be
wrong, lazy, or malicious (poisoned code suggestions).
**Mitigations built into the design:**
- The connector runs CLI agents **in an empty scratch working directory**,
non-interactive, with agentic turns capped — not in the operator's repos.
- Direct-API runners (Anthropic/OpenAI over HTTPS) have **no tool access at
all** and are the recommended default for serving strangers.
- Donated code lands as **a PR, never a merge**. Human review is the trust
boundary on the receiving side, exactly as it is for human contributors.
- **Everything is public by default.** Every job's prompt and output is
visible on the project page. Sunlight is the anti-abuse mechanism: nobody
routes their private chatbot traffic through a queue the whole internet
can read.
- Operators choose **which projects they serve**, per node. Nothing is
opt-out; everything is opt-in.
### 2.2 Provider terms of service
This must be said plainly: **flat-rate subscription seats (Claude Pro/Max,
ChatGPT Plus) generally prohibit resharing access as a service.** Metered
API keys are different — that's your own pay-per-use spend, and donating
*outputs you paid for* is the clean model.
So the platform's stance is:
- PromptPool is **donation-only. No money moves. Tokens are never sold.**
This keeps the platform out of "reselling API access" territory.
- Each node operator is responsible for their own provider agreement. The
connector documentation says so, and the direct-API runners (metered keys,
clearly compliant) are the recommended path.
- **Local models close the loop entirely.** An `ollama` runner (roadmap)
makes "donating compute" literal — your GPU, your electricity, zero ToS
questions. This is the long-term center of gravity for the donation pool.
### 2.3 Privacy
Whoever processes your prompt reads your prompt. There is no way around
this, so the product must never pretend otherwise:
- Positioning is **for open-source projects** — work that's already public.
- The UI warns at submission: *prompts are public and processed by
volunteers; never include secrets.*
- Roadmap: **trusted-donor tiers** — maintainers can mark specific donors
trusted and route sensitive queues only to them (web of trust, like
maintainer bits in package ecosystems).
### 2.4 Quality and verification
You cannot cryptographically prove a volunteer used the requested model or
gave an honest answer. Don't build fake assurances; build reputation:
- Every completion is attributed to a node and an account. Bad output is
visible in public job logs next to the donor's name.
- Model preference + fallback is **matched at claim time** — a node only
receives jobs it declared it can serve.
- PR-based work is **self-verifying**: CI runs it, a maintainer reviews it.
- Roadmap: spot-check jobs (same prompt to two donors, diff), and donor
reputation scores derived from accepted-vs-rejected PRs.
### 2.5 Cold start
A two-sided marketplace with zero donors is a static website. The design
dodges this because **PromptPool is useful at N=1 account**: connect your
own three machines (work box with Claude, home box with a metered key,
laptop with codex) and you have a personal compute pool — one URL that
fans out to whichever of your own runners is alive. Donation is a *layer*
on top of a tool that's already worth running selfishly. That's the same
bootstrap that made BitTorrent work: seeding is a side effect of using it.
### 2.6 Realtime latency honesty
Volunteer nodes on long-poll will answer in seconds-to-minutes, not
milliseconds. That's fine for agent workflows and batch queues; it's not a
snappy chatbot. The proxy sets honest timeouts, supports fallback models,
and the docs say: realtime is *best-effort*; batch is the reliable core.
---
## 3. Additions to the vision
Things the original idea implied but didn't name — these are what make it
sticky for every vibe coder, not just the altruistic ones:
**Karma credits (non-monetary reciprocity).** Donating processing earns
credits; credits give *your* projects priority in the queue when you're the
one out of tokens. Tit-for-tat turns one-way charity into a flywheel:
tonight my idle Max serves your issue queue, next week your nodes carry me
past my rate limit. (Credits are never purchasable — that would re-import
every payment/ToS problem we just avoided.)
**AI Donors as a first-class identity.** Public donor leaderboards per
project and globally; "Powered by 12 AI Donors" badges for READMEs; donor
profiles listing the projects they've carried. GitHub made a green graph a
status symbol; PromptPool makes donated tokens one.
**Pledges, not just polling.** "My node serves `doge-miner` nightly,
11pm–7am, cap 200k tokens/night." Scheduled, capped, recurring patronage —
the GoFundMe recurring-donation model, but for compute.
**Runner adapters as plugins.** CLI runners (claude / codex / gemini /
cursor-agent), direct-API runners (which honor temperature and max_tokens
*exactly*, where CLIs can't), a builtin `echo` runner for testing, and
roadmap `ollama` for local models. One JSON template per runner; adding a
new CLI is config, not code.
**Three job kinds, one pipeline.**
1. `realtime` — the OpenAI-compatible proxy call; a tool any agent can call.
2. `batch` — queued prompts; results collected on the project page.
3. `issue` (roadmap) — node clones the linked repo in a sandbox, works the
GitHub issue on a branch, opens a PR credited to the donor.
**Transparency as a feature.** The public job feed doubles as the project's
"AI activity" page — proof of life for the project, provenance for answers,
and the strongest spam deterrent available.
---
## 4. What ships in this MVP
| Piece | Status |
|---|---|
| FastAPI server + SQLite, single process | ✅ |
| Accounts with API keys (hashed at rest, shown once) | ✅ |
| Project marketplace: create, browse, support, donor leaderboards | ✅ |
| Model preference + fallback, temperature, max_tokens per project/job | ✅ |
| Batch prompt queue with public job feed | ✅ |
| OpenAI-compatible `/v1/chat/completions` realtime proxy (incl. streaming) | ✅ |
| `node-connector.py` — stdlib-only, pairing-code onboarding, auto-detects runners | ✅ |
| Runners: claude CLI, codex CLI, gemini CLI, cursor-agent, direct Anthropic API, direct OpenAI API, echo | ✅ |
| Keys stay in node env vars; never transmitted, never stored server-side | ✅ |
| Karma credits ledger | roadmap |
| GitHub `issue` job kind → branch → PR | roadmap |
| Pledge scheduling, trusted-donor tiers, ollama runner, spot-checks | roadmap |
The MVP is deliberately one `pip install` + one script, because the entire
thesis is that this must be *clean and simple to run* — a vibe coder should
go from "found the repo" to "my node is serving a project" in five minutes.
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""PromptPool node connector — donate your idle AI tokens to projects you support.
Stdlib only. First run (pairing code from your Account page):
python node_connector.py --server http://localhost:8377 --code AB12-CD34
Later runs just: python node_connector.py
Your API keys NEVER leave this machine. The connector reads prompts from the
pool, runs them through a local CLI (claude/codex/gemini/cursor-agent) or a
direct provider API using YOUR environment variables, and posts back only the
answer. CLI runners execute in an empty scratch directory with agentic turns
capped. You are responsible for staying within your provider's terms of
service — metered API keys are the recommended way to donate.
"""
import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from fnmatch import fnmatch
from pathlib import Path
CONFIG_DIR = Path(os.environ.get("PROMPTPOOL_HOME", Path.home() / ".promptpool"))
CONFIG_PATH = CONFIG_DIR / "node.json"
WORK_DIR = CONFIG_DIR / "workdir" # empty scratch cwd for CLI runners
# Runner templates. {model} is substituted; prompt goes via stdin when
# "stdin" is true, otherwise replaces "{prompt}" in the argument list.
# Override any of these in node.json under "runner_overrides".
CLI_RUNNERS = {
"claude": {
"bin": "claude",
"models": ["claude*", "sonnet*", "opus*", "haiku*"],
"cmd": ["claude", "-p", "--model", "{model}",
"--output-format", "text", "--max-turns", "1"],
"stdin": True,
},
"codex": {
"bin": "codex",
"models": ["gpt*", "codex*", "o1*", "o3*", "o4*"],
"cmd": ["codex", "exec", "-m", "{model}", "{prompt}"],
"stdin": False,
},
"gemini": {
"bin": "gemini",
"models": ["gemini*"],
"cmd": ["gemini", "-m", "{model}", "-p", "{prompt}"],
"stdin": False,
},
"cursor-agent": {
"bin": "cursor-agent",
"models": ["cursor*"],
"cmd": ["cursor-agent", "-p", "--model", "{model}",
"--output-format", "text", "{prompt}"],
"stdin": False,
},
}
API_RUNNERS = {
"anthropic-api": {"env": "ANTHROPIC_API_KEY", "models": ["claude*"]},
"openai-api": {"env": "OPENAI_API_KEY", "models": ["gpt*", "o1*", "o3*", "o4*"]},
}
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def http_json(method: str, url: str, body=None, token=None, timeout=40):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
# ------------------------------------------------------------------ runners
def detect_runners(requested: list[str] | None) -> dict[str, dict]:
found = {}
for name, spec in CLI_RUNNERS.items():
if shutil.which(spec["bin"]):
found[name] = dict(spec, kind="cli")
for name, spec in API_RUNNERS.items():
if os.environ.get(spec["env"]):
found[name] = dict(spec, kind="api")
if requested:
if "echo" in requested:
found["echo"] = {"kind": "echo", "models": ["*"]}
found = {k: v for k, v in found.items() if k in requested}
missing = [r for r in requested if r not in found]
if missing:
log(f"warning: requested runners not available here: {', '.join(missing)}")
if not found:
log("no runners detected (no AI CLIs on PATH, no provider keys in env).")
log("falling back to the 'echo' test runner — it just echoes prompts back.")
found["echo"] = {"kind": "echo", "models": ["*"]}
return found
def run_cli(spec: dict, job: dict) -> dict:
cmd = []
for part in spec["cmd"]:
part = part.replace("{model}", job["model"])
part = part.replace("{prompt}", job["prompt"])
cmd.append(part)
exe = shutil.which(cmd[0])
if exe:
cmd[0] = exe
timeout = max(60, int(job.get("lease_seconds", 300)) - 30)
WORK_DIR.mkdir(parents=True, exist_ok=True)
proc = subprocess.run(
cmd,
input=job["prompt"] if spec.get("stdin") else None,
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, cwd=str(WORK_DIR),
)
if proc.returncode != 0:
raise RuntimeError(
f"runner exited {proc.returncode}: {(proc.stderr or proc.stdout or '')[:500]}"
)
out = (proc.stdout or "").strip()
if not out:
raise RuntimeError("runner produced no output")
return {"output": out}
def run_anthropic(job: dict) -> dict:
body = {
"model": job["model"],
"max_tokens": job.get("max_tokens") or 1024,
"messages": [{"role": "user", "content": job["prompt"]}],
}
if job.get("temperature") is not None:
body["temperature"] = job["temperature"]
req = urllib.request.Request(
"https://api.anthropic.com/v1/messages",
data=json.dumps(body).encode(), method="POST",
)
req.add_header("Content-Type", "application/json")
req.add_header("x-api-key", os.environ["ANTHROPIC_API_KEY"])
req.add_header("anthropic-version", "2023-06-01")
with urllib.request.urlopen(req, timeout=180) as resp:
r = json.loads(resp.read().decode())
text = "".join(b.get("text", "") for b in r.get("content", []))
usage = r.get("usage", {})
return {"output": text, "tokens_in": usage.get("input_tokens"),
"tokens_out": usage.get("output_tokens")}
def run_openai(job: dict) -> dict:
body = {
"model": job["model"],
"messages": [{"role": "user", "content": job["prompt"]}],
}
if job.get("temperature") is not None:
body["temperature"] = job["temperature"]
if job.get("max_tokens"):
body["max_tokens"] = job["max_tokens"]
req = urllib.request.Request(
"https://api.openai.com/v1/chat/completions",
data=json.dumps(body).encode(), method="POST",
)
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {os.environ['OPENAI_API_KEY']}")
with urllib.request.urlopen(req, timeout=180) as resp:
r = json.loads(resp.read().decode())
usage = r.get("usage", {})
return {"output": r["choices"][0]["message"]["content"],
"tokens_in": usage.get("prompt_tokens"),
"tokens_out": usage.get("completion_tokens")}
def execute(runners: dict, job: dict) -> dict:
name = pick_runner(runners, job["model"])
if not name:
raise RuntimeError(f"no local runner serves model '{job['model']}'")
spec = runners[name]
log(f" running job #{job['id']} with {name} (model {job['model']}) ...")
if spec["kind"] == "echo":
result = {"output": f"[echo runner] You said:\n\n{job['prompt']}"}
elif spec["kind"] == "api":
result = run_anthropic(job) if name == "anthropic-api" else run_openai(job)
else:
result = run_cli(spec, job)
result["runner"] = name
result["model_used"] = job["model"]
return result
def pick_runner(runners: dict, model: str) -> str | None:
for name, spec in runners.items():
if any(fnmatch(model.lower(), pat.lower()) for pat in spec["models"]):
return name
return None
# ------------------------------------------------------------------ lifecycle
def load_config() -> dict:
if CONFIG_PATH.exists():
return json.loads(CONFIG_PATH.read_text())
return {}
def save_config(cfg: dict) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
def register(server: str, code: str, name: str, runners: dict,
models: list[str]) -> dict:
r = http_json("POST", f"{server}/api/nodes/register", {
"code": code, "name": name,
"runners": sorted(runners), "models": models,
})
cfg = {"server": server, "node_token": r["node_token"],
"node_id": r["node_id"], "name": name, "account": r["account"]}
save_config(cfg)
log(f"node '{name}' registered to account @{r['account']} "
f"(config: {CONFIG_PATH})")
return cfg
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--server", help="PromptPool server URL")
ap.add_argument("--code", help="pairing code from your Account page")
ap.add_argument("--name", default=platform.node() or "node")
ap.add_argument("--runners", help="comma list to restrict runners (e.g. claude,echo)")
ap.add_argument("--models", help="comma list of model patterns this node serves")
ap.add_argument("--once", action="store_true", help="process one job, then exit")
args = ap.parse_args()
requested = [r.strip() for r in args.runners.split(",")] if args.runners else None
runners = detect_runners(requested)
overrides = load_config().get("runner_overrides", {})
for rname, patch in overrides.items():
if rname in runners:
runners[rname].update(patch)
models = ([m.strip() for m in args.models.split(",")] if args.models
else sorted({p for s in runners.values() for p in s["models"]}))
cfg = load_config()
if args.code:
if not args.server:
ap.error("--code requires --server")
cfg = register(args.server.rstrip("/"), args.code.upper(), args.name,
runners, models)
if not cfg.get("node_token"):
ap.error("not registered — run once with --server URL --code XXXX-XXXX")
server, token = cfg["server"], cfg["node_token"]
log(f"PromptPool node '{cfg.get('name', args.name)}' → {server}")
log(f"runners: {', '.join(runners)} models: {', '.join(models)}")
log("keys stay in this machine's environment; only prompts/answers move.")
while True:
try:
resp = http_json("POST", f"{server}/api/nodes/poll",
{"runners": sorted(runners), "models": models,
"wait": 20},
token=token, timeout=40)
except urllib.error.HTTPError as e:
log(f"server rejected poll ({e.code}): {e.read().decode()[:200]}")
if e.code == 401:
return 1
time.sleep(10)
continue
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
log(f"cannot reach server ({e}); retrying in 10s")
time.sleep(10)
continue
job = resp.get("job")
if not job:
continue
log(f"claimed job #{job['id']} ({job['kind']}) "
f"for model {job['model']}")
try:
result = execute(runners, job)
http_json("POST", f"{server}/api/nodes/jobs/{job['id']}/complete",
result, token=token)
log(f" ✓ job #{job['id']} done "
f"({len(result['output'])} chars, runner {result['runner']})")
except Exception as e: # noqa: BLE001 — report any failure to the pool
log(f" ✗ job #{job['id']} failed: {e}")
try:
http_json("POST", f"{server}/api/nodes/jobs/{job['id']}/fail",
{"error": str(e)[:1000]}, token=token)
except Exception:
pass
if args.once:
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nbye — your node is offline.")
sys.exit(0)
View File
Whitespace-only changes.
+154
View File
@@ -0,0 +1,154 @@
"""SQLite layer for PromptPool. One file, WAL mode, short-lived connections."""
import hashlib
import os
import secrets
import sqlite3
import time
from contextlib import contextmanager
DATA_DIR = os.environ.get(
"PROMPTPOOL_DATA",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data"),
)
DB_PATH = os.path.join(DATA_DIR, "promptpool.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
key_hash TEXT UNIQUE NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS projects(
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES accounts(id),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
tagline TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
repo_url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL,
fallback_model TEXT NOT NULL DEFAULT '',
temperature REAL,
max_tokens INTEGER,
inference_key_hash TEXT UNIQUE NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes(
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
name TEXT NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
runners TEXT NOT NULL DEFAULT '[]',
models TEXT NOT NULL DEFAULT '[]',
last_seen REAL,
jobs_done INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS supports(
account_id INTEGER NOT NULL REFERENCES accounts(id),
project_id INTEGER NOT NULL REFERENCES projects(id),
created_at REAL NOT NULL,
PRIMARY KEY(account_id, project_id)
);
CREATE TABLE IF NOT EXISTS jobs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
kind TEXT NOT NULL DEFAULT 'batch',
title TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL,
model TEXT NOT NULL,
fallback_model TEXT NOT NULL DEFAULT '',
temperature REAL,
max_tokens INTEGER,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
node_id INTEGER REFERENCES nodes(id),
use_model TEXT,
output TEXT,
error TEXT,
runner TEXT,
model_used TEXT,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
claimed_at REAL,
deadline REAL,
finished_at REAL
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, project_id);
CREATE TABLE IF NOT EXISTS pair_codes(
code TEXT PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id),
expires_at REAL NOT NULL
);
"""
MAX_ATTEMPTS = 3
LEASE_REALTIME = 240 # seconds a node has to finish a realtime job
LEASE_BATCH = 900
@contextmanager
def connect():
"""Commit-and-close connection context (plain `with sqlite3.connect(...)`
commits but never closes, leaking handles in a long-running server)."""
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init() -> None:
os.makedirs(DATA_DIR, exist_ok=True)
with connect() as conn:
conn.executescript(SCHEMA)
def new_key(prefix: str) -> tuple[str, str]:
"""Return (plaintext, sha256hash). Plaintext is shown once, never stored."""
plain = f"{prefix}_{secrets.token_urlsafe(24)}"
return plain, hash_key(plain)
def hash_key(plain: str) -> str:
return hashlib.sha256(plain.encode()).hexdigest()
def now() -> float:
return time.time()
def requeue_expired(conn: sqlite3.Connection) -> None:
"""Return timed-out running jobs to the queue; fail them after MAX_ATTEMPTS."""
t = now()
conn.execute(
"UPDATE jobs SET status='queued', node_id=NULL, use_model=NULL, "
"claimed_at=NULL, deadline=NULL "
"WHERE status='running' AND deadline < ? AND attempts < ?",
(t, MAX_ATTEMPTS),
)
conn.execute(
"UPDATE jobs SET status='failed', finished_at=?, "
"error='no node completed this job in time' "
"WHERE status='running' AND deadline < ? AND attempts >= ?",
(t, t, MAX_ATTEMPTS),
)
# Realtime jobs nobody ever claimed go stale after 10 minutes.
conn.execute(
"UPDATE jobs SET status='expired', finished_at=?, "
"error='no node picked this up in time' "
"WHERE status='queued' AND kind='realtime' AND created_at < ?",
(t, t - 600),
)
+752
View File
@@ -0,0 +1,752 @@
"""PromptPool server: marketplace API, node queue, OpenAI-compatible proxy.
Run: uvicorn server.main:app --port 8377
"""
import asyncio
import json
import os
import re
import secrets
import time
from fnmatch import fnmatch
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from . import db
WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "web")
NODE_ONLINE_WINDOW = 90 # seconds since last poll to count a node as online
app = FastAPI(title="PromptPool")
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
db.init()
# ---------------------------------------------------------------- helpers
def bearer(request: Request) -> str:
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
raise HTTPException(401, "missing bearer token")
return auth[7:].strip()
def require_account(conn, request: Request):
row = conn.execute(
"SELECT * FROM accounts WHERE key_hash=?", (db.hash_key(bearer(request)),)
).fetchone()
if not row:
raise HTTPException(401, "invalid account key")
return row
def require_node(conn, request: Request):
row = conn.execute(
"SELECT * FROM nodes WHERE token_hash=?", (db.hash_key(bearer(request)),)
).fetchone()
if not row:
raise HTTPException(401, "invalid node token")
return row
def require_project_by_inference_key(conn, request: Request):
row = conn.execute(
"SELECT * FROM projects WHERE inference_key_hash=?",
(db.hash_key(bearer(request)),),
).fetchone()
if not row:
raise HTTPException(401, "invalid project inference key")
return row
def slugify(text: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return slug[:40]
def estimate_tokens(text: str) -> int:
return max(1, len(text or "") // 4)
def job_public(row, donor=None) -> dict:
d = {
k: row[k]
for k in (
"id", "project_id", "kind", "title", "prompt", "model",
"fallback_model", "status", "output", "error", "runner",
"model_used", "tokens_in", "tokens_out", "created_at", "finished_at",
)
}
d["donor"] = donor
return d
def project_public(row, extra=None) -> dict:
d = {
k: row[k]
for k in (
"id", "slug", "name", "tagline", "description", "repo_url",
"model", "fallback_model", "temperature", "max_tokens", "created_at",
)
}
if extra:
d.update(extra)
return d
def project_stats(conn, pid: int) -> dict:
r = conn.execute(
"""SELECT
SUM(CASE WHEN status='done' THEN 1 ELSE 0 END) AS jobs_done,
SUM(CASE WHEN status IN ('queued','running') THEN 1 ELSE 0 END) AS jobs_open,
SUM(CASE WHEN status='done' THEN tokens_in+tokens_out ELSE 0 END) AS tokens
FROM jobs WHERE project_id=?""",
(pid,),
).fetchone()
supporters = conn.execute(
"SELECT COUNT(*) FROM supports WHERE project_id=?", (pid,)
).fetchone()[0]
return {
"jobs_done": r["jobs_done"] or 0,
"jobs_open": r["jobs_open"] or 0,
"tokens_donated": r["tokens"] or 0,
"supporters": supporters,
}
def project_donors(conn, pid: int, owner_id: int) -> list[dict]:
rows = conn.execute(
"""SELECT a.id, a.name, a.display_name,
COUNT(*) AS jobs, SUM(j.tokens_in + j.tokens_out) AS tokens
FROM jobs j
JOIN nodes n ON n.id = j.node_id
JOIN accounts a ON a.id = n.account_id
WHERE j.project_id=? AND j.status='done'
GROUP BY a.id ORDER BY tokens DESC""",
(pid,),
).fetchall()
return [
{
"name": r["name"],
"display_name": r["display_name"],
"jobs": r["jobs"],
"tokens": r["tokens"] or 0,
"is_maintainer": r["id"] == owner_id,
}
for r in rows
]
# ---------------------------------------------------------------- job claiming
def match_model(job, node_models: list[str]) -> str | None:
"""Return the model the node should use for this job, or None if no match."""
for candidate in (job["model"], job["fallback_model"]):
if not candidate:
continue
for pattern in node_models:
if fnmatch(candidate.lower(), pattern.lower()):
return candidate
return None
def try_claim(node, node_models: list[str]) -> dict | None:
"""Atomically claim the best queued job this node is allowed to serve."""
with db.connect() as conn:
db.requeue_expired(conn)
conn.execute(
"UPDATE nodes SET last_seen=? WHERE id=?", (db.now(), node["id"])
)
candidates = conn.execute(
"""SELECT j.* FROM jobs j JOIN projects p ON p.id=j.project_id
WHERE j.status='queued'
AND (p.owner_id=:acct OR EXISTS(
SELECT 1 FROM supports s
WHERE s.project_id=p.id AND s.account_id=:acct))
ORDER BY (j.kind='realtime') DESC, j.created_at ASC LIMIT 50""",
{"acct": node["account_id"]},
).fetchall()
for job in candidates:
use_model = match_model(job, node_models)
if not use_model:
continue
lease = db.LEASE_REALTIME if job["kind"] == "realtime" else db.LEASE_BATCH
t = db.now()
cur = conn.execute(
"""UPDATE jobs SET status='running', node_id=?, use_model=?,
attempts=attempts+1, claimed_at=?, deadline=?
WHERE id=? AND status='queued'""",
(node["id"], use_model, t, t + lease, job["id"]),
)
if cur.rowcount == 1:
return {
"id": job["id"],
"kind": job["kind"],
"title": job["title"],
"prompt": job["prompt"],
"model": use_model,
"temperature": job["temperature"],
"max_tokens": job["max_tokens"],
"lease_seconds": lease,
}
return None
def create_job(conn, project, body: dict, kind: str) -> int:
prompt = (body.get("prompt") or "").strip()
if not prompt:
raise HTTPException(400, "prompt is required")
model = (body.get("model") or project["model"]).strip()
fallback = body.get("fallback_model")
fallback = project["fallback_model"] if fallback is None else fallback
temp = body.get("temperature", project["temperature"])
max_tok = body.get("max_tokens", project["max_tokens"])
cur = conn.execute(
"""INSERT INTO jobs(project_id, kind, title, prompt, model, fallback_model,
temperature, max_tokens, created_at)
VALUES(?,?,?,?,?,?,?,?,?)""",
(
project["id"], kind, (body.get("title") or "")[:120], prompt, model,
fallback or "", temp, max_tok, db.now(),
),
)
return cur.lastrowid
# ---------------------------------------------------------------- accounts
@app.post("/api/register")
async def register(request: Request):
body = await request.json()
name = (body.get("name") or "").strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{2,29}", name):
raise HTTPException(400, "name must be 3-30 chars: a-z, 0-9, dashes")
display = (body.get("display_name") or name).strip()[:60]
key, key_hash = db.new_key("ppk")
with db.connect() as conn:
try:
cur = conn.execute(
"INSERT INTO accounts(name, display_name, key_hash, created_at) "
"VALUES(?,?,?,?)",
(name, display, key_hash, db.now()),
)
except Exception:
raise HTTPException(409, "that account name is taken")
return {
"account": {"id": cur.lastrowid, "name": name, "display_name": display},
"api_key": key,
"note": "Save this key now - it is hashed server-side and never shown again.",
}
@app.get("/api/me")
async def me(request: Request):
with db.connect() as conn:
acct = require_account(conn, request)
db.requeue_expired(conn)
nodes = conn.execute(
"SELECT id, name, runners, models, last_seen, jobs_done FROM nodes "
"WHERE account_id=? ORDER BY id", (acct["id"],)
).fetchall()
projects = conn.execute(
"SELECT * FROM projects WHERE owner_id=? ORDER BY id", (acct["id"],)
).fetchall()
supports = conn.execute(
"SELECT p.slug, p.name FROM supports s JOIN projects p "
"ON p.id=s.project_id WHERE s.account_id=?", (acct["id"],)
).fetchall()
t = db.now()
return {
"account": {"name": acct["name"], "display_name": acct["display_name"]},
"nodes": [
{
"id": n["id"], "name": n["name"],
"runners": json.loads(n["runners"]),
"models": json.loads(n["models"]),
"online": bool(n["last_seen"] and t - n["last_seen"] < NODE_ONLINE_WINDOW),
"jobs_done": n["jobs_done"],
}
for n in nodes
],
"projects": [project_public(p) for p in projects],
"supports": [dict(s) for s in supports],
}
@app.get("/api/stats")
async def stats():
with db.connect() as conn:
db.requeue_expired(conn)
t = db.now()
return {
"projects": conn.execute("SELECT COUNT(*) FROM projects").fetchone()[0],
"accounts": conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0],
"nodes_online": conn.execute(
"SELECT COUNT(*) FROM nodes WHERE last_seen > ?",
(t - NODE_ONLINE_WINDOW,),
).fetchone()[0],
"jobs_done": conn.execute(
"SELECT COUNT(*) FROM jobs WHERE status='done'"
).fetchone()[0],
"tokens_donated": conn.execute(
"SELECT COALESCE(SUM(tokens_in+tokens_out),0) FROM jobs "
"WHERE status='done'"
).fetchone()[0],
}
# ---------------------------------------------------------------- projects
@app.post("/api/projects")
async def create_project(request: Request):
body = await request.json()
with db.connect() as conn:
acct = require_account(conn, request)
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
model = (body.get("model") or "").strip()
if not model:
raise HTTPException(400, "model preference is required")
slug = slugify(body.get("slug") or name)
if not slug:
raise HTTPException(400, "could not derive a slug from that name")
key, key_hash = db.new_key("ppi")
try:
cur = conn.execute(
"""INSERT INTO projects(owner_id, slug, name, tagline, description,
repo_url, model, fallback_model, temperature, max_tokens,
inference_key_hash, created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(
acct["id"], slug, name[:80],
(body.get("tagline") or "")[:140],
(body.get("description") or "")[:4000],
(body.get("repo_url") or "")[:300],
model, (body.get("fallback_model") or "").strip(),
body.get("temperature"), body.get("max_tokens"),
key_hash, db.now(),
),
)
except Exception:
raise HTTPException(409, f"slug '{slug}' is taken")
# Owners implicitly support their own projects; make it visible too.
conn.execute(
"INSERT OR IGNORE INTO supports(account_id, project_id, created_at) "
"VALUES(?,?,?)", (acct["id"], cur.lastrowid, db.now()),
)
return {
"slug": slug,
"inference_key": key,
"note": "Save the inference key now - it is the Bearer token for "
"/v1/chat/completions and is never shown again.",
}
@app.get("/api/projects")
async def list_projects():
with db.connect() as conn:
db.requeue_expired(conn)
rows = conn.execute(
"SELECT p.*, a.display_name AS owner_name FROM projects p "
"JOIN accounts a ON a.id=p.owner_id ORDER BY p.created_at DESC"
).fetchall()
out = []
for r in rows:
out.append(project_public(r, {"owner": r["owner_name"],
**project_stats(conn, r["id"])}))
out.sort(key=lambda p: (-p["jobs_done"], -p["supporters"]))
return {"projects": out}
@app.get("/api/projects/{slug}")
async def get_project(slug: str):
with db.connect() as conn:
db.requeue_expired(conn)
p = conn.execute("SELECT * FROM projects WHERE slug=?", (slug,)).fetchone()
if not p:
raise HTTPException(404, "no such project")
owner = conn.execute(
"SELECT name, display_name FROM accounts WHERE id=?", (p["owner_id"],)
).fetchone()
return project_public(p, {
"owner": owner["display_name"],
"owner_name": owner["name"],
**project_stats(conn, p["id"]),
"donors": project_donors(conn, p["id"], p["owner_id"]),
})
@app.get("/api/projects/{slug}/jobs")
async def project_jobs(slug: str, limit: int = 25):
with db.connect() as conn:
db.requeue_expired(conn)
p = conn.execute("SELECT id FROM projects WHERE slug=?", (slug,)).fetchone()
if not p:
raise HTTPException(404, "no such project")
rows = conn.execute(
"""SELECT j.*, a.display_name AS donor FROM jobs j
LEFT JOIN nodes n ON n.id=j.node_id
LEFT JOIN accounts a ON a.id=n.account_id
WHERE j.project_id=? ORDER BY j.id DESC LIMIT ?""",
(p["id"], min(max(limit, 1), 100)),
).fetchall()
return {"jobs": [job_public(r, r["donor"]) for r in rows]}
@app.post("/api/projects/{slug}/support")
async def support(slug: str, request: Request):
with db.connect() as conn:
acct = require_account(conn, request)
p = conn.execute("SELECT id FROM projects WHERE slug=?", (slug,)).fetchone()
if not p:
raise HTTPException(404, "no such project")
conn.execute(
"INSERT OR IGNORE INTO supports(account_id, project_id, created_at) "
"VALUES(?,?,?)", (acct["id"], p["id"], db.now()),
)
return {"ok": True, "supporting": slug}
@app.delete("/api/projects/{slug}/support")
async def unsupport(slug: str, request: Request):
with db.connect() as conn:
acct = require_account(conn, request)
p = conn.execute("SELECT id FROM projects WHERE slug=?", (slug,)).fetchone()
if not p:
raise HTTPException(404, "no such project")
conn.execute(
"DELETE FROM supports WHERE account_id=? AND project_id=?",
(acct["id"], p["id"]),
)
return {"ok": True}
@app.post("/api/projects/{slug}/jobs")
async def queue_job(slug: str, request: Request):
body = await request.json()
with db.connect() as conn:
acct = require_account(conn, request)
p = conn.execute("SELECT * FROM projects WHERE slug=?", (slug,)).fetchone()
if not p:
raise HTTPException(404, "no such project")
if p["owner_id"] != acct["id"]:
raise HTTPException(403, "only the project owner can queue work")
jid = create_job(conn, p, body, kind="batch")
return {"job_id": jid, "status": "queued"}
# ---------------------------------------------------------------- jobs
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: int):
with db.connect() as conn:
db.requeue_expired(conn)
r = conn.execute(
"""SELECT j.*, a.display_name AS donor FROM jobs j
LEFT JOIN nodes n ON n.id=j.node_id
LEFT JOIN accounts a ON a.id=n.account_id WHERE j.id=?""",
(job_id,),
).fetchone()
if not r:
raise HTTPException(404, "no such job")
return job_public(r, r["donor"])
@app.get("/api/jobs/{job_id}/wait")
async def wait_job(job_id: int, timeout: float = 120):
deadline = time.time() + min(max(timeout, 1), 570)
while time.time() < deadline:
with db.connect() as conn:
db.requeue_expired(conn)
r = conn.execute(
"""SELECT j.*, a.display_name AS donor FROM jobs j
LEFT JOIN nodes n ON n.id=j.node_id
LEFT JOIN accounts a ON a.id=n.account_id WHERE j.id=?""",
(job_id,),
).fetchone()
if not r:
raise HTTPException(404, "no such job")
if r["status"] in ("done", "failed", "expired"):
return job_public(r, r["donor"])
await asyncio.sleep(0.5)
raise HTTPException(504, "job still pending")
# ---------------------------------------------------------------- nodes
@app.post("/api/pair")
async def make_pair_code(request: Request):
with db.connect() as conn:
acct = require_account(conn, request)
conn.execute("DELETE FROM pair_codes WHERE expires_at < ?", (db.now(),))
code = "-".join(secrets.token_hex(2).upper() for _ in range(2))
conn.execute(
"INSERT INTO pair_codes(code, account_id, expires_at) VALUES(?,?,?)",
(code, acct["id"], db.now() + 900),
)
return {"code": code, "expires_in": 900}
@app.post("/api/nodes/register")
async def register_node(request: Request):
body = await request.json()
code = (body.get("code") or "").strip().upper()
with db.connect() as conn:
row = conn.execute(
"SELECT * FROM pair_codes WHERE code=? AND expires_at > ?",
(code, db.now()),
).fetchone()
if not row:
raise HTTPException(401, "invalid or expired pairing code")
conn.execute("DELETE FROM pair_codes WHERE code=?", (code,))
token, token_hash = db.new_key("ppn")
cur = conn.execute(
"INSERT INTO nodes(account_id, name, token_hash, runners, models, "
"created_at) VALUES(?,?,?,?,?,?)",
(
row["account_id"], (body.get("name") or "node")[:60], token_hash,
json.dumps(body.get("runners") or []),
json.dumps(body.get("models") or []),
db.now(),
),
)
acct = conn.execute(
"SELECT name FROM accounts WHERE id=?", (row["account_id"],)
).fetchone()
return {
"node_id": cur.lastrowid,
"node_token": token,
"account": acct["name"],
}
@app.post("/api/nodes/poll")
async def node_poll(request: Request):
body = await request.json()
with db.connect() as conn:
node = require_node(conn, request)
models = body.get("models") or json.loads(node["models"])
runners = body.get("runners")
if runners is not None or body.get("models") is not None:
conn.execute(
"UPDATE nodes SET runners=COALESCE(?,runners), "
"models=COALESCE(?,models) WHERE id=?",
(
json.dumps(runners) if runners is not None else None,
json.dumps(body.get("models")) if body.get("models") is not None else None,
node["id"],
),
)
node_d = dict(node)
wait = min(float(body.get("wait", 20)), 25)
deadline = time.time() + wait
while True:
job = try_claim(node_d, models)
if job or time.time() >= deadline:
return {"job": job}
await asyncio.sleep(1.0)
def _node_job(conn, request: Request, job_id: int):
node = require_node(conn, request)
job = conn.execute(
"SELECT * FROM jobs WHERE id=? AND node_id=? AND status='running'",
(job_id, node["id"]),
).fetchone()
if not job:
raise HTTPException(409, "job is not running on this node (lease expired?)")
return node, job
@app.post("/api/nodes/jobs/{job_id}/complete")
async def complete_job(job_id: int, request: Request):
body = await request.json()
with db.connect() as conn:
node, job = _node_job(conn, request, job_id)
output = body.get("output") or ""
conn.execute(
"""UPDATE jobs SET status='done', output=?, runner=?, model_used=?,
tokens_in=?, tokens_out=?, finished_at=? WHERE id=?""",
(
output, body.get("runner"), body.get("model_used") or job["use_model"],
int(body.get("tokens_in") or estimate_tokens(job["prompt"])),
int(body.get("tokens_out") or estimate_tokens(output)),
db.now(), job_id,
),
)
conn.execute(
"UPDATE nodes SET jobs_done=jobs_done+1, last_seen=? WHERE id=?",
(db.now(), node["id"]),
)
return {"ok": True}
@app.post("/api/nodes/jobs/{job_id}/fail")
async def fail_job(job_id: int, request: Request):
body = await request.json()
with db.connect() as conn:
node, job = _node_job(conn, request, job_id)
# Give other nodes a chance until attempts run out.
if job["attempts"] >= db.MAX_ATTEMPTS:
conn.execute(
"UPDATE jobs SET status='failed', error=?, finished_at=? WHERE id=?",
((body.get("error") or "node failed")[:2000], db.now(), job_id),
)
else:
conn.execute(
"UPDATE jobs SET status='queued', node_id=NULL, use_model=NULL, "
"claimed_at=NULL, deadline=NULL, error=? WHERE id=?",
((body.get("error") or "node failed")[:2000], job_id),
)
conn.execute(
"UPDATE nodes SET last_seen=? WHERE id=?", (db.now(), node["id"])
)
return {"ok": True}
# ------------------------------------------- OpenAI-compatible realtime proxy
def messages_to_prompt(messages: list) -> str:
parts = []
for m in messages or []:
content = m.get("content", "")
if isinstance(content, list):
content = "\n".join(
p.get("text", "") for p in content
if isinstance(p, dict) and p.get("type") == "text"
)
parts.append((m.get("role", "user"), str(content)))
if len(parts) == 1 and parts[0][0] == "user":
return parts[0][1]
labels = {"system": "System", "user": "User", "assistant": "Assistant"}
text = "\n\n".join(f"{labels.get(r, r.title())}: {c}" for r, c in parts)
return text + "\n\nAssistant:"
def openai_error(status: int, message: str, code: str) -> JSONResponse:
return JSONResponse(
{"error": {"message": message, "type": "invalid_request_error", "code": code}},
status_code=status,
)
@app.get("/v1/models")
async def list_models():
with db.connect() as conn:
t = db.now()
rows = conn.execute(
"SELECT models FROM nodes WHERE last_seen > ?",
(t - NODE_ONLINE_WINDOW,),
).fetchall()
seen = sorted({m for r in rows for m in json.loads(r["models"])})
return {
"object": "list",
"data": [
{"id": m, "object": "model", "owned_by": "promptpool"} for m in seen
],
}
@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
try:
body = await request.json()
except Exception:
return openai_error(400, "body must be JSON", "invalid_json")
with db.connect() as conn:
try:
project = require_project_by_inference_key(conn, request)
except HTTPException as e:
return openai_error(e.status_code, e.detail, "invalid_api_key")
prompt = messages_to_prompt(body.get("messages"))
if not prompt.strip():
return openai_error(400, "messages are required", "missing_messages")
job_body = {
"prompt": prompt,
"model": body.get("model") or None,
"temperature": body.get("temperature", project["temperature"]),
"max_tokens": body.get("max_tokens", project["max_tokens"]),
"title": "realtime via /v1/chat/completions",
}
if job_body["model"] in (None, "", "default"):
job_body["model"] = project["model"]
jid = create_job(conn, project, job_body, kind="realtime")
timeout = min(float(request.query_params.get("timeout", 180)), 570)
deadline = time.time() + timeout
row = None
while time.time() < deadline:
with db.connect() as conn:
db.requeue_expired(conn)
row = conn.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone()
if row["status"] in ("done", "failed", "expired"):
break
await asyncio.sleep(0.4)
if not row or row["status"] not in ("done", "failed", "expired"):
return openai_error(
504,
f"no donor node answered within {int(timeout)}s "
"(job stays queued; poll /api/jobs/%d)" % jid,
"pool_timeout",
)
if row["status"] != "done":
return openai_error(502, row["error"] or "job failed", "pool_job_failed")
created = int(row["finished_at"] or time.time())
model = row["model_used"] or row["use_model"] or row["model"]
usage = {
"prompt_tokens": row["tokens_in"],
"completion_tokens": row["tokens_out"],
"total_tokens": row["tokens_in"] + row["tokens_out"],
}
if body.get("stream"):
def sse():
base = {
"id": f"chatcmpl-pp{jid}", "object": "chat.completion.chunk",
"created": created, "model": model,
}
first = dict(base, choices=[{"index": 0, "delta": {"role": "assistant"},
"finish_reason": None}])
yield f"data: {json.dumps(first)}\n\n"
text = row["output"] or ""
for i in range(0, len(text), 200):
chunk = dict(base, choices=[{"index": 0,
"delta": {"content": text[i:i+200]},
"finish_reason": None}])
yield f"data: {json.dumps(chunk)}\n\n"
last = dict(base, choices=[{"index": 0, "delta": {},
"finish_reason": "stop"}])
yield f"data: {json.dumps(last)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(sse(), media_type="text/event-stream")
return {
"id": f"chatcmpl-pp{jid}",
"object": "chat.completion",
"created": created,
"model": model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": row["output"] or ""},
"finish_reason": "stop",
}],
"usage": usage,
}
# ---------------------------------------------------------------- web ui
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
@app.get("/")
async def index():
return FileResponse(os.path.join(WEB_DIR, "index.html"))
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.110
uvicorn>=0.29
+457
View File
@@ -0,0 +1,457 @@
/* PromptPool web UI — vanilla JS hash router, no build step. */
const $app = document.getElementById("app");
let refreshTimer = null;
const key = () => localStorage.getItem("pp_key") || "";
const setKey = (k) => k ? localStorage.setItem("pp_key", k) : localStorage.removeItem("pp_key");
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
async function api(path, opts = {}) {
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
if (key() && !headers.Authorization) headers.Authorization = "Bearer " + key();
const res = await fetch(path, { ...opts, headers });
let body = null;
try { body = await res.json(); } catch { /* empty */ }
if (!res.ok) throw new Error(body?.detail || body?.error?.message || res.statusText);
return body;
}
const fmtN = (n) => {
n = n || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
return String(n);
};
const ago = (t) => {
if (!t) return "";
const s = Math.max(1, Math.floor(Date.now() / 1000 - t));
if (s < 60) return s + "s ago";
if (s < 3600) return Math.floor(s / 60) + "m ago";
if (s < 86400) return Math.floor(s / 3600) + "h ago";
return Math.floor(s / 86400) + "d ago";
};
function render(html) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
$app.innerHTML = html;
}
// ------------------------------------------------------------------ home
async function viewHome() {
render(`<p class="muted">Loading marketplace…</p>`);
const [stats, list] = await Promise.all([api("/api/stats"), api("/api/projects")]);
const cards = list.projects.map((p) => `
<a class="card" href="#/p/${esc(p.slug)}" style="color:inherit;text-decoration:none">
<h3>${esc(p.name)}</h3>
<div class="tagline">${esc(p.tagline || p.description.slice(0, 120) || "A vibe-coding project looking for AI donors.")}</div>
<div class="meta">
<span>by <b>${esc(p.owner)}</b></span>
<span><b>${fmtN(p.jobs_done)}</b> jobs done</span>
<span><b>${fmtN(p.tokens_donated)}</b> tokens donated</span>
<span><b>${p.supporters}</b> ${p.supporters === 1 ? "supporter" : "supporters"}</span>
${p.jobs_open ? `<span class="badge queued">${p.jobs_open} open</span>` : ""}
</div>
</a>`).join("");
render(`
<div class="hero">
<h1>Some devs have tokens and no ideas.<br>Some have ideas and no tokens.</h1>
<p class="tag">PromptPool is a marketplace of vibe-coding projects and a pool of donated
AI compute. Browse projects, point your idle agent at one, and become an
<b>AI Donor</b> — or bring your project and let the pool carry it.</p>
<div class="statrow">
<span class="chip"><b>${stats.projects}</b> projects</span>
<span class="chip"><b>${stats.nodes_online}</b> nodes online</span>
<span class="chip"><b>${fmtN(stats.jobs_done)}</b> jobs completed</span>
<span class="chip"><b>${fmtN(stats.tokens_donated)}</b> tokens donated</span>
</div>
</div>
<div class="grid">${cards || `<div class="panel">No projects yet — <a href="#/new">create the first one</a>.</div>`}</div>
`);
}
// ------------------------------------------------------------------ project
function jobDetails(j) {
return `
<details>
<summary>
<span class="badge ${esc(j.status)}">${esc(j.status)}</span>
<span class="grow">${esc(j.title || j.prompt.slice(0, 80))}</span>
<span class="muted">${esc(j.model)}${j.model_used && j.model_used !== j.model ? " → " + esc(j.model_used) : ""}</span>
${j.donor ? `<span class="muted">🤝 ${esc(j.donor)}</span>` : ""}
<span class="muted">${ago(j.created_at)}</span>
</summary>
<div class="jobbody">
<h4>Prompt</h4><pre>${esc(j.prompt)}</pre>
${j.output ? `<h4>Output ${j.runner ? `<span class="muted">(runner: ${esc(j.runner)}, ${fmtN(j.tokens_out)} tokens out)</span>` : ""}</h4><pre>${esc(j.output)}</pre>` : ""}
${j.error && j.status !== "done" ? `<h4>Error</h4><pre>${esc(j.error)}</pre>` : ""}
</div>
</details>`;
}
async function viewProject(slug) {
render(`<p class="muted">Loading project…</p>`);
let me = null;
if (key()) { try { me = await api("/api/me"); } catch { setKey(""); } }
const [p, jobs] = await Promise.all([
api(`/api/projects/${slug}`),
api(`/api/projects/${slug}/jobs`),
]);
const isOwner = me?.projects?.some((x) => x.slug === slug);
const supporting = me?.supports?.some((x) => x.slug === slug);
const origin = window.location.origin;
const donors = p.donors.length
? `<table class="donors"><tr><th></th><th>AI Donor</th><th>Jobs</th><th>Tokens</th></tr>
${p.donors.map((d, i) => `<tr>
<td>${["🥇", "🥈", "🥉"][i] || i + 1}</td>
<td>${esc(d.display_name)} ${d.is_maintainer ? '<span class="badge owner">maintainer</span>' : ""}</td>
<td>${fmtN(d.jobs)}</td><td>${fmtN(d.tokens)}</td></tr>`).join("")}
</table>`
: `<p class="muted small">No completed donations yet. Be the first AI Donor.</p>`;
render(`
<div class="panel projhead">
<h2 class="pagetitle">${esc(p.name)}</h2>
<div class="row">
<span class="muted">by ${esc(p.owner)}</span>
${p.repo_url ? `<a href="${esc(p.repo_url)}" target="_blank" rel="noopener">↗ repository</a>` : ""}
<span class="grow"></span>
${me && !isOwner ? `<button class="btn small ${supporting ? "ghost" : ""}" id="btn-support">
${supporting ? "✓ Supporting — stop" : "🤝 Support this project"}</button>` : ""}
${!me ? `<a class="btn small ghost" href="#/account">Sign in to support</a>` : ""}
</div>
${p.tagline ? `<p>${esc(p.tagline)}</p>` : ""}
${p.description ? `<p class="muted small">${esc(p.description)}</p>` : ""}
<div class="statrow" style="justify-content:flex-start">
<span class="chip"><b>${fmtN(p.jobs_done)}</b> jobs done</span>
<span class="chip"><b>${fmtN(p.tokens_donated)}</b> tokens donated</span>
<span class="chip"><b>${p.supporters}</b> supporters</span>
<span class="chip">model <b>${esc(p.model)}</b>${p.fallback_model ? ` → <b>${esc(p.fallback_model)}</b>` : ""}</span>
${p.temperature != null ? `<span class="chip">temp <b>${p.temperature}</b></span>` : ""}
${p.max_tokens ? `<span class="chip">max <b>${fmtN(p.max_tokens)}</b> tok</span>` : ""}
</div>
</div>
<div class="panel">
<h2>🏆 AI Donors</h2>
${donors}
<p class="muted small mt">Supporting a project means your connected nodes volunteer to
process its queue with <i>your</i> tokens. Every completed job credits you here.</p>
</div>
${isOwner ? `
<div class="panel">
<h2>Queue a batch prompt</h2>
<label>Title (optional)</label><input id="job-title" placeholder="Summarize open issues">
<label>Prompt</label><textarea id="job-prompt" placeholder="Prompts are public and processed by volunteer nodes — never include secrets."></textarea>
<div class="mt"><button class="btn" id="btn-queue">Queue job</button></div>
<div id="queue-msg"></div>
</div>` : ""}
<div class="panel">
<h2>Realtime endpoint</h2>
<p class="muted small">Any OpenAI-compatible client can send this project's realtime
work to the pool — Hermes Desktop, OpenClaw, aider, or plain curl. Use the project's
inference key (shown once at creation) as the Bearer token.</p>
<pre class="code">curl ${esc(origin)}/v1/chat/completions \\
-H "Authorization: Bearer ppi_..." -H "Content-Type: application/json" \\
-d '{"messages": [{"role": "user", "content": "hello pool"}]}'</pre>
</div>
<div class="panel">
<h2>Job feed <span class="muted small">(public, refreshes live)</span></h2>
<div class="joblist" id="joblist">${jobs.jobs.map(jobDetails).join("") || `<p class="muted small">Nothing queued yet.</p>`}</div>
</div>
`);
document.getElementById("btn-support")?.addEventListener("click", async () => {
try {
await api(`/api/projects/${slug}/support`, { method: supporting ? "DELETE" : "POST" });
viewProject(slug);
} catch (e) { alert(e.message); }
});
document.getElementById("btn-queue")?.addEventListener("click", async () => {
const msg = document.getElementById("queue-msg");
try {
const r = await api(`/api/projects/${slug}/jobs`, {
method: "POST",
body: JSON.stringify({
title: document.getElementById("job-title").value,
prompt: document.getElementById("job-prompt").value,
}),
});
msg.innerHTML = `<p class="ok">Queued as job #${r.job_id} — a supporting node will pick it up.</p>`;
document.getElementById("job-prompt").value = "";
refreshJobs(slug);
} catch (e) { msg.innerHTML = `<p class="error">${esc(e.message)}</p>`; }
});
refreshTimer = setInterval(() => refreshJobs(slug), 5000);
}
async function refreshJobs(slug) {
try {
const jobs = await api(`/api/projects/${slug}/jobs`);
const el = document.getElementById("joblist");
const open = new Set([...(el?.querySelectorAll("details[open]") || [])]
.map((d) => d.querySelector("summary .grow")?.textContent));
if (el) {
el.innerHTML = jobs.jobs.map(jobDetails).join("") || `<p class="muted small">Nothing queued yet.</p>`;
el.querySelectorAll("details").forEach((d) => {
if (open.has(d.querySelector("summary .grow")?.textContent)) d.open = true;
});
}
} catch { /* transient */ }
}
// ------------------------------------------------------------------ new project
function viewNew() {
if (!key()) { location.hash = "#/account"; return; }
render(`
<div class="panel">
<h2>New project</h2>
<p class="muted small">Your project gets a public marketplace page, a batch queue,
and an OpenAI-compatible realtime endpoint backed by the pool.</p>
<div class="formrow">
<div><label>Name</label><input id="f-name" placeholder="doge-miner-fullstack"></div>
<div><label>Repository URL</label><input id="f-repo" placeholder="https://github.com/you/project"></div>
</div>
<label>Tagline</label><input id="f-tagline" placeholder="One sentence that makes a donor care.">
<label>Description</label><textarea id="f-desc"></textarea>
<div class="formrow">
<div><label>Preferred model</label><input id="f-model" placeholder="claude-sonnet-4-5" value="claude-sonnet-4-5"></div>
<div><label>Fallback model</label><input id="f-fallback" placeholder="gpt-5-mini"></div>
</div>
<div class="formrow">
<div><label>Temperature (optional — honored by API runners)</label><input id="f-temp" type="number" step="0.1" min="0" max="2"></div>
<div><label>Max output tokens (optional)</label><input id="f-maxtok" type="number" min="1"></div>
</div>
<div class="mt"><button class="btn" id="btn-create">Create project</button></div>
<div id="create-msg"></div>
</div>
`);
document.getElementById("btn-create").addEventListener("click", async () => {
const msg = document.getElementById("create-msg");
const num = (id) => { const v = document.getElementById(id).value; return v === "" ? null : Number(v); };
try {
const r = await api("/api/projects", {
method: "POST",
body: JSON.stringify({
name: document.getElementById("f-name").value,
repo_url: document.getElementById("f-repo").value,
tagline: document.getElementById("f-tagline").value,
description: document.getElementById("f-desc").value,
model: document.getElementById("f-model").value,
fallback_model: document.getElementById("f-fallback").value,
temperature: num("f-temp"),
max_tokens: num("f-maxtok"),
}),
});
msg.innerHTML = `
<div class="notice"><b>Save this inference key — it is shown exactly once.</b>
It is the Bearer token for this project's <code class="inline">/v1/chat/completions</code> endpoint.
<div class="keybox">${esc(r.inference_key)}</div>
<a href="#/p/${esc(r.slug)}">Open your project page →</a></div>`;
} catch (e) { msg.innerHTML = `<p class="error">${esc(e.message)}</p>`; }
});
}
// ------------------------------------------------------------------ account
async function viewAccount() {
if (!key()) {
render(`
<div class="panel">
<h2>Create an account</h2>
<div class="formrow">
<div><label>Username (a-z, 0-9, dashes)</label><input id="r-name" placeholder="steph"></div>
<div><label>Display name</label><input id="r-display" placeholder="Steph"></div>
</div>
<div class="mt"><button class="btn" id="btn-register">Create account</button></div>
<div id="reg-msg"></div>
</div>
<div class="panel">
<h2>Already have a key?</h2>
<label>Account API key</label><input id="l-key" placeholder="ppk_...">
<div class="mt"><button class="btn ghost" id="btn-login">Sign in</button></div>
<div id="login-msg"></div>
</div>
`);
document.getElementById("btn-register").addEventListener("click", async () => {
const msg = document.getElementById("reg-msg");
try {
const r = await api("/api/register", {
method: "POST",
body: JSON.stringify({
name: document.getElementById("r-name").value,
display_name: document.getElementById("r-display").value,
}),
});
setKey(r.api_key);
msg.innerHTML = `
<div class="notice"><b>Save your account key — it is shown exactly once.</b>
It is stored hashed on the server; this browser keeps a copy locally so you stay signed in.
<div class="keybox">${esc(r.api_key)}</div>
<a href="#/account" onclick="viewAccount()">Continue →</a></div>`;
} catch (e) { msg.innerHTML = `<p class="error">${esc(e.message)}</p>`; }
});
document.getElementById("btn-login").addEventListener("click", async () => {
const msg = document.getElementById("login-msg");
setKey(document.getElementById("l-key").value.trim());
try { await api("/api/me"); viewAccount(); }
catch (e) { setKey(""); msg.innerHTML = `<p class="error">${esc(e.message)}</p>`; }
});
return;
}
let me;
try { me = await api("/api/me"); }
catch { setKey(""); viewAccount(); return; }
const nodes = me.nodes.map((n) => `
<tr><td><span class="dot ${n.online ? "on" : "off"}"></span>${esc(n.name)}</td>
<td class="muted">${esc((n.runners || []).join(", ") || "—")}</td>
<td class="muted">${esc((n.models || []).join(", ") || "—")}</td>
<td>${fmtN(n.jobs_done)}</td></tr>`).join("");
render(`
<div class="panel">
<div class="row"><h2 class="pagetitle">@${esc(me.account.name)}</h2>
<span class="muted">${esc(me.account.display_name)}</span>
<span class="grow"></span>
<button class="btn small ghost" id="btn-logout">Sign out</button></div>
</div>
<div class="panel">
<h2>🖥️ My nodes</h2>
${nodes ? `<table class="donors"><tr><th>Node</th><th>Runners</th><th>Models</th><th>Jobs done</th></tr>${nodes}</table>`
: `<p class="muted small">No nodes yet. Generate a pairing code and run the connector.</p>`}
<div class="mt row">
<button class="btn" id="btn-pair">Generate pairing code</button>
<span class="muted small">Codes are single-use and expire in 15 minutes.</span>
</div>
<div id="pair-out"></div>
</div>
<div class="panel">
<h2>📦 My projects</h2>
${me.projects.length
? me.projects.map((p) => `<div class="row mt"><a href="#/p/${esc(p.slug)}">${esc(p.name)}</a>
<span class="muted small">model ${esc(p.model)}</span></div>`).join("")
: `<p class="muted small">None yet — <a href="#/new">create one</a>.</p>`}
</div>
<div class="panel">
<h2>🤝 Projects I support</h2>
${me.supports.length
? me.supports.map((s) => `<div class="row mt"><a href="#/p/${esc(s.slug)}">${esc(s.name)}</a></div>`).join("")
: `<p class="muted small">None yet — <a href="#/">browse the marketplace</a>.</p>`}
<p class="muted small mt">Your online nodes automatically serve the queues of projects
you support (and your own projects).</p>
</div>
`);
document.getElementById("btn-logout").addEventListener("click", () => { setKey(""); viewAccount(); });
document.getElementById("btn-pair").addEventListener("click", async () => {
try {
const r = await api("/api/pair", { method: "POST" });
document.getElementById("pair-out").innerHTML = `
<div class="notice">On the machine with your AI CLI / API keys, run:
<pre class="code">python connector/node_connector.py --server ${esc(location.origin)} --code ${esc(r.code)}</pre>
The node inherits <b>your</b> identity: work it completes is credited to @${esc(me.account.name)}.</div>`;
} catch (e) { alert(e.message); }
});
}
// ------------------------------------------------------------------ docs pages
function viewConnect() {
render(`
<div class="panel">
<h2>Connect a node</h2>
<p>A node is any machine with an AI CLI (<code class="inline">claude</code>,
<code class="inline">codex</code>, <code class="inline">gemini</code>,
<code class="inline">cursor-agent</code>) or a metered API key in an environment
variable (<code class="inline">ANTHROPIC_API_KEY</code>, <code class="inline">OPENAI_API_KEY</code>).
The connector is one stdlib-only Python script — no pip installs.</p>
<h3>1. Get a pairing code</h3>
<p class="small muted">Sign in → <a href="#/account">Account</a> → <i>Generate pairing code</i>.</p>
<h3>2. Run the connector on the node machine</h3>
<pre class="code">python connector/node_connector.py --server ${esc(location.origin)} --code XXXX-XXXX</pre>
<p class="small muted">It auto-detects available runners, registers the node under your
account, saves its node token in <code class="inline">~/.promptpool/</code>, and starts
polling for work from projects you support.</p>
<h3>3. Support projects</h3>
<p class="small muted">Browse the <a href="#/">marketplace</a> and hit <i>Support</i>.
Your node only ever serves projects you explicitly chose. Every completed job lists you
as an <b>AI Donor</b> on the project page.</p>
<div class="notice"><b>Your keys never leave the node.</b> The connector sends prompts
and answers — never credentials. CLI runners execute in an empty scratch directory with
agentic turns capped; API runners have no tool access at all. You are responsible for
staying within your provider's terms — metered API keys are the recommended way to donate.</div>
</div>
`);
}
function viewDocs() {
const o = location.origin;
render(`
<div class="panel">
<h2>API docs</h2>
<h3>Realtime: OpenAI-compatible proxy</h3>
<p class="small muted">Point any OpenAI-style client at the pool. The Bearer token is a
<b>project inference key</b> (<code class="inline">ppi_…</code>). Model, temperature and
max_tokens default to the project's settings; requests wait up to 180s for a donor node.</p>
<pre class="code">curl ${esc(o)}/v1/chat/completions \\
-H "Authorization: Bearer ppi_..." -H "Content-Type: application/json" \\
-d '{"model": "claude-sonnet-4-5", "stream": false,
"messages": [{"role": "user", "content": "Refactor plan for miner.py?"}]}'</pre>
<pre class="code"># python
from openai import OpenAI
client = OpenAI(base_url="${esc(o)}/v1", api_key="ppi_...")
r = client.chat.completions.create(model="default",
messages=[{"role": "user", "content": "hello pool"}])
print(r.choices[0].message.content)</pre>
<h3>Batch</h3>
<pre class="code">> POST /api/projects/&lt;slug&gt;/jobs (Bearer: account key; owner only)
{"title": "…", "prompt": "…", "model": "…"} → {"job_id": 7}
> GET /api/jobs/7 → status/output
> GET /api/jobs/7/wait?timeout=120 → long-poll until done</pre>
<h3>Everything else</h3>
<pre class="code">POST /api/register create account → account key (once)
GET /api/projects marketplace
GET /api/projects/&lt;slug&gt; project + donors
POST /api/projects/&lt;slug&gt;/support become a donor
POST /api/pair pairing code for a new node
GET /v1/models models currently online in the pool</pre>
<p class="small muted">Full node protocol lives in the connector source —
<code class="inline">connector/node_connector.py</code> — ~300 lines of stdlib Python.</p>
</div>
`);
}
// ------------------------------------------------------------------ router
async function route() {
const h = location.hash || "#/";
try {
if (h === "#/" || h === "") await viewHome();
else if (h.startsWith("#/p/")) await viewProject(h.slice(4));
else if (h === "#/new") viewNew();
else if (h === "#/account") await viewAccount();
else if (h === "#/connect") viewConnect();
else if (h === "#/docs") viewDocs();
else await viewHome();
} catch (e) {
render(`<div class="panel"><p class="error">${esc(e.message)}</p></div>`);
}
}
window.addEventListener("hashchange", route);
route();
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PromptPool — tokens, meet ideas</title>
<link rel="stylesheet" href="/static/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🪙</text></svg>">
</head>
<body>
<nav>
<a class="logo" href="#/">Prompt<span>Pool</span></a>
<a class="navlink" href="#/">Marketplace</a>
<a class="navlink" href="#/connect">Connect a node</a>
<a class="navlink" href="#/docs">API docs</a>
<span class="spacer"></span>
<a class="navlink" href="#/new">+ New project</a>
<a class="navlink" href="#/account" id="nav-account">Account</a>
</nav>
<main id="app"><p class="muted">Loading…</p></main>
<script src="/static/app.js"></script>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
:root {
--bg: #f7f7fb;
--panel: #ffffff;
--ink: #1a1c25;
--muted: #6b7080;
--line: #e4e5ee;
--accent: #5b5bd6;
--accent-ink: #ffffff;
--good: #1a9e5c;
--warn: #c77b00;
--bad: #cc3d3d;
--code-bg: #14151d;
--code-ink: #d8dbea;
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1017;
--panel: #171922;
--ink: #e8eaf2;
--muted: #8f94a6;
--line: #262a38;
--accent: #7b7bf0;
--accent-ink: #0f1017;
--code-bg: #0a0b10;
--code-ink: #c9cde0;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.55;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
nav {
display: flex; align-items: center; gap: 1.4rem;
padding: 0.9rem 1.4rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
position: sticky; top: 0; z-index: 5;
}
nav .logo { font-weight: 800; font-size: 1.05rem; color: var(--ink); }
nav .logo span { color: var(--accent); }
nav a.navlink { color: var(--muted); font-size: 0.92rem; }
nav a.navlink:hover { color: var(--ink); text-decoration: none; }
nav .spacer { flex: 1; }
main { max-width: 1060px; margin: 0 auto; padding: 1.6rem 1.2rem 4rem; }
.hero { text-align: center; padding: 2.2rem 0 1.4rem; }
.hero h1 { font-size: 2rem; margin: 0 0 0.4rem; letter-spacing: -0.02em; }
.hero p.tag { color: var(--muted); font-size: 1.05rem; margin: 0 auto 1.2rem; max-width: 620px; }
.statrow { display: flex; gap: 0.7rem; justify-content: center; flex-wrap: wrap; }
.chip {
background: var(--panel); border: 1px solid var(--line);
border-radius: 999px; padding: 0.3rem 0.9rem; font-size: 0.85rem;
color: var(--muted);
}
.chip b { color: var(--ink); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; margin-top: 1.4rem; }
.card {
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); padding: 1.1rem 1.2rem;
display: flex; flex-direction: column; gap: 0.45rem;
}
.card h3 { margin: 0; font-size: 1.06rem; }
.card .tagline { color: var(--muted); font-size: 0.9rem; flex: 1; }
.card .meta { display: flex; gap: 0.9rem; flex-wrap: wrap; font-size: 0.8rem; color: var(--muted); }
.card .meta b { color: var(--ink); }
.panel {
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); padding: 1.3rem 1.5rem; margin-top: 1.1rem;
}
.panel h2 { margin: 0 0 0.6rem; font-size: 1.15rem; }
.panel h3 { margin: 1rem 0 0.4rem; font-size: 1rem; }
.btn {
display: inline-block; border: 1px solid var(--accent);
background: var(--accent); color: var(--accent-ink);
padding: 0.45rem 1rem; border-radius: 8px; cursor: pointer;
font-size: 0.9rem; font-weight: 600; font-family: inherit;
}
.btn:hover { filter: brightness(1.08); }
.btn.ghost { background: transparent; color: var(--accent); }
.btn.small { padding: 0.25rem 0.7rem; font-size: 0.8rem; }
.btn:disabled { opacity: 0.5; cursor: default; }
label { display: block; font-size: 0.82rem; color: var(--muted); margin: 0.7rem 0 0.2rem; }
input, textarea, select {
width: 100%; padding: 0.5rem 0.65rem; border-radius: 8px;
border: 1px solid var(--line); background: var(--bg); color: var(--ink);
font-family: inherit; font-size: 0.92rem;
}
textarea { min-height: 110px; resize: vertical; }
.formrow { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
@media (max-width: 640px) { .formrow { grid-template-columns: 1fr; } }
pre.code {
background: var(--code-bg); color: var(--code-ink);
border-radius: 10px; padding: 0.9rem 1rem; overflow-x: auto;
font-size: 0.8rem; line-height: 1.5;
}
code.inline {
background: var(--line); border-radius: 5px; padding: 0.1rem 0.35rem;
font-size: 0.85em;
}
.keybox {
background: var(--code-bg); color: #8ce99a; padding: 0.7rem 0.9rem;
border-radius: 10px; font-family: ui-monospace, Consolas, monospace;
font-size: 0.85rem; word-break: break-all; margin: 0.5rem 0;
}
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 0.35rem; }
.dot.on { background: var(--good); }
.dot.off { background: var(--muted); opacity: 0.5; }
.badge {
display: inline-block; font-size: 0.72rem; font-weight: 700;
padding: 0.1rem 0.55rem; border-radius: 999px; text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge.queued { background: #f2e4c0; color: #7a5b00; }
.badge.running { background: #cfe0ff; color: #1d4fa8; }
.badge.done { background: #c8ecd4; color: #14683a; }
.badge.failed, .badge.expired { background: #f4cccc; color: #8f2626; }
.badge.owner { background: var(--accent); color: var(--accent-ink); }
.joblist details {
border: 1px solid var(--line); border-radius: 10px;
padding: 0.55rem 0.9rem; margin-top: 0.55rem; background: var(--bg);
}
.joblist summary { cursor: pointer; display: flex; gap: 0.7rem; align-items: center; flex-wrap: wrap; font-size: 0.9rem; }
.joblist summary .grow { flex: 1; }
.joblist summary .muted { color: var(--muted); font-size: 0.8rem; }
.joblist .jobbody { margin-top: 0.7rem; }
.joblist .jobbody h4 { margin: 0.6rem 0 0.25rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.joblist pre { white-space: pre-wrap; word-break: break-word; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem 0.8rem; font-size: 0.82rem; max-height: 320px; overflow-y: auto; }
table.donors { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
table.donors td, table.donors th { padding: 0.45rem 0.6rem; border-bottom: 1px solid var(--line); text-align: left; }
table.donors th { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; }
.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.mt { margin-top: 1rem; }
.row { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
.row .grow { flex: 1; }
.notice {
border-left: 3px solid var(--warn); background: var(--panel);
border-radius: 0 10px 10px 0; padding: 0.7rem 1rem; font-size: 0.88rem;
margin: 0.8rem 0; color: var(--muted);
}
.error { color: var(--bad); font-size: 0.88rem; margin-top: 0.6rem; }
.ok { color: var(--good); font-size: 0.88rem; margin-top: 0.6rem; }
h2.pagetitle { margin: 0.4rem 0 0; }
.projhead .row { margin-top: 0.3rem; }