Drawn mockups instead of recorded demos; delete conversations; drop Llama; fix the weights cache re-downloading
Browse files- distinct_agent/README.md +3 -5
- distinct_agent/cli.py +79 -2
- distinct_agent/models.py +11 -22
- distinct_agent/weights.py +69 -13
- distinct_server/catalog.py +7 -66
- distinct_server/control_plane.py +63 -0
- distinct_server/models.py +9 -0
- distinct_server/presentation.py +129 -55
- distinct_server/render.py +403 -51
- distinct_server/ui.py +398 -84
- distinct_server/ui_state.py +198 -0
- model_assessments.json +1 -49
- pyproject.toml +23 -7
- scripts/test_library_members.py +326 -0
distinct_agent/README.md
CHANGED
|
@@ -61,7 +61,7 @@ whole PC. Unsupported and failed measurements contain `available: false` and
|
|
| 61 |
## Models and runner
|
| 62 |
|
| 63 |
Discovery recognizes local GGUF files for OLMoE-1B-7B-0924-Instruct,
|
| 64 |
-
OLMo-2-1124-7B-Instruct
|
| 65 |
executes no remote model code. `LlamaCppRunner` invokes an installed
|
| 66 |
`llama-cli` with an argument array and `shell=False`.
|
| 67 |
|
|
@@ -71,10 +71,8 @@ model-specific assessment of its training, recorded with its primary source in
|
|
| 71 |
offered, because measured and disclosed environmental cost is the only claim
|
| 72 |
this project makes. The two OLMo entries are first party end to end -- Ai2
|
| 73 |
trained the models, measured the training, quantised the weights and published
|
| 74 |
-
the digests -- and both are pinned by repository, revision and SHA-256.
|
| 75 |
-
|
| 76 |
-
gated, so there is no digest to pin, and the worker names it as unavailable on
|
| 77 |
-
every start rather than dropping it silently.
|
| 78 |
|
| 79 |
An explicit deterministic smoke test is:
|
| 80 |
|
|
|
|
| 61 |
## Models and runner
|
| 62 |
|
| 63 |
Discovery recognizes local GGUF files for OLMoE-1B-7B-0924-Instruct,
|
| 64 |
+
OLMo-2-1124-7B-Instruct. It performs no download and
|
| 65 |
executes no remote model code. `LlamaCppRunner` invokes an installed
|
| 66 |
`llama-cli` with an argument array and `shell=False`.
|
| 67 |
|
|
|
|
| 71 |
offered, because measured and disclosed environmental cost is the only claim
|
| 72 |
this project makes. The two OLMo entries are first party end to end -- Ai2
|
| 73 |
trained the models, measured the training, quantised the weights and published
|
| 74 |
+
the digests -- and both are pinned by repository, revision and SHA-256.
|
| 75 |
+
|
|
|
|
|
|
|
| 76 |
|
| 77 |
An explicit deterministic smoke test is:
|
| 78 |
|
distinct_agent/cli.py
CHANGED
|
@@ -124,6 +124,50 @@ class ConsoleTransport:
|
|
| 124 |
self.done.set()
|
| 125 |
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
def _find_llama_server() -> str:
|
| 128 |
"""Look in runtime/ first, then PATH."""
|
| 129 |
|
|
@@ -315,6 +359,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 315 |
)
|
| 316 |
parser.add_argument(
|
| 317 |
"--llama-server",
|
|
|
|
|
|
|
| 318 |
help=(
|
| 319 |
"path to llama-server. This is the default runner: clean output, "
|
| 320 |
"real token counts, streaming, and a resident model across queued "
|
|
@@ -323,6 +369,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 323 |
)
|
| 324 |
parser.add_argument(
|
| 325 |
"--llama-cli",
|
|
|
|
|
|
|
| 326 |
help="legacy per-request runner. Only used when --llama-server is absent.",
|
| 327 |
)
|
| 328 |
parser.add_argument("--queue-capacity", type=int, default=4)
|
|
@@ -929,6 +977,19 @@ def main(
|
|
| 929 |
# else's request, on a worker that advertised itself as ready, and if it
|
| 930 |
# fails it fails as a failed run rather than as a worker that could not
|
| 931 |
# start.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
if pending and not args.fetch_on_demand and not (args.demo_runner or args.demo_prompt is not None):
|
| 933 |
print(
|
| 934 |
f"Fetching {len(pending)} model(s) now: {', '.join(pending)}. "
|
|
@@ -1045,6 +1106,11 @@ def main(
|
|
| 1045 |
if demo_runner:
|
| 1046 |
runner = DeterministicDemoRunner()
|
| 1047 |
elif args.llama_server or not args.llama_cli:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1048 |
# llama-server is the default. It returns clean output with no chat
|
| 1049 |
# furniture, reports real token counts, streams, keeps the model
|
| 1050 |
# resident across a queue, and speaks the OpenAI-compatible protocol
|
|
@@ -1062,7 +1128,7 @@ def main(
|
|
| 1062 |
)
|
| 1063 |
return 2
|
| 1064 |
else:
|
| 1065 |
-
runner = LlamaCppRunner(args.llama_cli)
|
| 1066 |
if not runner.available:
|
| 1067 |
print(
|
| 1068 |
"llama-cli was not found; supply --llama-cli or use --demo-runner.",
|
|
@@ -1404,7 +1470,18 @@ def _run_with_dashboard(
|
|
| 1404 |
"""
|
| 1405 |
|
| 1406 |
from .dashboard import ActivityLog, WorkerView
|
| 1407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1408 |
|
| 1409 |
log = getattr(sys.stderr, "_distinct_activity_log", None)
|
| 1410 |
if log is None:
|
|
|
|
| 124 |
self.done.set()
|
| 125 |
|
| 126 |
|
| 127 |
+
def _runner_problem(args) -> str:
|
| 128 |
+
"""Why this machine cannot run a model yet, in words, or empty if it can.
|
| 129 |
+
|
| 130 |
+
Written as an instruction rather than a diagnosis. "llama-server was not
|
| 131 |
+
found. Supply --llama-server" told somebody who had just waited out a nine
|
| 132 |
+
gigabyte download the name of a flag, not where to get the program that
|
| 133 |
+
flag points at, and read as though the flag took no value — which it does,
|
| 134 |
+
so the next thing they saw was an argparse usage dump.
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
if args.llama_cli and not args.llama_server:
|
| 138 |
+
from shutil import which
|
| 139 |
+
|
| 140 |
+
if which(args.llama_cli) or Path(args.llama_cli).is_file():
|
| 141 |
+
return ""
|
| 142 |
+
return (
|
| 143 |
+
f"llama-cli was not found at {args.llama_cli!r}. Give the path to the "
|
| 144 |
+
"binary, or run with --demo-runner to start without a model."
|
| 145 |
+
)
|
| 146 |
+
if args.llama_server and args.llama_server is not True:
|
| 147 |
+
if Path(str(args.llama_server)).is_file():
|
| 148 |
+
return ""
|
| 149 |
+
return (
|
| 150 |
+
f"llama-server was not found at {str(args.llama_server)!r}. Give the "
|
| 151 |
+
"path to the binary, or run with --demo-runner to start without a model."
|
| 152 |
+
)
|
| 153 |
+
if _find_llama_server():
|
| 154 |
+
return ""
|
| 155 |
+
return (
|
| 156 |
+
"This worker has no llama.cpp to run a model with.\n"
|
| 157 |
+
"\n"
|
| 158 |
+
" llama.cpp is a separate program, not part of this project. Get a build\n"
|
| 159 |
+
" from https://github.com/ggml-org/llama.cpp/releases, unzip it, and either\n"
|
| 160 |
+
" put llama-server on your PATH or point at it:\n"
|
| 161 |
+
"\n"
|
| 162 |
+
" --llama-server C:\\path\\to\\llama-server.exe\n"
|
| 163 |
+
"\n"
|
| 164 |
+
" Or start without a model. The worker still pairs and still runs every\n"
|
| 165 |
+
" tool and skill in the library; only the model's own replies are absent:\n"
|
| 166 |
+
"\n"
|
| 167 |
+
" --demo-runner\n"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
def _find_llama_server() -> str:
|
| 172 |
"""Look in runtime/ first, then PATH."""
|
| 173 |
|
|
|
|
| 359 |
)
|
| 360 |
parser.add_argument(
|
| 361 |
"--llama-server",
|
| 362 |
+
nargs="?",
|
| 363 |
+
const=True,
|
| 364 |
help=(
|
| 365 |
"path to llama-server. This is the default runner: clean output, "
|
| 366 |
"real token counts, streaming, and a resident model across queued "
|
|
|
|
| 369 |
)
|
| 370 |
parser.add_argument(
|
| 371 |
"--llama-cli",
|
| 372 |
+
nargs="?",
|
| 373 |
+
const=True,
|
| 374 |
help="legacy per-request runner. Only used when --llama-server is absent.",
|
| 375 |
)
|
| 376 |
parser.add_argument("--queue-capacity", type=int, default=4)
|
|
|
|
| 977 |
# else's request, on a worker that advertised itself as ready, and if it
|
| 978 |
# fails it fails as a failed run rather than as a worker that could not
|
| 979 |
# start.
|
| 980 |
+
# CHECK FOR THE THING THAT RUNS THE MODEL BEFORE FETCHING THE MODEL.
|
| 981 |
+
#
|
| 982 |
+
# The runner was resolved after this block, so a machine without
|
| 983 |
+
# llama.cpp downloaded nine gigabytes of weights, verified both digests,
|
| 984 |
+
# printed its sandbox report, and only then said it had nothing to run
|
| 985 |
+
# them with. Every second of that was avoidable: whether `llama-server`
|
| 986 |
+
# exists is knowable before the first byte.
|
| 987 |
+
if not (args.demo_runner or args.demo_prompt is not None):
|
| 988 |
+
problem = _runner_problem(args)
|
| 989 |
+
if problem:
|
| 990 |
+
print(problem, file=sys.stderr)
|
| 991 |
+
return 2
|
| 992 |
+
|
| 993 |
if pending and not args.fetch_on_demand and not (args.demo_runner or args.demo_prompt is not None):
|
| 994 |
print(
|
| 995 |
f"Fetching {len(pending)} model(s) now: {', '.join(pending)}. "
|
|
|
|
| 1106 |
if demo_runner:
|
| 1107 |
runner = DeterministicDemoRunner()
|
| 1108 |
elif args.llama_server or not args.llama_cli:
|
| 1109 |
+
# `--llama-server` with no value means "look for it"; the finder is
|
| 1110 |
+
# what a bare flag asks for, and passing True into a path would have
|
| 1111 |
+
# been a confusing failure two screens later.
|
| 1112 |
+
if args.llama_server is True:
|
| 1113 |
+
args.llama_server = ""
|
| 1114 |
# llama-server is the default. It returns clean output with no chat
|
| 1115 |
# furniture, reports real token counts, streams, keeps the model
|
| 1116 |
# resident across a queue, and speaks the OpenAI-compatible protocol
|
|
|
|
| 1128 |
)
|
| 1129 |
return 2
|
| 1130 |
else:
|
| 1131 |
+
runner = LlamaCppRunner("" if args.llama_cli is True else args.llama_cli)
|
| 1132 |
if not runner.available:
|
| 1133 |
print(
|
| 1134 |
"llama-cli was not found; supply --llama-cli or use --demo-runner.",
|
|
|
|
| 1470 |
"""
|
| 1471 |
|
| 1472 |
from .dashboard import ActivityLog, WorkerView
|
| 1473 |
+
|
| 1474 |
+
try:
|
| 1475 |
+
from .tui import DashboardApp
|
| 1476 |
+
except ImportError as exc: # pragma: no cover - only without the dependency
|
| 1477 |
+
# A traceback about a module nobody asked for is not an answer. The
|
| 1478 |
+
# worker itself does not need Textual, so this is the one place its
|
| 1479 |
+
# absence matters and the one place worth explaining it.
|
| 1480 |
+
raise SystemExit(
|
| 1481 |
+
f"The live status screen needs the 'textual' package ({exc}). "
|
| 1482 |
+
"Install this project's dependencies with `pip install -e .` in the "
|
| 1483 |
+
"folder you cloned, or drop --dashboard to run with a scrolling log."
|
| 1484 |
+
) from exc
|
| 1485 |
|
| 1486 |
log = getattr(sys.stderr, "_distinct_activity_log", None)
|
| 1487 |
if log is None:
|
distinct_agent/models.py
CHANGED
|
@@ -44,7 +44,17 @@ class UnpinnedModelError(ModelVerificationError):
|
|
| 44 |
# pending, and an empty digest is an unanswered question rather than a passing
|
| 45 |
# check; `discover_models(verify_hashes=True)` refuses them loudly.
|
| 46 |
#
|
| 47 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
# placed on disk by the operator, so a manifest is only usable when its
|
| 49 |
# repository, revision *and* digest are all recorded: without a revision there
|
| 50 |
# is no exact file to fetch, and without a digest there is no way to say what
|
|
@@ -126,31 +136,10 @@ KNOWN_MODEL_MANIFESTS: tuple[ModelManifest, ...] = (
|
|
| 126 |
source_revision="410e0069f64869e4b1d17d8de04810b881fd824b",
|
| 127 |
context_length=4096,
|
| 128 |
),
|
| 129 |
-
# Kept as the contrast case: the only non-Ai2 release in this catalogue with
|
| 130 |
-
# a published model-specific figure, and a partial one. Meta published
|
| 131 |
-
# GPU-hours and a modelled 31.22 tCO2e; no energy, no water, no materials.
|
| 132 |
-
#
|
| 133 |
-
# Still unpinned, and for two reasons that are worth keeping visible rather
|
| 134 |
-
# than resolving quietly. Meta publish no GGUF, so any file would be a third
|
| 135 |
-
# party's, which the publisher policy above rules out. And the source
|
| 136 |
-
# repository is gated, so there is no unauthenticated revision to pin even
|
| 137 |
-
# if that policy changed. ``fetchable_manifests`` therefore withholds it and
|
| 138 |
-
# the worker says so on every start. It is here to be named, not run.
|
| 139 |
-
ModelManifest(
|
| 140 |
-
id="llama-2-7b-chat",
|
| 141 |
-
label="Llama 2 7B Chat · Meta · gated licence",
|
| 142 |
-
filename="llama-2-7b-chat.Q4_K_M.gguf",
|
| 143 |
-
min_ram_gb=8.0,
|
| 144 |
-
context_length=4096,
|
| 145 |
-
),
|
| 146 |
)
|
| 147 |
|
| 148 |
|
| 149 |
_FILENAME_ALIASES: Mapping[str, tuple[str, ...]] = {
|
| 150 |
-
"llama-2-7b-chat": (
|
| 151 |
-
"llama-2-7b-chat.Q4_K_M.gguf",
|
| 152 |
-
"Llama-2-7B-Chat.Q4_K_M.gguf",
|
| 153 |
-
),
|
| 154 |
"olmoe-1b-7b-0924-instruct": (
|
| 155 |
"olmoe-1b-7b-0924-instruct-q4_k_m.gguf",
|
| 156 |
"OLMoE-1B-7B-0924-Instruct-Q4_K_M.gguf",
|
|
|
|
| 44 |
# pending, and an empty digest is an unanswered question rather than a passing
|
| 45 |
# check; `discover_models(verify_hashes=True)` refuses them loudly.
|
| 46 |
#
|
| 47 |
+
# EVERY ENTRY HERE IS RUNNABLE.
|
| 48 |
+
#
|
| 49 |
+
# There was one that was not: Llama 2 7B Chat, kept to be named rather than
|
| 50 |
+
# run, because Meta publish no GGUF and the source repository is gated, so
|
| 51 |
+
# there was no file to pin and the worker withheld it on every start. It has
|
| 52 |
+
# been removed. A catalogue whose entries can all be fetched and verified is
|
| 53 |
+
# easier to reason about than one carrying a permanent exception, and nothing
|
| 54 |
+
# was lost from the assessment surface: describing what a publisher did or did
|
| 55 |
+
# not disclose never required shipping their weights.
|
| 56 |
+
#
|
| 57 |
+
# WHAT AN UNPINNED ENTRY WOULD MEAN. Weights are fetched on demand rather than
|
| 58 |
# placed on disk by the operator, so a manifest is only usable when its
|
| 59 |
# repository, revision *and* digest are all recorded: without a revision there
|
| 60 |
# is no exact file to fetch, and without a digest there is no way to say what
|
|
|
|
| 136 |
source_revision="410e0069f64869e4b1d17d8de04810b881fd824b",
|
| 137 |
context_length=4096,
|
| 138 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
)
|
| 140 |
|
| 141 |
|
| 142 |
_FILENAME_ALIASES: Mapping[str, tuple[str, ...]] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
"olmoe-1b-7b-0924-instruct": (
|
| 144 |
"olmoe-1b-7b-0924-instruct-q4_k_m.gguf",
|
| 145 |
"OLMoE-1B-7B-0924-Instruct-Q4_K_M.gguf",
|
distinct_agent/weights.py
CHANGED
|
@@ -134,7 +134,21 @@ def _weights_opener() -> Callable[..., Any]:
|
|
| 134 |
|
| 135 |
#: Default cache ceiling. Small enough to be a polite default on a volunteer's
|
| 136 |
#: machine, large enough to hold the models in the shipped catalogue.
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
#: Anything above this is almost certainly a typo rather than an intention.
|
| 140 |
MAX_CACHE_GB = 512.0
|
|
@@ -357,18 +371,45 @@ class WeightsCache:
|
|
| 357 |
]
|
| 358 |
return [root / name for root in roots for name in names]
|
| 359 |
|
| 360 |
-
def
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
try:
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
except OSError:
|
| 370 |
continue
|
| 371 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
|
| 373 |
def installed(self) -> tuple[DiscoveredModel, ...]:
|
| 374 |
"""Cached models, each verified against its digest.
|
|
@@ -449,7 +490,19 @@ class WeightsCache:
|
|
| 449 |
with self._opener(request, timeout=60) as response:
|
| 450 |
declared = _content_length(response)
|
| 451 |
if declared:
|
| 452 |
-
self._make_room(declared, keep=manifest.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
with part.open("wb") as handle:
|
| 454 |
while True:
|
| 455 |
if cancel_event is not None and cancel_event.is_set():
|
|
@@ -520,9 +573,12 @@ class WeightsCache:
|
|
| 520 |
usage = self.usage()
|
| 521 |
if usage.used_bytes + needed_bytes <= self.limit_bytes:
|
| 522 |
return evicted
|
|
|
|
|
|
|
|
|
|
| 523 |
candidates = [
|
| 524 |
-
(self._last_used(
|
| 525 |
-
for model_id in
|
| 526 |
if model_id != keep
|
| 527 |
]
|
| 528 |
if not candidates:
|
|
|
|
| 134 |
|
| 135 |
#: Default cache ceiling. Small enough to be a polite default on a volunteer's
|
| 136 |
#: machine, large enough to hold the models in the shipped catalogue.
|
| 137 |
+
#: The cache ceiling, in gigabytes.
|
| 138 |
+
#:
|
| 139 |
+
#: A DEFAULT SMALLER THAN THE CATALOGUE IS A DEFAULT THAT THRASHES.
|
| 140 |
+
#:
|
| 141 |
+
#: This was 8, and the two models this project ships come to about nine
|
| 142 |
+
#: together. So a worker offering both downloaded the first, evicted it to fit
|
| 143 |
+
#: the second, and re-downloaded it on the next start — five gigabytes over
|
| 144 |
+
#: the wire, every run, for ever, on a project whose entire subject is the
|
| 145 |
+
#: energy cost of running models. Nothing reported it, because eviction was
|
| 146 |
+
#: silent and the usage line only counted the models that run offered.
|
| 147 |
+
#:
|
| 148 |
+
#: Sized to hold the shipped catalogue with room for one more, so the common
|
| 149 |
+
#: case never evicts anything. An operator with a small disk lowers it and is
|
| 150 |
+
#: told what that costs.
|
| 151 |
+
DEFAULT_CACHE_GB = 16.0
|
| 152 |
|
| 153 |
#: Anything above this is almost certainly a typo rather than an intention.
|
| 154 |
MAX_CACHE_GB = 512.0
|
|
|
|
| 371 |
]
|
| 372 |
return [root / name for root in roots for name in names]
|
| 373 |
|
| 374 |
+
def _cached_entries(self) -> list[tuple[str, Path, int]]:
|
| 375 |
+
"""Everything actually in the cache, as ``(model_id, path, bytes)``.
|
| 376 |
+
|
| 377 |
+
THE CACHE IS ON DISK, NOT IN THIS RUN'S ARGUMENTS.
|
| 378 |
+
|
| 379 |
+
This used to be derived from ``self.manifests``, which is only the
|
| 380 |
+
models the current invocation offers. A worker started with
|
| 381 |
+
``--models olmoe`` therefore reported an empty cache while several
|
| 382 |
+
gigabytes of another model sat right there in it — the operator was
|
| 383 |
+
told "0.00 GB used, ready: none" about a directory that was nearly
|
| 384 |
+
full — and the eviction arithmetic below inherited the same blind
|
| 385 |
+
spot, so it believed there was room it did not have.
|
| 386 |
+
"""
|
| 387 |
+
|
| 388 |
+
entries: list[tuple[str, Path, int]] = []
|
| 389 |
+
try:
|
| 390 |
+
folders = sorted(self.directory.iterdir())
|
| 391 |
+
except OSError:
|
| 392 |
+
return entries
|
| 393 |
+
for folder in folders:
|
| 394 |
+
if not folder.is_dir():
|
| 395 |
+
continue
|
| 396 |
try:
|
| 397 |
+
for path in folder.iterdir():
|
| 398 |
+
# `.part` files are downloads in flight; they occupy space
|
| 399 |
+
# but they are not a cached model and must never be counted
|
| 400 |
+
# as one, or an interrupted fetch looks like a hit.
|
| 401 |
+
if not path.is_file() or path.suffix == ".part":
|
| 402 |
+
continue
|
| 403 |
+
entries.append((folder.name, path, path.stat().st_size))
|
| 404 |
except OSError:
|
| 405 |
continue
|
| 406 |
+
return entries
|
| 407 |
+
|
| 408 |
+
def usage(self) -> CacheUsage:
|
| 409 |
+
entries = self._cached_entries()
|
| 410 |
+
used = sum(size for _, _, size in entries)
|
| 411 |
+
present = sorted({model_id for model_id, _, size in entries if size > 0})
|
| 412 |
+
return CacheUsage(used, self.limit_bytes, tuple(present))
|
| 413 |
|
| 414 |
def installed(self) -> tuple[DiscoveredModel, ...]:
|
| 415 |
"""Cached models, each verified against its digest.
|
|
|
|
| 490 |
with self._opener(request, timeout=60) as response:
|
| 491 |
declared = _content_length(response)
|
| 492 |
if declared:
|
| 493 |
+
evicted = self._make_room(declared, keep=manifest.id)
|
| 494 |
+
if evicted and progress is not None:
|
| 495 |
+
# Deleting gigabytes somebody already waited for is
|
| 496 |
+
# not a detail. Silent, it looks like the download
|
| 497 |
+
# simply never persisted, which is exactly how it was
|
| 498 |
+
# reported to us.
|
| 499 |
+
progress(
|
| 500 |
+
0.0,
|
| 501 |
+
"Evicted from the weights cache to make room: "
|
| 502 |
+
+ ", ".join(evicted)
|
| 503 |
+
+ f" — raise --model-cache-gb above {_gb(self.limit_bytes):.0f}"
|
| 504 |
+
" to keep them",
|
| 505 |
+
)
|
| 506 |
with part.open("wb") as handle:
|
| 507 |
while True:
|
| 508 |
if cancel_event is not None and cancel_event.is_set():
|
|
|
|
| 573 |
usage = self.usage()
|
| 574 |
if usage.used_bytes + needed_bytes <= self.limit_bytes:
|
| 575 |
return evicted
|
| 576 |
+
# Any model in the cache is a candidate, not only the ones this
|
| 577 |
+
# run happens to offer: they take up the same disk either way, and
|
| 578 |
+
# a run that could not see them could not free them.
|
| 579 |
candidates = [
|
| 580 |
+
(self._last_used(path), model_id)
|
| 581 |
+
for model_id, path, _ in self._cached_entries()
|
| 582 |
if model_id != keep
|
| 583 |
]
|
| 584 |
if not candidates:
|
distinct_server/catalog.py
CHANGED
|
@@ -245,13 +245,6 @@ MODEL_CATALOG: dict[str, ModelManifest] = {
|
|
| 245 |
min_ram_gb=8.0,
|
| 246 |
context_length=4_096,
|
| 247 |
),
|
| 248 |
-
"llama-2-7b-chat": ModelManifest(
|
| 249 |
-
id="llama-2-7b-chat",
|
| 250 |
-
label="Llama 2 7B Chat · Meta · gated licence",
|
| 251 |
-
filename="llama-2-7b-chat.Q4_K_M.gguf",
|
| 252 |
-
min_ram_gb=8.0,
|
| 253 |
-
context_length=4_096,
|
| 254 |
-
),
|
| 255 |
}
|
| 256 |
|
| 257 |
|
|
@@ -273,12 +266,14 @@ MODEL_CATALOG: dict[str, ModelManifest] = {
|
|
| 273 |
# This structure is the in-catalogue record: enough to be correct on its own if
|
| 274 |
# that file is absent, never the fuller surface.
|
| 275 |
#
|
| 276 |
-
# WHAT IS STILL FORBIDDEN.
|
| 277 |
-
#
|
| 278 |
-
#
|
| 279 |
-
#
|
|
|
|
|
|
|
|
|
|
| 280 |
_AI2_ASSESSMENT = "https://arxiv.org/abs/2503.05804"
|
| 281 |
-
_LLAMA2_ASSESSMENT = "https://arxiv.org/abs/2307.09288"
|
| 282 |
|
| 283 |
_NO_LAND = AreaResult(
|
| 284 |
AreaStatus.MISSING,
|
|
@@ -419,60 +414,6 @@ RELEASE_ASSESSMENT: dict[str, ReleaseAssessment] = {
|
|
| 419 |
{"label": "Published assessment", "url": _AI2_ASSESSMENT},
|
| 420 |
),
|
| 421 |
),
|
| 422 |
-
"llama-2-7b-chat": ReleaseAssessment(
|
| 423 |
-
model_id="llama-2-7b-chat",
|
| 424 |
-
coverage="Partial",
|
| 425 |
-
covers=(
|
| 426 |
-
"Llama 2 7B pretraining. This Chat checkpoint is a post-trained variant; Meta "
|
| 427 |
-
"report fine-tuning compute inside the family total without breaking it out."
|
| 428 |
-
),
|
| 429 |
-
summary=(
|
| 430 |
-
"The only non-Ai2 release here with a published model-specific figure, and a "
|
| 431 |
-
"thin one: carbon and nothing else, modelled from rated chip wattage rather "
|
| 432 |
-
"than measured draw. It is a different kind of number from the two above and "
|
| 433 |
-
"should not be lined up beside them. Meta also report a second carbon column "
|
| 434 |
-
"reading zero on the grounds that they buy matching renewable electricity "
|
| 435 |
-
"annually; that is an accounting position, not a physical one, and the figure "
|
| 436 |
-
"shown here is what the grid actually delivered."
|
| 437 |
-
),
|
| 438 |
-
reason="unpinned: Meta publish no GGUF and the source repository is gated",
|
| 439 |
-
areas={
|
| 440 |
-
"energy": AreaResult(
|
| 441 |
-
AreaStatus.MISSING,
|
| 442 |
-
note=(
|
| 443 |
-
"Meta disclosed 184,320 A100-80GB GPU-hours at 400 W rated draw but "
|
| 444 |
-
"published no energy figure. Multiplying those out would be an estimate "
|
| 445 |
-
"this catalogue invented, so the area stays Missing and the disclosure "
|
| 446 |
-
"is named instead."
|
| 447 |
-
),
|
| 448 |
-
),
|
| 449 |
-
"climate": AreaResult(
|
| 450 |
-
AreaStatus.MODELLED,
|
| 451 |
-
value="31.22",
|
| 452 |
-
unit="tCO2e",
|
| 453 |
-
note=(
|
| 454 |
-
"Modelled by Meta from GPU-hours and rated device power. Location-based. "
|
| 455 |
-
"Meta separately report it as fully offset, which nets to zero on paper "
|
| 456 |
-
"but not on the grid."
|
| 457 |
-
),
|
| 458 |
-
source_url=_LLAMA2_ASSESSMENT,
|
| 459 |
-
),
|
| 460 |
-
"water": AreaResult(AreaStatus.MISSING, note="Not disclosed."),
|
| 461 |
-
"land": _NO_LAND,
|
| 462 |
-
"materials": AreaResult(
|
| 463 |
-
AreaStatus.MISSING,
|
| 464 |
-
note="Not disclosed. Embodied hardware impact is absent from Meta's accounting.",
|
| 465 |
-
),
|
| 466 |
-
"pollution": AreaResult(AreaStatus.MISSING, note="Not disclosed."),
|
| 467 |
-
},
|
| 468 |
-
links=(
|
| 469 |
-
{
|
| 470 |
-
"label": "Model card",
|
| 471 |
-
"url": "https://huggingface.co/meta-llama/Llama-2-7b-chat-hf",
|
| 472 |
-
},
|
| 473 |
-
{"label": "Published assessment", "url": _LLAMA2_ASSESSMENT},
|
| 474 |
-
),
|
| 475 |
-
),
|
| 476 |
}
|
| 477 |
|
| 478 |
|
|
|
|
| 245 |
min_ram_gb=8.0,
|
| 246 |
context_length=4_096,
|
| 247 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
}
|
| 249 |
|
| 250 |
|
|
|
|
| 266 |
# This structure is the in-catalogue record: enough to be correct on its own if
|
| 267 |
# that file is absent, never the fuller surface.
|
| 268 |
#
|
| 269 |
+
# WHAT IS STILL FORBIDDEN. GPU-hours are not energy. A publisher who reports
|
| 270 |
+
# hours on hardware of a stated wattage has not reported a measurement, and
|
| 271 |
+
# multiplying the two here would be this surface inventing the number it
|
| 272 |
+
# exists to report. An area with no published figure stays Missing.
|
| 273 |
+
|
| 274 |
+
#: The document each Ai2 figure is quoted from. Named once so a citation can
|
| 275 |
+
#: never drift from the number beside it.
|
| 276 |
_AI2_ASSESSMENT = "https://arxiv.org/abs/2503.05804"
|
|
|
|
| 277 |
|
| 278 |
_NO_LAND = AreaResult(
|
| 279 |
AreaStatus.MISSING,
|
|
|
|
| 414 |
{"label": "Published assessment", "url": _AI2_ASSESSMENT},
|
| 415 |
),
|
| 416 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
}
|
| 418 |
|
| 419 |
|
distinct_server/control_plane.py
CHANGED
|
@@ -226,6 +226,22 @@ class ControlPlane:
|
|
| 226 |
self._cleanup_pairing_codes_locked(current)
|
| 227 |
if len(self._pairing_codes) >= self.limits.max_pairing_codes:
|
| 228 |
raise CapacityError("too many active pairing codes")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
while True:
|
| 230 |
code = new_pairing_code()
|
| 231 |
digest = pairing_code_digest(code, self._pairing_pepper)
|
|
@@ -600,6 +616,53 @@ class ControlPlane:
|
|
| 600 |
self._children.pop(job_id, None)
|
| 601 |
return len(job_ids)
|
| 602 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
def create_conversation(
|
| 604 |
self,
|
| 605 |
session_id: str,
|
|
|
|
| 226 |
self._cleanup_pairing_codes_locked(current)
|
| 227 |
if len(self._pairing_codes) >= self.limits.max_pairing_codes:
|
| 228 |
raise CapacityError("too many active pairing codes")
|
| 229 |
+
# THE GLOBAL CAP PROTECTS NOBODY ON ITS OWN.
|
| 230 |
+
#
|
| 231 |
+
# Filling it is cheap — one signed-in account asking in a loop —
|
| 232 |
+
# and the codes live ten minutes, so the pool can be held full and
|
| 233 |
+
# every other person on this server is then unable to pair a
|
| 234 |
+
# worker at all. A per-owner share makes that account spend only
|
| 235 |
+
# its own allowance. Counted after the expiry sweep above, so the
|
| 236 |
+
# number is codes still alive rather than codes ever asked for.
|
| 237 |
+
mine = sum(
|
| 238 |
+
1 for record in self._pairing_codes.values() if record.owner_id == owner_id
|
| 239 |
+
)
|
| 240 |
+
if mine >= self.limits.max_pairing_codes_per_owner:
|
| 241 |
+
raise CapacityError(
|
| 242 |
+
"you already have the maximum number of unused pairing codes; "
|
| 243 |
+
"use one, or wait for them to expire"
|
| 244 |
+
)
|
| 245 |
while True:
|
| 246 |
code = new_pairing_code()
|
| 247 |
digest = pairing_code_digest(code, self._pairing_pepper)
|
|
|
|
| 616 |
self._children.pop(job_id, None)
|
| 617 |
return len(job_ids)
|
| 618 |
|
| 619 |
+
def delete_conversation(
|
| 620 |
+
self,
|
| 621 |
+
session_id: str,
|
| 622 |
+
conversation_id: str,
|
| 623 |
+
*,
|
| 624 |
+
now: Optional[float] = None,
|
| 625 |
+
) -> int:
|
| 626 |
+
"""Remove one conversation and the jobs it held. Returns how many.
|
| 627 |
+
|
| 628 |
+
The same erasure :meth:`delete_session` performs, narrowed to a single
|
| 629 |
+
conversation, because a person wanting one exchange gone should not
|
| 630 |
+
have to take the whole session with it.
|
| 631 |
+
|
| 632 |
+
A job still in flight is cancelled rather than merely forgotten. Its
|
| 633 |
+
worker is holding it and will come back for it, so the cancellation is
|
| 634 |
+
left where that worker will find it on its next poll; dropping the
|
| 635 |
+
record alone would leave the machine working on an answer for a
|
| 636 |
+
conversation that no longer exists.
|
| 637 |
+
|
| 638 |
+
Unknown ids are not an error. A conversation whose runs have all
|
| 639 |
+
finished is cleaned up on its own, so a browser asking to delete one
|
| 640 |
+
that has already gone is asking for a state that already holds.
|
| 641 |
+
"""
|
| 642 |
+
|
| 643 |
+
with self._lock:
|
| 644 |
+
current = self._now(now)
|
| 645 |
+
session = self._sessions.get(session_id)
|
| 646 |
+
if session is None:
|
| 647 |
+
return 0
|
| 648 |
+
conversation = self._conversations.pop((session_id, conversation_id), None)
|
| 649 |
+
if conversation is None:
|
| 650 |
+
return 0
|
| 651 |
+
if conversation_id in session.conversation_ids:
|
| 652 |
+
session.conversation_ids.remove(conversation_id)
|
| 653 |
+
job_ids = set(conversation.job_ids)
|
| 654 |
+
for children in self._children.values():
|
| 655 |
+
children.difference_update(job_ids)
|
| 656 |
+
for job_id in job_ids:
|
| 657 |
+
job = self._jobs.pop(job_id, None)
|
| 658 |
+
if job is not None and job.status not in _TERMINAL_STATUSES:
|
| 659 |
+
self._orphan_cancellations.setdefault(
|
| 660 |
+
job.spec.target_agent_id,
|
| 661 |
+
{},
|
| 662 |
+
)[job_id] = current + self.limits.job_ttl_seconds
|
| 663 |
+
self._children.pop(job_id, None)
|
| 664 |
+
return len(job_ids)
|
| 665 |
+
|
| 666 |
def create_conversation(
|
| 667 |
self,
|
| 668 |
session_id: str,
|
distinct_server/models.py
CHANGED
|
@@ -59,6 +59,14 @@ class ControlPlaneLimits:
|
|
| 59 |
max_jobs: int = 50_000
|
| 60 |
max_jobs_per_conversation: int = 1_024
|
| 61 |
max_pairing_codes: int = 1_024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
max_nonces_per_agent: int = 2_048
|
| 63 |
max_models_per_agent: int = 64
|
| 64 |
max_tools_per_agent: int = 64
|
|
@@ -81,6 +89,7 @@ class ControlPlaneLimits:
|
|
| 81 |
"max_jobs",
|
| 82 |
"max_jobs_per_conversation",
|
| 83 |
"max_pairing_codes",
|
|
|
|
| 84 |
"max_nonces_per_agent",
|
| 85 |
"max_models_per_agent",
|
| 86 |
"max_tools_per_agent",
|
|
|
|
| 59 |
max_jobs: int = 50_000
|
| 60 |
max_jobs_per_conversation: int = 1_024
|
| 61 |
max_pairing_codes: int = 1_024
|
| 62 |
+
#: And how many of that global pool any one identity may hold at once.
|
| 63 |
+
#:
|
| 64 |
+
#: The global cap alone is not a limit on anybody, it is a limit on
|
| 65 |
+
#: everybody: one signed-in account minting in a loop fills all 1024
|
| 66 |
+
#: slots, and since they live ten minutes it can hold them full, so
|
| 67 |
+
#: nobody else on the server can pair a worker. A per-owner share turns
|
| 68 |
+
#: that from an outage into one account wasting its own allowance.
|
| 69 |
+
max_pairing_codes_per_owner: int = 8
|
| 70 |
max_nonces_per_agent: int = 2_048
|
| 71 |
max_models_per_agent: int = 64
|
| 72 |
max_tools_per_agent: int = 64
|
|
|
|
| 89 |
"max_jobs",
|
| 90 |
"max_jobs_per_conversation",
|
| 91 |
"max_pairing_codes",
|
| 92 |
+
"max_pairing_codes_per_owner",
|
| 93 |
"max_nonces_per_agent",
|
| 94 |
"max_models_per_agent",
|
| 95 |
"max_tools_per_agent",
|
distinct_server/presentation.py
CHANGED
|
@@ -95,42 +95,11 @@ HEAD = """
|
|
| 95 |
<script>
|
| 96 |
(function(){
|
| 97 |
function card(el){return el && el.closest ? el.closest('.c-libcard') : null;}
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
var img = document.createElement('img');
|
| 104 |
-
img.src = src; img.alt='';
|
| 105 |
-
m.appendChild(img); m.classList.add('is-playing');
|
| 106 |
-
}
|
| 107 |
-
function hide(m){
|
| 108 |
-
if(!m) return;
|
| 109 |
-
m.classList.remove('is-playing');
|
| 110 |
-
m.querySelectorAll('img').forEach(function(i){i.remove();});
|
| 111 |
-
}
|
| 112 |
-
/* Hover previews, scoped to the media block so sweeping the grid does not
|
| 113 |
-
start twenty GIFs at once. */
|
| 114 |
-
document.addEventListener('mouseover', function(e){
|
| 115 |
-
var m = e.target.closest ? e.target.closest('.c-libcard__media') : null; show(m);
|
| 116 |
-
});
|
| 117 |
-
document.addEventListener('mouseout', function(e){
|
| 118 |
-
var m = e.target.closest ? e.target.closest('.c-libcard__media') : null;
|
| 119 |
-
if(m && !m.contains(e.relatedTarget)) hide(m);
|
| 120 |
-
});
|
| 121 |
-
/* Press-and-hold anywhere on the card previews on a phone; a plain tap is
|
| 122 |
-
the selection. The two must not fire together, hence the flag. */
|
| 123 |
-
var holdTimer=null, held=false;
|
| 124 |
-
document.addEventListener('touchstart', function(e){
|
| 125 |
-
var c = card(e.target); if(!c) return;
|
| 126 |
-
held=false;
|
| 127 |
-
holdTimer = setTimeout(function(){held=true; show(mediaOf(c));}, 350);
|
| 128 |
-
}, {passive:true});
|
| 129 |
-
['touchend','touchcancel'].forEach(function(name){
|
| 130 |
-
document.addEventListener(name, function(e){
|
| 131 |
-
clearTimeout(holdTimer); hide(mediaOf(card(e.target)));
|
| 132 |
-
}, {passive:true});
|
| 133 |
-
});
|
| 134 |
/* THE CARD IS THE CONTROL, and the way it saves is by forwarding its click
|
| 135 |
to the framework's own box for that member. Writing a hidden field's
|
| 136 |
value and dispatching an input event was tried first: it updates the DOM
|
|
@@ -152,7 +121,6 @@ HEAD = """
|
|
| 152 |
document.addEventListener('click', function(e){
|
| 153 |
var c = card(e.target);
|
| 154 |
if(!c || !c.hasAttribute('data-ref')) return;
|
| 155 |
-
if(held){held=false; return;}
|
| 156 |
if(e.target.closest && e.target.closest('a')) return;
|
| 157 |
var box = boxFor(c.getAttribute('data-ref'));
|
| 158 |
if(!box) return; /* no transport, no pretend selection */
|
|
@@ -168,6 +136,61 @@ HEAD = """
|
|
| 168 |
if(!c || e.target !== c) return;
|
| 169 |
e.preventDefault(); c.click();
|
| 170 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
})();
|
| 172 |
</script>
|
| 173 |
"""
|
|
@@ -1326,6 +1349,58 @@ details[open] .c-libhelp__toggle::after{content:" -";}
|
|
| 1326 |
.c-join__note .c-join__code{display:inline-block; padding:2px 8px;
|
| 1327 |
font-size:11.5px; margin-top:4px;}
|
| 1328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1329 |
/* SETTING UP A WORKER, AS NUMBERED STEPS.
|
| 1330 |
Three things happen in order and each can fail on its own, so each is
|
| 1331 |
drawn as its own step with its own command. Told as one paragraph, a
|
|
@@ -1546,31 +1621,19 @@ details[open] .c-libhelp__toggle::after{content:" -";}
|
|
| 1546 |
.c-libcard.is-on .c-libcard__check::before{content:"✓";}
|
| 1547 |
.c-libcard__media{margin:calc(var(--s4)*-1) calc(var(--s4)*-1) var(--s2);
|
| 1548 |
aspect-ratio:16/9; border-radius:calc(var(--r-lg) - 2px) calc(var(--r-lg) - 2px) 0 0;
|
| 1549 |
-
background:var(--c-fill)
|
| 1550 |
display:flex; align-items:center; justify-content:center;
|
| 1551 |
overflow:hidden; position:relative;}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1552 |
.c-libcard__media img{width:100%; height:100%; object-fit:cover;
|
| 1553 |
object-position:top center; display:block;
|
| 1554 |
position:absolute; inset:0;}
|
| 1555 |
/* The still is the run's own result. It sits under the animation, and it is
|
| 1556 |
what the card shows on a phone, where nothing hovers. */
|
| 1557 |
-
|
| 1558 |
-
.c-libcard__media.is-playing .c-libcard__poster{opacity:0;}
|
| 1559 |
-
.c-libcard__play{position:relative; z-index:1;
|
| 1560 |
-
display:inline-flex; align-items:center; gap:6px;
|
| 1561 |
-
font-size:var(--t-micro); color:var(--c-text-2) !important;
|
| 1562 |
-
background:color-mix(in srgb, var(--c-surface) 92%, transparent);
|
| 1563 |
-
border:1px solid var(--c-hairline) !important; backdrop-filter:blur(3px);
|
| 1564 |
-
border-radius:999px; padding:4px 10px; opacity:0;
|
| 1565 |
-
transition:opacity .15s ease;}
|
| 1566 |
-
.c-libcard__play::before{content:"▶"; font-size:9px; color:var(--c-sage-deep) !important;}
|
| 1567 |
-
/* The invitation appears on approach and never sits permanently over the
|
| 1568 |
-
result it is inviting you to watch. Always visible where there is no
|
| 1569 |
-
poster to obscure, since then it is the only thing in the frame. */
|
| 1570 |
-
.c-libcard:hover .c-libcard__play,
|
| 1571 |
-
.c-libcard:focus-visible .c-libcard__play{opacity:1;}
|
| 1572 |
-
.c-libcard__media:not(:has(.c-libcard__poster)) .c-libcard__play{opacity:1;}
|
| 1573 |
-
.c-libcard__media.is-playing .c-libcard__play{display:none;}
|
| 1574 |
.c-libcard__head{display:flex; align-items:center; gap:var(--s2); padding-right:28px;}
|
| 1575 |
.c-libcard__kind{font-size:var(--t-micro); letter-spacing:.06em; text-transform:uppercase;
|
| 1576 |
padding:2px 8px; border-radius:999px; background:var(--c-fill);
|
|
@@ -1586,6 +1649,17 @@ details[open] .c-libhelp__toggle::after{content:" -";}
|
|
| 1586 |
.c-libcard__ref{margin-top:auto; padding-top:var(--s2); font-family:var(--mono);
|
| 1587 |
font-size:11px; color:var(--c-muted) !important;}
|
| 1588 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1589 |
/* ---- Navigation, in the header band -----------------------------------
|
| 1590 |
A button (the library, which navigates) beside a link (a section of this
|
| 1591 |
page). They must look like one row of controls, so the button is styled to
|
|
|
|
| 95 |
<script>
|
| 96 |
(function(){
|
| 97 |
function card(el){return el && el.closest ? el.closest('.c-libcard') : null;}
|
| 98 |
+
/* The preview machinery that lived here — hover to swap in a GIF, press and
|
| 99 |
+
hold to do the same on a phone, plus the flag that stopped a long press
|
| 100 |
+
also counting as a tap — went with the GIFs. Each card now draws what its
|
| 101 |
+
member makes, so there is nothing to reveal and nothing to time. */
|
| 102 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
/* THE CARD IS THE CONTROL, and the way it saves is by forwarding its click
|
| 104 |
to the framework's own box for that member. Writing a hidden field's
|
| 105 |
value and dispatching an input event was tried first: it updates the DOM
|
|
|
|
| 121 |
document.addEventListener('click', function(e){
|
| 122 |
var c = card(e.target);
|
| 123 |
if(!c || !c.hasAttribute('data-ref')) return;
|
|
|
|
| 124 |
if(e.target.closest && e.target.closest('a')) return;
|
| 125 |
var box = boxFor(c.getAttribute('data-ref'));
|
| 126 |
if(!box) return; /* no transport, no pretend selection */
|
|
|
|
| 136 |
if(!c || e.target !== c) return;
|
| 137 |
e.preventDefault(); c.click();
|
| 138 |
});
|
| 139 |
+
/* COPY BUTTONS ON THE SETUP COMMANDS.
|
| 140 |
+
The text comes out of the <pre> the button sits with, never out of an
|
| 141 |
+
attribute written by the server. Two copies of a command line, one to
|
| 142 |
+
read and one to copy, is one escaping mistake away from a clipboard that
|
| 143 |
+
disagrees with the screen, and a command that looks right and pastes
|
| 144 |
+
wrong is worse than no button at all. */
|
| 145 |
+
function commandOf(btn){
|
| 146 |
+
var block = btn.closest ? btn.closest('.c-cmd') : null;
|
| 147 |
+
var src = block ? block.querySelector('pre, code') : null;
|
| 148 |
+
return src ? (src.textContent || '').trim() : '';
|
| 149 |
+
}
|
| 150 |
+
/* execCommand is deprecated and is still the only thing that copies on a
|
| 151 |
+
page that is not on a secure origin, which is where this panel is read:
|
| 152 |
+
a worker joins a server on 192.168.x.x over plain http, and there
|
| 153 |
+
navigator.clipboard does not exist. */
|
| 154 |
+
function legacyCopy(text){
|
| 155 |
+
var box = document.createElement('textarea');
|
| 156 |
+
box.value = text; box.setAttribute('readonly','');
|
| 157 |
+
box.style.position='fixed'; box.style.top='0'; box.style.opacity='0';
|
| 158 |
+
document.body.appendChild(box);
|
| 159 |
+
box.select(); box.setSelectionRange(0, text.length);
|
| 160 |
+
var ok = false;
|
| 161 |
+
try{ ok = document.execCommand('copy'); }catch(err){ ok = false; }
|
| 162 |
+
box.remove();
|
| 163 |
+
return ok;
|
| 164 |
+
}
|
| 165 |
+
/* A press that copied and a press that did nothing look identical unless
|
| 166 |
+
the button says which happened, so it says, and it says "Copy failed"
|
| 167 |
+
when it did rather than claiming a clipboard it never wrote. The command
|
| 168 |
+
stays selectable by hand either way. */
|
| 169 |
+
function flash(btn, word){
|
| 170 |
+
var slot = btn.querySelector('.c-cmd__word') || btn;
|
| 171 |
+
if(!btn.hasAttribute('data-word')) btn.setAttribute('data-word', slot.textContent);
|
| 172 |
+
slot.textContent = word;
|
| 173 |
+
btn.classList.add('is-copied');
|
| 174 |
+
clearTimeout(btn.copyTimer);
|
| 175 |
+
btn.copyTimer = setTimeout(function(){
|
| 176 |
+
slot.textContent = btn.getAttribute('data-word');
|
| 177 |
+
btn.classList.remove('is-copied');
|
| 178 |
+
}, 1600);
|
| 179 |
+
}
|
| 180 |
+
document.addEventListener('click', function(e){
|
| 181 |
+
var btn = e.target.closest ? e.target.closest('.c-cmd__copy') : null;
|
| 182 |
+
if(!btn) return;
|
| 183 |
+
e.preventDefault();
|
| 184 |
+
var text = commandOf(btn);
|
| 185 |
+
if(!text) return;
|
| 186 |
+
if(navigator.clipboard && navigator.clipboard.writeText){
|
| 187 |
+
navigator.clipboard.writeText(text).then(
|
| 188 |
+
function(){ flash(btn, 'Copied'); },
|
| 189 |
+
function(){ flash(btn, legacyCopy(text) ? 'Copied' : 'Copy failed'); });
|
| 190 |
+
} else {
|
| 191 |
+
flash(btn, legacyCopy(text) ? 'Copied' : 'Copy failed');
|
| 192 |
+
}
|
| 193 |
+
});
|
| 194 |
})();
|
| 195 |
</script>
|
| 196 |
"""
|
|
|
|
| 1349 |
.c-join__note .c-join__code{display:inline-block; padding:2px 8px;
|
| 1350 |
font-size:11.5px; margin-top:4px;}
|
| 1351 |
|
| 1352 |
+
/* ONE COMMAND PER BLOCK, AND A BUTTON THAT COPIES THAT ONE COMMAND.
|
| 1353 |
+
`cd distinct` and the install line used to share a <pre>. A <pre> is copied
|
| 1354 |
+
whole or not at all, so taking one of the two meant selecting a line by
|
| 1355 |
+
hand, which is fiddly with a mouse and impossible with a thumb. Each
|
| 1356 |
+
command is now its own block with its own button, and the bar above the
|
| 1357 |
+
command carries the button plus, where the command differs by platform, the
|
| 1358 |
+
name of the platform it is for. */
|
| 1359 |
+
.c-cmd{display:flex; flex-direction:column; gap:4px;}
|
| 1360 |
+
.c-cmd__bar{display:flex; align-items:center; gap:var(--s2); min-height:24px;}
|
| 1361 |
+
.c-cmd__for{font-size:var(--t-micro); font-weight:650; letter-spacing:.01em;
|
| 1362 |
+
color:var(--c-text-2) !important;}
|
| 1363 |
+
.c-cmd__copy{margin-left:auto; flex:none; cursor:pointer; appearance:none;
|
| 1364 |
+
font-family:var(--font) !important; font-size:var(--t-micro) !important;
|
| 1365 |
+
font-weight:600 !important; line-height:1.5 !important;
|
| 1366 |
+
padding:3px 10px !important; border-radius:var(--r-md) !important;
|
| 1367 |
+
background:var(--c-surface) !important; color:var(--c-text-2) !important;
|
| 1368 |
+
border:1px solid var(--c-border) !important;}
|
| 1369 |
+
.c-cmd__copy:hover{border-color:var(--c-accent) !important;
|
| 1370 |
+
color:var(--c-sage-deep) !important;}
|
| 1371 |
+
/* The confirmation is the button itself for a moment. A toast somewhere else
|
| 1372 |
+
on the page is a second thing to find, and the question being answered
|
| 1373 |
+
("did that press do anything?") is asked about this button. */
|
| 1374 |
+
.c-cmd__copy.is-copied{background:var(--c-mint) !important;
|
| 1375 |
+
border-color:var(--c-sage-dark) !important;
|
| 1376 |
+
color:var(--c-sage-deep) !important;}
|
| 1377 |
+
|
| 1378 |
+
/* WHICH MACHINE, AS TWO RADIOS AND NO SCRIPT.
|
| 1379 |
+
Neither is checked when the panel renders, so both platforms' commands are
|
| 1380 |
+
visible and labelled until somebody chooses. A checked default would be
|
| 1381 |
+
silently wrong for half the readers, and a volunteer pasting a Windows
|
| 1382 |
+
command into a Mac terminal is exactly the failure this switch exists to
|
| 1383 |
+
prevent. Choosing hides the other platform's blocks. */
|
| 1384 |
+
.c-os{display:flex; flex-wrap:wrap; align-items:center; gap:var(--s2);}
|
| 1385 |
+
.c-os__ask{font-size:var(--t-micro); color:var(--c-muted) !important;}
|
| 1386 |
+
.c-os__radio{position:absolute; width:1px; height:1px; opacity:0;
|
| 1387 |
+
margin:0; pointer-events:none;}
|
| 1388 |
+
.c-os__tab{display:inline-flex; align-items:center; cursor:pointer;
|
| 1389 |
+
padding:4px 12px; border-radius:var(--r-pill);
|
| 1390 |
+
border:1px solid var(--c-border); background:var(--c-surface);
|
| 1391 |
+
font-size:var(--t-micro); font-weight:600;
|
| 1392 |
+
color:var(--c-text-2) !important;}
|
| 1393 |
+
.c-os__tab:hover{border-color:var(--c-accent);}
|
| 1394 |
+
#distinct-os-win:checked ~ .c-os label[for="distinct-os-win"],
|
| 1395 |
+
#distinct-os-nix:checked ~ .c-os label[for="distinct-os-nix"]{
|
| 1396 |
+
background:var(--c-mint); border-color:var(--c-sage-dark);
|
| 1397 |
+
color:var(--c-sage-deep) !important;}
|
| 1398 |
+
#distinct-os-win:focus-visible ~ .c-os label[for="distinct-os-win"],
|
| 1399 |
+
#distinct-os-nix:focus-visible ~ .c-os label[for="distinct-os-nix"]{
|
| 1400 |
+
outline:3px solid var(--c-focus); outline-offset:2px;}
|
| 1401 |
+
#distinct-os-win:checked ~ .c-steps-num .c-cmd--nix,
|
| 1402 |
+
#distinct-os-nix:checked ~ .c-steps-num .c-cmd--win{display:none;}
|
| 1403 |
+
|
| 1404 |
/* SETTING UP A WORKER, AS NUMBERED STEPS.
|
| 1405 |
Three things happen in order and each can fail on its own, so each is
|
| 1406 |
drawn as its own step with its own command. Told as one paragraph, a
|
|
|
|
| 1621 |
.c-libcard.is-on .c-libcard__check::before{content:"✓";}
|
| 1622 |
.c-libcard__media{margin:calc(var(--s4)*-1) calc(var(--s4)*-1) var(--s2);
|
| 1623 |
aspect-ratio:16/9; border-radius:calc(var(--r-lg) - 2px) calc(var(--r-lg) - 2px) 0 0;
|
| 1624 |
+
background:var(--c-fill);
|
| 1625 |
display:flex; align-items:center; justify-content:center;
|
| 1626 |
overflow:hidden; position:relative;}
|
| 1627 |
+
/* The drawing of what this member makes. It sits still and says its piece;
|
| 1628 |
+
there is nothing to hover, nothing to wait for, and nothing to fetch. */
|
| 1629 |
+
.c-libcard__mock{width:100%; height:100%; display:block;}
|
| 1630 |
+
.c-libcard.is-on .c-libcard__media{background:color-mix(in srgb, var(--c-mint) 55%, var(--c-fill));}
|
| 1631 |
.c-libcard__media img{width:100%; height:100%; object-fit:cover;
|
| 1632 |
object-position:top center; display:block;
|
| 1633 |
position:absolute; inset:0;}
|
| 1634 |
/* The still is the run's own result. It sits under the animation, and it is
|
| 1635 |
what the card shows on a phone, where nothing hovers. */
|
| 1636 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1637 |
.c-libcard__head{display:flex; align-items:center; gap:var(--s2); padding-right:28px;}
|
| 1638 |
.c-libcard__kind{font-size:var(--t-micro); letter-spacing:.06em; text-transform:uppercase;
|
| 1639 |
padding:2px 8px; border-radius:999px; background:var(--c-fill);
|
|
|
|
| 1649 |
.c-libcard__ref{margin-top:auto; padding-top:var(--s2); font-family:var(--mono);
|
| 1650 |
font-size:11px; color:var(--c-muted) !important;}
|
| 1651 |
|
| 1652 |
+
/* Deleting a conversation is a quiet control, not a red one. It sits under
|
| 1653 |
+
the list it acts on, at the size of the thing it removes, and says which
|
| 1654 |
+
conversation it means by acting on the selected one. */
|
| 1655 |
+
.c-rail__delete button,button.c-rail__delete{
|
| 1656 |
+
width:100% !important; margin-top:var(--s2) !important;
|
| 1657 |
+
background:transparent !important; border:1px solid var(--c-hairline) !important;
|
| 1658 |
+
color:var(--c-muted) !important; font-size:var(--t-micro) !important;
|
| 1659 |
+
font-weight:550 !important; padding:5px 10px !important; min-height:0 !important;}
|
| 1660 |
+
.c-rail__delete button:hover,button.c-rail__delete:hover{
|
| 1661 |
+
border-color:var(--c-border) !important; color:var(--c-text-2) !important;}
|
| 1662 |
+
|
| 1663 |
/* ---- Navigation, in the header band -----------------------------------
|
| 1664 |
A button (the library, which navigates) beside a link (a section of this
|
| 1665 |
page). They must look like one row of controls, so the button is styled to
|
distinct_server/render.py
CHANGED
|
@@ -177,6 +177,67 @@ def no_tools_note(notice_text: str) -> str:
|
|
| 177 |
)
|
| 178 |
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def join_this_server(
|
| 181 |
*, addresses, code: str, qr: str = "", tools: str = "local", source: str = ""
|
| 182 |
) -> str:
|
|
@@ -189,6 +250,23 @@ def join_this_server(
|
|
| 189 |
the three steps in between, so those are what this says: get the code,
|
| 190 |
install it, run it.
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
``source`` is where the code lives — this Space's own repository when the
|
| 193 |
server knows it. Absent, the step says so plainly instead of guessing at
|
| 194 |
a URL, because sending somebody to a repository that may not exist is
|
|
@@ -219,11 +297,12 @@ def join_this_server(
|
|
| 219 |
)
|
| 220 |
|
| 221 |
# STEP ONE: the code. A git clone, because a Space is a git repository and
|
| 222 |
-
# a clone is the one form that also updates later with `git pull`.
|
|
|
|
| 223 |
if source:
|
| 224 |
get_it = (
|
| 225 |
-
f
|
| 226 |
-
'<p class="c-join__note">Or use "Download repository" on the Files tab '
|
| 227 |
"and unzip it. Either way you end up with a folder of Python.</p>"
|
| 228 |
)
|
| 229 |
else:
|
|
@@ -234,10 +313,27 @@ def join_this_server(
|
|
| 234 |
|
| 235 |
# STEP TWO: the install. `-e` so the worker runs from the folder that was
|
| 236 |
# cloned, which is also the folder `git pull` updates.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
install = (
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
)
|
| 242 |
|
| 243 |
# STEP THREE: the run, with this server's address and this browser's own
|
|
@@ -246,20 +342,25 @@ def join_this_server(
|
|
| 246 |
f'<p class="c-join__note">Pairing code, one use and ten minutes: '
|
| 247 |
f'<code class="c-join__code">{esc(code)}</code></p>'
|
| 248 |
if code
|
| 249 |
-
else '<p class="c-join__note">
|
| 250 |
-
"command
|
|
|
|
| 251 |
)
|
| 252 |
# One line on purpose. The previous form used backslash continuations,
|
| 253 |
# which are bash syntax: pasted into PowerShell they became stray
|
| 254 |
# arguments and the command half-ran. A single line pastes correctly
|
| 255 |
# into every shell anybody actually uses.
|
| 256 |
-
|
| 257 |
-
f"
|
| 258 |
f"--pair {code or 'YOUR-CODE'} --models olmoe-1b-7b-0924-instruct --tools {tools or 'local'}"
|
| 259 |
)
|
|
|
|
|
|
|
|
|
|
| 260 |
picture = f'<div class="c-join__qr">{qr}</div>' if qr else ""
|
| 261 |
return (
|
| 262 |
'<div class="c-join">'
|
|
|
|
| 263 |
'<ol class="c-steps-num">'
|
| 264 |
'<li class="c-step-num"><span class="c-step-num__n">1</span>'
|
| 265 |
'<div class="c-step-num__body"><p class="c-step-num__head">Get the code</p>'
|
|
@@ -270,7 +371,7 @@ def join_this_server(
|
|
| 270 |
'<li class="c-step-num"><span class="c-step-num__n">3</span>'
|
| 271 |
'<div class="c-step-num__body"><p class="c-step-num__head">Point it at this server</p>'
|
| 272 |
f'<code class="c-join__address">{esc(first)}</code>{alternates}{code_note}'
|
| 273 |
-
f
|
| 274 |
f"{picture}</div></li>"
|
| 275 |
# STEP FOUR EXISTS BECAUSE PEOPLE ASK FOR IT AFTER READING STEP THREE.
|
| 276 |
#
|
|
@@ -927,6 +1028,51 @@ STEP_LEAD = {
|
|
| 927 |
#: this is the backstop that keeps a surprise out of the render path.
|
| 928 |
_PREVIEW_READ_CAP = 400_000
|
| 929 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
_TEXT_SUFFIXES = {
|
| 931 |
".txt", ".css", ".json", ".mermaid", ".py", ".js", ".html", ".svg",
|
| 932 |
".yaml", ".yml", ".toml", ".xml",
|
|
@@ -958,7 +1104,7 @@ def _docx_preview(blob: bytes) -> Optional[Mapping[str, object]]:
|
|
| 958 |
|
| 959 |
try:
|
| 960 |
with _zipfile.ZipFile(_io.BytesIO(blob)) as archive:
|
| 961 |
-
document =
|
| 962 |
except Exception:
|
| 963 |
return None
|
| 964 |
paragraphs: list[str] = []
|
|
@@ -977,12 +1123,13 @@ def _xlsx_preview(blob: bytes) -> Optional[Mapping[str, object]]:
|
|
| 977 |
import re as _re
|
| 978 |
import zipfile as _zipfile
|
| 979 |
|
|
|
|
| 980 |
try:
|
| 981 |
with _zipfile.ZipFile(_io.BytesIO(blob)) as archive:
|
| 982 |
-
sheet =
|
| 983 |
try:
|
| 984 |
-
shared =
|
| 985 |
-
except KeyError:
|
| 986 |
shared = b""
|
| 987 |
except Exception:
|
| 988 |
return None
|
|
@@ -1039,8 +1186,13 @@ def _pptx_preview(blob: bytes) -> Optional[Mapping[str, object]]:
|
|
| 1039 |
if _re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
|
| 1040 |
)
|
| 1041 |
slides: list[Mapping[str, str]] = []
|
|
|
|
| 1042 |
for name in names[:8]:
|
| 1043 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1044 |
texts = [text for text in (t.strip() for t in texts) if text]
|
| 1045 |
if texts:
|
| 1046 |
slides.append({"title": texts[0][:120]})
|
|
@@ -1061,11 +1213,20 @@ def _pdf_preview(blob: bytes) -> Optional[Mapping[str, object]]:
|
|
| 1061 |
# Streams that will not inflate are skipped rather than guessed at, and a
|
| 1062 |
# PDF nobody can read still reports its page count, which is a fact.
|
| 1063 |
chunks = [blob]
|
|
|
|
| 1064 |
for raw in _re.findall(rb"stream\r?\n(.*?)\r?\nendstream", blob, _re.S):
|
|
|
|
|
|
|
| 1065 |
try:
|
| 1066 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
except Exception:
|
| 1068 |
continue
|
|
|
|
|
|
|
| 1069 |
lines: list[str] = []
|
| 1070 |
seen: set[str] = set()
|
| 1071 |
for chunk in chunks:
|
|
@@ -1604,32 +1765,231 @@ def _energy_block(lines: Sequence[str], *, measured: bool, quiet: bool = False)
|
|
| 1604 |
# Library cards
|
| 1605 |
# --------------------------------------------------------------------------
|
| 1606 |
|
| 1607 |
-
_DEMO_DIR = (Path(__file__).resolve().parent.parent / "assets" / "library-demos")
|
| 1608 |
-
_DEMO_BASE = f"/gradio_api/file={_DEMO_DIR.as_posix()}"
|
| 1609 |
|
| 1610 |
|
| 1611 |
-
|
| 1612 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1613 |
|
| 1614 |
-
|
| 1615 |
-
|
| 1616 |
-
``.`` flattened, which is how the recorder names them.
|
| 1617 |
|
| 1618 |
-
|
| 1619 |
-
|
| 1620 |
-
|
| 1621 |
"""
|
| 1622 |
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
|
| 1626 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1627 |
|
| 1628 |
|
| 1629 |
-
def
|
| 1630 |
-
"""
|
| 1631 |
|
| 1632 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1633 |
|
| 1634 |
|
| 1635 |
def library_cards(rows: Sequence[Mapping[str, object]], chosen: Sequence[str]) -> str:
|
|
@@ -1655,23 +2015,15 @@ def library_cards(rows: Sequence[Mapping[str, object]], chosen: Sequence[str]) -
|
|
| 1655 |
name = str(row.get("name", ref))
|
| 1656 |
author = str(row.get("author", "distinct"))
|
| 1657 |
description = str(row.get("description", ""))
|
| 1658 |
-
|
| 1659 |
-
|
| 1660 |
-
|
| 1661 |
-
|
| 1662 |
-
|
| 1663 |
-
|
| 1664 |
-
|
| 1665 |
-
|
| 1666 |
-
|
| 1667 |
-
f'<figure class="c-libcard__media" data-gif="{esc(gif)}">'
|
| 1668 |
-
f"{still}"
|
| 1669 |
-
'<span class="c-libcard__play">watch it work</span>'
|
| 1670 |
-
'<figcaption class="c-visually-hidden">'
|
| 1671 |
-
"Hover, or press and hold, to play this member's demonstration"
|
| 1672 |
-
"</figcaption>"
|
| 1673 |
-
"</figure>"
|
| 1674 |
-
)
|
| 1675 |
on = ref in picked
|
| 1676 |
# The card itself is the control: role, state and keyboard focus are
|
| 1677 |
# its own, and the check mark is drawn by the stylesheet from the
|
|
|
|
| 177 |
)
|
| 178 |
|
| 179 |
|
| 180 |
+
_PLATFORM_LABELS = {"win": "Windows", "nix": "macOS or Linux"}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _command_block(command: str, *, platform: str = "") -> str:
|
| 184 |
+
"""One command, drawn as its own block with its own copy button.
|
| 185 |
+
|
| 186 |
+
Two commands used to share a ``<pre>``. A block holding two lines can only
|
| 187 |
+
be copied whole, and on a phone it cannot even be dragged apart, so
|
| 188 |
+
somebody following the panel had to retype half of it. A block now holds
|
| 189 |
+
one command and nothing else, and the button beside it copies that one.
|
| 190 |
+
|
| 191 |
+
The button carries no copy of the text. The handler in
|
| 192 |
+
:data:`distinct_server.presentation.HEAD` reads it out of the ``<pre>`` at
|
| 193 |
+
click time, which makes the clipboard equal to the screen by construction.
|
| 194 |
+
A second copy of the command in a ``data-`` attribute would be a second
|
| 195 |
+
string to escape and a second string to get wrong.
|
| 196 |
+
|
| 197 |
+
``platform`` labels a command that differs between Windows and everything
|
| 198 |
+
else. The label sits on the block, not only on the switch above it,
|
| 199 |
+
because the switch starts on neither platform: nobody is shown a command
|
| 200 |
+
for a machine that may not be theirs without the block saying whose it is.
|
| 201 |
+
"""
|
| 202 |
+
|
| 203 |
+
label = _PLATFORM_LABELS.get(platform, "")
|
| 204 |
+
tag = f'<span class="c-cmd__for">{esc(label)}</span>' if label else ""
|
| 205 |
+
variant = f" c-cmd--{esc(platform)}" if label else ""
|
| 206 |
+
whose = f'<span class="c-visually-hidden"> the {esc(label)} command</span>' if label else ""
|
| 207 |
+
return (
|
| 208 |
+
f'<div class="c-cmd{variant}">'
|
| 209 |
+
f'<div class="c-cmd__bar">{tag}'
|
| 210 |
+
'<button type="button" class="c-cmd__copy">'
|
| 211 |
+
f'<span class="c-cmd__word">Copy</span>{whose}</button></div>'
|
| 212 |
+
f'<pre class="c-join__cmd">{esc(command)}</pre>'
|
| 213 |
+
"</div>"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# WHICH MACHINE, ASKED BEFORE THE COMMANDS RATHER THAN AFTER THEM.
|
| 218 |
+
#
|
| 219 |
+
# Neither radio is checked, so both platforms' commands are on screen until
|
| 220 |
+
# somebody picks one. That is deliberate: a default would be right for one
|
| 221 |
+
# half of the readers and silently wrong for the other, and being silently
|
| 222 |
+
# wrong is the whole failure this panel is being rebuilt to stop. Picking
|
| 223 |
+
# hides the other platform; picking nothing costs a few extra lines, each of
|
| 224 |
+
# them labelled.
|
| 225 |
+
#
|
| 226 |
+
# The switch is two radios and CSS, with no script behind it, because this
|
| 227 |
+
# function returns a plain HTML string that has to work wherever it is
|
| 228 |
+
# rendered. The copy buttons need JavaScript and cannot avoid it; the choice
|
| 229 |
+
# of what a person reads should not.
|
| 230 |
+
_OS_SWITCH = (
|
| 231 |
+
'<input class="c-os__radio" type="radio" name="distinct-os" id="distinct-os-win">'
|
| 232 |
+
'<input class="c-os__radio" type="radio" name="distinct-os" id="distinct-os-nix">'
|
| 233 |
+
'<div class="c-os">'
|
| 234 |
+
'<span class="c-os__ask">Which machine are you on?</span>'
|
| 235 |
+
'<label class="c-os__tab" for="distinct-os-win">Windows</label>'
|
| 236 |
+
'<label class="c-os__tab" for="distinct-os-nix">macOS or Linux</label>'
|
| 237 |
+
"</div>"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
def join_this_server(
|
| 242 |
*, addresses, code: str, qr: str = "", tools: str = "local", source: str = ""
|
| 243 |
) -> str:
|
|
|
|
| 250 |
the three steps in between, so those are what this says: get the code,
|
| 251 |
install it, run it.
|
| 252 |
|
| 253 |
+
THE COMMANDS ARE PER-PLATFORM BECAUSE THE FOOTNOTE VERSION DID NOT WORK.
|
| 254 |
+
|
| 255 |
+
The install step used to read ``python -m pip install -e .`` with
|
| 256 |
+
a line underneath saying to use something else on Windows. People paste
|
| 257 |
+
the command; they do not paste the footnote. On Windows the bare
|
| 258 |
+
``python`` is routinely an old interpreter that arrived with some other
|
| 259 |
+
program, and the first volunteer to follow this panel got two failures in
|
| 260 |
+
a row from it: pip 20.2.3 refusing an editable install of a project with
|
| 261 |
+
no ``setup.py``, then the worker refusing to start on Python 3.9. Both
|
| 262 |
+
were the panel's fault, not theirs. So the command a person copies is now
|
| 263 |
+
the correct one for the machine they say they are on, and the caveat that
|
| 264 |
+
used to live underneath is spelled ``py -3`` inside the command itself.
|
| 265 |
+
|
| 266 |
+
``py -3`` and not ``py -3.12``: the launcher picks the newest installed
|
| 267 |
+
Python 3, and naming a minor version asserts something about a machine
|
| 268 |
+
this server has never seen.
|
| 269 |
+
|
| 270 |
``source`` is where the code lives — this Space's own repository when the
|
| 271 |
server knows it. Absent, the step says so plainly instead of guessing at
|
| 272 |
a URL, because sending somebody to a repository that may not exist is
|
|
|
|
| 297 |
)
|
| 298 |
|
| 299 |
# STEP ONE: the code. A git clone, because a Space is a git repository and
|
| 300 |
+
# a clone is the one form that also updates later with `git pull`. Same
|
| 301 |
+
# command everywhere, so it carries no platform label.
|
| 302 |
if source:
|
| 303 |
get_it = (
|
| 304 |
+
_command_block(f"git clone {source} distinct")
|
| 305 |
+
+ '<p class="c-join__note">Or use "Download repository" on the Files tab '
|
| 306 |
"and unzip it. Either way you end up with a folder of Python.</p>"
|
| 307 |
)
|
| 308 |
else:
|
|
|
|
| 313 |
|
| 314 |
# STEP TWO: the install. `-e` so the worker runs from the folder that was
|
| 315 |
# cloned, which is also the folder `git pull` updates.
|
| 316 |
+
#
|
| 317 |
+
# THE PIP UPGRADE IS A STEP, NOT ADVICE. `py -3` fixes the interpreter and
|
| 318 |
+
# not the pip inside it: Python 3.10.0 shipped pip 21.2, PEP 660 editable
|
| 319 |
+
# installs landed in pip 21.3, and an old pip meeting a project with no
|
| 320 |
+
# `setup.py` stops with a message about setup.py that reads as though the
|
| 321 |
+
# repository is broken. One line ahead of the install turns that into a
|
| 322 |
+
# non-event, so it is a line people can copy rather than a warning they
|
| 323 |
+
# get to read after it has already failed.
|
| 324 |
install = (
|
| 325 |
+
_command_block("cd distinct")
|
| 326 |
+
+ _command_block("py -3 -m pip install --upgrade pip", platform="win")
|
| 327 |
+
+ _command_block("python3 -m pip install --upgrade pip", platform="nix")
|
| 328 |
+
+ _command_block('py -3 -m pip install -e .', platform="win")
|
| 329 |
+
+ _command_block('python3 -m pip install -e .', platform="nix")
|
| 330 |
+
+ '<p class="c-join__note">The worker needs Python 3.10 or newer. On Windows '
|
| 331 |
+
"<code>py -3</code> runs the newest Python 3 on the machine, which is why the "
|
| 332 |
+
"commands use it: the bare <code>python</code> there is usually whichever old "
|
| 333 |
+
"one arrived with some other program, and it fails on its own version rather "
|
| 334 |
+
"than on anything you did. Run the pip line even if pip looks current: pip "
|
| 335 |
+
"older than 21.3 cannot do the install below at all, and says so by asking for "
|
| 336 |
+
"a <code>setup.py</code> this project does not have.</p>"
|
| 337 |
)
|
| 338 |
|
| 339 |
# STEP THREE: the run, with this server's address and this browser's own
|
|
|
|
| 342 |
f'<p class="c-join__note">Pairing code, one use and ten minutes: '
|
| 343 |
f'<code class="c-join__code">{esc(code)}</code></p>'
|
| 344 |
if code
|
| 345 |
+
else '<p class="c-join__note">Sign in at the top of this page and a pairing '
|
| 346 |
+
"code appears here. Until then the command below is complete apart from "
|
| 347 |
+
"that one value.</p>"
|
| 348 |
)
|
| 349 |
# One line on purpose. The previous form used backslash continuations,
|
| 350 |
# which are bash syntax: pasted into PowerShell they became stray
|
| 351 |
# arguments and the command half-ran. A single line pastes correctly
|
| 352 |
# into every shell anybody actually uses.
|
| 353 |
+
arguments = (
|
| 354 |
+
f"-m distinct_agent --server {first} "
|
| 355 |
f"--pair {code or 'YOUR-CODE'} --models olmoe-1b-7b-0924-instruct --tools {tools or 'local'}"
|
| 356 |
)
|
| 357 |
+
run_it = _command_block(f"py -3 {arguments}", platform="win") + _command_block(
|
| 358 |
+
f"python3 {arguments}", platform="nix"
|
| 359 |
+
)
|
| 360 |
picture = f'<div class="c-join__qr">{qr}</div>' if qr else ""
|
| 361 |
return (
|
| 362 |
'<div class="c-join">'
|
| 363 |
+
f"{_OS_SWITCH}"
|
| 364 |
'<ol class="c-steps-num">'
|
| 365 |
'<li class="c-step-num"><span class="c-step-num__n">1</span>'
|
| 366 |
'<div class="c-step-num__body"><p class="c-step-num__head">Get the code</p>'
|
|
|
|
| 371 |
'<li class="c-step-num"><span class="c-step-num__n">3</span>'
|
| 372 |
'<div class="c-step-num__body"><p class="c-step-num__head">Point it at this server</p>'
|
| 373 |
f'<code class="c-join__address">{esc(first)}</code>{alternates}{code_note}'
|
| 374 |
+
f"{run_it}"
|
| 375 |
f"{picture}</div></li>"
|
| 376 |
# STEP FOUR EXISTS BECAUSE PEOPLE ASK FOR IT AFTER READING STEP THREE.
|
| 377 |
#
|
|
|
|
| 1028 |
#: this is the backstop that keeps a surprise out of the render path.
|
| 1029 |
_PREVIEW_READ_CAP = 400_000
|
| 1030 |
|
| 1031 |
+
#: How much a compressed member may be allowed to become, and how much of a
|
| 1032 |
+
#: whole file may be inflated across all its members.
|
| 1033 |
+
#:
|
| 1034 |
+
#: THE FILE ON THE WIRE IS NOT THE FILE IN MEMORY. A worker is somebody
|
| 1035 |
+
#: else's machine, and the protocol caps what it may send, not what that
|
| 1036 |
+
#: expands to: a 300 KB archive of compressible bytes inflates to 300 MB, and
|
| 1037 |
+
#: a run may carry eight of them. Read whole, that is 2.4 GB and half a minute
|
| 1038 |
+
#: of CPU inside the one process every viewer of this server shares — a worker
|
| 1039 |
+
#: could stop the service for everybody by answering a question.
|
| 1040 |
+
#:
|
| 1041 |
+
#: A preview needs the first few paragraphs, rows or slides, so these are
|
| 1042 |
+
#: generous by the standard of what is actually read and mean the arithmetic
|
| 1043 |
+
#: cannot run away.
|
| 1044 |
+
_MAX_INFLATED_MEMBER = 2 * 1024 * 1024
|
| 1045 |
+
_MAX_INFLATED_TOTAL = 8 * 1024 * 1024
|
| 1046 |
+
|
| 1047 |
+
|
| 1048 |
+
class _InflationRefused(Exception):
|
| 1049 |
+
"""A member claimed, or turned out to be, more than a preview may inflate."""
|
| 1050 |
+
|
| 1051 |
+
|
| 1052 |
+
def _read_member(archive, name: str, budget: list[int]) -> bytes:
|
| 1053 |
+
"""One zip member, refused rather than inflated when it is too large.
|
| 1054 |
+
|
| 1055 |
+
Two checks, because either alone is defeated. The header's ``file_size``
|
| 1056 |
+
is what the archive claims and can simply lie; reading one byte past the
|
| 1057 |
+
limit is what actually happened. ``budget`` is the file's remaining total,
|
| 1058 |
+
mutated as members are read, so many merely-large members cannot add up to
|
| 1059 |
+
the same attack that one huge one would.
|
| 1060 |
+
"""
|
| 1061 |
+
|
| 1062 |
+
try:
|
| 1063 |
+
info = archive.getinfo(name)
|
| 1064 |
+
except KeyError as exc:
|
| 1065 |
+
raise _InflationRefused(f"no member {name!r}") from exc
|
| 1066 |
+
allowed = min(_MAX_INFLATED_MEMBER, budget[0])
|
| 1067 |
+
if info.file_size > allowed:
|
| 1068 |
+
raise _InflationRefused(f"{name} declares {info.file_size} bytes")
|
| 1069 |
+
with archive.open(name) as handle:
|
| 1070 |
+
data = handle.read(allowed + 1)
|
| 1071 |
+
if len(data) > allowed:
|
| 1072 |
+
raise _InflationRefused(f"{name} is larger than it declared")
|
| 1073 |
+
budget[0] -= len(data)
|
| 1074 |
+
return data
|
| 1075 |
+
|
| 1076 |
_TEXT_SUFFIXES = {
|
| 1077 |
".txt", ".css", ".json", ".mermaid", ".py", ".js", ".html", ".svg",
|
| 1078 |
".yaml", ".yml", ".toml", ".xml",
|
|
|
|
| 1104 |
|
| 1105 |
try:
|
| 1106 |
with _zipfile.ZipFile(_io.BytesIO(blob)) as archive:
|
| 1107 |
+
document = _read_member(archive, "word/document.xml", [_MAX_INFLATED_TOTAL])
|
| 1108 |
except Exception:
|
| 1109 |
return None
|
| 1110 |
paragraphs: list[str] = []
|
|
|
|
| 1123 |
import re as _re
|
| 1124 |
import zipfile as _zipfile
|
| 1125 |
|
| 1126 |
+
budget = [_MAX_INFLATED_TOTAL]
|
| 1127 |
try:
|
| 1128 |
with _zipfile.ZipFile(_io.BytesIO(blob)) as archive:
|
| 1129 |
+
sheet = _read_member(archive, "xl/worksheets/sheet1.xml", budget)
|
| 1130 |
try:
|
| 1131 |
+
shared = _read_member(archive, "xl/sharedStrings.xml", budget)
|
| 1132 |
+
except (KeyError, _InflationRefused):
|
| 1133 |
shared = b""
|
| 1134 |
except Exception:
|
| 1135 |
return None
|
|
|
|
| 1186 |
if _re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
|
| 1187 |
)
|
| 1188 |
slides: list[Mapping[str, str]] = []
|
| 1189 |
+
budget = [_MAX_INFLATED_TOTAL]
|
| 1190 |
for name in names[:8]:
|
| 1191 |
+
try:
|
| 1192 |
+
member = _read_member(archive, name, budget)
|
| 1193 |
+
except _InflationRefused:
|
| 1194 |
+
continue
|
| 1195 |
+
texts = _xml_texts(member, r"<a:t>(.*?)</a:t>")
|
| 1196 |
texts = [text for text in (t.strip() for t in texts) if text]
|
| 1197 |
if texts:
|
| 1198 |
slides.append({"title": texts[0][:120]})
|
|
|
|
| 1213 |
# Streams that will not inflate are skipped rather than guessed at, and a
|
| 1214 |
# PDF nobody can read still reports its page count, which is a fact.
|
| 1215 |
chunks = [blob]
|
| 1216 |
+
budget = _MAX_INFLATED_TOTAL
|
| 1217 |
for raw in _re.findall(rb"stream\r?\n(.*?)\r?\nendstream", blob, _re.S):
|
| 1218 |
+
if budget <= 0:
|
| 1219 |
+
break
|
| 1220 |
try:
|
| 1221 |
+
# Bounded inflation: `decompress(data, max_length)` stops at the
|
| 1222 |
+
# limit instead of returning however many gigabytes the stream
|
| 1223 |
+
# encodes. What is left in the decompressor is dropped with it.
|
| 1224 |
+
allowed = min(_MAX_INFLATED_MEMBER, budget)
|
| 1225 |
+
piece = _zlib.decompressobj().decompress(raw, allowed)
|
| 1226 |
except Exception:
|
| 1227 |
continue
|
| 1228 |
+
budget -= len(piece)
|
| 1229 |
+
chunks.append(piece)
|
| 1230 |
lines: list[str] = []
|
| 1231 |
seen: set[str] = set()
|
| 1232 |
for chunk in chunks:
|
|
|
|
| 1765 |
# Library cards
|
| 1766 |
# --------------------------------------------------------------------------
|
| 1767 |
|
|
|
|
|
|
|
| 1768 |
|
| 1769 |
|
| 1770 |
+
#: What a member leaves behind, keyed by reference. Drawn on its card as a
|
| 1771 |
+
#: small picture of that kind of thing.
|
| 1772 |
+
#:
|
| 1773 |
+
#: A RECORDING WAS THE WRONG ANSWER TO "WHAT IS THIS FOR".
|
| 1774 |
+
#:
|
| 1775 |
+
#: These cards used to carry a filmed demonstration: a GIF per member, with a
|
| 1776 |
+
#: still of the run's own output underneath, 2.5 MB of them in the repository.
|
| 1777 |
+
#: They were honest — every frame was a real run — and they answered the wrong
|
| 1778 |
+
#: question. Somebody scanning a shelf of thirty members wants to know what
|
| 1779 |
+
#: each one *makes*, at a glance, before deciding which to look at closely; a
|
| 1780 |
+
#: recording only answers that after it has played, one card at a time, and
|
| 1781 |
+
#: not at all on a phone where nothing hovers.
|
| 1782 |
+
#:
|
| 1783 |
+
#: A drawing of the artifact answers it instantly and identically for every
|
| 1784 |
+
#: member, weighs a few hundred bytes, needs no recording pass to stay true
|
| 1785 |
+
#: when a skill changes, and reads the same on any screen.
|
| 1786 |
+
_MEMBER_OUTPUT: Mapping[str, str] = {
|
| 1787 |
+
"create_pdf": "page",
|
| 1788 |
+
"create_docx": "page",
|
| 1789 |
+
"create_deck": "deck",
|
| 1790 |
+
"create_xlsx": "sheet",
|
| 1791 |
+
"csv_table": "sheet",
|
| 1792 |
+
"frontend_design": "web",
|
| 1793 |
+
"theme_factory": "palette",
|
| 1794 |
+
"make_plan": "list",
|
| 1795 |
+
"todo": "list",
|
| 1796 |
+
"review_checklist": "list",
|
| 1797 |
+
"skill_scaffold": "note",
|
| 1798 |
+
"status_update": "note",
|
| 1799 |
+
"scratchpad": "note",
|
| 1800 |
+
"search_document": "find",
|
| 1801 |
+
"extract_from_text": "find",
|
| 1802 |
+
"verify_quote": "find",
|
| 1803 |
+
"calculate": "value",
|
| 1804 |
+
"calculate_date": "value",
|
| 1805 |
+
"convert_units": "value",
|
| 1806 |
+
}
|
| 1807 |
+
|
| 1808 |
+
#: Everything driven by ``tailored_guide`` writes a guide in markdown, so it
|
| 1809 |
+
#: is one shape rather than nineteen near-identical entries above.
|
| 1810 |
+
_GUIDE_OUTPUT = "guide"
|
| 1811 |
+
|
| 1812 |
+
#: The palette the drawings use. Deliberately the page's own tokens rather
|
| 1813 |
+
#: than fixed colours, so a card follows the theme instead of sitting in a
|
| 1814 |
+
#: little rectangle of last year's palette.
|
| 1815 |
+
_MOCK_INK = "var(--c-text-2)"
|
| 1816 |
+
_MOCK_FAINT = "var(--c-hairline)"
|
| 1817 |
+
_MOCK_ACCENT = "var(--c-accent)"
|
| 1818 |
+
|
| 1819 |
+
|
| 1820 |
+
def _member_output(ref: str) -> str:
|
| 1821 |
+
"""Which drawing belongs to a member, from its reference."""
|
| 1822 |
+
|
| 1823 |
+
tool_id = ref.split("@", 1)[0]
|
| 1824 |
+
if tool_id in _MEMBER_OUTPUT:
|
| 1825 |
+
return _MEMBER_OUTPUT[tool_id]
|
| 1826 |
+
try:
|
| 1827 |
+
from distinct_tools.skills import REPOSITORY_SKILLS
|
| 1828 |
+
|
| 1829 |
+
for skill in REPOSITORY_SKILLS:
|
| 1830 |
+
if skill.tool_id == tool_id and getattr(skill, "handler", "") == "tailored_guide":
|
| 1831 |
+
return _GUIDE_OUTPUT
|
| 1832 |
+
except Exception:
|
| 1833 |
+
pass
|
| 1834 |
+
return "generic"
|
| 1835 |
+
|
| 1836 |
|
| 1837 |
+
def _rows(count: int, *, x: float, y: float, gap: float, width: float, short: int = -1) -> str:
|
| 1838 |
+
"""Horizontal rules standing in for lines of text.
|
|
|
|
| 1839 |
|
| 1840 |
+
``short`` is the index drawn at two thirds width. A block of identical
|
| 1841 |
+
full-width bars reads as a barcode; one ragged edge is what makes it read
|
| 1842 |
+
as writing.
|
| 1843 |
"""
|
| 1844 |
|
| 1845 |
+
bars = []
|
| 1846 |
+
for index in range(count):
|
| 1847 |
+
length = width * (0.62 if index == short else 1.0)
|
| 1848 |
+
bars.append(
|
| 1849 |
+
f'<rect x="{x}" y="{y + index * gap:.1f}" width="{length:.1f}" height="2" '
|
| 1850 |
+
f'rx="1" fill="{_MOCK_FAINT}"/>'
|
| 1851 |
+
)
|
| 1852 |
+
return "".join(bars)
|
| 1853 |
|
| 1854 |
|
| 1855 |
+
def _mockup(kind: str) -> str:
|
| 1856 |
+
"""One small drawing of the thing a member produces.
|
| 1857 |
|
| 1858 |
+
Inline SVG rather than an image file: it is a few hundred bytes, it is
|
| 1859 |
+
sharp at any size, it takes the page's colours, and there is no asset to
|
| 1860 |
+
fetch, cache, forget to ship, or let drift out of date.
|
| 1861 |
+
"""
|
| 1862 |
+
|
| 1863 |
+
body = {
|
| 1864 |
+
# A page of writing: a heading, a rule, and paragraphs.
|
| 1865 |
+
"page": (
|
| 1866 |
+
f'<rect x="34" y="8" width="92" height="88" rx="3" fill="var(--c-surface)" '
|
| 1867 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1868 |
+
f'<rect x="44" y="20" width="46" height="5" rx="2" fill="{_MOCK_ACCENT}"/>'
|
| 1869 |
+
+ _rows(6, x=44, y=34, gap=9, width=72, short=5)
|
| 1870 |
+
),
|
| 1871 |
+
# Slides: one wide frame, two beneath it.
|
| 1872 |
+
"deck": (
|
| 1873 |
+
f'<rect x="26" y="10" width="108" height="46" rx="3" fill="var(--c-surface)" '
|
| 1874 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1875 |
+
f'<rect x="36" y="22" width="42" height="5" rx="2" fill="{_MOCK_ACCENT}"/>'
|
| 1876 |
+
f'<rect x="36" y="34" width="66" height="2" rx="1" fill="{_MOCK_FAINT}"/>'
|
| 1877 |
+
f'<rect x="26" y="62" width="50" height="32" rx="3" fill="var(--c-surface)" '
|
| 1878 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1879 |
+
f'<rect x="84" y="62" width="50" height="32" rx="3" fill="var(--c-surface)" '
|
| 1880 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1881 |
+
),
|
| 1882 |
+
# A grid, with the header row filled in.
|
| 1883 |
+
"sheet": (
|
| 1884 |
+
f'<rect x="26" y="16" width="108" height="72" rx="3" fill="var(--c-surface)" '
|
| 1885 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1886 |
+
f'<rect x="26" y="16" width="108" height="16" fill="var(--c-fill)"/>'
|
| 1887 |
+
+ "".join(
|
| 1888 |
+
f'<line x1="{26 + column * 36}" y1="16" x2="{26 + column * 36}" y2="88" '
|
| 1889 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1890 |
+
for column in (1, 2)
|
| 1891 |
+
)
|
| 1892 |
+
+ "".join(
|
| 1893 |
+
f'<line x1="26" y1="{16 + row * 18}" x2="134" y2="{16 + row * 18}" '
|
| 1894 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1895 |
+
for row in (1, 2, 3)
|
| 1896 |
+
)
|
| 1897 |
+
+ "".join(
|
| 1898 |
+
f'<rect x="{34 + column * 36}" y="22" width="18" height="4" rx="2" '
|
| 1899 |
+
f'fill="{_MOCK_ACCENT}"/>'
|
| 1900 |
+
for column in (0, 1, 2)
|
| 1901 |
+
)
|
| 1902 |
+
),
|
| 1903 |
+
# A browser: chrome, a hero block, two columns.
|
| 1904 |
+
"web": (
|
| 1905 |
+
f'<rect x="20" y="14" width="120" height="76" rx="4" fill="var(--c-surface)" '
|
| 1906 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1907 |
+
f'<rect x="20" y="14" width="120" height="14" rx="4" fill="var(--c-fill)"/>'
|
| 1908 |
+
+ "".join(
|
| 1909 |
+
f'<circle cx="{30 + dot * 9}" cy="21" r="2.4" fill="{_MOCK_FAINT}"/>'
|
| 1910 |
+
for dot in range(3)
|
| 1911 |
+
)
|
| 1912 |
+
+ f'<rect x="30" y="38" width="54" height="6" rx="3" fill="{_MOCK_ACCENT}"/>'
|
| 1913 |
+
+ _rows(2, x=30, y=52, gap=8, width=80, short=1)
|
| 1914 |
+
+ '<rect x="30" y="72" width="46" height="10" rx="3" fill="var(--c-mint)"/>'
|
| 1915 |
+
+ '<rect x="84" y="72" width="46" height="10" rx="3" fill="var(--c-fill)"/>'
|
| 1916 |
+
),
|
| 1917 |
+
# A set of colours, which is what a theme is.
|
| 1918 |
+
"palette": (
|
| 1919 |
+
"".join(
|
| 1920 |
+
f'<rect x="{24 + column * 30}" y="{22 + row * 30}" width="24" height="24" '
|
| 1921 |
+
f'rx="4" fill="{fill}" stroke="{_MOCK_FAINT}"/>'
|
| 1922 |
+
for row, fills in enumerate(
|
| 1923 |
+
(
|
| 1924 |
+
(_MOCK_ACCENT, "var(--c-sage)", "var(--c-mint)", "var(--c-fill)"),
|
| 1925 |
+
("var(--c-text)", "var(--c-text-2)", "var(--c-muted)", "var(--c-surface)"),
|
| 1926 |
+
)
|
| 1927 |
+
)
|
| 1928 |
+
for column, fill in enumerate(fills)
|
| 1929 |
+
)
|
| 1930 |
+
),
|
| 1931 |
+
# Numbered steps.
|
| 1932 |
+
"list": (
|
| 1933 |
+
f'<rect x="30" y="10" width="100" height="84" rx="3" fill="var(--c-surface)" '
|
| 1934 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1935 |
+
+ "".join(
|
| 1936 |
+
f'<circle cx="44" cy="{26 + row * 20}" r="5" fill="none" '
|
| 1937 |
+
f'stroke="{_MOCK_ACCENT}" stroke-width="1.5"/>'
|
| 1938 |
+
f'<rect x="56" y="{24 + row * 20}" width="{58 - row * 10}" height="3" rx="1.5" '
|
| 1939 |
+
f'fill="{_MOCK_FAINT}"/>'
|
| 1940 |
+
for row in range(4)
|
| 1941 |
+
)
|
| 1942 |
+
),
|
| 1943 |
+
# A short note, torn from a pad.
|
| 1944 |
+
"note": (
|
| 1945 |
+
f'<path d="M40 12 h80 v66 l-12 12 h-68 z" fill="var(--c-surface)" '
|
| 1946 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1947 |
+
f'<path d="M120 78 h-12 v12 z" fill="var(--c-fill)" stroke="{_MOCK_FAINT}"/>'
|
| 1948 |
+
f'<rect x="50" y="26" width="38" height="5" rx="2" fill="{_MOCK_ACCENT}"/>'
|
| 1949 |
+
+ _rows(4, x=50, y=40, gap=9, width=60, short=3)
|
| 1950 |
+
),
|
| 1951 |
+
# A guide: a heading and bulleted method.
|
| 1952 |
+
"guide": (
|
| 1953 |
+
f'<rect x="32" y="8" width="96" height="88" rx="3" fill="var(--c-surface)" '
|
| 1954 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1955 |
+
f'<rect x="42" y="20" width="52" height="5" rx="2" fill="{_MOCK_ACCENT}"/>'
|
| 1956 |
+
+ "".join(
|
| 1957 |
+
f'<circle cx="45" cy="{40 + row * 14}" r="2" fill="{_MOCK_ACCENT}"/>'
|
| 1958 |
+
f'<rect x="53" y="{38 + row * 14}" width="{62 - row * 8}" height="3" rx="1.5" '
|
| 1959 |
+
f'fill="{_MOCK_FAINT}"/>'
|
| 1960 |
+
for row in range(4)
|
| 1961 |
+
)
|
| 1962 |
+
),
|
| 1963 |
+
# Looking through a document for something.
|
| 1964 |
+
"find": (
|
| 1965 |
+
f'<rect x="30" y="10" width="86" height="80" rx="3" fill="var(--c-surface)" '
|
| 1966 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1967 |
+
+ _rows(5, x=40, y=26, gap=12, width=62, short=4)
|
| 1968 |
+
+ '<rect x="40" y="48" width="40" height="7" rx="2" fill="var(--c-mint)"/>'
|
| 1969 |
+
+ f'<circle cx="112" cy="70" r="17" fill="var(--c-surface)" stroke="{_MOCK_ACCENT}" '
|
| 1970 |
+
f'stroke-width="2.5"/>'
|
| 1971 |
+
+ f'<line x1="124" y1="82" x2="134" y2="92" stroke="{_MOCK_ACCENT}" '
|
| 1972 |
+
f'stroke-width="2.5" stroke-linecap="round"/>'
|
| 1973 |
+
),
|
| 1974 |
+
# An answer, which is what a calculation gives back.
|
| 1975 |
+
"value": (
|
| 1976 |
+
f'<rect x="34" y="24" width="92" height="52" rx="6" fill="var(--c-surface)" '
|
| 1977 |
+
f'stroke="{_MOCK_FAINT}"/>'
|
| 1978 |
+
f'<rect x="46" y="36" width="26" height="4" rx="2" fill="{_MOCK_FAINT}"/>'
|
| 1979 |
+
f'<rect x="46" y="48" width="52" height="12" rx="3" fill="{_MOCK_ACCENT}"/>'
|
| 1980 |
+
),
|
| 1981 |
+
"generic": (
|
| 1982 |
+
f'<rect x="38" y="12" width="84" height="80" rx="3" fill="var(--c-surface)" '
|
| 1983 |
+
f'stroke="{_MOCK_FAINT}"/>' + _rows(5, x=50, y=30, gap=11, width=60, short=4)
|
| 1984 |
+
),
|
| 1985 |
+
}.get(kind)
|
| 1986 |
+
if body is None:
|
| 1987 |
+
body = ""
|
| 1988 |
+
return (
|
| 1989 |
+
'<svg class="c-libcard__mock" viewBox="0 0 160 104" role="img" '
|
| 1990 |
+
'aria-hidden="true" preserveAspectRatio="xMidYMid meet">'
|
| 1991 |
+
f"{body}</svg>"
|
| 1992 |
+
)
|
| 1993 |
|
| 1994 |
|
| 1995 |
def library_cards(rows: Sequence[Mapping[str, object]], chosen: Sequence[str]) -> str:
|
|
|
|
| 2015 |
name = str(row.get("name", ref))
|
| 2016 |
author = str(row.get("author", "distinct"))
|
| 2017 |
description = str(row.get("description", ""))
|
| 2018 |
+
# Every card carries one, because a shelf where some members have a
|
| 2019 |
+
# picture and some do not reads as a shelf where some are broken.
|
| 2020 |
+
output = _member_output(ref)
|
| 2021 |
+
media = (
|
| 2022 |
+
f'<figure class="c-libcard__media c-libcard__media--{esc(output)}">'
|
| 2023 |
+
f"{_mockup(output)}"
|
| 2024 |
+
f'<figcaption class="c-visually-hidden">Makes a {esc(output)}</figcaption>'
|
| 2025 |
+
"</figure>"
|
| 2026 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2027 |
on = ref in picked
|
| 2028 |
# The card itself is the control: role, state and keyboard focus are
|
| 2029 |
# its own, and the check mark is drawn by the stylesheet from the
|
distinct_server/ui.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
|
|
|
| 6 |
import math
|
| 7 |
import os
|
| 8 |
import time
|
|
@@ -65,14 +66,18 @@ from .presentation import PALETTE
|
|
| 65 |
from .ui_state import (
|
| 66 |
active_conversation,
|
| 67 |
add_conversation,
|
|
|
|
| 68 |
append_job,
|
| 69 |
conversation_choices,
|
| 70 |
conversation_turns,
|
| 71 |
new_session,
|
| 72 |
record_answer,
|
|
|
|
| 73 |
select_conversation,
|
| 74 |
)
|
| 75 |
|
|
|
|
|
|
|
| 76 |
# The composer's library choices are not a constant. They are derived, per
|
| 77 |
# refresh, from what the connected agents actually advertise, by the same
|
| 78 |
# route the model dropdown and the agent filter already use. A hardcoded empty
|
|
@@ -87,6 +92,28 @@ NO_TOOLS_NOTICE = (
|
|
| 87 |
"the model on its own."
|
| 88 |
)
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
#: What an empty transcript says. A module constant rather than a literal
|
| 91 |
#: buried in the layout, because it is the only instruction on a page that has
|
| 92 |
#: nothing else on it, and because the previous version pointed at the wrong
|
|
@@ -1105,13 +1132,6 @@ ASSESSMENT_ASSIGNMENT: Mapping[str, Mapping[str, Any]] = {
|
|
| 1105 |
"checkpoint is not separately costed in the source."
|
| 1106 |
),
|
| 1107 |
},
|
| 1108 |
-
"llama-2-7b-chat": {
|
| 1109 |
-
"record": "llama-2-7b",
|
| 1110 |
-
"note": (
|
| 1111 |
-
"Figures cover Llama 2 7B pretraining; Meta report fine-tuning compute inside "
|
| 1112 |
-
"the family total without breaking it out per checkpoint."
|
| 1113 |
-
),
|
| 1114 |
-
},
|
| 1115 |
}
|
| 1116 |
|
| 1117 |
|
|
@@ -1794,9 +1814,8 @@ class DistinctUI:
|
|
| 1794 |
|
| 1795 |
# Authentication/session helpers ----------------------------------
|
| 1796 |
|
| 1797 |
-
def
|
| 1798 |
self,
|
| 1799 |
-
state: Mapping[str, Any],
|
| 1800 |
profile: Optional[Mapping[str, Any]],
|
| 1801 |
request: Optional[Any] = None,
|
| 1802 |
) -> str:
|
|
@@ -1816,10 +1835,46 @@ class DistinctUI:
|
|
| 1816 |
user_id = _identity_from_request(request)
|
| 1817 |
if user_id is None:
|
| 1818 |
user_id = user_id_from_profile(profile)
|
| 1819 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1820 |
self.sessions.bind(session_id, user_id)
|
| 1821 |
self.control.create_session(session_id)
|
| 1822 |
-
for conversation_id in state.get("conversations"
|
| 1823 |
self.control.create_conversation(session_id, str(conversation_id))
|
| 1824 |
return user_id
|
| 1825 |
|
|
@@ -1849,13 +1904,17 @@ class DistinctUI:
|
|
| 1849 |
# the session and conversations server-side (in _authorize) and keeps
|
| 1850 |
# every answer the browser already holds. This is the persistence the
|
| 1851 |
# privacy notice promises: the user holds their own history.
|
| 1852 |
-
|
| 1853 |
-
|
| 1854 |
-
|
| 1855 |
-
|
| 1856 |
-
|
| 1857 |
-
|
| 1858 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1859 |
updated = dict(state)
|
| 1860 |
messages: list[dict[str, str]] = []
|
| 1861 |
energy = _session_energy_markdown(())
|
|
@@ -1881,6 +1940,25 @@ class DistinctUI:
|
|
| 1881 |
)
|
| 1882 |
activity = gr.HTML(value="", visible=False)
|
| 1883 |
user_id = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1884 |
states = self._conversation_states(updated)
|
| 1885 |
choices = conversation_choices(updated, states)
|
| 1886 |
selected = updated["active_conversation_id"]
|
|
@@ -2198,16 +2276,23 @@ class DistinctUI:
|
|
| 2198 |
selected_agent: str,
|
| 2199 |
mode: str = MODE_SIMPLE,
|
| 2200 |
state: Optional[Mapping[str, Any]] = None,
|
| 2201 |
-
profile:
|
| 2202 |
request: "gr.Request | None" = None,
|
| 2203 |
) -> tuple:
|
| 2204 |
# Declared, because it is used two lines down. It was not, and the
|
| 2205 |
# handler raised NameError on every change of the model dropdown, the
|
| 2206 |
# library checkboxes or the mode: a live crash on three of the most
|
| 2207 |
# common interactions in the interface, in a handler no test called.
|
| 2208 |
-
#
|
| 2209 |
-
#
|
| 2210 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2211 |
#
|
| 2212 |
# Re-checked here rather than trusted from the last render. A hidden
|
| 2213 |
# component is still callable through the API, so every handler that
|
|
@@ -2260,6 +2345,61 @@ class DistinctUI:
|
|
| 2260 |
except (ControlPlaneError, LoginRequired, ValueError) as exc:
|
| 2261 |
raise gr.Error(str(exc)) from exc
|
| 2262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2263 |
def change_conversation(
|
| 2264 |
self,
|
| 2265 |
state: Mapping[str, Any],
|
|
@@ -2360,6 +2500,12 @@ class DistinctUI:
|
|
| 2360 |
except (ControlPlaneError, LoginRequired, ValueError) as exc:
|
| 2361 |
raise gr.Error(str(exc)) from exc
|
| 2362 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2363 |
def refresh_session(
|
| 2364 |
self,
|
| 2365 |
state: Mapping[str, Any],
|
|
@@ -2369,6 +2515,37 @@ class DistinctUI:
|
|
| 2369 |
mode: str,
|
| 2370 |
profile: gr.OAuthProfile | None,
|
| 2371 |
request: gr.Request | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2372 |
) -> tuple:
|
| 2373 |
updated = dict(state)
|
| 2374 |
try:
|
|
@@ -2380,6 +2557,26 @@ class DistinctUI:
|
|
| 2380 |
messages, status = [], _status_html("Sign in to run a model.")
|
| 2381 |
energy = _session_energy_html(())
|
| 2382 |
activity = gr.HTML(value="", visible=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2383 |
rows, choices, value, narrowed = self._agent_components(
|
| 2384 |
model_id, tool_values, selected_agent, mode, viewer_id=viewer_id
|
| 2385 |
)
|
|
@@ -2493,6 +2690,15 @@ class DistinctUI:
|
|
| 2493 |
A signed-out caller now gets a fresh blank state and touches nothing,
|
| 2494 |
which is the honest answer to "erase my data" from somebody who has no
|
| 2495 |
data here.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2496 |
"""
|
| 2497 |
|
| 2498 |
try:
|
|
@@ -2502,14 +2708,21 @@ class DistinctUI:
|
|
| 2502 |
)
|
| 2503 |
except LoginRequired:
|
| 2504 |
session_id = ""
|
|
|
|
| 2505 |
if session_id:
|
| 2506 |
self.sessions.release(session_id)
|
| 2507 |
self.control.delete_session(session_id)
|
| 2508 |
self._delete_outputs(session_id)
|
| 2509 |
-
|
| 2510 |
-
|
| 2511 |
-
|
| 2512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2513 |
library, library_note = self._library_components(())
|
| 2514 |
fresh_states = self._conversation_states(fresh)
|
| 2515 |
return (
|
|
@@ -3066,22 +3279,86 @@ class DistinctUI:
|
|
| 3066 |
state: Mapping[str, Any],
|
| 3067 |
profile: gr.OAuthProfile | None,
|
| 3068 |
request: gr.Request | None = None,
|
| 3069 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3070 |
try:
|
| 3071 |
user_id = self._authorize(state, profile, request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3072 |
value = self.control.create_pairing_code(user_id)
|
| 3073 |
-
|
| 3074 |
-
|
| 3075 |
-
|
| 3076 |
-
|
| 3077 |
-
|
| 3078 |
-
|
| 3079 |
-
|
| 3080 |
-
|
| 3081 |
-
|
| 3082 |
-
|
| 3083 |
|
| 3084 |
-
#
|
|
|
|
|
|
|
| 3085 |
|
| 3086 |
def build(self) -> gr.Blocks:
|
| 3087 |
# ``head`` belongs to launch() in Gradio 6, not to the constructor; the
|
|
@@ -3109,7 +3386,9 @@ class DistinctUI:
|
|
| 3109 |
# delivery; the user holds their own history, and Clear my data
|
| 3110 |
# erases this copy too.
|
| 3111 |
browser_state = gr.BrowserState(
|
| 3112 |
-
default_value=None,
|
|
|
|
|
|
|
| 3113 |
)
|
| 3114 |
# One artwork per page load. Chosen here, once, and handed to both
|
| 3115 |
# the splash and the in-app credit, so the two can never show
|
|
@@ -3225,6 +3504,21 @@ class DistinctUI:
|
|
| 3225 |
container=False,
|
| 3226 |
elem_classes="c-convlist",
|
| 3227 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3228 |
|
| 3229 |
with gr.Column(elem_classes="c-side", min_width=0):
|
| 3230 |
gr.HTML(render.section_heading("Run setup"))
|
|
@@ -3492,29 +3786,25 @@ class DistinctUI:
|
|
| 3492 |
# is Python and always was; the panel above now says
|
| 3493 |
# so, and the only control left is the one that mints
|
| 3494 |
# the code those instructions need.
|
| 3495 |
-
# NO BUTTON
|
| 3496 |
#
|
| 3497 |
-
#
|
| 3498 |
-
#
|
| 3499 |
-
#
|
| 3500 |
-
#
|
| 3501 |
-
#
|
| 3502 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3503 |
#
|
| 3504 |
-
#
|
| 3505 |
-
#
|
| 3506 |
-
#
|
| 3507 |
-
#
|
| 3508 |
-
#
|
| 3509 |
-
#
|
| 3510 |
-
# one — the access code it prints once paired, which
|
| 3511 |
-
# is what you hand to the people you want to let use
|
| 3512 |
-
# your machine, and which any number of them can
|
| 3513 |
-
# redeem.
|
| 3514 |
-
refresh_code = gr.Button(
|
| 3515 |
-
"New code", size="sm", variant="secondary", scale=0,
|
| 3516 |
-
elem_classes="c-join__refresh",
|
| 3517 |
-
)
|
| 3518 |
|
| 3519 |
with gr.Accordion("Model assessments and their sources", open=False):
|
| 3520 |
gr.HTML(
|
|
@@ -3676,6 +3966,23 @@ class DistinctUI:
|
|
| 3676 |
api_visibility="private",
|
| 3677 |
js=_scroll_top,
|
| 3678 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3679 |
new_conversation_button.click(
|
| 3680 |
self.new_conversation,
|
| 3681 |
inputs=[session_state],
|
|
@@ -3721,7 +4028,12 @@ class DistinctUI:
|
|
| 3721 |
for component in (model, tools, mode):
|
| 3722 |
component.input(
|
| 3723 |
self.refresh_agents,
|
| 3724 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3725 |
outputs=[agent_table, agent, tools, library_note],
|
| 3726 |
queue=False,
|
| 3727 |
api_visibility="private",
|
|
@@ -3821,14 +4133,10 @@ class DistinctUI:
|
|
| 3821 |
inputs=[session_state],
|
| 3822 |
# Into the panel, so the address and the code appear in one
|
| 3823 |
# command rather than in two places the reader has to join up.
|
| 3824 |
-
|
| 3825 |
-
|
| 3826 |
-
|
| 3827 |
-
|
| 3828 |
-
refresh_code.click(
|
| 3829 |
-
self.pairing_code,
|
| 3830 |
-
inputs=[session_state],
|
| 3831 |
-
outputs=[join_panel],
|
| 3832 |
queue=False,
|
| 3833 |
api_visibility="private",
|
| 3834 |
)
|
|
@@ -3837,24 +4145,30 @@ class DistinctUI:
|
|
| 3837 |
# every five seconds reads as a stall. The refresh is in-memory
|
| 3838 |
# and unqueued; the worker's own poll cadence is unchanged.
|
| 3839 |
timer = gr.Timer(2, active=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3840 |
timer.tick(
|
| 3841 |
self.refresh_session,
|
| 3842 |
inputs=[session_state, model, tools, agent, mode],
|
| 3843 |
-
outputs=
|
| 3844 |
-
session_state,
|
| 3845 |
-
browser_state,
|
| 3846 |
-
chat,
|
| 3847 |
-
run_status,
|
| 3848 |
-
energy_total,
|
| 3849 |
-
agent_table,
|
| 3850 |
-
agent,
|
| 3851 |
-
tools,
|
| 3852 |
-
library_note,
|
| 3853 |
-
outputs_files,
|
| 3854 |
-
activity_panel,
|
| 3855 |
-
conversation,
|
| 3856 |
-
rail_tones,
|
| 3857 |
-
],
|
| 3858 |
queue=False,
|
| 3859 |
api_visibility="private",
|
| 3860 |
)
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
+
import logging
|
| 7 |
import math
|
| 8 |
import os
|
| 9 |
import time
|
|
|
|
| 66 |
from .ui_state import (
|
| 67 |
active_conversation,
|
| 68 |
add_conversation,
|
| 69 |
+
adopt_saved_state,
|
| 70 |
append_job,
|
| 71 |
conversation_choices,
|
| 72 |
conversation_turns,
|
| 73 |
new_session,
|
| 74 |
record_answer,
|
| 75 |
+
remove_conversation,
|
| 76 |
select_conversation,
|
| 77 |
)
|
| 78 |
|
| 79 |
+
_LOG = logging.getLogger(__name__)
|
| 80 |
+
|
| 81 |
# The composer's library choices are not a constant. They are derived, per
|
| 82 |
# refresh, from what the connected agents actually advertise, by the same
|
| 83 |
# route the model dropdown and the agent filter already use. A hardcoded empty
|
|
|
|
| 92 |
"the model on its own."
|
| 93 |
)
|
| 94 |
|
| 95 |
+
#: What obfuscates the transcript in localStorage, and why it is written down.
|
| 96 |
+
#:
|
| 97 |
+
#: `gr.BrowserState` encrypts what it stores, and left to itself it invents a
|
| 98 |
+
#: sixteen-character key at start-up — which it then ships to the browser in
|
| 99 |
+
#: the page's own configuration, where anybody can read it. So the key is not
|
| 100 |
+
#: a secret and never was; what it is is a *version stamp on the stored data*,
|
| 101 |
+
#: and a random one means a new stamp on every process.
|
| 102 |
+
#:
|
| 103 |
+
#: The consequence was the whole of the persistence this product promises.
|
| 104 |
+
#: Every restart of the Space — every push, every wake from sleep — made the
|
| 105 |
+
#: saved transcript undecryptable, so gradio logged "Error reading from
|
| 106 |
+
#: localStorage" to the console and silently replaced the user's entire
|
| 107 |
+
#: history with the default value. The privacy notice says the user holds
|
| 108 |
+
#: their own history; in practice they held it until the next deploy.
|
| 109 |
+
#:
|
| 110 |
+
#: A written-down constant is the honest form of a key that is published in
|
| 111 |
+
#: the page anyway, and it is what makes the stored copy survive a restart.
|
| 112 |
+
#: The suffix is a data-format version: change it only to deliberately
|
| 113 |
+
#: abandon every transcript in every browser, which
|
| 114 |
+
#: `ui_state.adopt_saved_state` exists so that a shape change never needs.
|
| 115 |
+
BROWSER_STATE_SECRET = "distinct-transcript-v1"
|
| 116 |
+
|
| 117 |
#: What an empty transcript says. A module constant rather than a literal
|
| 118 |
#: buried in the layout, because it is the only instruction on a page that has
|
| 119 |
#: nothing else on it, and because the previous version pointed at the wrong
|
|
|
|
| 1132 |
"checkpoint is not separately costed in the source."
|
| 1133 |
),
|
| 1134 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1135 |
}
|
| 1136 |
|
| 1137 |
|
|
|
|
| 1814 |
|
| 1815 |
# Authentication/session helpers ----------------------------------
|
| 1816 |
|
| 1817 |
+
def _viewer(
|
| 1818 |
self,
|
|
|
|
| 1819 |
profile: Optional[Mapping[str, Any]],
|
| 1820 |
request: Optional[Any] = None,
|
| 1821 |
) -> str:
|
|
|
|
| 1835 |
user_id = _identity_from_request(request)
|
| 1836 |
if user_id is None:
|
| 1837 |
user_id = user_id_from_profile(profile)
|
| 1838 |
+
return user_id
|
| 1839 |
+
|
| 1840 |
+
def _authorize(
|
| 1841 |
+
self,
|
| 1842 |
+
state: Mapping[str, Any],
|
| 1843 |
+
profile: Optional[Mapping[str, Any]],
|
| 1844 |
+
request: Optional[Any] = None,
|
| 1845 |
+
) -> str:
|
| 1846 |
+
"""Who is asking, and this browser's session made real on the server.
|
| 1847 |
+
|
| 1848 |
+
A HANDLER WITH NO SESSION STATE MUST NOT INVENT ONE.
|
| 1849 |
+
|
| 1850 |
+
This used to pass `str(state.get("session_id", ""))` straight to
|
| 1851 |
+
`create_session`, and that call reads an empty id as "mint me a new
|
| 1852 |
+
one". `refresh_agents` is wired without the session state, so its
|
| 1853 |
+
`state` is always `{}` — which meant every change of the model
|
| 1854 |
+
dropdown, every tick of a library box and every change of mode minted
|
| 1855 |
+
a fresh server-side session that nothing would ever delete, since
|
| 1856 |
+
`cleanup_expired` does not reap sessions.
|
| 1857 |
+
|
| 1858 |
+
Two thousand and forty-eight of those and the registry is full. The
|
| 1859 |
+
failure is not local to the handler that filled it: `create_session`
|
| 1860 |
+
then raises `CapacityError` for *every* caller of this method, so
|
| 1861 |
+
`load_session` fails on every page load and the two-second timer fails
|
| 1862 |
+
on every tick, for everybody on the server at once. That is the
|
| 1863 |
+
"random errors" this was hunted for — it takes a while to build up and
|
| 1864 |
+
then it arrives everywhere.
|
| 1865 |
+
|
| 1866 |
+
Identity does not need a session, so a caller without one gets the
|
| 1867 |
+
identity and nothing else is touched.
|
| 1868 |
+
"""
|
| 1869 |
+
|
| 1870 |
+
state = _as_mapping(state)
|
| 1871 |
+
user_id = self._viewer(profile, request)
|
| 1872 |
+
session_id = str(state.get("session_id", "") or "")
|
| 1873 |
+
if not session_id:
|
| 1874 |
+
return user_id
|
| 1875 |
self.sessions.bind(session_id, user_id)
|
| 1876 |
self.control.create_session(session_id)
|
| 1877 |
+
for conversation_id in state.get("conversations") or {}:
|
| 1878 |
self.control.create_conversation(session_id, str(conversation_id))
|
| 1879 |
return user_id
|
| 1880 |
|
|
|
|
| 1904 |
# the session and conversations server-side (in _authorize) and keeps
|
| 1905 |
# every answer the browser already holds. This is the persistence the
|
| 1906 |
# privacy notice promises: the user holds their own history.
|
| 1907 |
+
#
|
| 1908 |
+
# Rebuilt rather than believed. See `ui_state.adopt_saved_state`: the
|
| 1909 |
+
# four top-level checks that used to stand here trusted everything one
|
| 1910 |
+
# level down, and a transcript written by an older build could make
|
| 1911 |
+
# this handler raise on every page load.
|
| 1912 |
+
#
|
| 1913 |
+
# The live state is put through the same rebuild, and a fresh session
|
| 1914 |
+
# is the last resort. This is the one handler that runs before any
|
| 1915 |
+
# other, so the shape it settles on is the shape every handler after
|
| 1916 |
+
# it can rely on — which is why none of them repeat this work.
|
| 1917 |
+
state = adopt_saved_state(saved_state) or adopt_saved_state(state) or new_session()
|
| 1918 |
updated = dict(state)
|
| 1919 |
messages: list[dict[str, str]] = []
|
| 1920 |
energy = _session_energy_markdown(())
|
|
|
|
| 1940 |
)
|
| 1941 |
activity = gr.HTML(value="", visible=False)
|
| 1942 |
user_id = None
|
| 1943 |
+
except (ControlPlaneError, ValueError) as exc:
|
| 1944 |
+
# THE PAGE HAS TO OPEN.
|
| 1945 |
+
#
|
| 1946 |
+
# This runs on every load, and anything that escapes it is a red
|
| 1947 |
+
# toast on arrival with no control to press and no way to guess
|
| 1948 |
+
# what went wrong. The one that actually happened was
|
| 1949 |
+
# `CapacityError: session registry is full`, which is a fact about
|
| 1950 |
+
# the server and not about this visitor, and it greeted every
|
| 1951 |
+
# visitor at once. The session is left un-materialised, the
|
| 1952 |
+
# transcript still draws from the browser's own copy, and the
|
| 1953 |
+
# reason goes to the log where somebody can act on it.
|
| 1954 |
+
_LOG.exception("load_session could not prepare this session")
|
| 1955 |
+
identity = _identity_html("")
|
| 1956 |
+
status = _status_html(
|
| 1957 |
+
"This server could not open a session just now. Your "
|
| 1958 |
+
f"conversation is safe in this browser. Reason: {exc}"
|
| 1959 |
+
)
|
| 1960 |
+
activity = gr.HTML(value="", visible=False)
|
| 1961 |
+
user_id = None
|
| 1962 |
states = self._conversation_states(updated)
|
| 1963 |
choices = conversation_choices(updated, states)
|
| 1964 |
selected = updated["active_conversation_id"]
|
|
|
|
| 2276 |
selected_agent: str,
|
| 2277 |
mode: str = MODE_SIMPLE,
|
| 2278 |
state: Optional[Mapping[str, Any]] = None,
|
| 2279 |
+
profile: "gr.OAuthProfile | None" = None,
|
| 2280 |
request: "gr.Request | None" = None,
|
| 2281 |
) -> tuple:
|
| 2282 |
# Declared, because it is used two lines down. It was not, and the
|
| 2283 |
# handler raised NameError on every change of the model dropdown, the
|
| 2284 |
# library checkboxes or the mode: a live crash on three of the most
|
| 2285 |
# common interactions in the interface, in a handler no test called.
|
| 2286 |
+
#
|
| 2287 |
+
# `profile` was annotated `Optional[Mapping[str, Any]]`, and Gradio
|
| 2288 |
+
# injects a profile only into a parameter annotated with its own
|
| 2289 |
+
# `OAuthProfile`. On a Space that is the *only* way anybody is
|
| 2290 |
+
# identified, so this handler saw a signed-out visitor no matter who
|
| 2291 |
+
# was using it, and the worker table emptied itself every time
|
| 2292 |
+
# somebody picked a model. The annotation is now the one Gradio
|
| 2293 |
+
# matches on, and the session state is wired in beside it, so this
|
| 2294 |
+
# authorises against the same session as every other handler instead
|
| 2295 |
+
# of against `{}`.
|
| 2296 |
#
|
| 2297 |
# Re-checked here rather than trusted from the last render. A hidden
|
| 2298 |
# component is still callable through the API, so every handler that
|
|
|
|
| 2345 |
except (ControlPlaneError, LoginRequired, ValueError) as exc:
|
| 2346 |
raise gr.Error(str(exc)) from exc
|
| 2347 |
|
| 2348 |
+
def delete_conversation(
|
| 2349 |
+
self,
|
| 2350 |
+
state: Mapping[str, Any],
|
| 2351 |
+
profile: gr.OAuthProfile | None,
|
| 2352 |
+
request: gr.Request | None = None,
|
| 2353 |
+
) -> tuple:
|
| 2354 |
+
"""Erase the conversation on screen, here and on the server.
|
| 2355 |
+
|
| 2356 |
+
There was no way to do this at all: a conversation could be started and
|
| 2357 |
+
never removed, so the rail only ever grew, and "Clear my data" — the one
|
| 2358 |
+
control that erased anything — took the whole session with it. Wanting
|
| 2359 |
+
one exchange gone is not the same as wanting everything gone.
|
| 2360 |
+
|
| 2361 |
+
No confirmation step. What it erases is already erasable by design and
|
| 2362 |
+
gone from the server within a run's lifetime anyway, and a dialog in
|
| 2363 |
+
front of every deletion is how people learn to dismiss dialogs. The row
|
| 2364 |
+
disappearing is the feedback.
|
| 2365 |
+
"""
|
| 2366 |
+
|
| 2367 |
+
try:
|
| 2368 |
+
self._authorize(state, profile, request)
|
| 2369 |
+
removed = str(state.get("active_conversation_id") or "")
|
| 2370 |
+
updated = remove_conversation(state, removed)
|
| 2371 |
+
# The browser's copy is gone; tell the server so anything it still
|
| 2372 |
+
# holds for that conversation goes with it rather than ageing out.
|
| 2373 |
+
try:
|
| 2374 |
+
self.control.delete_conversation(updated["session_id"], removed)
|
| 2375 |
+
except (ControlPlaneError, ValueError):
|
| 2376 |
+
# It may already have gone: a conversation whose runs all
|
| 2377 |
+
# finished is cleaned up on its own. Nothing here depends on
|
| 2378 |
+
# it still being there.
|
| 2379 |
+
pass
|
| 2380 |
+
if removed in updated["conversations"]:
|
| 2381 |
+
# The last one was emptied rather than removed, so the server
|
| 2382 |
+
# needs it to exist again for the next run in it.
|
| 2383 |
+
self.control.create_conversation(updated["session_id"], removed)
|
| 2384 |
+
updated, messages, status, energy, activity = self._render(updated)
|
| 2385 |
+
states = self._conversation_states(updated)
|
| 2386 |
+
return (
|
| 2387 |
+
updated,
|
| 2388 |
+
updated,
|
| 2389 |
+
gr.Radio(
|
| 2390 |
+
choices=conversation_choices(updated, states),
|
| 2391 |
+
value=updated["active_conversation_id"],
|
| 2392 |
+
),
|
| 2393 |
+
render.conversation_tones(states),
|
| 2394 |
+
messages,
|
| 2395 |
+
_status_html("Conversation deleted."),
|
| 2396 |
+
energy,
|
| 2397 |
+
activity,
|
| 2398 |
+
self._outputs_component(updated),
|
| 2399 |
+
)
|
| 2400 |
+
except (ControlPlaneError, LoginRequired, ValueError) as exc:
|
| 2401 |
+
raise gr.Error(str(exc)) from exc
|
| 2402 |
+
|
| 2403 |
def change_conversation(
|
| 2404 |
self,
|
| 2405 |
state: Mapping[str, Any],
|
|
|
|
| 2500 |
except (ControlPlaneError, LoginRequired, ValueError) as exc:
|
| 2501 |
raise gr.Error(str(exc)) from exc
|
| 2502 |
|
| 2503 |
+
#: One `gr.skip()` per output the timer is wired to, for a tick that could
|
| 2504 |
+
#: not run at all. Asserted against the wiring in :meth:`build`, because a
|
| 2505 |
+
#: length that drifts from the outputs list would make the safety net
|
| 2506 |
+
#: itself the error it exists to prevent.
|
| 2507 |
+
TIMER_OUTPUTS = 13
|
| 2508 |
+
|
| 2509 |
def refresh_session(
|
| 2510 |
self,
|
| 2511 |
state: Mapping[str, Any],
|
|
|
|
| 2515 |
mode: str,
|
| 2516 |
profile: gr.OAuthProfile | None,
|
| 2517 |
request: gr.Request | None = None,
|
| 2518 |
+
) -> tuple:
|
| 2519 |
+
"""One tick of the two-second refresh, which can never fail loudly.
|
| 2520 |
+
|
| 2521 |
+
Everything below degrades in place: a failure inside the render is
|
| 2522 |
+
caught where it happens and the rest of the tick still runs. This
|
| 2523 |
+
outermost guard is for the failure nobody predicted, and it is a bare
|
| 2524 |
+
`Exception` on purpose. A user-initiated handler should be loud,
|
| 2525 |
+
because somebody is standing there having just pressed something and a
|
| 2526 |
+
toast tells them what happened. Nobody presses this. It runs unwatched
|
| 2527 |
+
thirty times a minute in every open tab, so a raise here is not an
|
| 2528 |
+
error message, it is an error message repeating forever, and the
|
| 2529 |
+
person reading it did nothing to cause it and can do nothing about it.
|
| 2530 |
+
"""
|
| 2531 |
+
|
| 2532 |
+
try:
|
| 2533 |
+
return self._refresh_session(
|
| 2534 |
+
state, model_id, tool_values, selected_agent, mode, profile, request
|
| 2535 |
+
)
|
| 2536 |
+
except Exception: # noqa: BLE001 - see the docstring; the log keeps it visible
|
| 2537 |
+
_LOG.exception("refresh_session skipped a tick")
|
| 2538 |
+
return tuple(gr.skip() for _ in range(self.TIMER_OUTPUTS))
|
| 2539 |
+
|
| 2540 |
+
def _refresh_session(
|
| 2541 |
+
self,
|
| 2542 |
+
state: Mapping[str, Any],
|
| 2543 |
+
model_id: str,
|
| 2544 |
+
tool_values: Sequence[str],
|
| 2545 |
+
selected_agent: str,
|
| 2546 |
+
mode: str,
|
| 2547 |
+
profile: gr.OAuthProfile | None,
|
| 2548 |
+
request: gr.Request | None = None,
|
| 2549 |
) -> tuple:
|
| 2550 |
updated = dict(state)
|
| 2551 |
try:
|
|
|
|
| 2557 |
messages, status = [], _status_html("Sign in to run a model.")
|
| 2558 |
energy = _session_energy_html(())
|
| 2559 |
activity = gr.HTML(value="", visible=False)
|
| 2560 |
+
except (ControlPlaneError, ValueError):
|
| 2561 |
+
# THE TRANSCRIPT IS LEFT ALONE, AND THE REST OF THE TICK RUNS.
|
| 2562 |
+
#
|
| 2563 |
+
# `LoginRequired` was caught here and `ControlPlaneError` was not,
|
| 2564 |
+
# and the one that actually arrived was `CapacityError: session
|
| 2565 |
+
# registry is full` from inside `_authorize` — a fact about the
|
| 2566 |
+
# server, raised for every user of it at the same instant, on a
|
| 2567 |
+
# handler that fires every two seconds. That is the shape of the
|
| 2568 |
+
# "random errors" this was hunted for.
|
| 2569 |
+
#
|
| 2570 |
+
# Skipping these four outputs leaves what is on screen exactly as
|
| 2571 |
+
# it is, which is right: the transcript is the browser's own copy
|
| 2572 |
+
# and the server has nothing to add this tick. The worker table
|
| 2573 |
+
# below still refreshes, so a server that recovers is visible
|
| 2574 |
+
# without a reload.
|
| 2575 |
+
_LOG.exception("refresh_session could not render this tick")
|
| 2576 |
+
viewer_id = None
|
| 2577 |
+
messages, status = gr.skip(), gr.skip()
|
| 2578 |
+
energy = gr.skip()
|
| 2579 |
+
activity = gr.skip()
|
| 2580 |
rows, choices, value, narrowed = self._agent_components(
|
| 2581 |
model_id, tool_values, selected_agent, mode, viewer_id=viewer_id
|
| 2582 |
)
|
|
|
|
| 2690 |
A signed-out caller now gets a fresh blank state and touches nothing,
|
| 2691 |
which is the honest answer to "erase my data" from somebody who has no
|
| 2692 |
data here.
|
| 2693 |
+
|
| 2694 |
+
**"Touches nothing" was still a sentence rather than the code.** The
|
| 2695 |
+
authorisation was added and the `create_session` under it was left
|
| 2696 |
+
unconditional, so the second half of the hole described above was
|
| 2697 |
+
never actually closed: a signed-out caller looping this handler filled
|
| 2698 |
+
the session registry exactly as before, and a full registry makes
|
| 2699 |
+
every other handler on the server raise. The fresh session is now
|
| 2700 |
+
materialised only for somebody the server knows, and a signed-out
|
| 2701 |
+
caller gets the blank state and no server-side trace at all.
|
| 2702 |
"""
|
| 2703 |
|
| 2704 |
try:
|
|
|
|
| 2708 |
)
|
| 2709 |
except LoginRequired:
|
| 2710 |
session_id = ""
|
| 2711 |
+
fresh = new_session()
|
| 2712 |
if session_id:
|
| 2713 |
self.sessions.release(session_id)
|
| 2714 |
self.control.delete_session(session_id)
|
| 2715 |
self._delete_outputs(session_id)
|
| 2716 |
+
# One out, one in. The replacement is materialised only when
|
| 2717 |
+
# there was something to replace, so this handler cannot add a
|
| 2718 |
+
# session to the registry however many times it is called — the
|
| 2719 |
+
# hole the docstring above describes and the code left open.
|
| 2720 |
+
# A caller with no session gets the blank state and no
|
| 2721 |
+
# server-side trace, and the next timer tick creates the session
|
| 2722 |
+
# for them if they ever need one.
|
| 2723 |
+
self.control.create_session(fresh["session_id"])
|
| 2724 |
+
for conversation_id in fresh["conversations"]:
|
| 2725 |
+
self.control.create_conversation(fresh["session_id"], conversation_id)
|
| 2726 |
library, library_note = self._library_components(())
|
| 2727 |
fresh_states = self._conversation_states(fresh)
|
| 2728 |
return (
|
|
|
|
| 3279 |
state: Mapping[str, Any],
|
| 3280 |
profile: gr.OAuthProfile | None,
|
| 3281 |
request: gr.Request | None = None,
|
| 3282 |
+
*,
|
| 3283 |
+
force: bool = False,
|
| 3284 |
+
) -> tuple:
|
| 3285 |
+
"""The code for this browser: the one it already has, or a new one.
|
| 3286 |
+
|
| 3287 |
+
MINTING ON EVERY OPEN WAS BOTH A BUG AND AN OUTAGE.
|
| 3288 |
+
|
| 3289 |
+
The bug: a person opens the panel, copies the code, opens it again to
|
| 3290 |
+
re-read the command, and is now looking at a different code from the
|
| 3291 |
+
one in their clipboard.
|
| 3292 |
+
|
| 3293 |
+
The outage: opening a section is cheap and scriptable, the pool of
|
| 3294 |
+
live codes is shared by everybody on this server, and they last ten
|
| 3295 |
+
minutes — so a loop over this handler fills the pool and stops anyone
|
| 3296 |
+
else pairing a worker. The control plane now caps codes per owner as
|
| 3297 |
+
well, which contains the damage; not minting one that nobody asked
|
| 3298 |
+
for is what stops it starting.
|
| 3299 |
+
|
| 3300 |
+
The plaintext lives in this browser's own state and nowhere else. The
|
| 3301 |
+
server keeps only a digest, deliberately, so this is the sole copy —
|
| 3302 |
+
which is exactly the right place for it, since this browser is the
|
| 3303 |
+
only party entitled to read it.
|
| 3304 |
+
|
| 3305 |
+
OPENING A SECTION IS NAVIGATION, SO IT CANNOT FAIL.
|
| 3306 |
+
|
| 3307 |
+
This raised `gr.Error`, and the accordion that opens the panel is
|
| 3308 |
+
wired straight to it, so a signed-out visitor who clicked "Run a
|
| 3309 |
+
community agent" — the ordinary way to find out what running one
|
| 3310 |
+
involves — got a red toast and a five hundred in the log for reading
|
| 3311 |
+
a page. The other way in was the per-owner cap: eight live codes is
|
| 3312 |
+
eight opens in a browser with no saved code, and the ninth open
|
| 3313 |
+
refused with a toast rather than a sentence.
|
| 3314 |
+
|
| 3315 |
+
Nothing here is worth a toast. The panel already reads correctly with
|
| 3316 |
+
no code in it — it is four steps, and the code belongs to one of them —
|
| 3317 |
+
so the reason a code was not minted is written into the panel, where
|
| 3318 |
+
the person is already looking, and the section opens either way.
|
| 3319 |
+
"""
|
| 3320 |
+
|
| 3321 |
+
updated = dict(state) if isinstance(state, Mapping) else {}
|
| 3322 |
try:
|
| 3323 |
user_id = self._authorize(state, profile, request)
|
| 3324 |
+
except LoginRequired:
|
| 3325 |
+
return (
|
| 3326 |
+
updated,
|
| 3327 |
+
updated,
|
| 3328 |
+
_status_html(
|
| 3329 |
+
"Sign in with Hugging Face to get a pairing code. The rest of "
|
| 3330 |
+
"the setup is the same either way, so it is all here to read "
|
| 3331 |
+
"first."
|
| 3332 |
+
)
|
| 3333 |
+
+ self._join_panel("", updated),
|
| 3334 |
+
)
|
| 3335 |
+
existing = str(updated.get("pairing_code") or "")
|
| 3336 |
+
expires = updated.get("pairing_expires")
|
| 3337 |
+
alive = (
|
| 3338 |
+
existing
|
| 3339 |
+
and isinstance(expires, (int, float))
|
| 3340 |
+
# A minute's margin: a code that expires while somebody is
|
| 3341 |
+
# pasting it is worse than one re-minted slightly early.
|
| 3342 |
+
and expires - 60 > time.time()
|
| 3343 |
+
)
|
| 3344 |
+
if alive and not force:
|
| 3345 |
+
return updated, updated, self._join_panel(existing, updated)
|
| 3346 |
+
try:
|
| 3347 |
value = self.control.create_pairing_code(user_id)
|
| 3348 |
+
except (ControlPlaneError, ValueError) as exc:
|
| 3349 |
+
return (
|
| 3350 |
+
updated,
|
| 3351 |
+
updated,
|
| 3352 |
+
_status_html(f"No new pairing code just now: {exc}")
|
| 3353 |
+
+ self._join_panel(existing, updated),
|
| 3354 |
+
)
|
| 3355 |
+
updated["pairing_code"] = value.code
|
| 3356 |
+
updated["pairing_expires"] = value.expires_at
|
| 3357 |
+
return updated, updated, self._join_panel(value.code, updated)
|
| 3358 |
|
| 3359 |
+
# `new_pairing_code` lived here to serve a "New code" button that no
|
| 3360 |
+
# longer exists. Opening the panel mints one when the held code has
|
| 3361 |
+
# expired, which is the only moment a second one was ever wanted.
|
| 3362 |
|
| 3363 |
def build(self) -> gr.Blocks:
|
| 3364 |
# ``head`` belongs to launch() in Gradio 6, not to the constructor; the
|
|
|
|
| 3386 |
# delivery; the user holds their own history, and Clear my data
|
| 3387 |
# erases this copy too.
|
| 3388 |
browser_state = gr.BrowserState(
|
| 3389 |
+
default_value=None,
|
| 3390 |
+
storage_key="distinct-transcript",
|
| 3391 |
+
secret=BROWSER_STATE_SECRET,
|
| 3392 |
)
|
| 3393 |
# One artwork per page load. Chosen here, once, and handed to both
|
| 3394 |
# the splash and the in-app credit, so the two can never show
|
|
|
|
| 3504 |
container=False,
|
| 3505 |
elem_classes="c-convlist",
|
| 3506 |
)
|
| 3507 |
+
# DELETING THE ONE YOU ARE LOOKING AT.
|
| 3508 |
+
#
|
| 3509 |
+
# A per-row control would be the obvious design and
|
| 3510 |
+
# is not available: the list is a Radio, and a radio
|
| 3511 |
+
# group gives no per-option hook to hang a button
|
| 3512 |
+
# on. One control acting on the selected
|
| 3513 |
+
# conversation is the same gesture in two steps, and
|
| 3514 |
+
# it is the step order people already use — you open
|
| 3515 |
+
# a conversation, then decide it can go.
|
| 3516 |
+
delete_conversation_button = gr.Button(
|
| 3517 |
+
"Delete this conversation",
|
| 3518 |
+
size="sm",
|
| 3519 |
+
variant="secondary",
|
| 3520 |
+
elem_classes="c-rail__delete",
|
| 3521 |
+
)
|
| 3522 |
|
| 3523 |
with gr.Column(elem_classes="c-side", min_width=0):
|
| 3524 |
gr.HTML(render.section_heading("Run setup"))
|
|
|
|
| 3786 |
# is Python and always was; the panel above now says
|
| 3787 |
# so, and the only control left is the one that mints
|
| 3788 |
# the code those instructions need.
|
| 3789 |
+
# NO BUTTON IN HERE AT ALL.
|
| 3790 |
#
|
| 3791 |
+
# There were two, and both were ceremony. "Generate
|
| 3792 |
+
# pairing code" asked a person to request the one thing
|
| 3793 |
+
# the panel exists to give, and nobody opens this panel
|
| 3794 |
+
# for another reason. "New code" survived it for a
|
| 3795 |
+
# while on the theory that somebody would want to
|
| 3796 |
+
# replace a code that had expired or been used — but
|
| 3797 |
+
# opening the panel already does exactly that: the code
|
| 3798 |
+
# is reused while it is alive and reminted when it is
|
| 3799 |
+
# not, so the button could only ever mint one that was
|
| 3800 |
+
# about to be minted anyway.
|
| 3801 |
#
|
| 3802 |
+
# The code stays SERVER-issued, which is not ceremony:
|
| 3803 |
+
# it is what proves an arriving worker was invited by
|
| 3804 |
+
# whoever owns this server. The code the *worker*
|
| 3805 |
+
# makes is the other one — the access code it prints
|
| 3806 |
+
# once paired, which is what you give to the people you
|
| 3807 |
+
# want to let use your machine.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3808 |
|
| 3809 |
with gr.Accordion("Model assessments and their sources", open=False):
|
| 3810 |
gr.HTML(
|
|
|
|
| 3966 |
api_visibility="private",
|
| 3967 |
js=_scroll_top,
|
| 3968 |
)
|
| 3969 |
+
delete_conversation_button.click(
|
| 3970 |
+
self.delete_conversation,
|
| 3971 |
+
inputs=[session_state],
|
| 3972 |
+
outputs=[
|
| 3973 |
+
session_state,
|
| 3974 |
+
browser_state,
|
| 3975 |
+
conversation,
|
| 3976 |
+
rail_tones,
|
| 3977 |
+
chat,
|
| 3978 |
+
run_status,
|
| 3979 |
+
energy_total,
|
| 3980 |
+
activity_panel,
|
| 3981 |
+
outputs_files,
|
| 3982 |
+
],
|
| 3983 |
+
queue=False,
|
| 3984 |
+
api_visibility="private",
|
| 3985 |
+
)
|
| 3986 |
new_conversation_button.click(
|
| 3987 |
self.new_conversation,
|
| 3988 |
inputs=[session_state],
|
|
|
|
| 4028 |
for component in (model, tools, mode):
|
| 4029 |
component.input(
|
| 4030 |
self.refresh_agents,
|
| 4031 |
+
# The session travels with it. Without it the handler
|
| 4032 |
+
# authorised against an empty state, and an empty session
|
| 4033 |
+
# id is the one `create_session` reads as "mint a new
|
| 4034 |
+
# one" — one leaked session per model change, until the
|
| 4035 |
+
# registry filled and the whole server started erroring.
|
| 4036 |
+
inputs=[model, tools, agent, mode, session_state],
|
| 4037 |
outputs=[agent_table, agent, tools, library_note],
|
| 4038 |
queue=False,
|
| 4039 |
api_visibility="private",
|
|
|
|
| 4133 |
inputs=[session_state],
|
| 4134 |
# Into the panel, so the address and the code appear in one
|
| 4135 |
# command rather than in two places the reader has to join up.
|
| 4136 |
+
# The state travels with it because the code itself is kept
|
| 4137 |
+
# in this browser: the server has only a digest, so re-opening
|
| 4138 |
+
# can show the same code rather than mint another.
|
| 4139 |
+
outputs=[session_state, browser_state, join_panel],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4140 |
queue=False,
|
| 4141 |
api_visibility="private",
|
| 4142 |
)
|
|
|
|
| 4145 |
# every five seconds reads as a stall. The refresh is in-memory
|
| 4146 |
# and unqueued; the worker's own poll cadence is unchanged.
|
| 4147 |
timer = gr.Timer(2, active=True)
|
| 4148 |
+
timer_outputs = [
|
| 4149 |
+
session_state,
|
| 4150 |
+
browser_state,
|
| 4151 |
+
chat,
|
| 4152 |
+
run_status,
|
| 4153 |
+
energy_total,
|
| 4154 |
+
agent_table,
|
| 4155 |
+
agent,
|
| 4156 |
+
tools,
|
| 4157 |
+
library_note,
|
| 4158 |
+
outputs_files,
|
| 4159 |
+
activity_panel,
|
| 4160 |
+
conversation,
|
| 4161 |
+
rail_tones,
|
| 4162 |
+
]
|
| 4163 |
+
# A skipped tick returns one `gr.skip()` per output, and a count
|
| 4164 |
+
# that disagreed with this list would raise inside the guard whose
|
| 4165 |
+
# whole job is to stop the timer raising. Checked here, where both
|
| 4166 |
+
# halves are visible, rather than trusted to stay in step.
|
| 4167 |
+
assert len(timer_outputs) == self.TIMER_OUTPUTS
|
| 4168 |
timer.tick(
|
| 4169 |
self.refresh_session,
|
| 4170 |
inputs=[session_state, model, tools, agent, mode],
|
| 4171 |
+
outputs=timer_outputs,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4172 |
queue=False,
|
| 4173 |
api_visibility="private",
|
| 4174 |
)
|
distinct_server/ui_state.py
CHANGED
|
@@ -42,6 +42,167 @@ def new_session() -> Dict[str, Any]:
|
|
| 42 |
}
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def _validated_copy(state: Mapping[str, Any]) -> Dict[str, Any]:
|
| 46 |
value = copy.deepcopy(dict(state))
|
| 47 |
if not isinstance(value.get("session_id"), str):
|
|
@@ -70,6 +231,43 @@ def select_conversation(state: Mapping[str, Any], conversation_id: str) -> Dict[
|
|
| 70 |
return value
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def append_job(
|
| 74 |
state: Mapping[str, Any],
|
| 75 |
*,
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
|
| 45 |
+
#: The longest identifier the control plane will accept. Anything longer is
|
| 46 |
+
#: refused there with a `ValidationError`, and a browser holding one would
|
| 47 |
+
#: turn every page load into a five-hundred.
|
| 48 |
+
_MAX_ID = 128
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _identifier(value: Any) -> str:
|
| 52 |
+
"""``value`` if it can be a session or conversation id, else empty."""
|
| 53 |
+
|
| 54 |
+
if not isinstance(value, str):
|
| 55 |
+
return ""
|
| 56 |
+
text = value.strip()
|
| 57 |
+
if not text or len(text) > _MAX_ID or any(c in text for c in "\r\n"):
|
| 58 |
+
return ""
|
| 59 |
+
return text
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _string_list(value: Any, *, limit: int) -> List[str]:
|
| 63 |
+
if not isinstance(value, (list, tuple)):
|
| 64 |
+
return []
|
| 65 |
+
return [str(item) for item in value if isinstance(item, str)][:limit]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _mapping_list(value: Any, *, limit: int) -> List[Dict[str, Any]]:
|
| 69 |
+
if not isinstance(value, (list, tuple)):
|
| 70 |
+
return []
|
| 71 |
+
return [dict(item) for item in value if isinstance(item, Mapping)][:limit]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
#: How much of one browser's history is worth carrying forward. A transcript
|
| 75 |
+
#: is per-browser and unbounded otherwise, and the whole of it is re-rendered
|
| 76 |
+
#: on a two-second timer.
|
| 77 |
+
_MAX_CONVERSATIONS = 128
|
| 78 |
+
_MAX_TURNS = 512
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _number(value: Any) -> float | None:
|
| 82 |
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
| 83 |
+
return None
|
| 84 |
+
return float(value)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _adopt_answer(value: Any) -> Dict[str, Any] | None:
|
| 88 |
+
"""One kept answer, rebuilt to the shape `DistinctUI._answer_payload` writes.
|
| 89 |
+
|
| 90 |
+
Every field is here, and each is the type the renderer indexes into: the
|
| 91 |
+
receipt walks `trace` and `steps` as mappings, the transcript walks `made`
|
| 92 |
+
as mappings, and the estimator reads `duration_seconds` as a number. An
|
| 93 |
+
older build that spelled any of them differently turned the transcript
|
| 94 |
+
into a `TypeError` on every render — which is to say on every page load
|
| 95 |
+
and every timer tick. Anything not listed here is dropped, because a key
|
| 96 |
+
nothing reads is a key nothing can be broken by.
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
if not isinstance(value, Mapping):
|
| 100 |
+
return None
|
| 101 |
+
answer: Dict[str, Any] = {
|
| 102 |
+
"output": str(value.get("output", "")),
|
| 103 |
+
"agent": str(value.get("agent", "")),
|
| 104 |
+
"model": str(value.get("model", "")),
|
| 105 |
+
"mode": str(value.get("mode", "")),
|
| 106 |
+
"tools": [item for item in _mapping_list(value.get("tools"), limit=64) if "id" in item],
|
| 107 |
+
"steps": _mapping_list(value.get("steps"), limit=64),
|
| 108 |
+
"trace": _mapping_list(value.get("trace"), limit=64),
|
| 109 |
+
"made": _mapping_list(value.get("made"), limit=32),
|
| 110 |
+
"artifacts": _string_list(value.get("artifacts"), limit=64),
|
| 111 |
+
"energy": dict(value["energy"]) if isinstance(value.get("energy"), Mapping) else {},
|
| 112 |
+
"guard": dict(value["guard"]) if isinstance(value.get("guard"), Mapping) else {},
|
| 113 |
+
"tool_calls": int(_number(value.get("tool_calls")) or 0),
|
| 114 |
+
}
|
| 115 |
+
duration = _number(value.get("duration_seconds"))
|
| 116 |
+
if duration is not None:
|
| 117 |
+
answer["duration_seconds"] = duration
|
| 118 |
+
return answer
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def adopt_saved_state(saved: Any) -> Dict[str, Any] | None:
|
| 122 |
+
"""Today's shape, rebuilt from whatever an older build left in the browser.
|
| 123 |
+
|
| 124 |
+
THE BROWSER'S COPY IS INPUT, NOT STATE.
|
| 125 |
+
|
| 126 |
+
It is JSON written by some earlier version of this file, kept in
|
| 127 |
+
localStorage across every deploy since, and editable by anyone with a
|
| 128 |
+
developer console. `load_session` used to check four facts about the top
|
| 129 |
+
level of it — an id, a conversations mapping, an active id inside that
|
| 130 |
+
mapping — and then hand the rest straight to the renderer. Everything one
|
| 131 |
+
level down was trusted: a conversation that was a string, a conversation
|
| 132 |
+
with no `title`, an answer that was a string, an answer whose `made` was
|
| 133 |
+
not a list. Each of those raised out of `load_session`, which runs on
|
| 134 |
+
every single page load, so the app opened on a red error toast and stayed
|
| 135 |
+
that way until the person cleared their site data — and they had no way
|
| 136 |
+
to know that was the fix.
|
| 137 |
+
|
| 138 |
+
So the saved copy is rebuilt here field by field instead of inspected.
|
| 139 |
+
What cannot be read is dropped rather than carried, and a transcript with
|
| 140 |
+
nothing readable left in it returns None, which the caller reads as "start
|
| 141 |
+
fresh". Dropping an unreadable turn loses something; refusing to load the
|
| 142 |
+
page loses everything.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
if not isinstance(saved, Mapping):
|
| 146 |
+
return None
|
| 147 |
+
conversations: Dict[str, Any] = {}
|
| 148 |
+
raw = saved.get("conversations")
|
| 149 |
+
if not isinstance(raw, Mapping):
|
| 150 |
+
return None
|
| 151 |
+
for key, value in list(raw.items())[:_MAX_CONVERSATIONS]:
|
| 152 |
+
conversation_id = _identifier(key)
|
| 153 |
+
if not conversation_id or not isinstance(value, Mapping):
|
| 154 |
+
continue
|
| 155 |
+
job_ids = _string_list(value.get("job_ids"), limit=_MAX_TURNS)
|
| 156 |
+
prompts = _string_list(value.get("prompts"), limit=_MAX_TURNS)
|
| 157 |
+
# Padded rather than trimmed to the shorter of the two. A prompt list
|
| 158 |
+
# that fell behind its job list is an older bug's leftovers, and the
|
| 159 |
+
# turn is still worth showing with an empty question attached.
|
| 160 |
+
prompts += [""] * max(0, len(job_ids) - len(prompts))
|
| 161 |
+
answers: Dict[str, Any] = {}
|
| 162 |
+
raw_answers = value.get("answers")
|
| 163 |
+
if isinstance(raw_answers, Mapping):
|
| 164 |
+
for job_id, answer in raw_answers.items():
|
| 165 |
+
if not isinstance(job_id, str) or job_id not in job_ids:
|
| 166 |
+
continue
|
| 167 |
+
adopted = _adopt_answer(answer)
|
| 168 |
+
if adopted is not None:
|
| 169 |
+
answers[job_id] = adopted
|
| 170 |
+
title = value.get("title")
|
| 171 |
+
conversations[conversation_id] = {
|
| 172 |
+
"title": str(title) if isinstance(title, str) and title.strip() else UNTITLED,
|
| 173 |
+
"job_ids": job_ids,
|
| 174 |
+
"prompts": prompts[: len(job_ids)],
|
| 175 |
+
"answers": answers,
|
| 176 |
+
}
|
| 177 |
+
if not conversations:
|
| 178 |
+
return None
|
| 179 |
+
active = _identifier(saved.get("active_conversation_id"))
|
| 180 |
+
if active not in conversations:
|
| 181 |
+
active = next(iter(conversations))
|
| 182 |
+
state: Dict[str, Any] = {
|
| 183 |
+
# A session id this server would refuse is worse than no session id:
|
| 184 |
+
# it fails at the control plane, inside the handler, rather than here.
|
| 185 |
+
"session_id": _identifier(saved.get("session_id")) or _id("session"),
|
| 186 |
+
"active_conversation_id": active,
|
| 187 |
+
"conversations": conversations,
|
| 188 |
+
"created_at": saved.get("created_at")
|
| 189 |
+
if isinstance(saved.get("created_at"), (int, float))
|
| 190 |
+
else time.time(),
|
| 191 |
+
}
|
| 192 |
+
defaults = _string_list(saved.get("library_defaults"), limit=256)
|
| 193 |
+
if defaults:
|
| 194 |
+
state["library_defaults"] = defaults
|
| 195 |
+
# The pairing code is browser-owned and worth keeping: dropping it makes
|
| 196 |
+
# the next open of the setup panel mint another, and a person only gets
|
| 197 |
+
# eight live codes before the control plane refuses.
|
| 198 |
+
code = saved.get("pairing_code")
|
| 199 |
+
expires = saved.get("pairing_expires")
|
| 200 |
+
if isinstance(code, str) and code and isinstance(expires, (int, float)):
|
| 201 |
+
state["pairing_code"] = code
|
| 202 |
+
state["pairing_expires"] = float(expires)
|
| 203 |
+
return state
|
| 204 |
+
|
| 205 |
+
|
| 206 |
def _validated_copy(state: Mapping[str, Any]) -> Dict[str, Any]:
|
| 207 |
value = copy.deepcopy(dict(state))
|
| 208 |
if not isinstance(value.get("session_id"), str):
|
|
|
|
| 231 |
return value
|
| 232 |
|
| 233 |
|
| 234 |
+
def remove_conversation(state: Mapping[str, Any], conversation_id: str) -> Dict[str, Any]:
|
| 235 |
+
"""Drop a conversation and everything it held, choosing the next active one.
|
| 236 |
+
|
| 237 |
+
THE LAST ONE IS EMPTIED RATHER THAN REMOVED. A session with no conversation
|
| 238 |
+
has no valid ``active_conversation_id``, and every renderer downstream reads
|
| 239 |
+
that key; deleting the only one would leave the state in a shape the rest of
|
| 240 |
+
this module is entitled to assume cannot happen. Emptying it gives the
|
| 241 |
+
person what they asked for — the transcript is gone — without inventing a
|
| 242 |
+
state nothing else can render.
|
| 243 |
+
|
| 244 |
+
The jobs are dropped from this state, which is the browser's copy and the
|
| 245 |
+
only durable one: the server erases a prompt when its run ends and the
|
| 246 |
+
answer once it has been collected. The caller is responsible for telling
|
| 247 |
+
the control plane, which owns anything still in flight.
|
| 248 |
+
"""
|
| 249 |
+
|
| 250 |
+
value = _validated_copy(state)
|
| 251 |
+
if conversation_id not in value["conversations"]:
|
| 252 |
+
raise ValueError("conversation does not belong to this session")
|
| 253 |
+
|
| 254 |
+
if len(value["conversations"]) == 1:
|
| 255 |
+
fresh = _new_conversation()
|
| 256 |
+
value["conversations"] = {conversation_id: fresh}
|
| 257 |
+
value["active_conversation_id"] = conversation_id
|
| 258 |
+
return value
|
| 259 |
+
|
| 260 |
+
order = list(value["conversations"])
|
| 261 |
+
position = order.index(conversation_id)
|
| 262 |
+
del value["conversations"][conversation_id]
|
| 263 |
+
if value["active_conversation_id"] == conversation_id:
|
| 264 |
+
# The neighbour, preferring the one above, which is where the eye
|
| 265 |
+
# already is after a row disappears.
|
| 266 |
+
remaining = list(value["conversations"])
|
| 267 |
+
value["active_conversation_id"] = remaining[max(0, position - 1)]
|
| 268 |
+
return value
|
| 269 |
+
|
| 270 |
+
|
| 271 |
def append_job(
|
| 272 |
state: Mapping[str, Any],
|
| 273 |
*,
|
model_assessments.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
| 13 |
"Cradle to release. Never added to, never averaged with, and never compared against the use-phase energy this network measures per run."
|
| 14 |
],
|
| 15 |
"website_display": {
|
| 16 |
-
"warning": "These figures are comparable within a lab and rarely between labs. The two Ai2 releases below were measured by the same team, on the same clusters, with the same method and the same boundary, so the difference between them is real.
|
| 17 |
"further_reading": [
|
| 18 |
{
|
| 19 |
"label": "Ai2 and CMU on measuring a model's footprint (ICLR 2025)",
|
|
@@ -148,54 +148,6 @@
|
|
| 148 |
"url": "https://arxiv.org/abs/2503.05804"
|
| 149 |
}
|
| 150 |
]
|
| 151 |
-
},
|
| 152 |
-
{
|
| 153 |
-
"model_id": "llama-2-7b",
|
| 154 |
-
"name": "Llama 2 7B",
|
| 155 |
-
"publisher": "Meta",
|
| 156 |
-
"covers": "Llama 2 7B pretraining. The Chat checkpoint is a post-trained variant; Meta reports fine-tuning compute inside the family total without breaking it out per checkpoint.",
|
| 157 |
-
"coverage": "Partial",
|
| 158 |
-
"summary": "Kept because it is the only non-Ai2 release in this catalogue with a published, model-specific figure, and because it shows what a partial disclosure looks like next to a full one. Meta published GPU-hours and a carbon estimate, and nothing else: no energy, no water, no materials. The estimate is modelled from rated chip wattage rather than measured draw, so it is a different kind of number from the two above and should not be lined up against them. Meta also reports a second carbon column reading zero, on the grounds that they buy enough renewable electricity annually to match what they used. That is an accounting position, not a physical one; the figure shown here is the electricity the grid actually delivered.",
|
| 159 |
-
"categories": {
|
| 160 |
-
"energy": {
|
| 161 |
-
"state": "missing",
|
| 162 |
-
"note": "Meta disclosed 184,320 A100-80GB GPU-hours at 400 W rated draw, but published no energy figure. Multiplying those out here would be an estimate this catalogue invented, so the area stays Missing and the disclosure is named instead."
|
| 163 |
-
},
|
| 164 |
-
"climate": {
|
| 165 |
-
"state": "reported",
|
| 166 |
-
"status": "Modelled",
|
| 167 |
-
"value": "31.22",
|
| 168 |
-
"unit": "tCO2e",
|
| 169 |
-
"note": "Modelled by Meta from GPU-hours and rated device power. Location-based. Meta separately reports the figure as fully offset, which nets to zero on paper but not on the grid.",
|
| 170 |
-
"source_url": "https://arxiv.org/abs/2307.09288"
|
| 171 |
-
},
|
| 172 |
-
"water": {
|
| 173 |
-
"state": "missing",
|
| 174 |
-
"note": "Not disclosed."
|
| 175 |
-
},
|
| 176 |
-
"land": {
|
| 177 |
-
"state": "missing",
|
| 178 |
-
"note": "Not disclosed. No published per-model land figure exists for any model, from any lab."
|
| 179 |
-
},
|
| 180 |
-
"materials": {
|
| 181 |
-
"state": "missing",
|
| 182 |
-
"note": "Not disclosed. Embodied hardware impact is absent from Meta's accounting."
|
| 183 |
-
},
|
| 184 |
-
"pollution": {
|
| 185 |
-
"state": "missing",
|
| 186 |
-
"note": "Not disclosed."
|
| 187 |
-
}
|
| 188 |
-
},
|
| 189 |
-
"links": [
|
| 190 |
-
{
|
| 191 |
-
"label": "Model card",
|
| 192 |
-
"url": "https://huggingface.co/meta-llama/Llama-2-7b-chat-hf"
|
| 193 |
-
},
|
| 194 |
-
{
|
| 195 |
-
"label": "Published assessment",
|
| 196 |
-
"url": "https://arxiv.org/abs/2307.09288"
|
| 197 |
-
}
|
| 198 |
-
]
|
| 199 |
}
|
| 200 |
]
|
| 201 |
}
|
|
|
|
| 13 |
"Cradle to release. Never added to, never averaged with, and never compared against the use-phase energy this network measures per run."
|
| 14 |
],
|
| 15 |
"website_display": {
|
| 16 |
+
"warning": "These figures are comparable within a lab and rarely between labs. The two Ai2 releases below were measured by the same team, on the same clusters, with the same method and the same boundary, so the difference between them is real. Every number covers the final pretraining run only: Ai2's own follow-up work found that development and failed runs accounted for 82.2% of total GPU-hours, so every figure on this page is a floor.",
|
| 17 |
"further_reading": [
|
| 18 |
{
|
| 19 |
"label": "Ai2 and CMU on measuring a model's footprint (ICLR 2025)",
|
|
|
|
| 148 |
"url": "https://arxiv.org/abs/2503.05804"
|
| 149 |
}
|
| 150 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
]
|
| 153 |
}
|
pyproject.toml
CHANGED
|
@@ -7,16 +7,24 @@ name = "distinct-server"
|
|
| 7 |
version = "0.1.0"
|
| 8 |
description = "Session-only Gradio control plane for community-run open-weight LLM agents"
|
| 9 |
requires-python = ">=3.10"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
[
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
# Ed25519, for the detached snapshot signature. A direct requirement, not
|
| 15 |
# a transitive one: `distinct_protocol.handshake` imports it and refuses
|
| 16 |
-
# to fall back to HMAC, so an
|
| 17 |
-
#
|
| 18 |
"cryptography>=42",
|
| 19 |
-
"dspy[deno]==3.3.0",
|
| 20 |
"gradio_client==2.6.0",
|
| 21 |
"pyRAPL==0.2.3.1; sys_platform == 'linux'",
|
| 22 |
# The worker's own screen. Pinned rather than ranged because the frozen
|
|
@@ -25,6 +33,14 @@ agent = [
|
|
| 25 |
# this repository would catch.
|
| 26 |
"textual==8.2.8",
|
| 27 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
dev = [
|
| 29 |
"pytest==8.4.2",
|
| 30 |
"ruff==0.12.11",
|
|
|
|
| 7 |
version = "0.1.0"
|
| 8 |
description = "Session-only Gradio control plane for community-run open-weight LLM agents"
|
| 9 |
requires-python = ">=3.10"
|
| 10 |
+
# WHAT A PLAIN `pip install -e .` HAS TO GIVE YOU: A WORKING WORKER.
|
| 11 |
+
#
|
| 12 |
+
# These were all behind the `[agent]` extra, which meant `pip install -e .`
|
| 13 |
+
# produced a worker that paired, ran, and then died the first time somebody
|
| 14 |
+
# asked for its dashboard — an ImportError for a package the install had
|
| 15 |
+
# quietly decided was optional. Optional to whom? Everybody who installs this
|
| 16 |
+
# repository outside the Space is installing it to run a worker.
|
| 17 |
+
#
|
| 18 |
+
# Costing the server nothing is what makes this free: HuggingFace Spaces
|
| 19 |
+
# installs `requirements.txt` and the SDK version in README.md, and never
|
| 20 |
+
# runs `pip install .` at all, so nothing here reaches the deployment.
|
| 21 |
+
dependencies = [
|
| 22 |
+
"gradio[oauth]==6.24.0",
|
| 23 |
# Ed25519, for the detached snapshot signature. A direct requirement, not
|
| 24 |
# a transitive one: `distinct_protocol.handshake` imports it and refuses
|
| 25 |
+
# to fall back to HMAC, so an install that happens to miss it fails at the
|
| 26 |
+
# moment a worker pairs rather than at install time.
|
| 27 |
"cryptography>=42",
|
|
|
|
| 28 |
"gradio_client==2.6.0",
|
| 29 |
"pyRAPL==0.2.3.1; sys_platform == 'linux'",
|
| 30 |
# The worker's own screen. Pinned rather than ranged because the frozen
|
|
|
|
| 33 |
# this repository would catch.
|
| 34 |
"textual==8.2.8",
|
| 35 |
]
|
| 36 |
+
|
| 37 |
+
[project.optional-dependencies]
|
| 38 |
+
# The DSPy RLM harness only. It is a large install and the worker degrades to
|
| 39 |
+
# the structured harness without it, saying so on start, so it is the one
|
| 40 |
+
# thing here that is genuinely optional.
|
| 41 |
+
agent = [
|
| 42 |
+
"dspy[deno]==3.3.0",
|
| 43 |
+
]
|
| 44 |
dev = [
|
| 45 |
"pytest==8.4.2",
|
| 46 |
"ruff==0.12.11",
|
scripts/test_library_members.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test every library member through the real stack.
|
| 2 |
+
|
| 3 |
+
For each member: a fresh conversation, a request carrying the deterministic
|
| 4 |
+
``[[demo-call:...]]`` directive, a real queue, a real worker executing the
|
| 5 |
+
real handler, and a real artifact back. Every member of the library is
|
| 6 |
+
exercised end to end, and one that cannot produce its artifact fails the run.
|
| 7 |
+
|
| 8 |
+
THIS USED TO FILM EACH MEMBER AND IT NO LONGER DOES.
|
| 9 |
+
|
| 10 |
+
The frames became a GIF on each library card. The cards now draw what their
|
| 11 |
+
member makes, which answers "what is this for" at a glance and on a phone,
|
| 12 |
+
where a hover-to-play recording answered nothing. The GIFs are gone and so is
|
| 13 |
+
the capture; the run they were evidence of is the part worth keeping, so this
|
| 14 |
+
is now a plain test.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import re
|
| 21 |
+
import subprocess
|
| 22 |
+
import sys
|
| 23 |
+
import threading
|
| 24 |
+
import time
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 28 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 29 |
+
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
| 30 |
+
|
| 31 |
+
from live_e2e import ( # noqa: E402
|
| 32 |
+
CLIENT_ID,
|
| 33 |
+
CLIENT_SECRET,
|
| 34 |
+
enter_the_app,
|
| 35 |
+
free_port,
|
| 36 |
+
generate_pairing_code,
|
| 37 |
+
redeem,
|
| 38 |
+
sign_in,
|
| 39 |
+
start_provider,
|
| 40 |
+
wait_for,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
#: ref -> (spoken request, directive arguments). The spoken half is what a
|
| 44 |
+
#: person would type; the directive half is what the deterministic runner
|
| 45 |
+
#: turns into the real tool call. Together they are the test.
|
| 46 |
+
CASES: dict[str, tuple[str, dict]] = {
|
| 47 |
+
"create_pdf@1": (
|
| 48 |
+
"Turn these notes into a PDF please",
|
| 49 |
+
{"title": "Field notes", "body": "Open weights on lent machines.\n\nEnergy read off a meter, never guessed."},
|
| 50 |
+
),
|
| 51 |
+
"make_plan@1": (
|
| 52 |
+
"Write up a plan for the community launch",
|
| 53 |
+
{"goal": "Launch the community worker network",
|
| 54 |
+
"steps": ["Pair two volunteer workers", "Run the smoke suite", "Open sign-ups"]},
|
| 55 |
+
),
|
| 56 |
+
"create_docx@1": (
|
| 57 |
+
"I need that as a Word document",
|
| 58 |
+
{"title": "Quarterly note", "body": "# Summary\nRevenue rose.\nCosts fell."},
|
| 59 |
+
),
|
| 60 |
+
"create_xlsx@1": (
|
| 61 |
+
"Make me a spreadsheet of the inventory",
|
| 62 |
+
{"text": "item | qty | price\nWidget | 4 | 9.50\nBolt, large | 12 | 0.35"},
|
| 63 |
+
),
|
| 64 |
+
"create_deck@1": (
|
| 65 |
+
"Turn this outline into slides",
|
| 66 |
+
{"slides": "distinct :: measured energy; open weights\nHow it works :: pair a worker; queue a run; read the meter"},
|
| 67 |
+
),
|
| 68 |
+
"csv_table@1": (
|
| 69 |
+
"Give me that table as a CSV",
|
| 70 |
+
{"text": "name | role\nAda | analysis\nGrace | systems"},
|
| 71 |
+
),
|
| 72 |
+
"status_update@1": (
|
| 73 |
+
"Draft this week's status update",
|
| 74 |
+
{"team": "Worker network", "period": "This week",
|
| 75 |
+
"progress": "Two new volunteer machines paired.",
|
| 76 |
+
"plans": "Add the GPU meter to the ladder.",
|
| 77 |
+
"problems": "One worker reports no usable counter."},
|
| 78 |
+
),
|
| 79 |
+
"review_checklist@1": (
|
| 80 |
+
"Make a review checklist for the energy patch",
|
| 81 |
+
{"subject": "Energy meter patch",
|
| 82 |
+
"claims": ["The GPU figure is measured, not modelled",
|
| 83 |
+
"Scopes are never summed",
|
| 84 |
+
"A missing reading never counts as zero"]},
|
| 85 |
+
),
|
| 86 |
+
"skill_scaffold@1": (
|
| 87 |
+
"Scaffold a new skill for meeting notes",
|
| 88 |
+
{"name": "meeting-notes",
|
| 89 |
+
"description": "Turn raw meeting notes into a clean summary document.",
|
| 90 |
+
"steps": ["Collect the raw notes", "Group them by decision", "Write the summary"]},
|
| 91 |
+
),
|
| 92 |
+
"frontend_design@1": (
|
| 93 |
+
"Build me a landing page for the project",
|
| 94 |
+
{"title": "distinct", "sections": "What it is :: Small models on lent machines.\nWhy :: The meter is the product."},
|
| 95 |
+
),
|
| 96 |
+
"web_artifacts_builder@1": (
|
| 97 |
+
"Plan the interactive dashboard artifact",
|
| 98 |
+
{"subject": "Energy dashboard artifact", "context": "Live joules per worker, one HTML file."},
|
| 99 |
+
),
|
| 100 |
+
"theme_factory@1": (
|
| 101 |
+
"Make a CSS theme in sage tones",
|
| 102 |
+
{"name": "Sage", "colors": "primary: #2E5E43\nsurface: #FAF9F5\nink: #203127"},
|
| 103 |
+
),
|
| 104 |
+
"brand_guidelines@1": (
|
| 105 |
+
"Apply our brand to the new deck",
|
| 106 |
+
{"subject": "Launch deck", "context": "Sage green on cream, Georgia headings."},
|
| 107 |
+
),
|
| 108 |
+
"mcp_builder@1": (
|
| 109 |
+
"Plan an MCP server for our metrics API",
|
| 110 |
+
{"subject": "Metrics API MCP server", "context": "Read-only REST API, token auth."},
|
| 111 |
+
),
|
| 112 |
+
"webapp_testing@1": (
|
| 113 |
+
"Write the browser test plan for sign-in",
|
| 114 |
+
{"subject": "Sign-in flow", "context": "OAuth redirect, then the app shell loads."},
|
| 115 |
+
),
|
| 116 |
+
"claude_api@1": (
|
| 117 |
+
"Plan the Claude API integration",
|
| 118 |
+
{"subject": "Support inbox triage bot", "context": "Python, low volume, cost matters."},
|
| 119 |
+
),
|
| 120 |
+
"algorithmic_art@1": (
|
| 121 |
+
"Plan a generative art piece about rivers",
|
| 122 |
+
{"subject": "River braiding", "context": "Blues and sand, seeded, printable."},
|
| 123 |
+
),
|
| 124 |
+
"canvas_design@1": (
|
| 125 |
+
"Design a poster for the meetup",
|
| 126 |
+
{"subject": "Community compute meetup", "context": "A2 poster, evening light mood."},
|
| 127 |
+
),
|
| 128 |
+
"academy_guide@1": (
|
| 129 |
+
"Build me a learning path for local LLMs",
|
| 130 |
+
{"subject": "Running local LLMs", "context": "Knows Python, has a gaming PC, 4 weeks."},
|
| 131 |
+
),
|
| 132 |
+
"discernment_nudge@1": (
|
| 133 |
+
"Give me a discernment nudge on this rollout",
|
| 134 |
+
{"subject": "Shipping the worker auto-update", "context": "Team is eager; rollback story unclear."},
|
| 135 |
+
),
|
| 136 |
+
"slack_gif_creator@1": (
|
| 137 |
+
"Plan a celebration GIF for the release",
|
| 138 |
+
{"subject": "v1 shipped", "context": "Confetti over the wordmark, two seconds."},
|
| 139 |
+
),
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def record(page, ref: str, spoken: str, arguments: dict) -> None:
|
| 144 |
+
"""One member: request in, run through, artifact out.
|
| 145 |
+
|
| 146 |
+
The assertion at the end is the point of the whole script. Every member of
|
| 147 |
+
this library exists to produce something, so a run that completes without
|
| 148 |
+
an artifact card in the transcript has not demonstrated the member — it has
|
| 149 |
+
demonstrated the plumbing around it.
|
| 150 |
+
"""
|
| 151 |
+
|
| 152 |
+
page.get_by_role("button", name=re.compile("^New conversation$", re.I)).click(timeout=15000)
|
| 153 |
+
page.wait_for_timeout(900)
|
| 154 |
+
|
| 155 |
+
prompt = f"{spoken}\n[[demo-call:{ref} {json.dumps(arguments)}]]"
|
| 156 |
+
box = page.get_by_label(re.compile("Your request", re.I))
|
| 157 |
+
box.fill(prompt, timeout=15000)
|
| 158 |
+
|
| 159 |
+
# Exactly one member selected: the one under test. A job carries at most
|
| 160 |
+
# sixteen selections, and a run that selects the whole shelf has not tested
|
| 161 |
+
# anything in particular.
|
| 162 |
+
wanted = ref.split("@", 1)[0].replace("_", " ")
|
| 163 |
+
picker = page.locator(".c-composer-tools input[type=checkbox]")
|
| 164 |
+
for index in range(picker.count()):
|
| 165 |
+
member = picker.nth(index)
|
| 166 |
+
if member.is_checked():
|
| 167 |
+
member.uncheck(timeout=5000, force=True)
|
| 168 |
+
page.wait_for_timeout(250)
|
| 169 |
+
target = page.locator(".c-composer-tools label", has_text=f"{wanted} ·")
|
| 170 |
+
if target.count() == 0:
|
| 171 |
+
raise RuntimeError(f"{ref}: not offered in the composer")
|
| 172 |
+
target.first.locator("input[type=checkbox]").check(timeout=5000, force=True)
|
| 173 |
+
page.wait_for_timeout(400)
|
| 174 |
+
page.get_by_role(
|
| 175 |
+
"checkbox", name=re.compile("plaintext to a community-operated worker", re.I)
|
| 176 |
+
).check(timeout=15000)
|
| 177 |
+
page.wait_for_timeout(200)
|
| 178 |
+
page.get_by_role("button", name=re.compile("^Queue run$", re.I)).click(timeout=15000)
|
| 179 |
+
|
| 180 |
+
deadline = time.monotonic() + 45
|
| 181 |
+
done = False
|
| 182 |
+
while time.monotonic() < deadline:
|
| 183 |
+
if "requested library call" in page.inner_text("body"):
|
| 184 |
+
done = True
|
| 185 |
+
break
|
| 186 |
+
page.wait_for_timeout(1000)
|
| 187 |
+
if not done:
|
| 188 |
+
raise RuntimeError(f"{ref}: the run did not complete in time")
|
| 189 |
+
page.wait_for_timeout(1500)
|
| 190 |
+
|
| 191 |
+
if "requested library call" not in page.inner_text("body"):
|
| 192 |
+
raise RuntimeError(f"{ref}: the answer does not show the tool ran")
|
| 193 |
+
made = page.locator("figure.c-madecard")
|
| 194 |
+
if made.count() == 0:
|
| 195 |
+
raise RuntimeError(f"{ref}: the run produced no artifact")
|
| 196 |
+
summary = " | ".join(made.first.inner_text().split("\n"))[:70]
|
| 197 |
+
print(f" {ref:26s} PASS {summary}")
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def main() -> int:
|
| 201 |
+
import os
|
| 202 |
+
|
| 203 |
+
from playwright.sync_api import sync_playwright
|
| 204 |
+
|
| 205 |
+
provider_url, provider = start_provider()
|
| 206 |
+
port = free_port()
|
| 207 |
+
server_url = f"http://127.0.0.1:{port}"
|
| 208 |
+
environment = {
|
| 209 |
+
**os.environ,
|
| 210 |
+
"DISTINCT_OAUTH_CLIENT_ID": CLIENT_ID,
|
| 211 |
+
"DISTINCT_OAUTH_CLIENT_SECRET": CLIENT_SECRET,
|
| 212 |
+
"DISTINCT_OAUTH_REDIRECT_URI": f"{server_url}/auth/callback",
|
| 213 |
+
"DISTINCT_OAUTH_PROVIDER_URL": provider_url,
|
| 214 |
+
"DISTINCT_SESSION_SECRET": "a-fixed-secret-so-this-run-is-repeatable",
|
| 215 |
+
"DISTINCT_BIND_HOST": "127.0.0.1",
|
| 216 |
+
"PORT": str(port),
|
| 217 |
+
"PYTHONPATH": str(REPO_ROOT),
|
| 218 |
+
}
|
| 219 |
+
environment.pop("DISTINCT_DEV_AUTH", None)
|
| 220 |
+
server_log = open("/tmp/record-server.log", "w")
|
| 221 |
+
server = subprocess.Popen(
|
| 222 |
+
[sys.executable, str(REPO_ROOT / "app.py")],
|
| 223 |
+
cwd=str(REPO_ROOT), env=environment,
|
| 224 |
+
stdout=server_log, stderr=subprocess.STDOUT, text=True,
|
| 225 |
+
)
|
| 226 |
+
workers: list[subprocess.Popen] = []
|
| 227 |
+
failures: list[str] = []
|
| 228 |
+
try:
|
| 229 |
+
wait_for(server_url)
|
| 230 |
+
with sync_playwright() as playwright:
|
| 231 |
+
browser = playwright.chromium.launch()
|
| 232 |
+
context = browser.new_context(viewport={"width": 1280, "height": 860})
|
| 233 |
+
page = context.new_page()
|
| 234 |
+
page.goto(server_url, wait_until="load")
|
| 235 |
+
sign_in(page, server_url, "alice")
|
| 236 |
+
enter_the_app(page)
|
| 237 |
+
pairing = generate_pairing_code(page)
|
| 238 |
+
|
| 239 |
+
tool_refs = ",".join(sorted(CASES))
|
| 240 |
+
worker_environment = {
|
| 241 |
+
**environment,
|
| 242 |
+
# The worker advertises what its operator policy names, not
|
| 243 |
+
# what --tools asks for: without this the registry is empty
|
| 244 |
+
# and the composer shows "no tools installed" forever.
|
| 245 |
+
"DISTINCT_APPROVED_TOOLS": tool_refs,
|
| 246 |
+
}
|
| 247 |
+
process = subprocess.Popen(
|
| 248 |
+
[
|
| 249 |
+
sys.executable, "-m", "distinct_agent",
|
| 250 |
+
"--server", server_url, "--pair", pairing,
|
| 251 |
+
"--name", "demo-recorder", "--approve", "--demo-runner",
|
| 252 |
+
"--tools", tool_refs,
|
| 253 |
+
],
|
| 254 |
+
cwd=str(REPO_ROOT), env=worker_environment,
|
| 255 |
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
| 256 |
+
)
|
| 257 |
+
workers.append(process)
|
| 258 |
+
access = None
|
| 259 |
+
deadline = time.monotonic() + 120
|
| 260 |
+
while time.monotonic() < deadline and access is None:
|
| 261 |
+
line = process.stdout.readline()
|
| 262 |
+
if not line:
|
| 263 |
+
if process.poll() is not None:
|
| 264 |
+
raise RuntimeError("worker exited before printing an access code")
|
| 265 |
+
continue
|
| 266 |
+
found = re.search(r"\b([A-Z0-9]{5}(?:-[A-Z0-9]{4,5}){5,8})\b", line)
|
| 267 |
+
if found:
|
| 268 |
+
access = found.group(1)
|
| 269 |
+
if access is None:
|
| 270 |
+
raise RuntimeError("no access code appeared")
|
| 271 |
+
threading.Thread(
|
| 272 |
+
target=lambda: [None for _ in process.stdout], daemon=True
|
| 273 |
+
).start()
|
| 274 |
+
|
| 275 |
+
page.reload(wait_until="load")
|
| 276 |
+
enter_the_app(page)
|
| 277 |
+
page.wait_for_timeout(2500)
|
| 278 |
+
redeem(page, access)
|
| 279 |
+
page.wait_for_timeout(4000)
|
| 280 |
+
|
| 281 |
+
# Wait for the worker's advertised members to reach the
|
| 282 |
+
# composer picker: they enter snapshots only after approval, so
|
| 283 |
+
# the first load can race them.
|
| 284 |
+
for _ in range(10):
|
| 285 |
+
if page.locator(".c-composer-tools input[type=checkbox]").count() > 0:
|
| 286 |
+
break
|
| 287 |
+
page.wait_for_timeout(1500)
|
| 288 |
+
page.reload(wait_until="load")
|
| 289 |
+
enter_the_app(page)
|
| 290 |
+
page.wait_for_timeout(2000)
|
| 291 |
+
offered = page.locator(".c-composer-tools input[type=checkbox]").count()
|
| 292 |
+
print(f" composer offers {offered} member(s)")
|
| 293 |
+
if offered == 0:
|
| 294 |
+
raise RuntimeError("the composer picker never populated")
|
| 295 |
+
|
| 296 |
+
for ref in sorted(CASES):
|
| 297 |
+
spoken, arguments = CASES[ref]
|
| 298 |
+
try:
|
| 299 |
+
record(page, ref, spoken, arguments)
|
| 300 |
+
except Exception as exc: # noqa: BLE001 - report and continue
|
| 301 |
+
failures.append(f"{ref}: {exc}")
|
| 302 |
+
print(f" {ref:26s} FAIL {exc}")
|
| 303 |
+
browser.close()
|
| 304 |
+
finally:
|
| 305 |
+
for process in workers:
|
| 306 |
+
if process.poll() is None:
|
| 307 |
+
process.terminate()
|
| 308 |
+
try:
|
| 309 |
+
process.wait(timeout=10)
|
| 310 |
+
except subprocess.TimeoutExpired:
|
| 311 |
+
process.kill()
|
| 312 |
+
server.terminate()
|
| 313 |
+
try:
|
| 314 |
+
server.wait(timeout=15)
|
| 315 |
+
except subprocess.TimeoutExpired:
|
| 316 |
+
server.kill()
|
| 317 |
+
provider.shutdown()
|
| 318 |
+
if failures:
|
| 319 |
+
print(f"\n{len(failures)} member(s) failed; no GIF was written for them.")
|
| 320 |
+
return 1
|
| 321 |
+
print(f"\nAll {len(CASES)} members produced their artifact.")
|
| 322 |
+
return 0
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
if __name__ == "__main__":
|
| 326 |
+
raise SystemExit(main())
|