mirror of
https://github.com/stevologic/spare-cycles.git
synced 2026-11-06 05:16:38 +00:00
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:
12 files changed
+2167
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "promptpool",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "uvicorn", "server.main:app", "--port", "8377"],
|
||||
"port": 8377
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
.venv/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Whitespace-only changes.
+154
@@ -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
@@ -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"))
|
||||
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.110
|
||||
uvicorn>=0.29
|
||||
+457
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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/<slug>/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/<slug> project + donors
|
||||
POST /api/projects/<slug>/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();
|
||||
@@ -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
@@ -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; }
|
||||
Reference in new issue
Block a user