pyrtan / src / pyrtan / page.py
126,256 bytes · last changed 2026-10-27 in Merge pull request #36 from b-i-t-8/claude/great-heisenberg-o6ocah
| 1 | """The console: one HTML file a service or hub serves at ``/``, and the same |
| 2 | file hosted anywhere else (``pyrtan console --out``), where it is a hub of |
| 3 | its own until it joins one. |
| 4 | |
| 5 | It asks a sentence and shows the answer, the output and the trace; lists |
| 6 | what the swarm can answer; shows the hub's peers and their ``link``; lists |
| 7 | the gaps; and, on a hub started with ``--chat``, pairs this browser with a |
| 8 | random stranger or a bot (``/chat``, see :mod:`pyrtan.chat`). Everything it does goes through the public endpoints |
| 9 | (``/api``, ``/health``, ``/capabilities``, ``/peers``, ``/gaps``, |
| 10 | ``/intent``, ``/resume``), so it works the same against a hub and a |
| 11 | service, and the same whether it was served by that hub or by a static |
| 12 | host somewhere else. |
| 13 | |
| 14 | Served by a hub or service, it talks to the origin it came from. Served |
| 15 | from elsewhere (it finds out by asking ``api`` on its own origin), the |
| 16 | browser is the hub: ``pyrtan.js``'s ``LocalHub`` answers the same |
| 17 | endpoints inside the page, with the catalog's trees (its ``trees.json``), |
| 18 | reading sentences and running trees here, keeping gaps and unanswered |
| 19 | sentences for as long as the page is open. Trees that need a box (files, |
| 20 | programs, devices, brokers, a browser to drive) or a site that will not |
| 21 | let a page read it say so. Joining a hub is explicit: its URL and token in |
| 22 | the connect box, kept in the browser's local storage, the token sent as a |
| 23 | bearer; the hub must allow the console's origin with ``--cors``. |
| 24 | Forgetting the hub makes the browser its own hub again. No external |
| 25 | assets, no build step. |
| 26 | |
| 27 | Its Identity tab does all of ``pyrtan id`` without a shell |
| 28 | (``identity.js``, inlined: WebCrypto's Ed25519, PBKDF2 and AES-GCM). It |
| 29 | makes your identity in this browser, hands you its backup at once |
| 30 | (encrypted with your passphrase, to download or keep in your own private |
| 31 | repository; the page then holds the key non-extractable), restores one, |
| 32 | claims a name (pyrtan.com's, through GitHub's own new-file page, which |
| 33 | opens the pull request from your account; or any network's names |
| 34 | repository), and adds and revokes devices. A browser can instead be a |
| 35 | device of an identity held elsewhere: it makes its own key and takes the |
| 36 | note its root signs. On a hub of 0.14 or later the key signs what the |
| 37 | console sends, and a member joined from here follows as it; the Access |
| 38 | tab keeps the hub's grants. A page served over plain ``http://`` to |
| 39 | anything but localhost has no WebCrypto, holds no key, and says so. |
| 40 | |
| 41 | Its Agents tab starts an agent beside the swarm (``agents.js``, inlined; |
| 42 | see _meta/agents.md): an open-weights model in this browser (WebLLM, |
| 43 | imported only when a person presses Start) or on a server of theirs. It |
| 44 | calls the same endpoints this page does, its suggestions are checked by |
| 45 | rules before they are shown, and accepting one grows the tree here when |
| 46 | the browser is its own hub, and keeps it at the hub's ``/suggestions``. |
| 47 | """ |
| 48 | |
| 49 | from __future__ import annotations |
| 50 | |
| 51 | import json |
| 52 | import urllib.parse |
| 53 | from pathlib import Path |
| 54 | |
| 55 | from . import __version__ |
| 56 | |
| 57 | # The tree runtime in JavaScript, served beside the console so a member can run trees. |
| 58 | PYRTAN_JS = (Path(__file__).parent / "js" / "pyrtan.js").read_text(encoding="utf-8") |
| 59 | # The wordmark's colour picker, inlined into the console and copied beside pyrtan.com's pages. |
| 60 | THEME_JS = (Path(__file__).parent / "js" / "theme.js").read_text(encoding="utf-8") |
| 61 | # Identifiers, canonical JSON and request signing, inlined into the console (_meta/identity.md). |
| 62 | IDENTITY_JS = (Path(__file__).parent / "js" / "identity.js").read_text(encoding="utf-8") |
| 63 | # Agents beside the swarm: open-weights models a person starts in the console (_meta/agents.md). |
| 64 | AGENTS_JS = (Path(__file__).parent / "js" / "agents.js").read_text(encoding="utf-8") |
| 65 | |
| 66 | PAGE = r"""<!doctype html> |
| 67 | <html lang="en"> |
| 68 | <head> |
| 69 | <meta charset="utf-8"> |
| 70 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 71 | <title>Pyrtan</title> |
| 72 | <meta name="description" content="Pyrtan console: ask the swarm, read the trace, see what it can answer, find the gaps, and join a hub as a member that keeps its blobs."> |
| 73 | <link rel="manifest" href="manifest.webmanifest"> |
| 74 | <script src="pyrtan.js"></script> |
| 75 | <meta name="theme-color" content="#c2410c"> |
| 76 | <script>__THEME__</script> |
| 77 | <script>__IDENTITY__</script> |
| 78 | <script>__AGENTS__</script> |
| 79 | <style> |
| 80 | :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 } |
| 81 | @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } } |
| 82 | :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } |
| 83 | :root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) } |
| 84 | * { box-sizing:border-box } |
| 85 | body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif } |
| 86 | main { max-width:920px; margin:0 auto; padding:24px 16px 64px } |
| 87 | header { display:flex; flex-wrap:wrap; align-items:baseline; gap:8px 16px; margin-bottom:12px } |
| 88 | h1 { font-size:22px; margin:0 } h1 small { color:var(--mute); font-weight:normal; font-size:14px; margin-left:8px } |
| 89 | h2 { font-size:17px; margin:24px 0 8px } |
| 90 | nav { display:flex; gap:4px; flex-wrap:wrap; margin:12px 0 16px } |
| 91 | nav a { padding:6px 12px; border-radius:999px; color:var(--fg); text-decoration:none; border:1px solid var(--line); background:var(--card); font-size:14px } |
| 92 | nav a:hover { border-color:var(--tan) } |
| 93 | nav a[aria-current=page] { background:var(--accent); color:var(--accent-fg); border-color:var(--accent) } |
| 94 | section[hidden] { display:none } |
| 95 | p.lede { color:var(--mute); margin:0 0 16px } |
| 96 | form.row { display:flex; gap:8px; margin:0 0 12px; flex-wrap:wrap } |
| 97 | div.row { display:flex; gap:8px; flex-wrap:wrap } |
| 98 | form.row input[type=text], form.row input[type=url], form.row input[type=password], div.row input[type=text] { flex:1 1 180px; min-width:0; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) } |
| 99 | [hidden] { display:none !important } /* even where an inline style sets display */ |
| 100 | #tab-identity input[type=text], #tab-identity input[type=password], #tab-identity input[type=file], #tab-access input[type=text] { flex:1 1 180px; min-width:0; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) } |
| 101 | button { font:inherit; padding:10px 16px; border:0; border-radius:8px; background:var(--accent); color:var(--accent-fg); cursor:pointer } |
| 102 | button.quiet { background:var(--soft); color:var(--fg) } |
| 103 | button:disabled { opacity:.6; cursor:wait } |
| 104 | .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:0 0 14px } |
| 105 | .status { font-weight:600 } .done{color:var(--ok)} .failed,.error{color:var(--bad)} .asked{color:var(--ask)} |
| 106 | .say { font-size:18px; margin:6px 0 } |
| 107 | pre { margin:8px 0 0; padding:10px 12px; background:var(--bg); border:1px solid var(--line); border-radius:8px; overflow:auto; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre-wrap } |
| 108 | .suggest { position:relative; flex:1 1 180px; min-width:0 } |
| 109 | form.row .suggest input[type=text] { position:relative; width:100%; background:transparent } |
| 110 | .suggest .ghost { position:absolute; inset:0; padding:10px 12px; border:1px solid transparent; border-radius:8px; background:var(--card); white-space:pre; overflow:hidden; pointer-events:none } |
| 111 | .suggest .ghost .typed { visibility:hidden } .suggest .ghost .rest { color:var(--mute) } |
| 112 | .suggestions { position:absolute; z-index:10; left:0; right:0; top:calc(100% + 4px); list-style:none; margin:0; padding:4px 0; background:var(--card); border:1px solid var(--line); border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.12); max-height:320px; overflow-y:auto } |
| 113 | .suggestions li { padding:7px 12px; cursor:pointer; display:flex; gap:8px; align-items:baseline } |
| 114 | .suggestions li[aria-selected=true], .suggestions li:hover { background:var(--soft) } |
| 115 | .suggestions li b { font-weight:600 } .suggestions li .text { flex:1; min-width:0; overflow-wrap:anywhere } |
| 116 | .suggestions li .from { color:var(--mute); font-size:13px } |
| 117 | details summary { cursor:pointer; color:var(--mute) } |
| 118 | ul.tries { list-style:none; padding:0; margin:0; display:flex; flex-wrap:wrap; gap:8px } |
| 119 | ul.tries li a { display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:var(--card); color:var(--fg); text-decoration:none; font-size:14px } |
| 120 | table { width:100%; border-collapse:collapse; font-size:14px } td,th { text-align:left; padding:6px 8px; border-top:1px solid var(--line); vertical-align:top } th { color:var(--mute); font-weight:600; border-top:0 } |
| 121 | code { font:13px ui-monospace,SFMono-Regular,Menlo,monospace } |
| 122 | .mute { color:var(--mute) } .gap { color:var(--ask) } |
| 123 | .link { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; vertical-align:middle; background:var(--mute) } |
| 124 | .link.up { background:var(--ok) } .link.stale { background:var(--ask) } .link.down { background:var(--bad) } |
| 125 | .notice { border-left:3px solid var(--ask); padding:8px 12px; background:var(--soft); border-radius:6px; margin:0 0 14px; font-size:14px } |
| 126 | .notice.bad { border-color:var(--bad) } |
| 127 | footer { color:var(--mute); font-size:13px; margin-top:32px } |
| 128 | footer a, .card a { color:var(--accent) } |
| 129 | .pyr { color:var(--pyr) } .tan { color:var(--tan) } |
| 130 | :focus-visible { outline:2px solid var(--tan); outline-offset:2px } |
| 131 | ::selection { background:var(--tan); color:var(--tan-fg) } |
| 132 | .chat-log { height:min(60vh,480px); overflow-y:auto; padding:2px 2px 8px; overflow-wrap:anywhere } |
| 133 | .chat-log .line { margin:6px 0 } |
| 134 | .chat-log .sys { color:var(--mute); font-size:14px } |
| 135 | .chat-log .who { font-weight:600; margin-right:6px } |
| 136 | .chat-log .you .who { color:var(--accent) } .chat-log .stranger .who { color:var(--bad) } .chat-log .bot .who { color:var(--ok) } .chat-log .tree .who { color:var(--tan) } |
| 137 | .chat-log details { font-size:13px; margin-top:2px } |
| 138 | .typing-line { min-height:20px; color:var(--mute); font-size:14px; font-style:italic } |
| 139 | #chat-say { flex-wrap:nowrap } #chat-say input { flex-basis:0 } |
| 140 | select { font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) } |
| 141 | @media (max-width:560px) { table thead { display:none } table td { display:block; border-top:0 } table tr { display:block; border-top:1px solid var(--line); padding:6px 0 } } |
| 142 | </style> |
| 143 | </head> |
| 144 | <body> |
| 145 | <main> |
| 146 | <header> |
| 147 | <h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span> <small id="who">…</small></h1> |
| 148 | <span class="mute" id="where"></span> |
| 149 | </header> |
| 150 | <p class="lede">A core set of expandable services that act on all forms of I/O through inspectable decision trees. Ask a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved in an answer; an agent you start sits beside the trees, never inside them.</p> |
| 151 | |
| 152 | <section id="connect" hidden> |
| 153 | <div class="card"> |
| 154 | <div style="margin-bottom:8px" id="connect-lede">This console is not being served by a Pyrtan. Point it at one:</div> |
| 155 | <div class="mute" style="font-size:14px;margin:-4px 0 8px" id="connect-local" hidden></div> |
| 156 | <form class="row" id="connect-form"> |
| 157 | <input type="url" id="hub-url" placeholder="https://hub.example.net or http://192.168.1.20:8700" autocomplete="off"> |
| 158 | <input type="password" id="hub-token" placeholder="token, if the hub needs one" autocomplete="off"> |
| 159 | <button type="submit" id="connect-go">Connect</button> |
| 160 | <button type="button" class="quiet" id="forget">Forget</button> |
| 161 | </form> |
| 162 | <div class="mute" style="font-size:14px" id="connect-help">Kept in this browser only. The hub must allow this page's origin: <code>pyrtan hub --cors <span id="origin"></span></code>. A hub on your LAN over <code>http://</code> may be refused by the browser when this page is <code>https://</code>; open the hub's own <code>/</code> instead, which is this same console.</div> |
| 163 | </div> |
| 164 | </section> |
| 165 | |
| 166 | <div id="problem"></div> |
| 167 | |
| 168 | <nav id="tabs"> |
| 169 | <a href="#ask" data-tab="ask">Ask</a> |
| 170 | <a href="#trees" data-tab="trees">Trees</a> |
| 171 | <a href="#peers" data-tab="peers" id="peers-tab" hidden>Peers</a> |
| 172 | <a href="#watches" data-tab="watches" id="watches-tab">Watches</a> |
| 173 | <a href="#gaps" data-tab="gaps">Gaps</a> |
| 174 | <a href="#unanswered" data-tab="unanswered">Unanswered</a> |
| 175 | <a href="#agents" data-tab="agents">Agents</a> |
| 176 | <a href="#directory" data-tab="directory" id="directory-tab" hidden>Directory</a> |
| 177 | <a href="#member" data-tab="member" id="member-tab" hidden>Member</a> |
| 178 | <a href="#identity" data-tab="identity" id="identity-tab" hidden>Identity</a> |
| 179 | <a href="#access" data-tab="access" id="access-tab" hidden>Access</a> |
| 180 | <a href="#chat" data-tab="chat" id="chat-tab" hidden>Chat</a> |
| 181 | <a href="#api" data-tab="api">API</a> |
| 182 | </nav> |
| 183 | |
| 184 | <section id="tab-ask"> |
| 185 | <form class="row" id="ask"> |
| 186 | <div class="suggest"> |
| 187 | <div class="ghost" id="ghost" aria-hidden="true"><span class="typed"></span><span class="rest"></span></div> |
| 188 | <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="both" aria-expanded="false" aria-controls="suggestions"> |
| 189 | <ul class="suggestions" id="suggestions" role="listbox" aria-label="Suggestions" hidden></ul> |
| 190 | </div> |
| 191 | <button type="submit" id="go">Ask</button> |
| 192 | </form> |
| 193 | <div class="card" style="margin-bottom:24px"> |
| 194 | <div class="mute" style="margin-bottom:8px">Try one</div> |
| 195 | <ul class="tries" id="tries"></ul> |
| 196 | </div> |
| 197 | <div id="out"></div> |
| 198 | </section> |
| 199 | |
| 200 | <section id="tab-trees" hidden> |
| 201 | <h2>What this swarm can answer</h2> |
| 202 | <div class="card"><table id="caps"><thead><tr><th>Tree</th><th>Answers to</th><th>From</th></tr></thead><tbody></tbody></table></div> |
| 203 | <p class="mute" style="font-size:14px" id="trees-more">Need a tree that is not here? Browse the <a href="https://pyrtan.com/catalog/" id="catalog-link">catalog</a>, then <code>pyrtan pull NAME</code> into a folder a service serves.</p> |
| 204 | </section> |
| 205 | |
| 206 | <section id="tab-peers" hidden> |
| 207 | <h2>Peers</h2> |
| 208 | <div class="card"><table id="peers"><thead><tr><th>Link</th><th>Peer</th><th>Trees</th><th>Last</th></tr></thead><tbody></tbody></table></div> |
| 209 | <p class="mute" style="font-size:14px"><span class="link up"></span>up: answered within 30 s · <span class="link stale"></span>stale: within 90 s · <span class="link down"></span>down: longer, or never.</p> |
| 210 | <div id="federation" hidden> |
| 211 | <h2>Federation</h2> |
| 212 | <p class="mute" style="font-size:14px;margin-top:0">This hub is <code id="fed-id"></code>. It routes to the hubs it follows; hubs that follow it receive its announcements and may ask it.</p> |
| 213 | <div class="card"><table id="hubs"><thead><tr><th>Link</th><th>Hub</th><th>Relation</th><th>Trees</th></tr></thead><tbody></tbody></table></div> |
| 214 | </div> |
| 215 | </section> |
| 216 | |
| 217 | <section id="tab-watches" hidden> |
| 218 | <h2>Watches</h2> |
| 219 | <p class="mute" style="font-size:14px;margin-top:0">Trees that run on their own: a clock, a file, a topic or a serial line starts them (<code>[[watches]]</code> in the tree, <code>--watch</code> on the service). Each row is what the watch last did; a path that changed between two runs of the same watch is how you learn a site changed before anyone asked.</p> |
| 220 | <div class="card"><table id="watches"><thead><tr><th>Watch</th><th>On</th><th>Fired</th><th>Last run</th></tr></thead><tbody></tbody></table></div> |
| 221 | </section> |
| 222 | |
| 223 | <section id="tab-gaps" hidden> |
| 224 | <h2>Gaps</h2> |
| 225 | <p class="mute" style="font-size:14px;margin-top:0">A gap is a decision that met a value no branch covers. Each one is the next branch to write, in the tree and node named.</p> |
| 226 | <div class="card"><table id="gaps"><thead><tr><th>Tree · node</th><th>Saw</th><th>Times</th><th>Where</th></tr></thead><tbody></tbody></table></div> |
| 227 | </section> |
| 228 | |
| 229 | <section id="tab-unanswered" hidden> |
| 230 | <h2>Waiting to be written</h2> |
| 231 | <p class="mute" style="font-size:14px;margin-top:0" id="unanswered-lede">Sentences nobody answered, each with the tree that would. <code>pyrtan unanswered --at <span class="at">URL</span> --scaffold DIR</code> writes the files.</p> |
| 232 | <div class="card"><table id="unanswered"><thead><tr><th>Nobody answered</th><th>Would be</th><th>To write it</th></tr></thead><tbody></tbody></table></div> |
| 233 | </section> |
| 234 | |
| 235 | <section id="tab-agents" hidden> |
| 236 | <h2>Agents beside the swarm</h2> |
| 237 | <p class="mute" style="font-size:14px;margin-top:0">An agent is an open-weights model you start here: in this browser, on its GPU, or on a server of your own (Ollama, llama.cpp). It tests the trees with sentences of its own, says what a gap's value means, finds the patterns a tree missed and the trees nobody has written, and talks with you. It never answers for a tree and never sits inside a run: what it suggests is checked by rules here, checked again by <code>pyrtan grow --suggestions</code> before it is written, and only you accept it. It remembers what it learns and follows its skills, in this browser only. Nothing is downloaded until you start one.</p> |
| 238 | <div class="card" id="agent-setup"> |
| 239 | <form class="row" id="agent-start" style="margin:0 0 8px"> |
| 240 | <input type="text" id="agent-name" placeholder="a name, e.g. scout" autocomplete="off" style="flex:0 1 140px"> |
| 241 | <select id="agent-engine" aria-label="where the model runs"><option value="webllm">in this browser (WebGPU)</option><option value="openai">on a server of mine (OpenAI API)</option></select> |
| 242 | <select id="agent-model" aria-label="model"></select> |
| 243 | <input type="url" id="agent-url" placeholder="http://localhost:11434/v1" autocomplete="off" hidden> |
| 244 | <input type="text" id="agent-server-model" placeholder="model, e.g. qwen2.5:3b" autocomplete="off" hidden> |
| 245 | <button type="submit" id="agent-go">Start</button> |
| 246 | </form> |
| 247 | <div class="mute" style="font-size:14px" id="agent-help">In this browser: the weights download once (from Hugging Face, through WebLLM) and stay in the browser's cache. On a server of yours, it must let this page's origin call it (Ollama: <code>OLLAMA_ORIGINS=<span class="origin-here"></span></code>).</div> |
| 248 | <div class="notice" id="agent-gpu" hidden style="margin:8px 0 0">This browser has no WebGPU, so a model cannot run in it. Point it at a server of yours instead, or use a browser that has WebGPU.</div> |
| 249 | <div class="mute" style="font-size:14px;margin-top:6px" id="agent-progress"></div> |
| 250 | </div> |
| 251 | <div id="agent-on" hidden> |
| 252 | <div class="card"> |
| 253 | <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between"> |
| 254 | <div><span class="status done" id="agent-who"></span> <span class="mute" id="agent-what"></span></div> |
| 255 | <div style="display:flex;gap:8px;flex-wrap:wrap"> |
| 256 | <button type="button" id="agent-patrol" title="probe every tree, then the gaps, then the unanswered">Patrol</button> |
| 257 | <button type="button" class="quiet" id="agent-stop" disabled>Stop</button> |
| 258 | <button type="button" class="quiet" id="agent-off">Put away</button> |
| 259 | </div> |
| 260 | </div> |
| 261 | <div class="chat-log" id="agent-log" aria-live="polite" style="height:min(50vh,420px);margin-top:8px"></div> |
| 262 | <div class="typing-line" id="agent-typing" aria-live="polite"></div> |
| 263 | <form class="row" id="agent-say" style="margin:8px 0 0;flex-wrap:nowrap"> |
| 264 | <input type="text" id="agent-text" placeholder="talk to the agent, or /help" autocomplete="off" maxlength="1000" style="flex-basis:0"> |
| 265 | <button type="submit">Send</button> |
| 266 | </form> |
| 267 | </div> |
| 268 | <h2>Suggestions</h2> |
| 269 | <p class="mute" style="font-size:14px;margin-top:0" id="agent-sug-lede">Each passed the rules in this page. Accept one and it is kept at the hub's <code>/suggestions</code>, for <code>pyrtan grow --suggestions <span class="at">URL</span> --trees DIR --apply</code>. Dismiss one and the agent remembers why.</p> |
| 270 | <div id="agent-sugs"><p class="mute">Nothing yet. Press Patrol, or ask it to <code>/probe</code> a tree.</p></div> |
| 271 | <div style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 14px"><button type="button" class="quiet" id="agent-download">Download accepted</button></div> |
| 272 | <details class="card" id="agent-memory"><summary>Memory</summary> |
| 273 | <p class="mute" style="font-size:14px">What this agent keeps, for this hub, in this browser: your notes, its own, and what happened (refusals, your verdicts, patrols). The most relevant go into every prompt.</p> |
| 274 | <form class="row" id="agent-note-form"><input type="text" id="agent-note" placeholder="a note for the agent, e.g. Oslo's weather API is in Celsius" autocomplete="off"><button type="submit">Remember</button></form> |
| 275 | <table id="agent-notes"><tbody></tbody></table> |
| 276 | <div style="margin-top:8px"><button type="button" class="quiet" id="agent-forget-all">Forget everything</button></div> |
| 277 | </details> |
| 278 | <details class="card" id="agent-skills-box"><summary>Skills</summary> |
| 279 | <p class="mute" style="font-size:14px">Instructions for a job, the way a skill is for Claude: the built-in ones are the jobs, yours are added to the job you name (or to all of them). A chat skill of yours can be called as <code>/name</code>. Kept in this browser, shared by every agent; a skill file is Markdown with <code>name</code>, <code>description</code> and <code>when</code> in its front matter, or JSON.</p> |
| 280 | <table id="agent-skills"><thead><tr><th>Skill</th><th>For</th><th></th></tr></thead><tbody></tbody></table> |
| 281 | <form id="agent-skill-form" style="margin-top:10px"> |
| 282 | <div class="row"><input type="text" id="skill-name" placeholder="name, e.g. tourist" autocomplete="off"><select id="skill-when" aria-label="for which job"><option value="chat">chat</option><option value="probe">probe</option><option value="meaning">meaning</option><option value="trigger">trigger</option><option value="tree">tree</option><option value="always">always</option></select><input type="text" id="skill-desc" placeholder="one line: what it is for" autocomplete="off"></div> |
| 283 | <textarea id="skill-body" rows="4" placeholder="the instructions, e.g. Write probe sentences the way a tourist would: short, a little misspelled, with place names in their own language." style="width:100%;font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg)"></textarea> |
| 284 | <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"><button type="submit">Add skill</button><label class="quiet" style="padding:10px 16px;border-radius:8px;background:var(--soft);cursor:pointer">Import…<input type="file" id="skill-import" accept=".md,.json,.txt" hidden></label><button type="button" class="quiet" id="skill-export">Export mine</button></div> |
| 285 | </form> |
| 286 | </details> |
| 287 | </div> |
| 288 | </section> |
| 289 | |
| 290 | <section id="tab-directory" hidden> |
| 291 | <h2>Directory</h2> |
| 292 | <p class="mute" style="font-size:14px;margin-top:0">Every hub that asked to be found, as this hub has heard it through the swarm. Entries are hints carried hub to hub; <em>link</em> is how recently each announced. Nothing here is routed to: to use one, follow it from your own hub, <code>pyrtan follow URL --at YOUR-HUB</code>. To be in it: <code>pyrtan hub --listed</code>.</p> |
| 293 | <div class="card"><table id="directory"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th>Last announced</th></tr></thead><tbody></tbody></table></div> |
| 294 | </section> |
| 295 | |
| 296 | <section id="tab-member" hidden> |
| 297 | <h2>This browser as a member</h2> |
| 298 | <p class="mute" style="font-size:14px;margin-top:0">A browser or a phone cannot be reached, so it joins a hub the other way round: it follows the hub, polls a mailbox the hub keeps for it, carries the directory, and keeps copies of the hub's blobs (its directory, gaps, unanswered sentences, anything given to <code>pyrtan keep</code>). When the hub's box dies, what it kept is still here. Install this page to the home screen and it is the phone app.</p> |
| 299 | <div class="card"> |
| 300 | <div id="member-off"> |
| 301 | <form class="row" id="join-form"> |
| 302 | <input type="text" id="member-name" placeholder="a name for this device, e.g. barry's phone" autocomplete="off"> |
| 303 | <input type="password" id="member-fedsecret" placeholder="federation secret, if the hub takes follows only with one" autocomplete="off"> |
| 304 | <button type="submit">Join this hub</button> |
| 305 | </form> |
| 306 | <div class="mute" style="font-size:14px">Makes an identity for this browser (kept in local storage), follows the hub with it, and starts polling. Nothing leaves this device but signed activities to the hub. Joined with the hub's token (entered above), this device is <em>trusted</em>: the hub hands it its trees and routes sentences to it, and the trees run here.</div> |
| 307 | </div> |
| 308 | <div id="member-on" hidden> |
| 309 | <div><span class="status done">member</span> <code id="member-id"></code> <span class="mute" id="member-state"></span></div> |
| 310 | <div class="mute" style="font-size:14px;margin:6px 0">Name <code id="member-name-shown"></code> · <span id="member-trust"></span> · mailbox polled <span id="member-polled">never</span> · <label><input type="checkbox" id="member-listed"> listed in the directory</label></div> |
| 311 | <div style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0"> |
| 312 | <button type="button" class="quiet" id="member-poll">Poll now</button> |
| 313 | <button type="button" class="quiet" id="member-leave">Leave</button> |
| 314 | </div> |
| 315 | <h2 style="font-size:15px;margin:14px 0 6px">Kept here for this hub</h2> |
| 316 | <table id="kept"><thead><tr><th>Key</th><th>Size</th><th>Updated</th><th></th></tr></thead><tbody></tbody></table> |
| 317 | <h2 style="font-size:15px;margin:14px 0 6px">Trees this device runs</h2> |
| 318 | <table id="member-trees"><thead><tr><th>Tree</th><th>Answers to</th><th>Reaches</th></tr></thead><tbody></tbody></table> |
| 319 | <form class="row" id="member-run" style="margin-top:8px"> |
| 320 | <select id="member-run-tree" style="font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg)"></select> |
| 321 | <input type="text" id="member-run-inputs" placeholder='inputs as JSON, e.g. {"place": "Oslo"}' autocomplete="off"> |
| 322 | <button type="submit">Run here</button> |
| 323 | </form> |
| 324 | <div id="member-out"></div> |
| 325 | <h2 style="font-size:15px;margin:14px 0 6px">Activity</h2> |
| 326 | <pre id="member-log" style="max-height:220px"></pre> |
| 327 | </div> |
| 328 | </div> |
| 329 | </section> |
| 330 | |
| 331 | <section id="tab-identity" hidden> |
| 332 | <h2>Your identity</h2> |
| 333 | <p class="mute" style="font-size:14px;margin-top:0">An identity is a key, made here and held by this browser, which will not hand it over, not even to this page. No account anywhere: hubs grant your key what they choose, a name like <code>you@pyrtan.com</code> points at it, and your other devices speak for it with a note it signs. Lose the key and its backup and nobody can give it back.</p> |
| 334 | <div id="ident-none" class="notice" hidden>This page cannot hold a key: it needs a secure page (https, or localhost) in a browser with Ed25519 in WebCrypto. It still asks, chats and joins, without a key: with the hub's token, or as an anonymous member.</div> |
| 335 | |
| 336 | <div class="card" id="ident-start" hidden> |
| 337 | <div style="display:flex;gap:8px;flex-wrap:wrap"> |
| 338 | <button type="button" id="ident-new">Make my identity</button> |
| 339 | <button type="button" class="quiet" id="ident-restore-open">Restore from a backup</button> |
| 340 | <button type="button" class="quiet" id="ident-make">Use this browser as a device of an identity I have</button> |
| 341 | </div> |
| 342 | <form id="ident-new-form" style="margin-top:12px" hidden> |
| 343 | <div class="mute" style="font-size:14px;margin-bottom:6px">Your key is made here, then sealed with a passphrase into a backup you keep. Choose a long sentence you will remember: nobody can reset it, and the backup is only as strong as it is.</div> |
| 344 | <div class="row"><input type="password" id="ident-pass" placeholder="passphrase (a long sentence)" autocomplete="new-password"><input type="password" id="ident-pass2" placeholder="the same again" autocomplete="new-password"><button type="submit">Make it</button></div> |
| 345 | </form> |
| 346 | <form id="ident-restore-form" style="margin-top:12px" hidden> |
| 347 | <div class="mute" style="font-size:14px;margin-bottom:6px">The backup file this console or <code>pyrtan id backup</code> wrote, and its passphrase.</div> |
| 348 | <div class="row"><input type="file" id="ident-file" accept="application/json,.json"><input type="password" id="ident-restore-pass" placeholder="passphrase" autocomplete="current-password"><button type="submit">Restore</button></div> |
| 349 | </form> |
| 350 | </div> |
| 351 | |
| 352 | <div class="card" id="ident-root" hidden> |
| 353 | <div><span class="status done">identity</span> <code id="root-id"></code></div> |
| 354 | <div id="root-backup-state" style="margin:8px 0"></div> |
| 355 | <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"> |
| 356 | <button type="button" id="root-download">Download my backup</button> |
| 357 | <input type="text" id="root-gh" placeholder="your GitHub login" autocomplete="off" style="max-width:220px"> |
| 358 | <button type="button" class="quiet" id="root-gh-go">Keep it in a private GitHub repository</button> |
| 359 | </div> |
| 360 | <div id="root-gh-out" class="mute" style="font-size:14px;margin-top:8px"></div> |
| 361 | </div> |
| 362 | |
| 363 | <div class="card" id="ident-claim" hidden> |
| 364 | <h2 style="font-size:15px;margin:0 0 8px">Claim a name</h2> |
| 365 | <form id="claim-form"> |
| 366 | <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:8px;font-size:14px"> |
| 367 | <label><input type="radio" name="claim-where" value="pyrtan" checked> at pyrtan.com (your GitHub login)</label> |
| 368 | <label><input type="radio" name="claim-where" value="network"> at another network</label> |
| 369 | </div> |
| 370 | <div class="row" id="claim-pyrtan"><input type="text" id="claim-login" placeholder="your GitHub login" autocomplete="off"><button type="submit">Make my claim</button></div> |
| 371 | <div id="claim-network" hidden> |
| 372 | <div class="row"><input type="text" id="claim-name" placeholder="name@network-domain, e.g. alice@names.example.org" autocomplete="off"></div> |
| 373 | <div class="row" style="margin-top:6px"><input type="text" id="claim-repo" placeholder="its names repository, e.g. https://github.com/example/names" autocomplete="off"><input type="text" id="claim-branch" value="main" style="max-width:120px" autocomplete="off"><button type="submit">Make my claim</button></div> |
| 374 | </div> |
| 375 | </form> |
| 376 | <div id="claim-out"></div> |
| 377 | </div> |
| 378 | |
| 379 | <div class="card" id="ident-devices" hidden> |
| 380 | <h2 style="font-size:15px;margin:0 0 8px">Devices that speak for you</h2> |
| 381 | <table id="dev-table"><thead><tr><th>Device</th><th>May</th><th>Until</th><th></th></tr></thead><tbody></tbody></table> |
| 382 | <form id="dev-form" style="margin-top:10px"> |
| 383 | <div class="mute" style="font-size:14px;margin-bottom:6px">On the other device, open this console's Identity tab, choose <em>Use this browser as a device</em>, and paste its code here.</div> |
| 384 | <div class="row"><input type="text" id="dev-code" placeholder="its code, pyr:…" autocomplete="off"><input type="text" id="dev-label" placeholder="what to call it: phone" style="max-width:200px" autocomplete="off"></div> |
| 385 | <div style="display:flex;gap:14px;flex-wrap:wrap;margin:8px 0;font-size:14px;align-items:center" id="dev-may"><span class="mute">it may</span> |
| 386 | <label><input type="checkbox" value="ask" checked> ask</label><label><input type="checkbox" value="chat" checked> chat</label><label><input type="checkbox" value="run"> run trees</label><label><input type="checkbox" value="operate"> operate hubs</label> |
| 387 | <button type="submit">Sign its note</button></div> |
| 388 | </form> |
| 389 | <div id="dev-out"></div> |
| 390 | <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"> |
| 391 | <button type="button" class="quiet" id="doc-push" hidden>Give this hub my newest document</button> |
| 392 | <button type="button" class="quiet" id="doc-download">Download my identity document</button> |
| 393 | </div> |
| 394 | </div> |
| 395 | |
| 396 | <div class="card" id="ident-on" hidden> |
| 397 | <div><span class="status done">device</span> <code id="ident-id"></code> <button type="button" class="quiet" id="ident-copy">Copy its code</button></div> |
| 398 | <div class="mute" style="font-size:14px;margin:6px 0" id="ident-paired"></div> |
| 399 | <form id="ident-note-form" style="margin:10px 0"> |
| 400 | <div class="mute" style="font-size:14px;margin-bottom:6px">Give this code to the browser that holds your identity (its Identity tab, <em>Devices</em>), or run <code id="ident-howto"></code>; then paste the note it gives back here.</div> |
| 401 | <div class="row"><input type="text" id="ident-note" placeholder='{"pyrtan-device": 1, "root": "pyr:…", ...}' autocomplete="off"><button type="submit">Use this note</button></div> |
| 402 | </form> |
| 403 | </div> |
| 404 | |
| 405 | <div id="ident-common" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-top:8px" hidden> |
| 406 | <label><input type="checkbox" id="ident-sign"> sign requests to the hub with this key</label> |
| 407 | <button type="button" class="quiet" id="ident-forget">Forget it on this browser</button> |
| 408 | </div> |
| 409 | </section> |
| 410 | |
| 411 | <section id="tab-access" hidden> |
| 412 | <h2>Who may do what here</h2> |
| 413 | <p class="mute" style="font-size:14px;margin-top:0">The identities this hub grants anything, instead of handing out its token: <em>ask</em> (the trees), <em>chat</em> (the lobby, bots included), <em>run</em> (be a trusted member, running its trees) and <em>operate</em> (everything the token allows). A device gets what its identity is granted, capped by its note. A name is looked up once and pinned.</p> |
| 414 | <div id="access-note" class="notice" hidden></div> |
| 415 | <div class="card"> |
| 416 | <table id="grant-table"><thead><tr><th>Who</th><th>May</th><th>Document</th><th></th></tr></thead><tbody></tbody></table> |
| 417 | <form id="grant-form" style="margin-top:10px"> |
| 418 | <div class="row"><input type="text" id="grant-who" placeholder="name@domain or pyr:…" autocomplete="off"><button type="submit">Grant</button></div> |
| 419 | <div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:8px;font-size:14px" id="grant-may"> |
| 420 | <label><input type="checkbox" value="ask" checked> ask</label><label><input type="checkbox" value="chat"> chat</label><label><input type="checkbox" value="run"> run</label><label><input type="checkbox" value="operate"> operate</label> |
| 421 | </div> |
| 422 | </form> |
| 423 | </div> |
| 424 | </section> |
| 425 | |
| 426 | <section id="tab-chat" hidden> |
| 427 | <h2>Talk to a stranger</h2> |
| 428 | <p class="mute" style="font-size:14px;margin-top:0">You are paired at random: with someone else on this hub or on a hub it follows, or with a bot. A bot is a set of trees on the network, not a person and not a model; each of its lines carries the path that produced it, and what it cannot answer it says so, with the tree that would. Nobody has a name and nothing is kept: the conversation is gone when either of you leaves. <span id="chat-count"></span></p> |
| 429 | <div class="notice" id="chat-guest" hidden>This hub's lobby is open to anyone, and you are here without its token: you meet people, not bots. A bot runs the hub's trees, and the token is what guards them.</div> |
| 430 | <div class="card" id="chat-off"> |
| 431 | <form class="row" id="chat-start" style="margin:0"> |
| 432 | <input type="text" id="chat-interests" placeholder="interests, optional: weather, news, music" autocomplete="off"> |
| 433 | <select id="chat-want" aria-label="who to meet"><option value="anyone">a stranger, or a bot</option><option value="people">people only</option><option value="bot">a bot</option></select> |
| 434 | <button type="submit">Start</button> |
| 435 | </form> |
| 436 | </div> |
| 437 | <div class="card" id="chat-on" hidden> |
| 438 | <div class="chat-log" id="chat-log" aria-live="polite"></div> |
| 439 | <div class="typing-line" id="chat-typing-line" aria-live="polite"></div> |
| 440 | <form class="row" id="chat-say" style="margin:8px 0 0"> |
| 441 | <button type="button" class="quiet" id="chat-next" title="Esc">New</button> |
| 442 | <input type="text" id="chat-text" placeholder="say something" autocomplete="off" maxlength="1000"> |
| 443 | <button type="submit">Send</button> |
| 444 | </form> |
| 445 | <div class="mute" style="font-size:13px">Stop (or Esc), twice: end this and meet someone new · <a href="#" id="chat-stop">leave the lobby</a></div> |
| 446 | </div> |
| 447 | <label class="mute" style="font-size:14px" id="chat-typing-opt"><input type="checkbox" id="chat-typing" checked> Typing indicator: see when the stranger is typing, and let them see when you are. Off, neither of you sees it.</label> |
| 448 | </section> |
| 449 | |
| 450 | <section id="tab-api" hidden> |
| 451 | <h2>The same endpoints this page uses</h2> |
| 452 | <div class="card"> |
| 453 | <pre id="api-pre">POST /intent {"text": "…"} |
| 454 | POST /run {"tree": "…", "inputs": {…}} |
| 455 | POST /resume {"snapshot": {…}, "answer": …} |
| 456 | GET /capabilities |
| 457 | GET /health |
| 458 | GET /gaps |
| 459 | GET /unanswered |
| 460 | GET /peers (hub only) |
| 461 | POST /chat/start {"want": "anyone"|"people"|"bot", "typing_indicator": true} (hub with --chat) |
| 462 | POST /chat/typing {"seat": "…", "typing": true}</pre> |
| 463 | <div class="mute" style="font-size:14px;margin-top:8px" id="api-help">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div> |
| 464 | </div> |
| 465 | </section> |
| 466 | |
| 467 | <footer><a href="https://pyrtan.com">pyrtan.com</a> · <a href="https://pyrtan.com/source/">source</a> · AGPL-3.0-or-later · console v__VERSION__<span id="hub-version"></span></footer> |
| 468 | </main> |
| 469 | <script> |
| 470 | const $ = (s) => document.querySelector(s); |
| 471 | const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); |
| 472 | const examples = { |
| 473 | "weather": "what's the weather in Oslo", |
| 474 | "hacker-news": "top 3 stories on hacker news", |
| 475 | "site-status": "is example.com up", |
| 476 | "github-release": "latest release of astral-sh/uv", |
| 477 | "hacker-news-item": "hacker news item 1", |
| 478 | "wikipedia": "tell me about Ada Lovelace", |
| 479 | "define": "what does serendipity mean", |
| 480 | "sun": "when is sunset in Oslo", |
| 481 | "air-quality": "air quality in Delhi", |
| 482 | "npm-package": "latest version of react on npm", |
| 483 | "pypi-package": "latest requests on pypi", |
| 484 | "hn-search": "search hacker news for sqlite", |
| 485 | "books": "who wrote The Left Hand of Darkness", |
| 486 | "holidays": "next holiday in US", |
| 487 | "exchange-rate": "convert 100 USD to EUR", |
| 488 | "synonyms": "synonyms for happy", |
| 489 | "forecast": "weather in Oslo tomorrow", |
| 490 | "uv-index": "uv index in Sydney", |
| 491 | "local-time": "what time is it in Tokyo", |
| 492 | "elevation": "how high is Quito", |
| 493 | "postal-code": "where is zip 90210", |
| 494 | "wayback": "is example.com archived", |
| 495 | "rhymes": "what rhymes with moon", |
| 496 | "github-repo": "how many stars does astral-sh/uv have", |
| 497 | "go-module": "latest version of github.com/gorilla/mux on go", |
| 498 | "homebrew": "brew formula wget", |
| 499 | "pokemon": "pokemon pikachu", |
| 500 | }; |
| 501 | // Served by a Pyrtan, only its token is kept, under its own origin; served elsewhere, the hub URL and token. |
| 502 | const KEY = "pyrtan.console:" + location.origin; |
| 503 | const store = { |
| 504 | get() { try { return JSON.parse(localStorage.getItem(KEY) || "{}"); } catch (e) { return {}; } }, |
| 505 | set(v) { try { localStorage.setItem(KEY, JSON.stringify(v)); } catch (e) {} }, |
| 506 | clear() { try { localStorage.removeItem(KEY); } catch (e) {} }, |
| 507 | }; |
| 508 | // base: "" when this page is served by a Pyrtan (relative URLs); otherwise the hub URL the person gave. |
| 509 | // local: the hub in this page (pyrtan.js's LocalHub), while no hub is joined. |
| 510 | let base = "", token = "", remote = false, isHub = false, local = null, catalog = ""; |
| 511 | |
| 512 | async function api(path, init) { |
| 513 | if (local) { |
| 514 | const body = init && init.body ? JSON.parse(init.body) : undefined; |
| 515 | return local.route((init && init.method) || "GET", path, body).then(([status, payload]) => new Response(JSON.stringify(payload), {status, headers: {"Content-Type": "application/json"}})); |
| 516 | } |
| 517 | const headers = Object.assign({"Accept": "application/json"}, (init && init.headers) || {}); |
| 518 | if (token) headers["Authorization"] = "Bearer " + token; |
| 519 | const url = base ? base.replace(/\/+$/, "") + "/" + path : path; |
| 520 | const method = ((init && init.method) || "GET").toUpperCase(); |
| 521 | const body = init && init.body ? JSON.parse(init.body) : (method === "POST" ? {} : null); |
| 522 | Object.assign(headers, await ident.headers(method, "/" + path.split("?")[0].replace(/^\/+/, ""), body)); |
| 523 | return fetch(url, Object.assign({}, init, {headers})); |
| 524 | } |
| 525 | async function json(path, init) { |
| 526 | const r = await api(path, init); |
| 527 | if (r.status === 401) throw new Error("401: this Pyrtan needs a token"); |
| 528 | return r.json(); |
| 529 | } |
| 530 | function problem(msg, bad) { $("#problem").innerHTML = msg ? `<div class="notice${bad ? " bad" : ""}">${esc(msg)}</div>` : ""; } |
| 531 | |
| 532 | // ---------------------------------------------------------------- identity |
| 533 | // This browser's key: made and held by crypto.subtle (non-extractable, in |
| 534 | // IndexedDB), paired with a root by a device note, used to sign requests |
| 535 | // for the hub's audience (named at /api). See identity.js. |
| 536 | // pyrtan.com's names: files in this repository, added by pull request (site/names/README.md). |
| 537 | const PYRTAN_NAMES = {repo: "b-i-t-8/pyrtan", branch: "main", dir: "site/names", domain: "pyrtan.com"}; |
| 538 | const githubNewFile = (repo, branch, dir, filename, text) => `https://github.com/${repo}/new/${encodeURIComponent(branch)}${dir ? "/" + dir : ""}?filename=${encodeURIComponent(filename)}&value=${encodeURIComponent(text)}`; |
| 539 | const githubRepo = (url) => { const m = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(String(url || "").trim()); return m ? m[1] : null; }; |
| 540 | function saveFile(name, data) { |
| 541 | const a = document.createElement("a"); |
| 542 | a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2) + "\n"], {type: "application/json"})); |
| 543 | a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); |
| 544 | } |
| 545 | const pre = (data) => `<pre style="max-height:180px">${esc(JSON.stringify(data, null, 2))}</pre>`; |
| 546 | |
| 547 | const ident = { |
| 548 | ok: null, key: null, audience: null, sign: true, |
| 549 | async init() { |
| 550 | this.ok = await PyrtanIdentity.available(); |
| 551 | if (this.ok) { try { this.key = await PyrtanIdentity.store.get(); } catch (e) { this.key = null; } } |
| 552 | try { this.sign = localStorage.getItem("pyrtan.identity.sign") !== "off"; } catch (e) {} |
| 553 | this.render(); |
| 554 | }, |
| 555 | isRoot() { return !!(this.key && this.key.role === "root"); }, |
| 556 | active() { return !!(this.ok && this.key && this.sign && this.audience); }, |
| 557 | async headers(method, path, body) { |
| 558 | if (!this.active()) return {}; |
| 559 | return PyrtanIdentity.signRequest(this.key, this.audience, method, path, body, this.key.note || null); |
| 560 | }, |
| 561 | async keep() { await PyrtanIdentity.store.put(this.key); this.render(); }, |
| 562 | // ---- a device of an identity held elsewhere |
| 563 | async make() { |
| 564 | const k = await PyrtanIdentity.generate(); |
| 565 | this.key = {role: "device", id: k.id, privateKey: k.privateKey, publicKey: k.publicKey, note: null, made: Date.now()}; |
| 566 | await this.keep(); |
| 567 | }, |
| 568 | async useNote(text) { |
| 569 | let note; try { note = JSON.parse(text); } catch (e) { problem("The note is JSON: paste what the browser holding your identity, or pyrtan id device add, gave you.", true); return; } |
| 570 | const bad = PyrtanIdentity.noteProblem(note, this.key.id); |
| 571 | if (bad) { problem(bad, true); return; } |
| 572 | this.key.note = note; problem(""); await this.keep(); |
| 573 | }, |
| 574 | // ---- your identity, in this browser |
| 575 | async makeRoot(pass) { |
| 576 | const r = await PyrtanIdentity.createRoot(); |
| 577 | const sealed = await PyrtanIdentity.makeBackup(r.seed, r.id, pass); |
| 578 | const key = await PyrtanIdentity.rootFromSeed(r.seed, r.id); // non-extractable from here on; the seed is wiped |
| 579 | const document = await PyrtanIdentity.makeIdentity(key); |
| 580 | this.key = {role: "root", id: key.id, privateKey: key.privateKey, sealed, document, backedUp: false, note: null, made: Date.now()}; |
| 581 | await this.keep(); |
| 582 | }, |
| 583 | async restore(file, pass) { |
| 584 | let b; try { b = JSON.parse(await file.text()); } catch (e) { throw new Error("that file is not a backup"); } |
| 585 | const seed = await PyrtanIdentity.openBackup(b, pass); |
| 586 | const key = await PyrtanIdentity.rootFromSeed(seed, b.id.toLowerCase()); |
| 587 | const sealed = Object.fromEntries(Object.entries(b).filter(([k]) => k !== "document")); |
| 588 | const document = b.document || await PyrtanIdentity.makeIdentity(key); |
| 589 | this.key = {role: "root", id: key.id, privateKey: key.privateKey, sealed, document, backedUp: true, note: null, made: Date.now()}; |
| 590 | await this.keep(); |
| 591 | }, |
| 592 | backup() { return Object.assign({}, this.key.sealed, {document: this.key.document}); }, |
| 593 | async backedUp() { if (!this.key.backedUp) { this.key.backedUp = true; await this.keep(); } }, |
| 594 | async addDevice(code, may, label) { |
| 595 | const note = await PyrtanIdentity.makeDevice(this.key, code.trim(), may, {label}); |
| 596 | this.key.document = await PyrtanIdentity.withDevice(this.key.document, this.key, note); |
| 597 | await this.keep(); |
| 598 | return note; |
| 599 | }, |
| 600 | async revoke(device) { |
| 601 | this.key.document = await PyrtanIdentity.withRevoked(this.key.document, this.key, device); |
| 602 | await this.keep(); |
| 603 | }, |
| 604 | async forget() { |
| 605 | const what = this.isRoot() ? "Forget your identity on this browser? Without its backup nobody can give it back." : "Forget this browser's key? A hub that granted it, or a member that joined as it, will not know this browser again."; |
| 606 | if (!confirm(what)) return; |
| 607 | await PyrtanIdentity.store.put(null); this.key = null; this.render(); |
| 608 | }, |
| 609 | render() { |
| 610 | if (!$("#tab-identity")) return; |
| 611 | const root = this.isRoot(), device = !!this.key && !root; |
| 612 | $("#ident-none").hidden = this.ok !== false; |
| 613 | $("#ident-start").hidden = !this.ok || !!this.key; |
| 614 | for (const id of ["#ident-root", "#ident-claim", "#ident-devices"]) $(id).hidden = !this.ok || !root; |
| 615 | $("#ident-on").hidden = !this.ok || !device; |
| 616 | $("#ident-common").hidden = !this.ok || !this.key; |
| 617 | if (!this.key) return; |
| 618 | $("#ident-sign").checked = this.sign; |
| 619 | $("#doc-push").hidden = !root || !this.audience; |
| 620 | if (device) { |
| 621 | $("#ident-id").textContent = this.key.id; |
| 622 | const n = this.key.note; |
| 623 | $("#ident-paired").innerHTML = n |
| 624 | ? `speaks for <code>${esc(n.root)}</code> · may ${esc(n.may.join(", ") || "nothing")} · until ${esc(new Date(n.expires * 1000).toLocaleDateString())}${n.label ? " · " + esc(n.label) : ""}` |
| 625 | : "not paired yet: it signs as itself, so a hub grants this key, or nothing"; |
| 626 | $("#ident-howto").textContent = `pyrtan id device add ${this.key.id} --may ask,chat --label browser`; |
| 627 | return; |
| 628 | } |
| 629 | $("#root-id").textContent = this.key.id; |
| 630 | $("#root-backup-state").innerHTML = this.key.backedUp |
| 631 | ? `<span class="mute" style="font-size:14px">Backed up. Keep the file somewhere only you can read; never in a public repository.</span>` |
| 632 | : `<div class="notice bad" style="margin:0">Save your backup now. It is the only way back to this identity if this browser's data is cleared. It is encrypted with your passphrase; keep it where only you can read it.</div>`; |
| 633 | const tb = $("#dev-table tbody"); tb.innerHTML = ""; |
| 634 | const doc = this.key.document; |
| 635 | for (const n of doc.devices) tb.insertAdjacentHTML("beforeend", `<tr><td>${esc(n.label || "-")}<div class="mute"><code>${esc(PyrtanIdentity.short(n.device))}</code></div></td><td>${esc(n.may.join(", "))}</td><td class="mute">${esc(new Date(n.expires * 1000).toLocaleDateString())}</td><td><button type="button" class="quiet" data-revoke="${esc(n.device)}">Revoke</button></td></tr>`); |
| 636 | for (const r of doc.revoked) tb.insertAdjacentHTML("beforeend", `<tr><td class="mute"><code>${esc(PyrtanIdentity.short(r))}</code></td><td class="mute" colspan="3">revoked</td></tr>`); |
| 637 | if (!doc.devices.length && !doc.revoked.length) tb.innerHTML = `<tr><td class="mute" colspan="4">none yet</td></tr>`; |
| 638 | }, |
| 639 | }; |
| 640 | |
| 641 | // ---- starting |
| 642 | $("#ident-make").addEventListener("click", () => ident.make().catch((e) => problem("Could not make a key: " + e.message, true))); |
| 643 | $("#ident-new").addEventListener("click", () => { $("#ident-new-form").hidden = false; $("#ident-restore-form").hidden = true; $("#ident-pass").focus(); }); |
| 644 | $("#ident-restore-open").addEventListener("click", () => { $("#ident-restore-form").hidden = false; $("#ident-new-form").hidden = true; }); |
| 645 | $("#ident-new-form").addEventListener("submit", async (e) => { |
| 646 | e.preventDefault(); |
| 647 | const a = $("#ident-pass").value, b = $("#ident-pass2").value; |
| 648 | if (a !== b) { problem("The two passphrases differ.", true); return; } |
| 649 | if (a.length < 8) { problem("A passphrase is at least 8 characters; a long sentence is better.", true); return; } |
| 650 | const btn = e.submitter || e.target.querySelector("button"); btn.disabled = true; btn.textContent = "Making it…"; |
| 651 | try { await ident.makeRoot(a); problem(""); } catch (err) { problem("Could not make your identity: " + err.message, true); } |
| 652 | btn.disabled = false; btn.textContent = "Make it"; $("#ident-pass").value = $("#ident-pass2").value = ""; |
| 653 | }); |
| 654 | $("#ident-restore-form").addEventListener("submit", async (e) => { |
| 655 | e.preventDefault(); |
| 656 | const f = $("#ident-file").files[0]; if (!f) { problem("Choose the backup file.", true); return; } |
| 657 | try { await ident.restore(f, $("#ident-restore-pass").value); problem(""); } catch (err) { problem(err.message, true); } |
| 658 | $("#ident-restore-pass").value = ""; |
| 659 | }); |
| 660 | // ---- a device |
| 661 | $("#ident-note-form").addEventListener("submit", (e) => { e.preventDefault(); ident.useNote($("#ident-note").value.trim()); }); |
| 662 | $("#ident-copy").addEventListener("click", () => { navigator.clipboard && navigator.clipboard.writeText(ident.key.id); }); |
| 663 | // ---- backup |
| 664 | $("#root-download").addEventListener("click", async () => { saveFile("pyrtan-identity-backup.json", ident.backup()); await ident.backedUp(); }); |
| 665 | $("#root-gh-go").addEventListener("click", async () => { |
| 666 | const login = $("#root-gh").value.trim(); |
| 667 | if (!/^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(login)) { problem("Enter your GitHub login.", true); return; } |
| 668 | const create = "https://github.com/new?name=pyrtan-identity&visibility=private&description=" + encodeURIComponent("My pyrtan identity backup (encrypted). Keep this repository private."); |
| 669 | const file = githubNewFile(`${login}/pyrtan-identity`, "main", "", "pyrtan-identity-backup.json", JSON.stringify(ident.backup(), null, 2) + "\n"); |
| 670 | $("#root-gh-out").innerHTML = `1. <a href="${esc(create)}" target="_blank" rel="noopener">Make a <strong>private</strong> repository named pyrtan-identity</a> (check that it says Private). 2. <a href="${esc(file)}" target="_blank" rel="noopener" id="root-gh-file">Commit your backup to it</a>. GitHub keeps every version; the backup is only as strong as your passphrase, so never make that repository public.`; |
| 671 | $("#root-gh-file").addEventListener("click", () => ident.backedUp()); |
| 672 | }); |
| 673 | // ---- claiming a name |
| 674 | for (const r of document.querySelectorAll('input[name="claim-where"]')) r.addEventListener("change", () => { const net = document.querySelector('input[name="claim-where"]:checked').value === "network"; $("#claim-pyrtan").hidden = net; $("#claim-network").hidden = !net; }); |
| 675 | $("#claim-form").addEventListener("submit", async (e) => { |
| 676 | e.preventDefault(); |
| 677 | const out = $("#claim-out"); out.innerHTML = ""; |
| 678 | try { |
| 679 | let claim, repo, branch, dir, gh = null; |
| 680 | if (document.querySelector('input[name="claim-where"]:checked').value === "pyrtan") { |
| 681 | const login = $("#claim-login").value.trim().toLowerCase(); |
| 682 | if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(login)) throw new Error("enter your GitHub login"); |
| 683 | const r = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, {headers: {"Accept": "application/json"}}); |
| 684 | if (!r.ok) throw new Error(`GitHub does not know ${login} (${r.status})`); |
| 685 | gh = (await r.json()).id; |
| 686 | claim = await PyrtanIdentity.makeName(ident.key, `${login}@${PYRTAN_NAMES.domain}`, {extra: {github: gh}}); |
| 687 | ({repo, branch, dir} = PYRTAN_NAMES); |
| 688 | } else { |
| 689 | claim = await PyrtanIdentity.makeName(ident.key, $("#claim-name").value); |
| 690 | repo = githubRepo($("#claim-repo").value); branch = $("#claim-branch").value.trim() || "main"; dir = "names"; |
| 691 | if (!repo && $("#claim-repo").value.trim()) repo = null; |
| 692 | } |
| 693 | const file = `${claim.name}.json`, docFile = `${claim.name}.id.json`, text = JSON.stringify(claim, null, 2) + "\n", docText = JSON.stringify(ident.key.document, null, 2) + "\n"; |
| 694 | let html = `<div style="margin-top:10px">Your claim to <code>${esc(claim.name)}@${esc(claim.domain)}</code>, signed by your key:</div>${pre(claim)}`; |
| 695 | if (repo) { |
| 696 | html += `<div>1. <a href="${esc(githubNewFile(repo, branch, dir, file, text))}" target="_blank" rel="noopener" id="claim-open">Propose it on GitHub</a>: GitHub opens <code>${esc(dir)}/${esc(file)}</code> filled in; choose <em>Propose changes</em>, then <em>Create pull request</em>${gh !== null ? `, from the GitHub account ${esc(claim.name)}` : ""}.</div>`; |
| 697 | html += `<div>2. Optional, once that is merged: <a href="${esc(githubNewFile(repo, branch, dir, docFile, docText))}" target="_blank" rel="noopener">propose your identity document</a> (<code>${esc(docFile)}</code>), so hubs find your devices there.</div>`; |
| 698 | } else { |
| 699 | html += `<div>Add it to the network's names repository as <code>names/${esc(file)}</code> (and your identity document as <code>names/${esc(docFile)}</code>), and open a merge request there.</div>`; |
| 700 | } |
| 701 | html += `<div style="margin-top:6px"><button type="button" class="quiet" id="claim-save">Download the claim</button> <button type="button" class="quiet" id="claim-copy">Copy it</button></div>`; |
| 702 | out.innerHTML = html; |
| 703 | $("#claim-save").addEventListener("click", () => saveFile(file, claim)); |
| 704 | $("#claim-copy").addEventListener("click", () => navigator.clipboard && navigator.clipboard.writeText(text)); |
| 705 | problem(""); |
| 706 | } catch (err) { problem("Could not make the claim: " + err.message, true); } |
| 707 | }); |
| 708 | // ---- devices |
| 709 | $("#dev-form").addEventListener("submit", async (e) => { |
| 710 | e.preventDefault(); |
| 711 | const may = Array.from(document.querySelectorAll("#dev-may input:checked")).map((i) => i.value); |
| 712 | try { |
| 713 | const note = await ident.addDevice($("#dev-code").value, may, $("#dev-label").value.trim()); |
| 714 | $("#dev-out").innerHTML = `<div style="margin-top:10px">Paste this on <code>${esc(note.label || PyrtanIdentity.short(note.device))}</code>, under <em>Use this note</em>:</div>${pre(note)}<button type="button" class="quiet" id="dev-copy">Copy it</button>`; |
| 715 | $("#dev-copy").addEventListener("click", () => navigator.clipboard && navigator.clipboard.writeText(JSON.stringify(note))); |
| 716 | $("#dev-code").value = $("#dev-label").value = ""; problem(""); |
| 717 | } catch (err) { problem("Could not sign the note: " + err.message, true); } |
| 718 | }); |
| 719 | $("#dev-table").addEventListener("click", async (e) => { |
| 720 | const b = e.target.closest("button[data-revoke]"); if (!b) return; |
| 721 | if (!confirm("Revoke this device? Hubs refuse it once they have your newer document.")) return; |
| 722 | await ident.revoke(b.dataset.revoke); |
| 723 | problem("Revoked. Give your newest document to the hubs you use, and to your name's repository, so they know now."); |
| 724 | }); |
| 725 | $("#doc-push").addEventListener("click", async () => { |
| 726 | const r = await api("identity", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({document: ident.key.document})}); |
| 727 | const b = await r.json().catch(() => ({})); |
| 728 | problem(r.ok ? "This hub holds your newest document." : `The hub did not take it: ${b.say || r.status}`, !r.ok); |
| 729 | }); |
| 730 | $("#doc-download").addEventListener("click", () => saveFile(`${ident.key.id.slice(4, 12)}.id.json`, ident.key.document)); |
| 731 | $("#ident-forget").addEventListener("click", () => ident.forget()); |
| 732 | $("#ident-sign").addEventListener("change", (e) => { ident.sign = e.target.checked; try { localStorage.setItem("pyrtan.identity.sign", ident.sign ? "on" : "off"); } catch (err) {} }); |
| 733 | |
| 734 | // ---------------------------------------------------------------- access: the hub's grants |
| 735 | async function loadGrants() { |
| 736 | const tb = $("#grant-table tbody"), note = $("#access-note"); |
| 737 | let r; try { r = await api("allow"); } catch (e) { note.hidden = false; note.textContent = String(e); return; } |
| 738 | if (r.status === 401 || r.status === 403) { note.hidden = false; note.textContent = "Only this hub's operator sees and changes its grants: connect with its token, or as an identity it grants operate."; tb.innerHTML = ""; return; } |
| 739 | note.hidden = true; |
| 740 | const body = await r.json().catch(() => ({grants: []})); |
| 741 | tb.innerHTML = (body.grants || []).map((g) => `<tr><td>${g.name ? `<code>${esc(g.name)}</code>` : ""}<div class="mute"><code>${esc(PyrtanIdentity.short(g.id))}</code></div></td><td>${esc(g.may.join(", "))}</td><td class="mute">${g.document ? `${esc(String(g.document.devices))} device(s), ${esc(String(g.document.revoked))} revoked` : "none yet"}</td><td><button type="button" class="quiet" data-disallow="${esc(g.id)}">Remove</button></td></tr>`).join("") || `<tr><td class="mute" colspan="4">nobody yet</td></tr>`; |
| 742 | } |
| 743 | async function grantPost(path, payload) { |
| 744 | const r = await api(path, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload)}); |
| 745 | const b = await r.json().catch(() => ({})); |
| 746 | if (!r.ok) problem(b.say || `the hub answered ${r.status}`, true); else problem(""); |
| 747 | loadGrants(); |
| 748 | } |
| 749 | $("#grant-form").addEventListener("submit", (e) => { |
| 750 | e.preventDefault(); |
| 751 | const may = Array.from(document.querySelectorAll("#grant-may input:checked")).map((i) => i.value); |
| 752 | grantPost("allow", {who: $("#grant-who").value.trim(), may}).then(() => { $("#grant-who").value = ""; }); |
| 753 | }); |
| 754 | $("#grant-table").addEventListener("click", (e) => { const b = e.target.closest("button[data-disallow]"); if (b) grantPost("disallow", {who: b.dataset.disallow}); }); |
| 755 | |
| 756 | function showTab(name) { |
| 757 | for (const a of document.querySelectorAll("#tabs a")) { if (a.dataset.tab === name) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); } |
| 758 | for (const s of document.querySelectorAll("main > section[id^=tab-]")) s.hidden = s.id !== "tab-" + name; |
| 759 | if (name === "peers") loadPeers(); |
| 760 | if (name === "gaps") loadGaps(); |
| 761 | if (name === "watches") loadWatches(); |
| 762 | if (name === "unanswered") loadUnanswered(); |
| 763 | if (name === "directory") loadDirectory(); |
| 764 | if (name === "member") member.render(); |
| 765 | if (name === "identity") ident.render(); |
| 766 | if (name === "access") loadGrants(); |
| 767 | if (name === "chat") chat.count(); |
| 768 | if (name === "agents") agentsUI.render(); |
| 769 | } |
| 770 | |
| 771 | async function loadDirectory() { |
| 772 | let d; try { d = await json("directory"); } catch (e) { problem(e.message, true); return; } |
| 773 | const body = $("#directory tbody"); body.innerHTML = ""; |
| 774 | for (const h of d.hubs || []) { |
| 775 | const tr = document.createElement("tr"); |
| 776 | tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code>${h.via === "self" ? ' <span class="mute">(this hub)</span>' : ""}${h.kind === "member" ? ' <span class="mute">(member)</span>' : ""}<div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td class="mute">${h.seen ? new Date(h.seen * 1000).toLocaleString() : ""}</td>`; |
| 777 | body.appendChild(tr); |
| 778 | } |
| 779 | if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody heard of yet. Join the default network (<code>pyrtan join</code>), or start this hub with <code>--listed</code>.</td></tr>`; |
| 780 | } |
| 781 | window.addEventListener("hashchange", () => showTab((location.hash || "#ask").slice(1))); |
| 782 | |
| 783 | // A trigger as a sentence: the first of each choice, an input's default or "…" |
| 784 | // for its blank, and the optional words kept or dropped. |
| 785 | function phrase(t, pat, optional) { |
| 786 | return pat.replace(/\[([^\]|]*)[^\]]*\]/g, optional ? "$1" : "").replace(/\(([^|)]*)\|[^)]*\)/g, "$1").replace(/\{(\w+)(?::\w+)?\}/g, (m, n) => (t.inputs && t.inputs[n] && t.inputs[n].default != null) ? t.inputs[n].default : "…").replace(/\s+/g, " ").trim(); |
| 787 | } |
| 788 | function sample(t) { |
| 789 | if (examples[t.name]) return examples[t.name]; |
| 790 | const pat = (t.triggers || [])[0]; return pat ? phrase(t, pat, false) : null; |
| 791 | } |
| 792 | // Every sentence a tree answers to, for the ask box to suggest; one that starts with a blank suggests nothing. |
| 793 | function samples(t) { |
| 794 | return [examples[t.name], ...(t.triggers || []).map(p => phrase(t, p, true))].filter(s => s && !s.startsWith("…")); |
| 795 | } |
| 796 | |
| 797 | async function detect() { |
| 798 | // Same origin first: a Pyrtan answers /api with {"pyrtan": "service"|"hub"}. |
| 799 | try { |
| 800 | const r = await fetch("api", {headers: {"Accept": "application/json"}}); |
| 801 | const a = r.ok ? await r.json() : null; |
| 802 | if (a && a.pyrtan) { |
| 803 | base = ""; remote = false; isHub = a.pyrtan === "hub"; |
| 804 | if (a.auth === "bearer") { |
| 805 | // This Pyrtan wants a token: show the connect box with only the token field. |
| 806 | token = store.get().token || ""; |
| 807 | $("#connect").hidden = false; |
| 808 | $("#hub-url").hidden = true; |
| 809 | $("#connect-lede").textContent = "This Pyrtan needs a token:"; |
| 810 | $("#connect-help").innerHTML = "Kept in this browser only. The token is the one the hub or service was started with (<code>--token</code> or <code>$PYRTAN_TOKEN</code>)."; |
| 811 | $("#hub-token").value = token; |
| 812 | if (!token) problem(a.chat_public ? "Enter the token to see what this Pyrtan can answer, or open Chat: its lobby is open to anyone." : "Enter the token to see what this Pyrtan can answer."); |
| 813 | } |
| 814 | return a; |
| 815 | } |
| 816 | } catch (e) {} |
| 817 | remote = true; |
| 818 | const saved = store.get(); |
| 819 | const asked = new URLSearchParams(location.search).get("hub"); |
| 820 | base = asked || saved.url || ""; token = saved.token || ""; |
| 821 | $("#connect").hidden = false; |
| 822 | $("#origin").textContent = location.origin; |
| 823 | $("#hub-url").value = base; $("#hub-token").value = token; |
| 824 | local = null; showLocal(false); |
| 825 | if (!base) return startLocal(); |
| 826 | try { |
| 827 | const a = await json("api"); |
| 828 | if (a && a.pyrtan) { isHub = a.pyrtan === "hub"; return a; } |
| 829 | problem(`${base} answered, but not as a Pyrtan.`, true); |
| 830 | } catch (e) { problem(`${base}: ${e.message}. Is the hub up, and started with --cors ${location.origin}?`, true); } |
| 831 | return null; |
| 832 | } |
| 833 | |
| 834 | // ---------------------------------------------------------------- the hub in this page |
| 835 | // No hub joined: this browser is one, for as long as the page is open. It |
| 836 | // holds the catalog's trees and answers the same endpoints a hub does. |
| 837 | const params = new URLSearchParams(location.search); |
| 838 | const CATALOGS = [params.get("catalog"), "../catalog/", "https://pyrtan.com/catalog/"].filter(Boolean); |
| 839 | const TREES_KEY = "pyrtan.console.trees"; |
| 840 | async function catalogTrees() { |
| 841 | for (const c of CATALOGS) { |
| 842 | try { |
| 843 | const at = new URL(c.endsWith("/") ? c : c + "/", location.href); |
| 844 | const r = await fetch(new URL("trees.json", at), {headers: {"Accept": "application/json"}}); |
| 845 | const d = r.ok ? await r.json() : null; |
| 846 | if (d && Array.isArray(d.trees)) { |
| 847 | catalog = at.href; |
| 848 | try { localStorage.setItem(TREES_KEY, JSON.stringify({catalog, trees: d.trees})); } catch (e) {} |
| 849 | return d.trees; |
| 850 | } |
| 851 | } catch (e) {} |
| 852 | } |
| 853 | // Offline, or no catalog answers: the trees this browser held last time. |
| 854 | try { const kept = JSON.parse(localStorage.getItem(TREES_KEY) || "null"); if (kept && Array.isArray(kept.trees)) { catalog = kept.catalog || ""; return kept.trees; } } catch (e) {} |
| 855 | return []; |
| 856 | } |
| 857 | // A tree runs here when every source it uses is http. Hosts of "*" mean any site, and most sites refuse a page, so "Try one" leaves those out. |
| 858 | const runsHere = (t) => (t.sources || []).every(s => s === "http"); |
| 859 | const anySite = (t) => (t.hosts || []).includes("*"); |
| 860 | async function startLocal() { |
| 861 | isHub = false; |
| 862 | local = new Pyrtan.LocalHub({name: "this browser", version: "__VERSION__"}); |
| 863 | const trees = await catalogTrees(); |
| 864 | for (const e of trees) local.add(e.tree, e.id); |
| 865 | showLocal(true); |
| 866 | return (await local.route("GET", "/api"))[1]; |
| 867 | } |
| 868 | function showLocal(on) { |
| 869 | $("#connect-local").hidden = !on; |
| 870 | $("#connect-lede").textContent = on ? "This browser is a hub of its own: it holds the catalog's trees, reads your sentences and runs the trees right here." : "This console is not being served by a Pyrtan. Point it at one:"; |
| 871 | $("#connect-local").innerHTML = on ? "Gaps and unanswered sentences are kept only while this page is open. Trees that need a box (files, programs, devices) or a site that will not let a page read it need a hub: to join one, give its URL and token." : ""; |
| 872 | $("#connect-go").textContent = on ? "Join" : "Connect"; |
| 873 | $("#watches-tab").hidden = on; |
| 874 | $("#unanswered-lede").innerHTML = on ? "Sentences nobody answered, each with the tree that would. Each command writes that tree's file; serve it from a hub and join the hub here, or publish it to the catalog and this page will hold it too." : 'Sentences nobody answered, each with the tree that would. <code>pyrtan unanswered --at <span class="at">URL</span> --scaffold DIR</code> writes the files.'; |
| 875 | $("#api-help").innerHTML = on ? "Answered inside this page by <code>pyrtan.js</code>: the same paths and the same answers as a hub, with no server behind them. Join a hub and they go to the hub." : 'With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.'; |
| 876 | $("#trees-more").innerHTML = on ? `These are the <a href="${esc(catalog || "https://pyrtan.com/catalog/")}">catalog</a>'s trees. Rows marked <em>needs a hub</em> reach what a page cannot; a service runs them.` : 'Need a tree that is not here? Browse the <a href="https://pyrtan.com/catalog/" id="catalog-link">catalog</a>, then <code>pyrtan pull NAME</code> into a folder a service serves.'; |
| 877 | } |
| 878 | |
| 879 | async function load() { |
| 880 | problem(""); |
| 881 | const a = await detect(); |
| 882 | for (const el of document.querySelectorAll(".at")) el.textContent = base || location.origin; |
| 883 | if (!a) { $("#who").textContent = remote ? "not connected" : "unreachable"; return; } |
| 884 | $("#peers-tab").hidden = !isHub; |
| 885 | $("#directory-tab").hidden = !(isHub && a.federation); |
| 886 | $("#member-tab").hidden = !(isHub && a.federation); |
| 887 | ident.audience = a.audience || null; |
| 888 | $("#identity-tab").hidden = false; // an identity needs no hub; signing does, when there is one |
| 889 | $("#access-tab").hidden = !(isHub && a.audience); |
| 890 | $("#chat-tab").hidden = !(isHub && a.chat); |
| 891 | if (a.chat && a.chat_bots === false && token) { |
| 892 | try { a.chat_bots = (await json("api")).chat_bots; } catch (e) {} // the first ask went without the token |
| 893 | } |
| 894 | if (a.chat && a.chat_bots === false) { |
| 895 | // A public lobby, and no token here: people only, since a bot runs the hub's trees. |
| 896 | $("#chat-want").innerHTML = `<option value="people">people only</option>`; |
| 897 | $("#chat-guest").hidden = false; |
| 898 | } |
| 899 | member.hub = a.federation || null; |
| 900 | if (member.hub) member.start(); |
| 901 | $("#where").textContent = local ? "running in this browser" : (base || location.origin) + (a.auth === "bearer" ? " · token" : ""); |
| 902 | $("#hub-version").textContent = a.version ? ` · ${a.pyrtan} v${a.version}` : ""; |
| 903 | try { |
| 904 | const h = await json("health"); |
| 905 | $("#who").textContent = (h.name ? h.name + " · " : "") + (h.peers != null ? `${h.up}/${h.peers} peers up` + (h.trees != null ? `, ${h.trees} trees` : "") : (h.trees ? `${h.trees.length} trees` : "")); |
| 906 | } catch (e) { $("#who").textContent = "unreachable"; } |
| 907 | for (const el of document.querySelectorAll(".at")) el.textContent = base || location.origin; |
| 908 | if (a.auth === "bearer" && !token) { |
| 909 | if (a.chat_public && location.hash === "#chat") showTab("chat"); // the one tab open without the token |
| 910 | return; |
| 911 | } |
| 912 | let c; |
| 913 | try { c = await json("capabilities"); } catch (e) { problem(e.message + (a.auth === "bearer" ? " (is it the right one?)" : ""), true); return; } |
| 914 | problem(local && !(c.trees || []).length ? "No catalog answered and none was kept in this browser, so it holds no trees yet. Join a hub above, or try again online." : "", true); |
| 915 | renderCaps(c); |
| 916 | showTab((location.hash || "#ask").slice(1)); |
| 917 | } |
| 918 | function renderCaps(c) { |
| 919 | const body = $("#caps tbody"); const tries = $("#tries"); |
| 920 | body.innerHTML = ""; tries.innerHTML = ""; |
| 921 | suggest.from((c.trees || []).filter(t => !local || (runsHere(t) && !anySite(t)))); |
| 922 | for (const t of c.trees || []) { |
| 923 | const tr = document.createElement("tr"); |
| 924 | const here = !local ? "" : !runsHere(t) ? `<div class="gap">needs a hub: ${esc((t.sources||[]).filter(x => x !== "http").join(", "))}</div>` : anySite(t) ? '<div class="gap">any site; most will not let a page read them</div>' : ""; |
| 925 | tr.innerHTML = `<td><code>${esc(t.name)}</code> <span class="mute">v${esc(t.version||1)}</span><div class="mute">${esc(t.description||"")}</div></td><td>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}</td><td class="mute">${esc((t.hosts||[]).join(", "))}${t.peer ? `<div><span class="link ${esc(t.link||"")}"></span>${esc(t.peer)}</div>` : ""}${here}</td>`; |
| 926 | body.appendChild(tr); |
| 927 | const s = sample(t); if (s && !s.includes("…") && (!local || (runsHere(t) && !anySite(t)))) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); } |
| 928 | } |
| 929 | // The greyed suggestion in the empty box (Tab takes it) is one this swarm answers. |
| 930 | const first = tries.querySelector("a[data-q]"), offered = [...tries.querySelectorAll("a[data-q]")].map(a => a.dataset.q); |
| 931 | if (first && !offered.includes($("#text").placeholder)) $("#text").placeholder = first.dataset.q; |
| 932 | } |
| 933 | |
| 934 | async function loadPeers() { |
| 935 | if (!isHub) return; |
| 936 | let p; try { p = await json("peers"); } catch (e) { problem(e.message, true); return; } |
| 937 | const body = $("#peers tbody"); body.innerHTML = ""; |
| 938 | for (const peer of p.peers || []) { |
| 939 | const tr = document.createElement("tr"); |
| 940 | const last = peer.last_ok ? new Date(peer.last_ok * 1000).toLocaleTimeString() : "never"; |
| 941 | tr.innerHTML = `<td><span class="link ${esc(peer.link)}"></span>${esc(peer.link)}</td><td><code>${esc(peer.name||"")}</code><div class="mute">${esc(peer.url)}</div></td><td>${(peer.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td class="mute">${esc(last)}${peer.last_error ? `<div class="failed">${esc(peer.last_error)}</div>` : ""}</td>`; |
| 942 | body.appendChild(tr); |
| 943 | } |
| 944 | if (!(p.peers || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">No peers yet. Start a service with <code>--hub ${esc(base || location.origin)}</code>.</td></tr>`; |
| 945 | const fed = $("#federation"); |
| 946 | fed.hidden = !p.id; |
| 947 | if (p.id) { |
| 948 | $("#fed-id").textContent = p.id; |
| 949 | const hb = $("#hubs tbody"); hb.innerHTML = ""; |
| 950 | for (const [rel, list] of [["follows", p.following || []], ["follower", p.followers || []]]) { |
| 951 | for (const r of list) { |
| 952 | const tr = document.createElement("tr"); |
| 953 | tr.innerHTML = `<td><span class="link ${esc(r.link)}"></span>${esc(r.link)}</td><td><code>${esc(r.name||"")}</code><div class="mute">${esc(r.url)}</div></td><td>${rel}${r.state && r.state !== "accepted" ? ` <span class="mute">(${esc(r.state)})</span>` : ""}</td><td>${(r.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}${r.last_error ? `<div class="failed">${esc(r.last_error)}</div>` : ""}</td>`; |
| 954 | hb.appendChild(tr); |
| 955 | } |
| 956 | } |
| 957 | if (!hb.children.length) hb.innerHTML = `<tr><td colspan="4" class="mute">Following nobody, followed by nobody. <code>pyrtan follow https://other-hub --at ${esc(base || location.origin)}</code>.</td></tr>`; |
| 958 | } |
| 959 | } |
| 960 | |
| 961 | async function loadWatches() { |
| 962 | let w; try { w = await json("watches"); } catch (e) { problem(e.message, true); return; } |
| 963 | const body = $("#watches tbody"); body.innerHTML = ""; |
| 964 | for (const x of w.watches || []) { |
| 965 | const last = x.last || {}; |
| 966 | const when = x.last_at ? new Date(x.last_at * 1000).toLocaleTimeString() : "never"; |
| 967 | const changed = x.path_changed_at ? `<div class="mute">path changed ${esc(new Date(x.path_changed_at * 1000).toLocaleString())}: <code>${esc((x.previous_path||[]).join(" > "))}</code> → <code>${esc((x.path||[]).join(" > "))}</code></div>` : ""; |
| 968 | const err = x.last_error ? `<div class="mute">error: ${esc(x.last_error)}</div>` : ""; |
| 969 | const tr = document.createElement("tr"); |
| 970 | tr.innerHTML = `<td><code>${esc(x.name)}</code>${x.peer && x.peer !== "local" ? `<div class="mute">${esc(x.peer)}</div>` : ""}</td><td>${esc(x.kind)} <code>${esc(x.what)}</code></td><td>${esc(x.fires||0)}× <span class="mute">${esc(when)}</span></td><td>${last.status ? `<span class="status ${esc(last.status)}">${esc(last.status)}</span>${last.reason ? ` <span class="mute">(${esc(last.reason)})</span>` : ""} ${esc(last.say||"")}` : '<span class="mute">not yet</span>'}${changed}${err}</td>`; |
| 971 | body.appendChild(tr); |
| 972 | } |
| 973 | if (!(w.watches || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nothing watched. Give a tree <code>[[watches]]</code> and start the service with <code>--watch</code>.</td></tr>`; |
| 974 | } |
| 975 | |
| 976 | async function loadGaps() { |
| 977 | let g; try { g = await json("gaps"); } catch (e) { problem(e.message, true); return; } |
| 978 | const body = $("#gaps tbody"); body.innerHTML = ""; |
| 979 | for (const gap of g.gaps || []) { |
| 980 | const tr = document.createElement("tr"); |
| 981 | tr.innerHTML = `<td><code>${esc(gap.tree)}</code> · <code>${esc(gap.node)}</code></td><td><code>${esc(gap.on)}</code> = <code class="gap">${esc(JSON.stringify(gap.observed))}</code></td><td>${esc(gap.count||1)}</td><td class="mute">${esc(gap.peer||"")}</td>`; |
| 982 | body.appendChild(tr); |
| 983 | } |
| 984 | if (!(g.gaps || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">No gaps: every decision so far had a branch.</td></tr>`; |
| 985 | } |
| 986 | |
| 987 | async function loadUnanswered() { |
| 988 | let u; try { u = await json("unanswered"); } catch (e) { problem(e.message, true); return; } |
| 989 | const body = $("#unanswered tbody"); body.innerHTML = ""; |
| 990 | for (const e of u.unanswered || []) { |
| 991 | const p = e.proposal || {}; |
| 992 | const tr = document.createElement("tr"); |
| 993 | tr.innerHTML = `<td>${esc(e.text)} <span class="mute">×${esc(e.count||1)}${e.peer ? " · " + esc(e.peer) : ""}</span></td><td><code>${esc(p.name||"")}</code><div class="mute"><code>${esc(p.trigger||"")}</code></div></td><td><code>${esc(p.scaffold||"")}</code></td>`; |
| 994 | body.appendChild(tr); |
| 995 | } |
| 996 | if (!(u.unanswered || []).length) body.innerHTML = `<tr><td colspan="3" class="mute">Nothing yet: every sentence so far found a tree.</td></tr>`; |
| 997 | } |
| 998 | |
| 999 | function renderTrace(tr) { |
| 1000 | if (!tr || !tr.steps) return ""; |
| 1001 | const lines = tr.steps.map(s => `${" ".repeat((s.depth||0)+1)}${s.node} (${s.kind})${s.chosen ? " [" + s.chosen + "]" : ""}: ${s.summary}${s.next ? " -> " + s.next : ""}`); |
| 1002 | for (const g of tr.gaps || []) lines.push(` gap: ${g.node} saw ${g.on} = ${JSON.stringify(g.observed)} and had no branch for it`); |
| 1003 | return `trace: ${tr.tree}\n` + lines.join("\n"); |
| 1004 | } |
| 1005 | |
| 1006 | async function ask(snapshot, answer) { |
| 1007 | const text = $("#text").value.trim(); if (!text && !snapshot) return; |
| 1008 | if (!snapshot) { suggest.remember(text); suggest.close(); } |
| 1009 | $("#go").disabled = true; |
| 1010 | const t0 = performance.now(); |
| 1011 | let r; |
| 1012 | try { |
| 1013 | const res = snapshot ? await api("resume", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({snapshot, answer})}) |
| 1014 | : await api("intent", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({text})}); |
| 1015 | r = res.status === 401 ? {status:"error", reason:"unauthorised", say:"This Pyrtan needs a token."} : await res.json(); |
| 1016 | } catch (e) { r = {status:"error", reason:"unreachable", say:String(e)}; } |
| 1017 | const ms = Math.round(performance.now() - t0); |
| 1018 | const out = $("#out"); |
| 1019 | const card = document.createElement("div"); card.className = "card"; |
| 1020 | let html = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${ms} ms${r.peer ? " · " + esc(r.peer) : ""}${local ? " · ran in this browser" : ""}</span></div>`; |
| 1021 | if (r.say) html += `<div class="say">${esc(r.say)}</div>`; |
| 1022 | if (r.output && r.output.reading && r.output.reading.steps && r.output.reading.steps.length) html += `<div class="mute" style="font-size:14px">Read as <code>${esc(r.output.reading.cleaned)}</code>: ${r.output.reading.steps.map(esc).join("; ")}</div>`; |
| 1023 | if (r.reason === "no-match" && r.output && r.output.proposal) { |
| 1024 | const p = r.output.proposal; |
| 1025 | html += `<div class="gap">No tree answers this yet. The one that would: <code>${esc(p.name)}</code>, answering <code>${esc(p.trigger)}</code>. It is listed under <a href="#unanswered">Unanswered</a>.</div><pre>${esc(p.scaffold)}</pre>`; |
| 1026 | } |
| 1027 | if (r.status === "asked") html += `<form class="row resume"><input type="text" placeholder="${esc(r.question||"")}" autofocus><button>Answer</button></form>`; |
| 1028 | if (r.output != null) html += `<details open><summary>output</summary><pre>${esc(JSON.stringify(r.output, null, 2))}</pre></details>`; |
| 1029 | if (r.trace) html += `<details open><summary>trace</summary><pre>${esc(renderTrace(r.trace))}</pre></details>`; |
| 1030 | if (local && (r.reason === "source-unsupported" || (r.reason === "transport" && /may not let a page/.test(r.say || "")))) html += `<div class="mute" style="font-size:14px;margin-top:8px">This one needs a hub: a page cannot reach it. Join a hub above that runs <code>${esc(r.tree||"")}</code>, and the same sentence goes there.</div>`; |
| 1031 | if (r.reason === "gap" && r.output && r.output.gap) html += `<div class="mute" style="font-size:14px;margin-top:8px">This run met a gap. It is listed under <a href="#gaps">Gaps</a>; the branch to write is in <code>${esc(r.output.gap.tree)}</code>, node <code>${esc(r.output.gap.node)}</code>.</div>`; |
| 1032 | card.innerHTML = html; |
| 1033 | out.prepend(card); |
| 1034 | const rf = card.querySelector("form.resume"); |
| 1035 | if (rf) rf.addEventListener("submit", (e) => { e.preventDefault(); ask(r.snapshot, rf.querySelector("input").value); }); |
| 1036 | if (r.reason === "no-match") loadUnanswered(); |
| 1037 | $("#go").disabled = false; |
| 1038 | } |
| 1039 | |
| 1040 | $("#ask").addEventListener("submit", (e) => { e.preventDefault(); ask(); }); |
| 1041 | |
| 1042 | // ---------------------------------------------------------------- suggestions in the ask box |
| 1043 | // The sentences this swarm's trees answer to, and the ones asked in this |
| 1044 | // browser before. The best that starts with what is typed shows greyed after |
| 1045 | // it (Tab or → takes it; Tab in the empty box takes the placeholder), and up to |
| 1046 | // eight drop down under the box: ↑ ↓ pick, Enter asks, Esc closes. A sentence |
| 1047 | // with a blank ("latest release of …") fills the box up to the blank instead of asking. |
| 1048 | const ASKED_KEY = "pyrtan.console.asked"; |
| 1049 | const suggest = { |
| 1050 | pool: [], items: [], active: -1, typed: "", open: false, |
| 1051 | input: $("#text"), list: $("#suggestions"), |
| 1052 | recent() { try { const r = JSON.parse(localStorage.getItem(ASKED_KEY) || "[]"); return Array.isArray(r) ? r.filter(s => typeof s === "string") : []; } catch (e) { return []; } }, |
| 1053 | remember(text) { |
| 1054 | const r = [text, ...this.recent().filter(s => s.toLowerCase() !== text.toLowerCase())].slice(0, 20); |
| 1055 | try { localStorage.setItem(ASKED_KEY, JSON.stringify(r)); } catch (e) {} |
| 1056 | }, |
| 1057 | from(trees) { |
| 1058 | const seen = new Set(); this.pool = []; |
| 1059 | for (const t of trees) for (const text of samples(t)) { const k = text.toLowerCase(); if (!seen.has(k)) { seen.add(k); this.pool.push({text, tree: t.name}); } } |
| 1060 | }, |
| 1061 | // Recent first, then the trees': those that start with what is typed, then those with every typed word starting one of theirs. |
| 1062 | match(q) { |
| 1063 | const low = q.toLowerCase(), words = low.split(/\s+/).filter(Boolean), seen = new Set(), out = []; |
| 1064 | const add = (e) => { const k = e.text.toLowerCase(); if (!seen.has(k) && out.length < 8) { seen.add(k); out.push(e); } }; |
| 1065 | const all = [...this.recent().map(text => ({text, recent: true})), ...this.pool]; |
| 1066 | for (const e of all) if (e.text.toLowerCase().startsWith(low)) add(e); |
| 1067 | for (const e of all) { const ws = e.text.toLowerCase().split(/\s+/); if (words.every(w => ws.some(x => x.startsWith(w)))) add(e); } |
| 1068 | return out; |
| 1069 | }, |
| 1070 | fill(e) { const i = e.text.indexOf("…"); return i < 0 ? e.text : e.text.slice(0, i); }, |
| 1071 | // The rest of the first suggestion that starts with what is typed, while the caret is at the end. |
| 1072 | completion() { |
| 1073 | const el = this.input, v = el.value, low = v.toLowerCase(); |
| 1074 | if (!v || document.activeElement !== el || el.selectionStart !== v.length || el.selectionEnd !== v.length) return ""; |
| 1075 | const e = this.items.find(e => { const f = this.fill(e); return f.length > v.length && f.toLowerCase().startsWith(low); }); |
| 1076 | return e ? this.fill(e).slice(v.length) : ""; |
| 1077 | }, |
| 1078 | ghost() { |
| 1079 | const rest = this.input.scrollWidth <= this.input.clientWidth ? this.completion() : ""; // scrolled text would not line up |
| 1080 | $("#ghost .typed").textContent = rest ? this.input.value : ""; |
| 1081 | $("#ghost .rest").textContent = rest; |
| 1082 | }, |
| 1083 | render() { |
| 1084 | const v = this.typed, low = v.toLowerCase(); |
| 1085 | this.list.innerHTML = ""; |
| 1086 | this.items.forEach((e, i) => { |
| 1087 | const li = document.createElement("li"); |
| 1088 | li.id = "suggestion-" + i; li.setAttribute("role", "option"); li.setAttribute("aria-selected", String(i === this.active)); |
| 1089 | const head = v && e.text.toLowerCase().startsWith(low) ? v.length : v ? 0 : e.text.length; // what is typed plain, the rest bold |
| 1090 | li.innerHTML = `<span class="text">${esc(e.text.slice(0, head))}<b>${esc(e.text.slice(head))}</b></span><span class="from">${e.recent ? "recent" : esc(e.tree)}</span>`; |
| 1091 | li.addEventListener("click", () => this.choose(e)); |
| 1092 | this.list.appendChild(li); |
| 1093 | }); |
| 1094 | const shown = this.open && this.items.length > 0; |
| 1095 | this.list.hidden = !shown; this.input.setAttribute("aria-expanded", String(shown)); |
| 1096 | if (shown && this.active >= 0) this.input.setAttribute("aria-activedescendant", "suggestion-" + this.active); else this.input.removeAttribute("aria-activedescendant"); |
| 1097 | }, |
| 1098 | refresh() { |
| 1099 | this.typed = this.input.value; this.active = -1; |
| 1100 | this.items = this.open ? this.match(this.typed) : []; |
| 1101 | this.render(); this.ghost(); |
| 1102 | }, |
| 1103 | close() { this.open = false; this.refresh(); }, |
| 1104 | choose(e) { |
| 1105 | this.input.value = this.fill(e); this.input.focus(); |
| 1106 | if (e.text.includes("…")) { this.open = true; this.refresh(); } else ask(); |
| 1107 | }, |
| 1108 | key(ev) { |
| 1109 | if (ev.isComposing || ev.ctrlKey || ev.altKey || ev.metaKey) return; |
| 1110 | const shown = !this.list.hidden; |
| 1111 | if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { |
| 1112 | ev.preventDefault(); |
| 1113 | if (!shown) { this.open = true; this.refresh(); return; } |
| 1114 | // -1 is what was typed: the list wraps through it, as a search box's does. |
| 1115 | const n = this.items.length + 1; |
| 1116 | this.active = ((this.active + 1 + (ev.key === "ArrowDown" ? 1 : -1)) % n + n) % n - 1; |
| 1117 | this.input.value = this.active < 0 ? this.typed : this.fill(this.items[this.active]); |
| 1118 | this.render(); this.ghost(); |
| 1119 | } else if ((ev.key === "Tab" && !ev.shiftKey) || ev.key === "ArrowRight") { |
| 1120 | const v = this.input.value, rest = this.completion(); |
| 1121 | const take = rest ? v + rest : ev.key === "Tab" && !v ? this.input.placeholder : ""; |
| 1122 | if (!take) return; // nothing to complete: Tab moves on, → moves the caret |
| 1123 | ev.preventDefault(); this.input.value = take; this.open = true; this.refresh(); |
| 1124 | } else if (ev.key === "Escape" && shown) { |
| 1125 | ev.preventDefault(); this.input.value = this.typed; this.close(); |
| 1126 | } else if (ev.key === "Enter" && shown && this.active >= 0 && this.items[this.active].text.includes("…")) { |
| 1127 | ev.preventDefault(); this.choose(this.items[this.active]); |
| 1128 | } |
| 1129 | }, |
| 1130 | }; |
| 1131 | suggest.input.addEventListener("input", () => { suggest.open = true; suggest.refresh(); }); |
| 1132 | suggest.input.addEventListener("keydown", (e) => suggest.key(e)); |
| 1133 | suggest.input.addEventListener("keyup", () => suggest.ghost()); |
| 1134 | suggest.input.addEventListener("mousedown", () => { if (!suggest.open) { suggest.open = true; suggest.refresh(); } }); |
| 1135 | suggest.input.addEventListener("click", () => suggest.ghost()); |
| 1136 | suggest.input.addEventListener("focus", () => suggest.ghost()); |
| 1137 | suggest.input.addEventListener("blur", () => suggest.close()); |
| 1138 | suggest.list.addEventListener("mousedown", (e) => e.preventDefault()); // keep the box focused, so the click lands |
| 1139 | window.addEventListener("resize", () => suggest.ghost()); |
| 1140 | $("#tries").addEventListener("click", (e) => { const a = e.target.closest("a[data-q]"); if (!a) return; e.preventDefault(); $("#text").value = a.dataset.q; location.hash = "#ask"; ask(); }); |
| 1141 | $("#connect-form").addEventListener("submit", (e) => { |
| 1142 | e.preventDefault(); |
| 1143 | agentsUI.putAway(); |
| 1144 | token = $("#hub-token").value; |
| 1145 | store.set(remote ? {url: $("#hub-url").value.trim(), token} : {token}); |
| 1146 | load(); |
| 1147 | }); |
| 1148 | // ---------------------------------------------------------------- member |
| 1149 | // A browser as a federation actor without an inbox. Everything it sends is |
| 1150 | // signed the way hubs sign: HMAC-SHA256 over method, path, date and the |
| 1151 | // SHA-256 of the canonical JSON body. HMAC is written out here (and SHA-256 |
| 1152 | // in identity.js) because crypto.subtle is not available on a plain http:// |
| 1153 | // LAN address. With a key (the Identity tab), it follows as that key. |
| 1154 | const {sha256, canonical} = PyrtanIdentity; |
| 1155 | const enc = new TextEncoder(); |
| 1156 | const hex = (b) => Array.from(b, x => x.toString(16).padStart(2, "0")).join(""); |
| 1157 | function hmac(keyText, msgBytes) { |
| 1158 | let key = enc.encode(keyText); if (key.length > 64) key = sha256(key); |
| 1159 | const k = new Uint8Array(64); k.set(key); |
| 1160 | const ipad = k.map(x => x ^ 0x36), opad = k.map(x => x ^ 0x5c); |
| 1161 | const inner = sha256(new Uint8Array([...ipad, ...msgBytes])); |
| 1162 | return hex(sha256(new Uint8Array([...opad, ...inner]))); |
| 1163 | } |
| 1164 | function signHeaders(secret, actor, method, path, body) { |
| 1165 | const date = String(Math.floor(Date.now() / 1000)); |
| 1166 | const digest = hex(sha256(enc.encode(body === null || body === undefined ? "" : canonical(body)))); |
| 1167 | const mac = hmac(secret, enc.encode([method.toUpperCase(), path, date, digest].join("\n"))); |
| 1168 | return {"X-Pyrtan-Actor": actor, "X-Pyrtan-Date": date, "X-Pyrtan-Signature": mac}; |
| 1169 | } |
| 1170 | const randomHex = (n) => hex(crypto.getRandomValues(new Uint8Array(n))); |
| 1171 | |
| 1172 | const member = { |
| 1173 | hub: null, me: null, timer: null, log: [], directory: new Map(), db: null, runner: null, runs: 0, |
| 1174 | key() { return "pyrtan.member:" + (this.hub || ""); }, |
| 1175 | load() { try { this.me = JSON.parse(localStorage.getItem(this.key()) || "null"); } catch (e) { this.me = null; } }, |
| 1176 | save() { try { if (this.me) localStorage.setItem(this.key(), JSON.stringify(this.me)); else localStorage.removeItem(this.key()); } catch (e) {} }, |
| 1177 | note(line) { this.log.unshift(new Date().toLocaleTimeString() + " " + line); this.log = this.log.slice(0, 40); const el = $("#member-log"); if (el) el.textContent = this.log.join("\n"); }, |
| 1178 | async open() { |
| 1179 | if (this.db) return this.db; |
| 1180 | this.db = await new Promise((res, rej) => { const r = indexedDB.open("pyrtan-member", 1); r.onupgradeneeded = () => r.result.createObjectStore("kept", {keyPath: "id"}); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); |
| 1181 | return this.db; |
| 1182 | }, |
| 1183 | async keptAll() { const db = await this.open(); return new Promise((res, rej) => { const r = db.transaction("kept").objectStore("kept").getAll(); r.onsuccess = () => res(r.result.filter(b => b.hub === this.hub)); r.onerror = () => rej(r.error); }); }, |
| 1184 | async keptGet(key) { const db = await this.open(); return new Promise((res, rej) => { const r = db.transaction("kept").objectStore("kept").get(this.hub + "|" + key); r.onsuccess = () => res(r.result || null); r.onerror = () => rej(r.error); }); }, |
| 1185 | async keptPut(blob) { const db = await this.open(); return new Promise((res, rej) => { const t = db.transaction("kept", "readwrite"); t.objectStore("kept").put({...blob, id: this.hub + "|" + blob.key, hub: this.hub}); t.oncomplete = () => res(); t.onerror = () => rej(t.error); }); }, |
| 1186 | async keptClear() { const all = await this.keptAll(); const db = await this.open(); await new Promise((res) => { const t = db.transaction("kept", "readwrite"); for (const b of all) t.objectStore("kept").delete(b.id); t.oncomplete = () => res(); }); }, |
| 1187 | async send(method, path, body) { |
| 1188 | if (!this.me) throw new Error("not a member"); |
| 1189 | const headers = Object.assign({"Accept": "application/json"}, signHeaders(this.me.secret, this.me.id, method, path, body === undefined ? null : body)); |
| 1190 | if (body !== undefined) headers["Content-Type"] = "application/json"; |
| 1191 | const r = await fetch((base ? base.replace(/\/+$/, "") : "") + path, {method, headers, body: body === undefined ? undefined : canonical(body)}); |
| 1192 | let data = null; try { data = await r.json(); } catch (e) {} |
| 1193 | return {status: r.status, data}; |
| 1194 | }, |
| 1195 | activity(type, object, extra) { return Object.assign({type, id: this.me.id + "/activities/" + randomHex(8), actor: this.me.id, published: new Date().toISOString().replace(/\.\d+Z$/, "Z"), object}, extra || {}); }, |
| 1196 | async join(name, fedSecret) { |
| 1197 | const keyed = ident.active(); |
| 1198 | this.me = {id: keyed ? ident.key.id : "urn:pyrtan:member:" + randomHex(16), secret: randomHex(32), name: name || "browser", state: "pending", listed: true, joined: Date.now(), trusted: false}; |
| 1199 | this.save(); |
| 1200 | const act = this.activity("Follow", this.hub, {secret: this.me.secret, inbox: "mailbox", name: this.me.name}); |
| 1201 | const headers = Object.assign({"Accept": "application/json", "Content-Type": "application/json"}, signHeaders(fedSecret || this.me.secret, this.me.id, "POST", "/inbox", act)); |
| 1202 | if (keyed) Object.assign(headers, await ident.headers("POST", "/inbox", act)); // a hub that grants this key, or its root, run makes it trusted |
| 1203 | if (token) headers["Authorization"] = "Bearer " + token; // the operator's token makes this device trusted: it runs the hub's trees |
| 1204 | const r = await fetch((base ? base.replace(/\/+$/, "") : "") + "/inbox", {method: "POST", headers, body: canonical(act)}); |
| 1205 | const data = await r.json().catch(() => ({})); |
| 1206 | if (r.status !== 202) { this.me = null; this.save(); this.note(`follow refused: ${data.say || r.status}`); problem(`The hub did not take the follow: ${data.say || r.status}`, true); this.render(); return; } |
| 1207 | this.me.trusted = !!data.trusted; this.me.identity = data.identity || null; this.save(); |
| 1208 | this.note(`followed ${this.hub}` + (this.me.trusted ? " as a trusted device" : "")); |
| 1209 | await this.poll(); |
| 1210 | this.start(); |
| 1211 | this.render(); |
| 1212 | }, |
| 1213 | async leave() { |
| 1214 | try { await this.send("POST", "/inbox", this.activity("Undo", {type: "Follow", actor: this.me.id, object: this.hub})); } catch (e) {} |
| 1215 | await this.keptClear(); |
| 1216 | this.me = null; this.save(); this.stop(); this.note("left"); this.render(); |
| 1217 | }, |
| 1218 | async poll() { |
| 1219 | if (!this.me) return; |
| 1220 | let r; try { r = await this.send("GET", "/mailbox"); } catch (e) { this.note("poll failed: " + e.message); return; } |
| 1221 | if (r.status === 401) { this.note("the hub no longer knows this member"); this.me.state = "refused"; this.save(); this.render(); return; } |
| 1222 | this.me.polled = Date.now(); |
| 1223 | for (const a of (r.data && r.data.activities) || []) { |
| 1224 | if (a.type === "Accept") { this.me.state = "accepted"; this.note("accepted"); } |
| 1225 | else if (a.type === "Announce") { for (const e of a.object.directory || []) this.directory.set(e.id, e); this.note(`announce: ${(a.object.trees||[]).length} trees, ${(a.object.directory||[]).length} directory entries`); } |
| 1226 | else if (a.type === "Keep") { await this.keptPut(a.object); if (a.object.key.startsWith("trees/")) this.hold(a.object.data); await this.send("POST", "/inbox", this.activity("Kept", {key: a.object.key, sha256: a.object.sha256})); this.note(`keeping ${a.object.key} (${a.object.size} B)`); } |
| 1227 | else if (a.type === "Ask") { await this.answer(a.object); } |
| 1228 | else if (a.type === "Fetch") { const b = await this.keptGet(a.object.key); if (b) { await this.send("POST", "/inbox", this.activity("Kept", {key: b.key, sha256: b.sha256, data: b.data})); this.note(`handed back ${b.key}`); } else this.note(`asked for ${a.object.key}, not kept here`); } |
| 1229 | else if (a.type === "Reject") { this.me.state = "rejected"; this.note("rejected: " + (a.summary || "")); } |
| 1230 | } |
| 1231 | this.save(); this.render(); |
| 1232 | }, |
| 1233 | // ---- running trees: the hub hands them over as trees/<name> blobs; Ask runs one here, Answer goes back. |
| 1234 | ensureRunner() { |
| 1235 | if (this.runner) return this.runner; |
| 1236 | const self = this; |
| 1237 | const resolver = { // a member may ask the hub it follows: delegate goes there, signed |
| 1238 | async runTree(name, inputs, depth) { const r = await self.send("POST", "/run", {tree: name, inputs, depth}); return r.data; }, |
| 1239 | async runIntent(text, depth) { const r = await self.send("POST", "/intent", {text, depth}); return r.data; }, |
| 1240 | }; |
| 1241 | this.runner = new Pyrtan.Runner({transport: new Pyrtan.FetchTransport(), resolver}); |
| 1242 | return this.runner; |
| 1243 | }, |
| 1244 | hold(raw) { try { this.ensureRunner().add(raw); } catch (e) { this.note(`tree refused: ${e.message}`); } }, |
| 1245 | async restoreTrees() { for (const b of await this.keptAll()) if (b.key.startsWith("trees/")) this.hold(b.data); }, |
| 1246 | capabilities() { return this.runner ? Object.values(this.runner.trees).map(t => t.capability()) : []; }, |
| 1247 | async answer(ask) { |
| 1248 | const runner = this.ensureRunner(); |
| 1249 | let outcome; |
| 1250 | try { outcome = Pyrtan.outcomeDict(await runner.runTree(ask.tree, ask.inputs || {}, ask.depth || 0)); } |
| 1251 | catch (e) { outcome = {status: "error", tree: ask.tree, reason: "member-error", say: String(e && e.message || e)}; } |
| 1252 | this.runs++; |
| 1253 | await this.send("POST", "/inbox", this.activity("Answer", {run: ask.run, outcome})); |
| 1254 | this.note(`ran ${ask.tree} for the hub: ${outcome.status}` + (outcome.say ? ` · ${String(outcome.say).slice(0, 80)}` : "")); |
| 1255 | this.render(); |
| 1256 | }, |
| 1257 | async announce() { |
| 1258 | if (!this.me || this.me.state !== "accepted") return; |
| 1259 | const kept = (await this.keptAll()).map(b => b.key).sort(); |
| 1260 | const trees = this.me.trusted ? this.capabilities() : []; |
| 1261 | const own = this.me.listed ? [{id: this.me.id, name: this.me.name, kind: "member", trees: trees.map(t => t.name), seen: Date.now() / 1000}] : []; |
| 1262 | const heard = [...this.directory.values()].filter(e => e.id !== this.me.id); |
| 1263 | try { |
| 1264 | const r = await this.send("POST", "/inbox", this.activity("Announce", {type: "Capabilities", name: this.me.name, trees, directory: own.concat(heard), kept})); |
| 1265 | for (const e of (r.data && r.data.directory) || []) this.directory.set(e.id, e); |
| 1266 | } catch (e) { this.note("announce failed: " + e.message); } |
| 1267 | }, |
| 1268 | start() { |
| 1269 | this.load(); |
| 1270 | if (!this.me || this.timer) { this.render(); return; } |
| 1271 | // A trusted device answers Asks, so it polls faster: a sentence waits one poll. |
| 1272 | this.timer = setInterval(() => this.poll(), this.me.trusted ? 4000 : 20000); |
| 1273 | this.announceTimer = setInterval(() => this.announce(), 60000); |
| 1274 | this.restoreTrees().then(() => this.poll()).then(() => this.announce()); |
| 1275 | }, |
| 1276 | stop() { clearInterval(this.timer); clearInterval(this.announceTimer); this.timer = null; }, |
| 1277 | async render() { |
| 1278 | if (!$("#member-on")) return; |
| 1279 | $("#member-off").hidden = !!this.me; $("#member-on").hidden = !this.me; |
| 1280 | if (!this.me) return; |
| 1281 | $("#member-id").textContent = this.me.id; $("#member-state").textContent = this.me.state; |
| 1282 | $("#member-name-shown").textContent = this.me.name; $("#member-polled").textContent = this.me.polled ? new Date(this.me.polled).toLocaleTimeString() : "never"; |
| 1283 | $("#member-listed").checked = !!this.me.listed; |
| 1284 | $("#member-trust").textContent = this.me.trusted ? "trusted: runs the hub's trees" + (this.me.identity ? ` (as ${PyrtanIdentity.short(this.me.identity)})` : "") : "keeper only (join with the hub's token, or as a key it grants run, to run trees)"; |
| 1285 | const tb = $("#member-trees tbody"); tb.innerHTML = ""; |
| 1286 | const sel = $("#member-run-tree"); const chosen = sel.value; sel.innerHTML = ""; |
| 1287 | for (const t of this.capabilities()) { |
| 1288 | const tr = document.createElement("tr"); tr.innerHTML = `<td><code>${esc(t.name)}</code> <span class="mute">v${esc(t.version)}</span></td><td>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}</td><td class="mute">${esc((t.hosts||[]).join(", "))}</td>`; tb.appendChild(tr); |
| 1289 | const o = document.createElement("option"); o.value = t.name; o.textContent = t.name; sel.appendChild(o); |
| 1290 | } |
| 1291 | if (chosen) sel.value = chosen; |
| 1292 | if (!tb.children.length) tb.innerHTML = `<tr><td colspan="3" class="mute">${this.me.trusted ? "None handed over yet; the hub sends what this device should run, and this device picks it up on its next poll." : "A keeper runs nothing."}</td></tr>`; |
| 1293 | const blobs = await this.keptAll(); |
| 1294 | const body = $("#kept tbody"); body.innerHTML = ""; // cleared after the await, so two renders at once do not double the rows |
| 1295 | for (const b of blobs) { const tr = document.createElement("tr"); tr.innerHTML = `<td><code>${esc(b.key)}</code></td><td>${esc(b.size)} B</td><td class="mute">${b.updated ? new Date(b.updated * 1000).toLocaleString() : ""}</td><td><a href="#" data-show="${esc(b.key)}">show</a></td>`; body.appendChild(tr); } |
| 1296 | if (!body.children.length) body.innerHTML = `<tr><td colspan="4" class="mute">Nothing yet. The hub sends its blobs as it needs keepers; "Poll now" asks sooner.</td></tr>`; |
| 1297 | }, |
| 1298 | }; |
| 1299 | $("#join-form").addEventListener("submit", (e) => { e.preventDefault(); member.join($("#member-name").value.trim(), $("#member-fedsecret").value); }); |
| 1300 | $("#member-poll").addEventListener("click", () => member.poll().then(() => member.announce())); |
| 1301 | $("#member-leave").addEventListener("click", () => member.leave()); |
| 1302 | $("#member-run").addEventListener("submit", async (e) => { |
| 1303 | e.preventDefault(); |
| 1304 | const name = $("#member-run-tree").value; if (!name) return; |
| 1305 | let inputs = {}; try { inputs = JSON.parse($("#member-run-inputs").value || "{}"); } catch (err) { problem("inputs must be JSON", true); return; } |
| 1306 | const t0 = performance.now(); |
| 1307 | const r = Pyrtan.outcomeDict(await member.ensureRunner().runTree(name, inputs)); |
| 1308 | const card = document.createElement("div"); card.className = "card"; |
| 1309 | card.innerHTML = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${Math.round(performance.now() - t0)} ms · ran in this browser</span></div>${r.say ? `<div class="say">${esc(r.say)}</div>` : ""}${r.output != null ? `<details open><summary>output</summary><pre>${esc(JSON.stringify(r.output, null, 2))}</pre></details>` : ""}${r.trace ? `<details open><summary>trace</summary><pre>${esc(renderTrace(r.trace))}</pre></details>` : ""}`; |
| 1310 | $("#member-out").prepend(card); |
| 1311 | }); |
| 1312 | $("#member-listed").addEventListener("change", (e) => { member.me.listed = e.target.checked; member.save(); member.announce(); }); |
| 1313 | $("#kept").addEventListener("click", async (e) => { const a = e.target.closest("a[data-show]"); if (!a) return; e.preventDefault(); const b = await member.keptGet(a.dataset.show); alert(b ? JSON.stringify(b.data, null, 2).slice(0, 4000) : "not kept"); }); |
| 1314 | if ("serviceWorker" in navigator && (location.protocol === "https:" || ["localhost", "127.0.0.1"].includes(location.hostname))) { navigator.serviceWorker.register("sw.js").catch(() => {}); } |
| 1315 | |
| 1316 | // ------------------------------------------------------------ chat |
| 1317 | // A seat in the hub's lobby: start, then long-poll for events (the hub |
| 1318 | // echoes our own lines too, so the log is the hub's word, in its order). |
| 1319 | const chat = { |
| 1320 | seat: null, after: 0, gen: 0, state: null, partner: null, sure: 0, |
| 1321 | indicator: false, partnerTyping: false, sentTyping: false, sentAt: 0, |
| 1322 | pref() { try { return localStorage.getItem("pyrtan.chat.typing") !== "0"; } catch (e) { return true; } }, |
| 1323 | async post(path, body, extra) { |
| 1324 | const r = await api(path, Object.assign({method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)}, extra || {})); |
| 1325 | if (r.status === 401) throw new Error("401: this Pyrtan needs a token"); |
| 1326 | return r.json(); |
| 1327 | }, |
| 1328 | async count() { |
| 1329 | try { const s = await json("chat"); $("#chat-count").textContent = `Now: ${s.waiting} waiting, ${s.talking} talking, ${s.bots} bot${s.bots === 1 ? "" : "s"}` + (s.hubs ? `, and ${s.hubs} other hub${s.hubs === 1 ? "" : "s"} to meet strangers on.` : "."); $("#chat-typing-opt").hidden = s.typing !== true; } catch (e) {} |
| 1330 | }, |
| 1331 | async start() { |
| 1332 | const interests = $("#chat-interests").value.split(/[,\s]+/).filter(Boolean); |
| 1333 | let r; try { r = await this.post("chat/start", {want: $("#chat-want").value, interests, typing_indicator: $("#chat-typing").checked}); } catch (e) { problem(e.message, true); return; } |
| 1334 | if (!r.seat) { problem(r.say || "The lobby did not take a seat.", true); return; } |
| 1335 | problem(""); this.seat = r.seat; this.after = 0; $("#chat-log").innerHTML = ""; |
| 1336 | $("#chat-off").hidden = true; $("#chat-on").hidden = false; |
| 1337 | this.take(r); this.loop(++this.gen); $("#chat-text").focus(); |
| 1338 | }, |
| 1339 | take(r) { |
| 1340 | for (const e of r.events || []) if (e.n > this.after) { this.render(e); this.after = e.n; } |
| 1341 | this.state = r.state; this.partner = r.partner; |
| 1342 | this.indicator = !!r.typing_indicator; this.partnerTyping = !!r.partner_typing; |
| 1343 | $("#chat-typing-line").textContent = this.partnerTyping ? (r.partner && r.partner.kind === "bot" ? "Bot is thinking…" : "Stranger is typing…") : ""; |
| 1344 | $("#chat-text").disabled = r.state !== "talking"; |
| 1345 | $("#chat-text").placeholder = r.state === "talking" ? (r.partner && r.partner.kind === "bot" ? "ask the bot something" : "say something") : r.state === "waiting" ? "waiting for someone…" : "press New for someone else"; |
| 1346 | this.resetNext(); |
| 1347 | }, |
| 1348 | async loop(gen) { |
| 1349 | while (this.seat && gen === this.gen) { |
| 1350 | // Saying what is shown lets the hub answer as soon as the stranger starts or stops typing. |
| 1351 | const ask = {seat: this.seat, after: this.after, wait: 20}; if (this.indicator) ask.partner_typing = this.partnerTyping; |
| 1352 | let r; try { r = await this.post("chat/poll", ask); } catch (e) { await new Promise(res => setTimeout(res, 2000)); continue; } |
| 1353 | if (gen !== this.gen) return; |
| 1354 | if (r.state === "left") return; |
| 1355 | if (r.reason === "no-seat") { this.line("sys", "This seat is gone (nobody polled it for a minute). Start again."); this.seat = null; this.take({state: "ended"}); return; } |
| 1356 | this.take(r); |
| 1357 | } |
| 1358 | }, |
| 1359 | render(e) { |
| 1360 | if (e.kind === "system") return this.line("sys", e.text); |
| 1361 | const el = this.line(e.kind, e.text, {you: "You", stranger: "Stranger", bot: "Bot"}[e.kind] || e.kind); |
| 1362 | if (e.kind === "bot" && (e.trace || e.tree)) { |
| 1363 | const d = document.createElement("details"); |
| 1364 | d.innerHTML = `<summary>${esc([e.status, e.tree, e.peer && e.peer !== "local" ? "from " + e.peer : ""].filter(Boolean).join(" · "))}</summary>${e.trace ? `<pre>${esc(renderTrace(e.trace))}</pre>` : ""}`; |
| 1365 | el.appendChild(d); |
| 1366 | } |
| 1367 | }, |
| 1368 | line(kind, text, who) { |
| 1369 | const log = $("#chat-log"), stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 40; |
| 1370 | const el = document.createElement("div"); el.className = "line " + kind; |
| 1371 | el.innerHTML = (who ? `<span class="who">${esc(who)}:</span>` : "") + esc(text); |
| 1372 | log.appendChild(el); if (stick) log.scrollTop = log.scrollHeight; |
| 1373 | return el; |
| 1374 | }, |
| 1375 | async say() { |
| 1376 | const text = $("#chat-text").value.trim(); if (!text || !this.seat) return; |
| 1377 | $("#chat-text").value = ""; this.sentTyping = false; // the line ends the typing, on the hub too |
| 1378 | try { const r = await this.post("chat/say", {seat: this.seat, text}); if (r.status !== "ok") this.line("sys", r.say || r.reason); } catch (e) { this.line("sys", e.message); } |
| 1379 | }, |
| 1380 | typed(on) { |
| 1381 | // Typing goes out when it starts or stops, and again every two seconds while it goes on. |
| 1382 | if (!this.seat || !this.indicator || this.state !== "talking" || (this.partner && this.partner.kind === "bot")) return; |
| 1383 | const now = Date.now(); |
| 1384 | if (on === this.sentTyping && (!on || now - this.sentAt < 2000)) return; |
| 1385 | this.sentTyping = on; this.sentAt = now; |
| 1386 | this.post("chat/typing", {seat: this.seat, typing: on}).catch(() => {}); |
| 1387 | }, |
| 1388 | async setIndicator(on) { |
| 1389 | try { localStorage.setItem("pyrtan.chat.typing", on ? "1" : "0"); } catch (e) {} |
| 1390 | if (!this.seat) return; |
| 1391 | this.sentTyping = false; |
| 1392 | try { this.take(await this.post("chat/settings", {seat: this.seat, typing_indicator: on, after: this.after})); } catch (e) { this.line("sys", e.message); } |
| 1393 | }, |
| 1394 | resetNext() { this.sure = 0; $("#chat-next").textContent = this.state === "talking" ? "Stop" : "New"; }, |
| 1395 | async next() { |
| 1396 | // Like Omegle: while talking, the first press asks, the second ends it and finds someone new. |
| 1397 | if (this.state === "talking" && !this.sure) { this.sure = 1; $("#chat-next").textContent = "Really?"; setTimeout(() => this.resetNext(), 3000); return; } |
| 1398 | if (!this.seat) return this.start(); |
| 1399 | this.sentTyping = false; |
| 1400 | try { this.take(await this.post("chat/next", {seat: this.seat, after: this.after, typing_indicator: $("#chat-typing").checked})); } catch (e) { this.line("sys", e.message); } |
| 1401 | }, |
| 1402 | leave(keepalive) { |
| 1403 | if (!this.seat) return; |
| 1404 | const seat = this.seat; this.seat = null; this.gen++; |
| 1405 | this.post("chat/leave", {seat}, keepalive ? {keepalive: true} : {}).catch(() => {}); |
| 1406 | }, |
| 1407 | }; |
| 1408 | $("#chat-start").addEventListener("submit", (e) => { e.preventDefault(); chat.start(); }); |
| 1409 | $("#chat-say").addEventListener("submit", (e) => { e.preventDefault(); chat.say(); }); |
| 1410 | $("#chat-text").addEventListener("input", () => chat.typed(!!$("#chat-text").value.trim())); |
| 1411 | $("#chat-text").addEventListener("blur", () => chat.typed(false)); |
| 1412 | $("#chat-typing").checked = chat.pref(); |
| 1413 | $("#chat-typing").addEventListener("change", (e) => chat.setIndicator(e.target.checked)); |
| 1414 | $("#chat-next").addEventListener("click", () => chat.next()); |
| 1415 | $("#chat-stop").addEventListener("click", (e) => { e.preventDefault(); chat.leave(); $("#chat-on").hidden = true; $("#chat-off").hidden = false; chat.count(); }); |
| 1416 | document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tab-chat").hidden && chat.seat) { e.preventDefault(); chat.next(); } }); |
| 1417 | window.addEventListener("pagehide", () => chat.leave(true)); |
| 1418 | |
| 1419 | // ---------------------------------------------------------------- agents |
| 1420 | // A model beside the swarm, never inside it (agents.js, _meta/agents.md). |
| 1421 | // It reaches the swarm only through this page's own calls, and what it |
| 1422 | // suggests is applied here only when you accept it, and only to the trees |
| 1423 | // this browser holds; a hub keeps it at /suggestions for pyrtan grow. |
| 1424 | const agentsUI = { |
| 1425 | agent: null, config: {}, skills: null, busy: false, |
| 1426 | init() { |
| 1427 | try { this.config = JSON.parse(localStorage.getItem("pyrtan.agents.config") || "{}"); } catch (e) { this.config = {}; } |
| 1428 | $("#agent-model").innerHTML = PyrtanAgents.MODELS.map(m => `<option value="${esc(m.id)}">${esc(m.label)} · ${esc(m.licence)} · ${esc(m.size)}</option>`).join(""); |
| 1429 | $("#agent-name").value = this.config.name || "scout"; |
| 1430 | $("#agent-engine").value = this.config.engine || (PyrtanAgents.WebLLMEngine.available() ? "webllm" : "openai"); |
| 1431 | $("#agent-model").value = this.config.model || PyrtanAgents.DEFAULT_MODEL; |
| 1432 | $("#agent-url").value = this.config.url || "http://localhost:11434/v1"; |
| 1433 | $("#agent-server-model").value = this.config.serverModel || "qwen2.5:3b"; |
| 1434 | for (const el of document.querySelectorAll(".origin-here")) el.textContent = location.origin; |
| 1435 | this.skills = new PyrtanAgents.Skills(PyrtanAgents.browserStorage("pyrtan.agents:")); |
| 1436 | this.engineChanged(); |
| 1437 | }, |
| 1438 | engineChanged() { |
| 1439 | const web = $("#agent-engine").value === "webllm"; |
| 1440 | $("#agent-model").hidden = !web; $("#agent-url").hidden = web; $("#agent-server-model").hidden = web; |
| 1441 | $("#agent-gpu").hidden = !web || PyrtanAgents.WebLLMEngine.available(); |
| 1442 | }, |
| 1443 | where() { return local ? "this browser" : (base || location.origin); }, |
| 1444 | hub(method, path, body) { return json(path, method === "GET" ? undefined : {method, headers: {"Content-Type": "application/json"}, body: JSON.stringify(body || {})}); }, |
| 1445 | raw(name) { const t = local && local.runner.get(name); return t ? t.raw : null; }, |
| 1446 | async start() { |
| 1447 | const engineKind = $("#agent-engine").value; |
| 1448 | this.config = {name: $("#agent-name").value.trim() || "scout", engine: engineKind, model: $("#agent-model").value, url: $("#agent-url").value.trim(), serverModel: $("#agent-server-model").value.trim()}; |
| 1449 | try { localStorage.setItem("pyrtan.agents.config", JSON.stringify(this.config)); } catch (e) {} |
| 1450 | const progress = (text) => { $("#agent-progress").textContent = text; }; |
| 1451 | const engine = engineKind === "webllm" |
| 1452 | ? new PyrtanAgents.WebLLMEngine({model: this.config.model, onProgress: (text) => progress(text)}) |
| 1453 | : new PyrtanAgents.OpenAIEngine({url: this.config.url, model: this.config.serverModel}); |
| 1454 | $("#agent-go").disabled = true; |
| 1455 | progress(engineKind === "webllm" ? "Loading WebLLM and the model; the first time, this is a download…" : `Asking ${this.config.url}…`); |
| 1456 | try { |
| 1457 | await engine.load(); |
| 1458 | if (engineKind === "openai") await engine.complete([{role: "user", content: "Say ok."}], {maxTokens: 4}); |
| 1459 | } catch (e) { progress(""); problem(`The agent could not start: ${e.message}`, true); $("#agent-go").disabled = false; return; } |
| 1460 | progress(""); |
| 1461 | const hubKey = local ? "browser" : (base || location.origin); |
| 1462 | this.agent = new PyrtanAgents.Agent({name: this.config.name, engine, hub: (m, p, b) => this.hub(m, p, b), raw: (n) => this.raw(n), storage: PyrtanAgents.browserStorage(`pyrtan.agents:${hubKey}:`), skills: this.skills, onEvent: (e) => this.event(e)}); |
| 1463 | $("#agent-go").disabled = false; |
| 1464 | $("#agent-setup").hidden = true; $("#agent-on").hidden = false; |
| 1465 | $("#agent-who").textContent = this.agent.name; |
| 1466 | const m = PyrtanAgents.MODELS.find(x => x.id === engine.model); |
| 1467 | $("#agent-what").textContent = `${m ? m.label : engine.model} · ${engineKind === "webllm" ? "in this browser" : "on " + this.config.url} · beside ${this.where()}`; |
| 1468 | $("#agent-log").innerHTML = ""; |
| 1469 | for (const msg of this.agent.memory.chat.slice(-10)) this.line(msg.role === "you" ? "you" : "bot", msg.role === "you" ? "You" : this.agent.name, esc(msg.content)); |
| 1470 | this.sys(`${this.agent.name} is a model, not a tree: what it says is its own, and what a tree says comes with the tree's name and path. /help lists what it can do.`); |
| 1471 | this.render(); |
| 1472 | }, |
| 1473 | putAway() { |
| 1474 | if (!this.agent) return; |
| 1475 | this.agent.stop(); |
| 1476 | if (this.agent.engine.unload) this.agent.engine.unload().catch(() => {}); |
| 1477 | this.agent = null; $("#agent-setup").hidden = false; $("#agent-on").hidden = true; |
| 1478 | }, |
| 1479 | line(cls, who, html, extra) { |
| 1480 | const log = $("#agent-log"), div = document.createElement("div"); |
| 1481 | div.className = "line " + cls; |
| 1482 | div.innerHTML = (who ? `<span class="who">${esc(who)}:</span>` : "") + html + (extra || ""); |
| 1483 | log.appendChild(div); log.scrollTop = log.scrollHeight; |
| 1484 | }, |
| 1485 | sys(text) { this.line("sys", "", esc(text)); }, |
| 1486 | event(e) { |
| 1487 | if (e.type === "thinking") $("#agent-typing").textContent = `${e.agent} is thinking (${e.job})…`; |
| 1488 | else if (e.type === "probe") { const r = e.result; this.sys(`probe ${e.tree}: “${r.text}” → ${r.read === null ? "no tree reads it" : r.read === "tie" ? "a tie" : r.read === e.tree ? (r.status ? `${r.status}${r.reason ? " (" + r.reason + ")" : ""}` : "reads, not run") : "reads as " + r.read}`); } |
| 1489 | else if (e.type === "refused") this.sys(`refused by the rules: ${e.what}: ${e.why}`); |
| 1490 | else if (e.type === "suggestion") { this.sys(`suggests a ${e.item.suggestion.kind} for ${e.item.suggestion.tree || "“" + e.item.suggestion.text + "”"}`); this.renderSuggestions(); } |
| 1491 | else if (e.type === "patrol" && e.step !== "done") this.sys(e.step === "probe" ? `patrol: probing ${e.tree}` : `patrol: the ${e.step}`); |
| 1492 | else if (e.type === "action") { const a = e.action; if (a.kind === "ask" && a.result) this.line("tree", a.result.tree || "no tree", esc(a.result.say || a.result.status), a.result.path && a.result.path.length ? `<details><summary>path</summary><code>${esc(a.result.path.join(" > "))}</code></details>` : ""); else if (a.kind === "remember") this.sys(`remembered: ${a.input}`); } |
| 1493 | }, |
| 1494 | async run(fn) { |
| 1495 | if (this.busy) return; this.busy = true; $("#agent-stop").disabled = false; |
| 1496 | try { await fn(); } catch (e) { this.sys(`error: ${e.message}`); } |
| 1497 | finally { this.busy = false; $("#agent-stop").disabled = true; $("#agent-typing").textContent = ""; this.renderMemory(); } |
| 1498 | }, |
| 1499 | say(text) { |
| 1500 | if (!this.agent || !text) return; |
| 1501 | this.line("you", "You", esc(text)); |
| 1502 | return this.run(async () => { |
| 1503 | const r = await this.agent.chat(text); |
| 1504 | if (r.outcome) this.line("tree", r.outcome.tree || "no tree", esc(r.outcome.say || r.outcome.status), (r.outcome.trace ? `<details><summary>trace</summary><pre>${esc(renderTrace(r.outcome.trace))}</pre></details>` : "")); |
| 1505 | else if (r.say) this.line("bot", this.agent.name, esc(r.say).replace(/\n/g, "<br>")); |
| 1506 | }); |
| 1507 | }, |
| 1508 | patrol() { return this.run(async () => { const r = await this.agent.patrol(); this.line("bot", this.agent.name, esc(`Patrolled ${r.trees.length} trees: ${r.tally.sentences} sentences, ${r.tally.read} read, ${r.tally.unread} read to nothing, ${r.tally.misread} to another tree, ${r.tally.gaps} met a gap. ${r.suggestions.length} suggestions, ${r.refused.length} refused by the rules.${r.stopped ? " (stopped)" : ""}`)); }); }, |
| 1509 | // Accepting: in a browser that is its own hub, the tree grows here and now; any hub keeps the suggestion for pyrtan grow. |
| 1510 | async accept(id) { |
| 1511 | const item = this.agent.suggestions.find(x => x.id === id); if (!item) return; |
| 1512 | const s = item.suggestion; let applied = ""; |
| 1513 | if (local && (s.kind === "branch" || s.kind === "trigger")) { |
| 1514 | const raw = this.raw(s.tree); |
| 1515 | try { |
| 1516 | let grown = null; |
| 1517 | if (raw && s.kind === "branch") { const d = Pyrtan.grow.draftBranch(raw, s); if (d) grown = Pyrtan.grow.growTree(raw, d, s.meaning); } |
| 1518 | if (raw && s.kind === "trigger") { grown = JSON.parse(JSON.stringify(raw)); grown.triggers = (grown.triggers || []).concat([{pattern: s.pattern}]); } |
| 1519 | if (grown) { new Pyrtan.Tree(grown); local.add(grown, local.held[s.tree]); applied = "grown in this page"; } |
| 1520 | } catch (e) { this.sys(`could not grow ${s.tree} here: ${e.message}`); return; } |
| 1521 | } |
| 1522 | let kept = ""; |
| 1523 | try { const out = await this.agent.send([item]); kept = (out.refused || []).length ? `refused by the hub: ${out.refused[0].why}` : `kept at ${this.where()}'s /suggestions`; } |
| 1524 | catch (e) { kept = `not kept: ${e.message}${/404/.test(e.message) ? " (a hub older than 0.18 has no /suggestions; download it instead)" : ""}`; } |
| 1525 | this.agent.decide(id, "accepted"); |
| 1526 | item.note = [applied, kept].filter(Boolean).join(" · "); |
| 1527 | if (applied) { try { renderCaps(await json("capabilities")); await this.agent.trees(true); } catch (e) {} } |
| 1528 | this.renderSuggestions(); this.renderMemory(); |
| 1529 | }, |
| 1530 | dismiss(id) { |
| 1531 | const why = prompt("Why not? The agent remembers. (optional)") || ""; |
| 1532 | this.agent.decide(id, "dismissed", why.trim()); this.renderSuggestions(); this.renderMemory(); |
| 1533 | }, |
| 1534 | download() { |
| 1535 | const items = (this.agent ? this.agent.suggestions : []).filter(x => x.status === "accepted").map(x => x.suggestion); |
| 1536 | if (!items.length) { this.sys("Nothing accepted yet."); return; } |
| 1537 | const a = document.createElement("a"); |
| 1538 | a.href = URL.createObjectURL(new Blob([JSON.stringify({"pyrtan-suggestions": 1, suggestions: items}, null, 2)], {type: "application/json"})); |
| 1539 | a.download = "pyrtan-suggestions.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); |
| 1540 | this.sys("Saved. Then: pyrtan grow --suggestions pyrtan-suggestions.json --trees DIR --apply"); |
| 1541 | }, |
| 1542 | render() { if (!this.skills) this.init(); this.renderSuggestions(); this.renderMemory(); this.renderSkills(); }, |
| 1543 | renderSuggestions() { |
| 1544 | const box = $("#agent-sugs"); if (!box) return; |
| 1545 | const items = this.agent ? this.agent.suggestions.slice().reverse() : []; |
| 1546 | if (!items.length) { box.innerHTML = '<p class="mute">Nothing yet. Press Patrol, or ask it to <code>/probe</code> a tree.</p>'; return; } |
| 1547 | box.innerHTML = items.map(x => { |
| 1548 | const s = x.suggestion, by = s.by || {}; |
| 1549 | const what = s.kind === "branch" ? `<code>${esc(s.tree)}</code> · <code>${esc(s.node)}</code>: <code>${esc(s.on || "")}</code> = <code class="gap">${esc(JSON.stringify(s.observed))}</code> means a <strong>${esc(s.meaning.kind)}</strong>${s.meaning.reason ? ` <span class="mute">(${esc(s.meaning.reason)})</span>` : ""}<div class="say">${esc(s.meaning.say)}</div>` |
| 1550 | : s.kind === "trigger" ? `<code>${esc(s.tree)}</code> should answer to <code>${esc(s.pattern)}</code><div class="mute" style="font-size:14px">reads ${(s.covers || []).map(c => "“" + esc(c) + "”").join(", ")}</div>` |
| 1551 | : `a new tree for “${esc(s.text)}”<div class="say">${esc(s.description || "")}</div>${(s.hosts_hint || []).length ? `<div class="mute" style="font-size:14px">hosts it guessed (a note, never a host): ${esc(s.hosts_hint.join(", "))}</div>` : ""}`; |
| 1552 | const state = x.status === "new" ? `<div style="display:flex;gap:8px;margin-top:8px"><button type="button" data-accept="${x.id}">Accept</button><button type="button" class="quiet" data-dismiss="${x.id}">Dismiss</button></div>` : `<div class="mute sug-state" style="font-size:14px;margin-top:6px">${esc(x.status)}${x.note ? " · " + esc(x.note) : ""}</div>`; |
| 1553 | return `<div class="card"><div><span class="status asked">${esc(s.kind)}</span> <span class="mute">by ${esc(by.agent || "")}${by.model ? " · " + esc(by.model) : ""}</span></div><div style="margin-top:4px">${what}</div>${s.why ? `<div class="mute" style="font-size:14px">${esc(s.why)}</div>` : ""}${x.preview && x.preview.text ? `<details><summary>what it would write</summary><pre>${esc(x.preview.text)}</pre></details>` : ""}${state}</div>`; |
| 1554 | }).join(""); |
| 1555 | }, |
| 1556 | renderMemory() { |
| 1557 | const body = $("#agent-notes tbody"); if (!body) return; |
| 1558 | const notes = this.agent ? this.agent.memory.notes.slice().reverse() : []; |
| 1559 | body.innerHTML = notes.length ? notes.map(n => `<tr><td class="mute">${esc(n.source)}</td><td>${esc(n.text)}</td><td><button type="button" class="quiet" data-forget="${n.id}">Forget</button></td></tr>`).join("") : `<tr><td class="mute">${this.agent ? "Nothing kept yet." : "Start an agent to see what it remembers."}</td></tr>`; |
| 1560 | }, |
| 1561 | renderSkills() { |
| 1562 | const body = $("#agent-skills tbody"); if (!body || !this.skills) return; |
| 1563 | body.innerHTML = this.skills.list().map(s => `<tr><td><code>${esc(s.name)}</code><div class="mute">${esc(s.description || "")}</div></td><td>${esc(s.when)}</td><td>${s.builtin ? '<span class="mute">built in</span>' : `<label><input type="checkbox" data-skill-on="${esc(s.name)}"${s.on ? " checked" : ""}> on</label> <button type="button" class="quiet" data-skill-remove="${esc(s.name)}">Remove</button>`}</td></tr>`).join(""); |
| 1564 | }, |
| 1565 | }; |
| 1566 | $("#agent-engine").addEventListener("change", () => agentsUI.engineChanged()); |
| 1567 | $("#agent-start").addEventListener("submit", (e) => { e.preventDefault(); agentsUI.start(); }); |
| 1568 | $("#agent-say").addEventListener("submit", (e) => { e.preventDefault(); const t = $("#agent-text").value.trim(); $("#agent-text").value = ""; agentsUI.say(t); }); |
| 1569 | $("#agent-patrol").addEventListener("click", () => agentsUI.patrol()); |
| 1570 | $("#agent-stop").addEventListener("click", () => { if (agentsUI.agent) { agentsUI.agent.stop(); agentsUI.sys("stopping after this step…"); } }); |
| 1571 | $("#agent-off").addEventListener("click", () => agentsUI.putAway()); |
| 1572 | $("#agent-download").addEventListener("click", () => agentsUI.download()); |
| 1573 | $("#agent-sugs").addEventListener("click", (e) => { const a = e.target.closest("[data-accept]"), d = e.target.closest("[data-dismiss]"); if (a) agentsUI.accept(Number(a.dataset.accept)); if (d) agentsUI.dismiss(Number(d.dataset.dismiss)); }); |
| 1574 | $("#agent-note-form").addEventListener("submit", (e) => { e.preventDefault(); if (agentsUI.agent && $("#agent-note").value.trim()) { agentsUI.agent.memory.remember($("#agent-note").value, "you"); $("#agent-note").value = ""; agentsUI.renderMemory(); } }); |
| 1575 | $("#agent-notes").addEventListener("click", (e) => { const f = e.target.closest("[data-forget]"); if (f && agentsUI.agent) { agentsUI.agent.memory.forget(f.dataset.forget); agentsUI.renderMemory(); } }); |
| 1576 | $("#agent-forget-all").addEventListener("click", () => { if (agentsUI.agent && confirm("Forget everything this agent keeps for this hub: notes, probes, verdicts and the conversation?")) { agentsUI.agent.memory.clear(); agentsUI.renderMemory(); } }); |
| 1577 | $("#agent-skill-form").addEventListener("submit", (e) => { |
| 1578 | e.preventDefault(); |
| 1579 | try { agentsUI.skills.add({name: $("#skill-name").value, when: $("#skill-when").value, description: $("#skill-desc").value, instructions: $("#skill-body").value}); for (const id of ["#skill-name", "#skill-desc", "#skill-body"]) $(id).value = ""; problem(""); } |
| 1580 | catch (err) { problem(err.message, true); } |
| 1581 | agentsUI.renderSkills(); |
| 1582 | }); |
| 1583 | $("#agent-skills").addEventListener("change", (e) => { const t = e.target.closest("[data-skill-on]"); if (t) agentsUI.skills.toggle(t.dataset.skillOn, t.checked); }); |
| 1584 | $("#agent-skills").addEventListener("click", (e) => { const r = e.target.closest("[data-skill-remove]"); if (r) { agentsUI.skills.remove(r.dataset.skillRemove); agentsUI.renderSkills(); } }); |
| 1585 | $("#skill-import").addEventListener("change", async (e) => { const f = e.target.files[0]; if (!f) return; try { const got = agentsUI.skills.import(await f.text()); problem(""); if (agentsUI.agent) agentsUI.sys(`imported ${got.map(s => s.name).join(", ")}`); } catch (err) { problem(`${f.name}: ${err.message}`, true); } e.target.value = ""; agentsUI.renderSkills(); }); |
| 1586 | $("#skill-export").addEventListener("click", () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([agentsUI.skills.export()], {type: "application/json"})); a.download = "pyrtan-skills.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); }); |
| 1587 | agentsUI.init(); |
| 1588 | |
| 1589 | // Forgetting the hub makes this browser its own hub again. |
| 1590 | $("#forget").addEventListener("click", () => { chat.leave(true); agentsUI.putAway(); store.clear(); base = ""; token = ""; member.stop(); $("#hub-url").value = ""; $("#hub-token").value = ""; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); if (remote) { const q = new URLSearchParams(location.search); if (q.has("hub")) { q.delete("hub"); history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : "") + location.hash); } load(); } }); |
| 1591 | ident.init().finally(load); |
| 1592 | </script> |
| 1593 | </body> |
| 1594 | </html> |
| 1595 | """.replace("__VERSION__", __version__).replace("__THEME__", THEME_JS).replace("__IDENTITY__", IDENTITY_JS).replace("__AGENTS__", AGENTS_JS) |
| 1596 | |
| 1597 | MANIFEST = json.dumps({ |
| 1598 | "name": "Pyrtan", |
| 1599 | "short_name": "Pyrtan", |
| 1600 | "description": "The console of a Pyrtan swarm, and a member of it: install it and this device keeps the hub's blobs.", |
| 1601 | "start_url": "./", |
| 1602 | "scope": "./", |
| 1603 | "display": "standalone", |
| 1604 | "background_color": "#fbfaf7", |
| 1605 | "theme_color": "#c2410c", |
| 1606 | "icons": [{"src": "data:image/svg+xml," + urllib.parse.quote('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#c2410c"/><path d="M18 46V18h14a9 9 0 0 1 0 18h-8" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>'), "sizes": "any", "type": "image/svg+xml", "purpose": "any"}], |
| 1607 | }, indent=2) |
| 1608 | |
| 1609 | # The service worker keeps the console shell so the page opens offline (a |
| 1610 | # phone in a tunnel, a hub that is down): the shell comes from the cache, |
| 1611 | # everything the page asks the hub for goes to the network and nowhere else. |
| 1612 | SERVICE_WORKER = r"""const SHELL = "pyrtan-shell-__VERSION__"; |
| 1613 | const FILES = ["./", "manifest.webmanifest", "pyrtan.js"]; |
| 1614 | self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then(c => c.addAll(FILES)).then(() => self.skipWaiting())); }); |
| 1615 | self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== SHELL).map(k => caches.delete(k)))).then(() => self.clients.claim())); }); |
| 1616 | self.addEventListener("fetch", (e) => { |
| 1617 | const url = new URL(e.request.url); |
| 1618 | const shell = e.request.mode === "navigate" || url.pathname.endsWith("/manifest.webmanifest"); |
| 1619 | if (!shell || e.request.method !== "GET") return; // API calls: the network, always |
| 1620 | e.respondWith(fetch(e.request).then(r => { const copy = r.clone(); caches.open(SHELL).then(c => c.put(e.request, copy)); return r; }).catch(() => caches.match(e.request).then(r => r || caches.match("./")))); |
| 1621 | }); |
| 1622 | """.replace("__VERSION__", __version__) |