transcript-viewer 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- transcript_viewer/__init__.py +5 -0
- transcript_viewer/ai.py +374 -0
- transcript_viewer/cli.py +87 -0
- transcript_viewer/config.py +193 -0
- transcript_viewer/corpus.py +340 -0
- transcript_viewer/fetch.py +771 -0
- transcript_viewer/library.py +169 -0
- transcript_viewer/page.html +2248 -0
- transcript_viewer/store.py +83 -0
- transcript_viewer/viewer.py +1000 -0
- transcript_viewer-0.5.0.dist-info/METADATA +440 -0
- transcript_viewer-0.5.0.dist-info/RECORD +14 -0
- transcript_viewer-0.5.0.dist-info/WHEEL +4 -0
- transcript_viewer-0.5.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
"""Local trajectory viewer.
|
|
2
|
+
|
|
3
|
+
Serves a single self-contained page on loopback. Trajectories are converted on
|
|
4
|
+
demand and cached in memory, so opening the viewer over a large corpus is cheap
|
|
5
|
+
and only the sessions actually opened pay conversion cost.
|
|
6
|
+
|
|
7
|
+
The server binds 127.0.0.1 only — it reads local session logs, which routinely
|
|
8
|
+
contain source code and credentials in tool output, and must never be reachable
|
|
9
|
+
off-host.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import errno
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
import webbrowser
|
|
22
|
+
from functools import partial
|
|
23
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
26
|
+
|
|
27
|
+
from atif_make.atif import ContentPart, Trajectory
|
|
28
|
+
from . import corpus
|
|
29
|
+
from atif_make.archive import extract, is_archive
|
|
30
|
+
from atif_make.convert import convert
|
|
31
|
+
from transcript_viewer.corpus import Entry, scan
|
|
32
|
+
|
|
33
|
+
from . import ai, config, fetch, library
|
|
34
|
+
|
|
35
|
+
# The page is a real .html file rather than a string in here: it is 2,232 lines
|
|
36
|
+
# of CSS and JavaScript, which is not Python and should not be typed as though
|
|
37
|
+
# it were. Read once at import, and served from memory.
|
|
38
|
+
PAGE = (Path(__file__).parent / "page.html").read_text(encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _point_images_at_server(trajectory: Trajectory, index: str) -> None:
|
|
42
|
+
"""Rewrite relative image paths to a URL this server can answer.
|
|
43
|
+
|
|
44
|
+
On disk an image path resolves next to the trajectory file, but nothing is
|
|
45
|
+
written to disk here — the bytes are in memory, so they need an endpoint.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def parts(value):
|
|
49
|
+
return [p for p in value if isinstance(p, ContentPart)] if isinstance(value, list) else []
|
|
50
|
+
|
|
51
|
+
def walk(t: Trajectory) -> None:
|
|
52
|
+
for step in t.steps:
|
|
53
|
+
targets = list(parts(step.message))
|
|
54
|
+
for result in (step.observation.results if step.observation else ()):
|
|
55
|
+
targets += parts(result.content)
|
|
56
|
+
for part in targets:
|
|
57
|
+
if part.type == "image" and part.source and not part.source.path.startswith(
|
|
58
|
+
("http://", "https://", "data:")
|
|
59
|
+
):
|
|
60
|
+
name = part.source.path.rsplit("/", 1)[-1]
|
|
61
|
+
part.source.path = f"/api/image?id={index}&name={name}"
|
|
62
|
+
for sub in t.subagent_trajectories or ():
|
|
63
|
+
walk(sub)
|
|
64
|
+
|
|
65
|
+
walk(trajectory)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _reveal(target: Path) -> bool:
|
|
69
|
+
"""Show a path in the OS file manager.
|
|
70
|
+
|
|
71
|
+
Reveal-only, never open: `open -R` selects the item in Finder rather than
|
|
72
|
+
launching whatever application is registered for it, so clicking a path in
|
|
73
|
+
a log cannot execute anything.
|
|
74
|
+
"""
|
|
75
|
+
if not target.exists():
|
|
76
|
+
return False
|
|
77
|
+
if sys.platform == "darwin":
|
|
78
|
+
command = ["open", "-R", str(target)]
|
|
79
|
+
elif sys.platform.startswith("linux") and shutil.which("xdg-open"):
|
|
80
|
+
# xdg-open has no reveal equivalent, so open the containing directory.
|
|
81
|
+
command = ["xdg-open", str(target if target.is_dir() else target.parent)]
|
|
82
|
+
else:
|
|
83
|
+
return False
|
|
84
|
+
try:
|
|
85
|
+
subprocess.run(command, check=False, timeout=10)
|
|
86
|
+
except (OSError, subprocess.SubprocessError):
|
|
87
|
+
return False
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Enough of a log to inspect its shape without shipping a 143 MB file.
|
|
92
|
+
RAW_LIMIT = 512 * 1024
|
|
93
|
+
|
|
94
|
+
# A browser upload crosses loopback, so this is generous; it exists to stop a
|
|
95
|
+
# stray multi-gigabyte archive from filling the disk, not to be restrictive.
|
|
96
|
+
UPLOAD_LIMIT = 2 * 1024 * 1024 * 1024
|
|
97
|
+
|
|
98
|
+
# Where opened files live. atif-make owns the location because it also has to
|
|
99
|
+
# recognise one during a scan; a second constant here could drift from it.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _groups(rows: list[dict]) -> list[str]:
|
|
103
|
+
"""Every node of the tree, implied parents included, in reading order."""
|
|
104
|
+
seen: set[str] = set()
|
|
105
|
+
for row in rows:
|
|
106
|
+
parts = [p for p in row["group"].split("/") if p]
|
|
107
|
+
for depth in range(1, len(parts) + 1):
|
|
108
|
+
seen.add("/".join(parts[:depth]))
|
|
109
|
+
# Local before Remote, then alphabetically within each.
|
|
110
|
+
return sorted(seen, key=lambda path: (not path.startswith("Local"), path))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
SITES = {"huggingface.co": "Hugging Face", "github.com": "GitHub"}
|
|
114
|
+
AGENTS = {"claude-code": "Claude Code", "codex": "Codex", "copilot": "Copilot"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _group(entry, source: str) -> str:
|
|
118
|
+
"""Where a session belongs in the tree.
|
|
119
|
+
|
|
120
|
+
Derived, never stored. Collections were a second way to organise laid over
|
|
121
|
+
one that already existed — where a transcript came from — and the two could
|
|
122
|
+
disagree. This reads the truth instead: local sessions under the project
|
|
123
|
+
that produced them, remote ones under the repository they came from. Tags
|
|
124
|
+
remain for grouping that cuts across both.
|
|
125
|
+
"""
|
|
126
|
+
if entry.origin == "fetched":
|
|
127
|
+
if not source:
|
|
128
|
+
return "Remote/Elsewhere"
|
|
129
|
+
if source.startswith("s3://"):
|
|
130
|
+
rest = source[len("s3://") :].strip("/")
|
|
131
|
+
return "/".join(["Remote", "S3", *[p for p in rest.split("/") if p]])
|
|
132
|
+
parsed = urlparse(source)
|
|
133
|
+
site = SITES.get(parsed.netloc, parsed.netloc or "Elsewhere")
|
|
134
|
+
parts = [p for p in parsed.path.split("/") if p]
|
|
135
|
+
if parsed.netloc == "huggingface.co" and parts[:1] == ["datasets"]:
|
|
136
|
+
parts = parts[1:]
|
|
137
|
+
# <owner>/<repo>/tree/<rev>/<inner…>
|
|
138
|
+
inner = parts[4:] if len(parts) > 4 and parts[2:3] in (["tree"], ["blob"]) else []
|
|
139
|
+
return "/".join(["Remote", site, *parts[:2], *inner])
|
|
140
|
+
|
|
141
|
+
# Local mirrors Remote: the thing that produced it, then the unit of work.
|
|
142
|
+
agent = AGENTS.get(entry.agent, entry.agent or "Unknown")
|
|
143
|
+
if entry.origin == "opened":
|
|
144
|
+
return f"Local/Opened/{agent}"
|
|
145
|
+
|
|
146
|
+
# A project is a directory path; only its own name belongs in the tree, or
|
|
147
|
+
# every session would nest one node deep per directory above it.
|
|
148
|
+
project = Path(entry.project).name if entry.project else Path(entry.path).parent.name
|
|
149
|
+
return f"Local/{agent}/{project or 'Elsewhere'}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _source_of(entry, home: Path, web: str, unpacked: dict | None = None) -> str:
|
|
153
|
+
"""Where a fetched session came from, folder and all.
|
|
154
|
+
|
|
155
|
+
A file that arrived inside an archive is placed by the archive, not by the
|
|
156
|
+
directories the archive happens to contain: a zip whose insides are called
|
|
157
|
+
"transcripts/transcripts" should not read that way in the tree.
|
|
158
|
+
"""
|
|
159
|
+
if not web:
|
|
160
|
+
return ""
|
|
161
|
+
here = Path(entry.path).parent
|
|
162
|
+
for root, came_from in (unpacked or {}).items():
|
|
163
|
+
if here == root or root in here.parents:
|
|
164
|
+
here = came_from
|
|
165
|
+
break
|
|
166
|
+
try:
|
|
167
|
+
inner = here.relative_to(home).as_posix()
|
|
168
|
+
except ValueError:
|
|
169
|
+
inner = ""
|
|
170
|
+
return web if inner in (".", "") else f"{web}/{inner}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _ai_state() -> dict:
|
|
174
|
+
"""What the page may know about credentials: whether, and from where.
|
|
175
|
+
|
|
176
|
+
Never the key itself — only a four-character tail, enough to tell two
|
|
177
|
+
apart. A page that cannot read the key cannot leak it.
|
|
178
|
+
"""
|
|
179
|
+
ok, reason = ai.status()
|
|
180
|
+
return {
|
|
181
|
+
"available": ok,
|
|
182
|
+
"reason": reason,
|
|
183
|
+
"source": config.source("anthropic"),
|
|
184
|
+
"hint": config.hint("anthropic"),
|
|
185
|
+
"model": ai.MODEL,
|
|
186
|
+
# Every credential the viewer can hold — names and tails, never values.
|
|
187
|
+
"secrets": config.state(),
|
|
188
|
+
"aws_profile": config.aws_profile(),
|
|
189
|
+
"aws_profiles": fetch._profiles(),
|
|
190
|
+
"hosts": sorted(set(fetch.HOSTS)),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _safe_name(raw: str) -> str:
|
|
195
|
+
"""Reduce a client-supplied filename to a leaf, so it cannot escape."""
|
|
196
|
+
name = Path(unquote(raw or "")).name.strip()
|
|
197
|
+
return name or "upload"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _associated_files(source: Path) -> list[dict]:
|
|
201
|
+
"""Everything that travelled with a session: subagents, sidecars, siblings.
|
|
202
|
+
|
|
203
|
+
A session is rarely one file — Claude Code keeps subagent traces in a
|
|
204
|
+
sibling directory, and a bundle carries images next to the trajectory.
|
|
205
|
+
"""
|
|
206
|
+
found: list[dict] = []
|
|
207
|
+
seen: set[Path] = set()
|
|
208
|
+
|
|
209
|
+
def add(path: Path, role: str) -> None:
|
|
210
|
+
resolved = path.resolve()
|
|
211
|
+
if resolved in seen or not path.is_file():
|
|
212
|
+
return
|
|
213
|
+
seen.add(resolved)
|
|
214
|
+
found.append({
|
|
215
|
+
"name": path.name,
|
|
216
|
+
"path": str(path),
|
|
217
|
+
"role": role,
|
|
218
|
+
"size": path.stat().st_size,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
add(source, "source")
|
|
222
|
+
subagents = source.parent / source.stem / "subagents"
|
|
223
|
+
if subagents.is_dir():
|
|
224
|
+
for file in sorted(subagents.iterdir()):
|
|
225
|
+
add(file, "subagent")
|
|
226
|
+
# A bundle keeps its images and manifest beside the trajectory.
|
|
227
|
+
for sibling in sorted(source.parent.iterdir()):
|
|
228
|
+
if sibling == source:
|
|
229
|
+
continue
|
|
230
|
+
if sibling.is_dir() and sibling.name == "images":
|
|
231
|
+
for image in sorted(sibling.iterdir()):
|
|
232
|
+
add(image, "image")
|
|
233
|
+
elif sibling.is_file() and sibling.suffix in {".json", ".jsonl", ".har", ".md", ".txt"}:
|
|
234
|
+
add(sibling, "sibling")
|
|
235
|
+
return found
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
239
|
+
entries: list[Entry] = []
|
|
240
|
+
cache: dict[str, dict] = {}
|
|
241
|
+
media: dict[str, dict] = {}
|
|
242
|
+
lock = threading.Lock()
|
|
243
|
+
|
|
244
|
+
def log_message(self, *args): # keep the console clean
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
def _send(self, body: bytes, ctype: str) -> None:
|
|
248
|
+
self.send_response(200)
|
|
249
|
+
self.send_header("Content-Type", ctype)
|
|
250
|
+
self.send_header("Content-Length", str(len(body)))
|
|
251
|
+
self.end_headers()
|
|
252
|
+
self.wfile.write(body)
|
|
253
|
+
|
|
254
|
+
def _entry(self, url):
|
|
255
|
+
"""Resolve ?id=<content key> to an entry, or None.
|
|
256
|
+
|
|
257
|
+
Addressing by list position broke the moment entries could be added,
|
|
258
|
+
removed or reordered — a stale link would open a different session.
|
|
259
|
+
"""
|
|
260
|
+
key = (parse_qs(url.query).get("id") or [""])[0]
|
|
261
|
+
if not key:
|
|
262
|
+
return None
|
|
263
|
+
return next((e for e in self.entries if e.key == key), None)
|
|
264
|
+
|
|
265
|
+
def _forget(self, keys: set[str]) -> int:
|
|
266
|
+
"""Drop sessions from the library and the index.
|
|
267
|
+
|
|
268
|
+
Files are removed only where they are the viewer's own. A copy under
|
|
269
|
+
~/.transcript-viewer/opened exists solely because the viewer made it; a scanned
|
|
270
|
+
session and a URL download are somebody's own files, sitting where they
|
|
271
|
+
chose, and forgetting a row is not permission to delete them.
|
|
272
|
+
"""
|
|
273
|
+
removed = 0
|
|
274
|
+
for key in keys:
|
|
275
|
+
entry = next((e for e in self.entries if e.key == key), None)
|
|
276
|
+
library.remove(key)
|
|
277
|
+
if entry is None:
|
|
278
|
+
continue
|
|
279
|
+
removed += 1
|
|
280
|
+
if entry.origin != "opened":
|
|
281
|
+
continue
|
|
282
|
+
source = Path(entry.path)
|
|
283
|
+
if corpus.OPENED_ROOT not in source.parents:
|
|
284
|
+
continue
|
|
285
|
+
# An unpacked archive puts several logs under one directory, so
|
|
286
|
+
# clearing it wholesale would delete the siblings' files and leave
|
|
287
|
+
# their rows pointing at nothing.
|
|
288
|
+
store = corpus.OPENED_ROOT / source.relative_to(corpus.OPENED_ROOT).parts[0]
|
|
289
|
+
shares = any(
|
|
290
|
+
e.key not in keys and store in Path(e.path).parents for e in self.entries
|
|
291
|
+
)
|
|
292
|
+
if shares:
|
|
293
|
+
source.unlink(missing_ok=True)
|
|
294
|
+
else:
|
|
295
|
+
shutil.rmtree(store, ignore_errors=True)
|
|
296
|
+
|
|
297
|
+
with self.lock:
|
|
298
|
+
self.entries = [e for e in self.entries if e.key not in keys]
|
|
299
|
+
_Handler.entries = self.entries
|
|
300
|
+
corpus.save(self.entries)
|
|
301
|
+
return removed
|
|
302
|
+
|
|
303
|
+
def _in_group(self, name: str) -> set[str]:
|
|
304
|
+
"""Keys sitting under a node of the tree, nested ones included."""
|
|
305
|
+
return {
|
|
306
|
+
e.key
|
|
307
|
+
for e in self.entries
|
|
308
|
+
if (at := _group(e, library.get(e.key).get("source", "")))
|
|
309
|
+
and (at == name or at.startswith(f"{name}/"))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
def do_DELETE(self) -> None:
|
|
313
|
+
url = urlparse(self.path)
|
|
314
|
+
query = parse_qs(url.query)
|
|
315
|
+
|
|
316
|
+
if url.path == "/api/group":
|
|
317
|
+
name = (query.get("name") or [""])[0]
|
|
318
|
+
if not name:
|
|
319
|
+
self._json({"error": "a folder is required"}, 400)
|
|
320
|
+
return
|
|
321
|
+
# A node of the tree is a fact about where sessions came from, so it
|
|
322
|
+
# cannot be removed on its own — only what sits under it can.
|
|
323
|
+
self._json({"removed": self._forget(self._in_group(name)), "folder": name})
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
if url.path != "/api/library":
|
|
327
|
+
self.send_error(404)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
if (query.get("all") or [""])[0] == "1":
|
|
331
|
+
# Everything the library knows. Files are left where they are by the
|
|
332
|
+
# same rule as a single removal: only a copy the viewer made is ours
|
|
333
|
+
# to delete.
|
|
334
|
+
self._json({"removed": self._forget({e.key for e in self.entries})})
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
key = (query.get("id") or [""])[0]
|
|
338
|
+
if not key:
|
|
339
|
+
self._json({"error": "a key is required"}, 400)
|
|
340
|
+
return
|
|
341
|
+
entry = next((e for e in self.entries if e.key == key), None)
|
|
342
|
+
origin = entry.origin if entry else ""
|
|
343
|
+
self._forget({key})
|
|
344
|
+
self._json({
|
|
345
|
+
"removed": True,
|
|
346
|
+
# Say plainly whether anything left the disk.
|
|
347
|
+
"removed_copy": origin == "opened",
|
|
348
|
+
"origin": origin,
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
def do_POST(self) -> None:
|
|
352
|
+
url = urlparse(self.path)
|
|
353
|
+
|
|
354
|
+
if url.path == "/api/scan":
|
|
355
|
+
# Deliberate, not automatic: this indexes every agent session on the
|
|
356
|
+
# machine, which is not something to do because a library is empty.
|
|
357
|
+
try:
|
|
358
|
+
found = scan()
|
|
359
|
+
except (ValueError, OSError) as exc:
|
|
360
|
+
self._json({"error": str(exc)}, 500)
|
|
361
|
+
return
|
|
362
|
+
with self.lock:
|
|
363
|
+
merged = corpus.merge(self.entries, found)
|
|
364
|
+
self.entries = merged
|
|
365
|
+
_Handler.entries = merged
|
|
366
|
+
corpus.save(merged)
|
|
367
|
+
self._json({"found": len(found), "total": len(merged)})
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if url.path == "/api/refetch":
|
|
371
|
+
try:
|
|
372
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
373
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
374
|
+
except (ValueError, json.JSONDecodeError):
|
|
375
|
+
self._json({"error": "expected a JSON body"}, 400)
|
|
376
|
+
return
|
|
377
|
+
# A node knows where it came from and where it was put, so refreshing
|
|
378
|
+
# is the original fetch asked again — no new decisions to make.
|
|
379
|
+
node = body.get("node") or ""
|
|
380
|
+
keys = self._in_group(node)
|
|
381
|
+
records = [library.get(k) for k in keys]
|
|
382
|
+
source = next((r["source"] for r in records if r.get("source")), "")
|
|
383
|
+
into = next((r["into"] for r in records if r.get("into")), "")
|
|
384
|
+
if not source:
|
|
385
|
+
self._json({"error": "that folder was not fetched from anywhere"}, 400)
|
|
386
|
+
return
|
|
387
|
+
self._fetch({"url": source, "into": into, "confirm": True})
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
if url.path == "/api/browse":
|
|
391
|
+
try:
|
|
392
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
393
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
394
|
+
except (ValueError, json.JSONDecodeError):
|
|
395
|
+
self._json({"error": "expected a JSON body"}, 400)
|
|
396
|
+
return
|
|
397
|
+
try:
|
|
398
|
+
nodes = fetch.browse(
|
|
399
|
+
body.get("url") or "", body.get("path") or "", config.tokens()
|
|
400
|
+
)
|
|
401
|
+
except fetch.FetchError as exc:
|
|
402
|
+
self._json({"error": str(exc)}, 400)
|
|
403
|
+
return
|
|
404
|
+
self._json({"nodes": [n._asdict() for n in nodes]})
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
if url.path == "/api/measure":
|
|
408
|
+
try:
|
|
409
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
410
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
411
|
+
except (ValueError, json.JSONDecodeError):
|
|
412
|
+
self._json({"error": "expected a JSON body"}, 400)
|
|
413
|
+
return
|
|
414
|
+
try:
|
|
415
|
+
self._json(
|
|
416
|
+
fetch.measure(
|
|
417
|
+
body.get("url") or "", body.get("paths") or [], config.tokens()
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
except fetch.FetchError as exc:
|
|
421
|
+
self._json({"error": str(exc)}, 400)
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
if url.path == "/api/fetch":
|
|
425
|
+
try:
|
|
426
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
427
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
428
|
+
except (ValueError, json.JSONDecodeError):
|
|
429
|
+
self._json({"error": "expected a JSON body"}, 400)
|
|
430
|
+
return
|
|
431
|
+
self._fetch(body)
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
if url.path == "/api/settings":
|
|
435
|
+
try:
|
|
436
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
437
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
438
|
+
except (ValueError, json.JSONDecodeError):
|
|
439
|
+
self._json({"error": "expected a JSON body"}, 400)
|
|
440
|
+
return
|
|
441
|
+
# The key is read out of the body and handed straight to storage:
|
|
442
|
+
# not logged, not echoed, not kept in memory beyond this call.
|
|
443
|
+
try:
|
|
444
|
+
name = body.get("name") or "anthropic"
|
|
445
|
+
if body.get("clear"):
|
|
446
|
+
config.clear_secret(name)
|
|
447
|
+
elif "aws_profile" in body:
|
|
448
|
+
config.set_aws_profile(body.get("aws_profile") or "")
|
|
449
|
+
elif "token" in body or "api_key" in body:
|
|
450
|
+
config.set_secret(name, body.get("token") or body.get("api_key") or "")
|
|
451
|
+
else:
|
|
452
|
+
self._json({"error": "nothing to change"}, 400)
|
|
453
|
+
return
|
|
454
|
+
except ValueError as exc:
|
|
455
|
+
self._json({"error": str(exc)}, 400)
|
|
456
|
+
return
|
|
457
|
+
except OSError as exc:
|
|
458
|
+
self._json({"error": f"could not save settings: {exc.strerror}"}, 500)
|
|
459
|
+
return
|
|
460
|
+
self._json(_ai_state())
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
if url.path == "/api/ai":
|
|
464
|
+
if not ai.available():
|
|
465
|
+
self._json({"error": "AI features are not configured here."}, 501)
|
|
466
|
+
return
|
|
467
|
+
try:
|
|
468
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
469
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
470
|
+
except (ValueError, json.JSONDecodeError):
|
|
471
|
+
self._json({"error": "expected a JSON body"}, 400)
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
entry = next((e for e in self.entries if e.key == body.get("key")), None)
|
|
475
|
+
if entry is None:
|
|
476
|
+
self._json({"error": "no such session"}, 404)
|
|
477
|
+
return
|
|
478
|
+
if not library.get(entry.key).get("ai", True):
|
|
479
|
+
self._json({"error": "AI is switched off for this transcript."}, 403)
|
|
480
|
+
return
|
|
481
|
+
trajectory = self._trajectory(entry)
|
|
482
|
+
if trajectory is None:
|
|
483
|
+
self._json({"error": "could not read that session"}, 500)
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
if body.get("what") == "call":
|
|
488
|
+
self._stream_call(body, entry, trajectory)
|
|
489
|
+
elif body.get("what") == "ask":
|
|
490
|
+
question = (body.get("question") or "").strip()
|
|
491
|
+
if not question:
|
|
492
|
+
self._json({"error": "ask what?"}, 400)
|
|
493
|
+
return
|
|
494
|
+
self._stream_ask(
|
|
495
|
+
question, trajectory, body.get("history"), body.get("focus")
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
self._json({"error": "unknown request"}, 400)
|
|
499
|
+
except ai.Unavailable as exc:
|
|
500
|
+
# Raised while building the client, before a byte is written, so
|
|
501
|
+
# a status code is still available.
|
|
502
|
+
self._json({"error": str(exc)}, 503)
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
if url.path == "/api/library":
|
|
506
|
+
try:
|
|
507
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
508
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
509
|
+
except (ValueError, json.JSONDecodeError):
|
|
510
|
+
self._json({"error": "expected a JSON body"}, 400)
|
|
511
|
+
return
|
|
512
|
+
key = body.get("key")
|
|
513
|
+
if not isinstance(key, str) or not key:
|
|
514
|
+
self._json({"error": "a key is required"}, 400)
|
|
515
|
+
return
|
|
516
|
+
fields = {k: v for k, v in body.items() if k != "key"}
|
|
517
|
+
try:
|
|
518
|
+
record = library.update(key, **fields)
|
|
519
|
+
except ValueError as exc:
|
|
520
|
+
self._json({"error": str(exc)}, 400)
|
|
521
|
+
return
|
|
522
|
+
self._json({"key": key, **record})
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
if url.path != "/api/open":
|
|
526
|
+
self.send_error(404)
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
531
|
+
except ValueError:
|
|
532
|
+
self.send_error(400)
|
|
533
|
+
return
|
|
534
|
+
if length <= 0:
|
|
535
|
+
self._json({"error": "empty upload"}, 400)
|
|
536
|
+
return
|
|
537
|
+
if length > UPLOAD_LIMIT:
|
|
538
|
+
self._json({"error": f"file is larger than {UPLOAD_LIMIT // 1024 ** 3} GB"}, 413)
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
name = _safe_name(self.headers.get("X-Filename", ""))
|
|
542
|
+
staging = Path(tempfile.mkdtemp(prefix="atif-open-"))
|
|
543
|
+
staged = staging / name
|
|
544
|
+
try:
|
|
545
|
+
remaining = length
|
|
546
|
+
with staged.open("wb") as handle:
|
|
547
|
+
while remaining > 0:
|
|
548
|
+
chunk = self.rfile.read(min(1 << 20, remaining))
|
|
549
|
+
if not chunk:
|
|
550
|
+
break
|
|
551
|
+
handle.write(chunk)
|
|
552
|
+
remaining -= len(chunk)
|
|
553
|
+
except OSError as exc:
|
|
554
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
555
|
+
self._json({"error": f"could not save upload: {exc}"}, 500)
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
# Derive the identity before choosing where it lands, so re-opening the
|
|
559
|
+
# same file updates its place instead of accumulating copies.
|
|
560
|
+
home = corpus.OPENED_ROOT / corpus.content_key(staged)
|
|
561
|
+
try:
|
|
562
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
563
|
+
if is_archive(staged):
|
|
564
|
+
# Keep what is inside, not the container: the logs are what get
|
|
565
|
+
# indexed, and unpacking here keeps their paths stable and any
|
|
566
|
+
# sibling images resolvable. Storing the zip would mean
|
|
567
|
+
# re-extracting to a temp directory on every start.
|
|
568
|
+
unpacked = extract(staged)
|
|
569
|
+
for item in unpacked.iterdir():
|
|
570
|
+
shutil.move(str(item), home / item.name)
|
|
571
|
+
target = home
|
|
572
|
+
else:
|
|
573
|
+
target = home / name
|
|
574
|
+
shutil.move(str(staged), target)
|
|
575
|
+
except (OSError, ValueError) as exc:
|
|
576
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
577
|
+
self._json({"error": f"could not store upload: {exc}"}, 500)
|
|
578
|
+
return
|
|
579
|
+
finally:
|
|
580
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
581
|
+
|
|
582
|
+
# Exactly what `transcript-viewer <path>` and `atif-make index` do: the CLI and
|
|
583
|
+
# this button must never disagree about what counts as openable.
|
|
584
|
+
try:
|
|
585
|
+
found = scan([target], origin="opened")
|
|
586
|
+
except (ValueError, OSError) as exc:
|
|
587
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
588
|
+
self._json({"error": str(exc)}, 400)
|
|
589
|
+
return
|
|
590
|
+
if not found:
|
|
591
|
+
# Nothing usable in it — do not keep the copy around.
|
|
592
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
593
|
+
self._json({"error": f"nothing convertible in {name}"}, 415)
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
with self.lock:
|
|
597
|
+
merged = corpus.merge(self.entries, found)
|
|
598
|
+
self.entries = merged
|
|
599
|
+
_Handler.entries = merged
|
|
600
|
+
# Persist so an opened file is present next start without a re-scan.
|
|
601
|
+
corpus.save(merged)
|
|
602
|
+
self._json({
|
|
603
|
+
"added": len(found),
|
|
604
|
+
"keys": [e.key for e in found],
|
|
605
|
+
"names": [Path(e.path).name for e in found],
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
def _fetch(self, body: dict) -> None:
|
|
609
|
+
"""List what a URL holds, then — only when told twice — download it.
|
|
610
|
+
|
|
611
|
+
Two steps on purpose: a dataset URL can name hundreds of files, and
|
|
612
|
+
nobody should discover that by pressing a button once.
|
|
613
|
+
"""
|
|
614
|
+
try:
|
|
615
|
+
picked = body.get("paths")
|
|
616
|
+
plan = (
|
|
617
|
+
fetch.select(body.get("url") or "", picked, config.tokens())
|
|
618
|
+
if picked
|
|
619
|
+
else fetch.plan(body.get("url") or "", config.tokens())
|
|
620
|
+
)
|
|
621
|
+
service, label, files = plan.service, plan.label, plan.files
|
|
622
|
+
into = fetch.destination(body.get("into"))
|
|
623
|
+
except fetch.FetchError as exc:
|
|
624
|
+
self._json({"error": str(exc)}, 400)
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
known = sum(f.size or 0 for f in files)
|
|
628
|
+
home = into / label
|
|
629
|
+
if not body.get("confirm"):
|
|
630
|
+
self._json({
|
|
631
|
+
"plan": True,
|
|
632
|
+
"service": service,
|
|
633
|
+
"label": label,
|
|
634
|
+
"count": len(files),
|
|
635
|
+
"bytes": known,
|
|
636
|
+
"names": [f.name for f in files[:12]],
|
|
637
|
+
"into": str(home),
|
|
638
|
+
})
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
def produce():
|
|
642
|
+
"""Frames as the download runs: a still screen reads as a hang."""
|
|
643
|
+
try:
|
|
644
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
645
|
+
done = 0
|
|
646
|
+
for path in fetch.download(service, files, home, config.tokens()):
|
|
647
|
+
done += 1
|
|
648
|
+
yield {"t": "file", "done": done, "total": len(files),
|
|
649
|
+
"name": path.name}
|
|
650
|
+
except fetch.FetchError as exc:
|
|
651
|
+
yield {"t": "error", "error": str(exc)}
|
|
652
|
+
return
|
|
653
|
+
except OSError as exc:
|
|
654
|
+
yield {"t": "error", "error": f"could not store: {exc.strerror}"}
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
# An archive is a container of transcripts, not a transcript, and a
|
|
658
|
+
# bucket of agent runs is mostly zips. Unpack on arrival so a fetch
|
|
659
|
+
# and a dropped archive end up in the same state.
|
|
660
|
+
# Where each unpacked tree came from, so what is inside an archive is
|
|
661
|
+
# placed by the archive's own location rather than by the folder
|
|
662
|
+
# names the archive happens to use internally.
|
|
663
|
+
unpacked_from: dict[Path, Path] = {}
|
|
664
|
+
for item in sorted(home.rglob("*")):
|
|
665
|
+
if not item.is_file() or not is_archive(item):
|
|
666
|
+
continue
|
|
667
|
+
try:
|
|
668
|
+
unpacked = extract(item)
|
|
669
|
+
except (ValueError, OSError):
|
|
670
|
+
continue
|
|
671
|
+
beside = item.with_suffix("")
|
|
672
|
+
beside.mkdir(parents=True, exist_ok=True)
|
|
673
|
+
for inner in unpacked.iterdir():
|
|
674
|
+
shutil.move(str(inner), beside / inner.name)
|
|
675
|
+
unpacked_from[beside] = item.parent
|
|
676
|
+
|
|
677
|
+
# The same path an uploaded file takes, so a URL and a drop cannot
|
|
678
|
+
# disagree about what counts as openable.
|
|
679
|
+
try:
|
|
680
|
+
found = scan([home], origin="fetched")
|
|
681
|
+
except (ValueError, OSError) as exc:
|
|
682
|
+
yield {"t": "error", "error": str(exc)}
|
|
683
|
+
return
|
|
684
|
+
if not found:
|
|
685
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
686
|
+
yield {"t": "error", "error": "nothing convertible was downloaded"}
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
with self.lock:
|
|
690
|
+
# What is new, not what the destination happens to contain: a
|
|
691
|
+
# second fetch into the same folder re-scans everything already
|
|
692
|
+
# there, and reporting that as "added" is a lie.
|
|
693
|
+
known = {e.key for e in self.entries}
|
|
694
|
+
merged = corpus.merge(self.entries, found)
|
|
695
|
+
self.entries = merged
|
|
696
|
+
_Handler.entries = merged
|
|
697
|
+
fresh = sum(1 for e in found if e.key not in known)
|
|
698
|
+
corpus.save(merged)
|
|
699
|
+
for entry in found:
|
|
700
|
+
library.update(
|
|
701
|
+
entry.key,
|
|
702
|
+
source=_source_of(entry, home, plan.web, unpacked_from),
|
|
703
|
+
into=str(into),
|
|
704
|
+
)
|
|
705
|
+
yield {"t": "added", "added": fresh, "into": str(home)}
|
|
706
|
+
|
|
707
|
+
self._frames(produce)
|
|
708
|
+
|
|
709
|
+
def _trajectory(self, entry) -> dict | None:
|
|
710
|
+
"""The converted trajectory for an entry, from cache when it is there."""
|
|
711
|
+
with self.lock:
|
|
712
|
+
cached = self.cache.get(entry.key)
|
|
713
|
+
if cached is not None:
|
|
714
|
+
return cached
|
|
715
|
+
try:
|
|
716
|
+
trajectory, _ = convert(Path(entry.path), entry.format)
|
|
717
|
+
except Exception:
|
|
718
|
+
return None
|
|
719
|
+
payload = trajectory.to_dict()
|
|
720
|
+
with self.lock:
|
|
721
|
+
self.cache[entry.key] = payload
|
|
722
|
+
return payload
|
|
723
|
+
|
|
724
|
+
def _find_call(self, trajectory: dict, call_id: str) -> tuple[dict | None, Any]:
|
|
725
|
+
"""One tool call and its output, by id."""
|
|
726
|
+
for step in trajectory.get("steps", []):
|
|
727
|
+
for candidate in step.get("tool_calls") or []:
|
|
728
|
+
if candidate.get("tool_call_id") == call_id:
|
|
729
|
+
output = None
|
|
730
|
+
for row in (step.get("observation") or {}).get("results") or []:
|
|
731
|
+
if row.get("source_call_id") == call_id:
|
|
732
|
+
output = row.get("content")
|
|
733
|
+
return candidate, output
|
|
734
|
+
return None, None
|
|
735
|
+
|
|
736
|
+
def _frames(self, produce) -> None:
|
|
737
|
+
"""Stream newline-delimited JSON frames as they are produced.
|
|
738
|
+
|
|
739
|
+
Not server-sent events: EventSource reconnects on close, which would
|
|
740
|
+
silently repeat a paid call. A plain streamed response read by fetch
|
|
741
|
+
does not.
|
|
742
|
+
"""
|
|
743
|
+
self.send_response(200)
|
|
744
|
+
self.send_header("Content-Type", "application/x-ndjson")
|
|
745
|
+
self.send_header("Cache-Control", "no-store")
|
|
746
|
+
# Without this the browser withholds the first bytes while it sniffs the
|
|
747
|
+
# type, which swallows the beginning of a stream.
|
|
748
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
749
|
+
self.end_headers()
|
|
750
|
+
try:
|
|
751
|
+
for frame in produce():
|
|
752
|
+
self.wfile.write(json.dumps(frame).encode() + b"\n")
|
|
753
|
+
self.wfile.flush()
|
|
754
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
755
|
+
# The reader navigated away. Nothing to report to.
|
|
756
|
+
pass
|
|
757
|
+
|
|
758
|
+
def _stream_call(self, body: dict, entry, trajectory: dict) -> None:
|
|
759
|
+
"""Explain one tool call, reusing a summary already paid for."""
|
|
760
|
+
call_id = body.get("call_id")
|
|
761
|
+
stored = library.get(entry.key).get("summaries") or {}
|
|
762
|
+
if call_id in stored and not body.get("again"):
|
|
763
|
+
self._frames(lambda: [{"t": "delta", "text": stored[call_id]}, {"t": "done"}])
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
call, output = self._find_call(trajectory, call_id)
|
|
767
|
+
if call is None:
|
|
768
|
+
self._json({"error": "no such call"}, 404)
|
|
769
|
+
return
|
|
770
|
+
|
|
771
|
+
# Built here so a credential problem is a status code, not a half-stream.
|
|
772
|
+
chunks = ai.summarise_call_stream(call, output)
|
|
773
|
+
|
|
774
|
+
def produce():
|
|
775
|
+
parts: list[str] = []
|
|
776
|
+
try:
|
|
777
|
+
for kind, piece in chunks:
|
|
778
|
+
if kind != "text":
|
|
779
|
+
yield {"t": "thinking"}
|
|
780
|
+
continue
|
|
781
|
+
parts.append(piece)
|
|
782
|
+
yield {"t": "delta", "text": piece}
|
|
783
|
+
except ai.Unavailable as exc:
|
|
784
|
+
yield {"t": "error", "error": str(exc)}
|
|
785
|
+
return
|
|
786
|
+
summary = "".join(parts).strip()
|
|
787
|
+
if summary:
|
|
788
|
+
library.update(entry.key, summaries={**stored, call_id: summary})
|
|
789
|
+
yield {"t": "done"}
|
|
790
|
+
|
|
791
|
+
self._frames(produce)
|
|
792
|
+
|
|
793
|
+
def _stream_ask(
|
|
794
|
+
self, question: str, trajectory: dict, history: Any, focus: Any
|
|
795
|
+
) -> None:
|
|
796
|
+
"""Answer a question, saying which steps it is reading first."""
|
|
797
|
+
turns = history if isinstance(history, list) else []
|
|
798
|
+
earlier = [n for n in focus if isinstance(n, int)] if isinstance(focus, list) else []
|
|
799
|
+
steps = trajectory.get("steps", [])
|
|
800
|
+
used, chunks = ai.ask_stream(question, steps, turns, earlier)
|
|
801
|
+
|
|
802
|
+
def produce():
|
|
803
|
+
# The total matters as much as the count: 40 of 40 means the whole
|
|
804
|
+
# transcript was read, 40 of 3,000 means the answer saw a sliver.
|
|
805
|
+
yield {"t": "steps", "steps": used, "total": len(steps)}
|
|
806
|
+
try:
|
|
807
|
+
for kind, piece in chunks:
|
|
808
|
+
# Thinking is signalled, not shown: the point is that
|
|
809
|
+
# something is happening, not what it says.
|
|
810
|
+
yield {"t": "thinking"} if kind != "text" else {"t": "delta", "text": piece}
|
|
811
|
+
except ai.Unavailable as exc:
|
|
812
|
+
yield {"t": "error", "error": str(exc)}
|
|
813
|
+
return
|
|
814
|
+
yield {"t": "done"}
|
|
815
|
+
|
|
816
|
+
self._frames(produce)
|
|
817
|
+
|
|
818
|
+
def _json(self, payload: dict, status: int = 200) -> None:
|
|
819
|
+
body = json.dumps(payload).encode()
|
|
820
|
+
self.send_response(status)
|
|
821
|
+
self.send_header("Content-Type", "application/json")
|
|
822
|
+
self.send_header("Content-Length", str(len(body)))
|
|
823
|
+
self.end_headers()
|
|
824
|
+
self.wfile.write(body)
|
|
825
|
+
|
|
826
|
+
def do_GET(self) -> None:
|
|
827
|
+
url = urlparse(self.path)
|
|
828
|
+
|
|
829
|
+
if url.path == "/":
|
|
830
|
+
self._send(PAGE.encode(), "text/html; charset=utf-8")
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
if url.path == "/api/index":
|
|
834
|
+
rows = [
|
|
835
|
+
{
|
|
836
|
+
"key": e.key,
|
|
837
|
+
"origin": e.origin,
|
|
838
|
+
"path": e.path,
|
|
839
|
+
"agent": e.agent,
|
|
840
|
+
"format": e.format,
|
|
841
|
+
"session_id": e.session_id,
|
|
842
|
+
"project": e.project,
|
|
843
|
+
"modified": e.modified,
|
|
844
|
+
"size_bytes": e.size_bytes,
|
|
845
|
+
"subagents": e.subagents,
|
|
846
|
+
"session_title": e.session_title,
|
|
847
|
+
"group": _group(e, library.get(e.key).get("source", "")),
|
|
848
|
+
}
|
|
849
|
+
for e in self.entries
|
|
850
|
+
# A downloaded folder the reader deleted, or a drive not mounted
|
|
851
|
+
# right now. Hidden rather than purged: an entry is cheap to
|
|
852
|
+
# keep, and deleting one because a volume is offline is not
|
|
853
|
+
# recoverable.
|
|
854
|
+
if Path(e.path).exists()
|
|
855
|
+
]
|
|
856
|
+
payload = {
|
|
857
|
+
"ai": _ai_state(),
|
|
858
|
+
"downloads": str(fetch.destination(None)),
|
|
859
|
+
"sessions": library.decorate(rows),
|
|
860
|
+
"groups": _groups(rows),
|
|
861
|
+
"tags": [{"name": t, "count": n} for t, n in library.tags()],
|
|
862
|
+
}
|
|
863
|
+
self._send(json.dumps(payload).encode(), "application/json")
|
|
864
|
+
return
|
|
865
|
+
|
|
866
|
+
if url.path == "/api/reveal":
|
|
867
|
+
raw = (parse_qs(url.query).get("path") or [""])[0]
|
|
868
|
+
if not raw:
|
|
869
|
+
self.send_error(400)
|
|
870
|
+
return
|
|
871
|
+
target = Path(raw).expanduser()
|
|
872
|
+
if _reveal(target):
|
|
873
|
+
self._send(b"ok", "text/plain")
|
|
874
|
+
else:
|
|
875
|
+
self.send_error(404)
|
|
876
|
+
return
|
|
877
|
+
|
|
878
|
+
if url.path == "/api/raw":
|
|
879
|
+
entry = self._entry(url)
|
|
880
|
+
if entry is None:
|
|
881
|
+
self.send_error(404)
|
|
882
|
+
return
|
|
883
|
+
source = Path(entry.path)
|
|
884
|
+
try:
|
|
885
|
+
# A rollout can be hundreds of MB; send a head, not the lot.
|
|
886
|
+
with source.open("rb") as handle:
|
|
887
|
+
body = handle.read(RAW_LIMIT + 1)
|
|
888
|
+
except OSError:
|
|
889
|
+
self.send_error(404)
|
|
890
|
+
return
|
|
891
|
+
truncated = len(body) > RAW_LIMIT
|
|
892
|
+
payload = {
|
|
893
|
+
"path": str(source),
|
|
894
|
+
"size": source.stat().st_size,
|
|
895
|
+
"truncated": truncated,
|
|
896
|
+
"text": body[:RAW_LIMIT].decode("utf-8", errors="replace"),
|
|
897
|
+
}
|
|
898
|
+
self._send(json.dumps(payload).encode(), "application/json")
|
|
899
|
+
return
|
|
900
|
+
|
|
901
|
+
if url.path == "/api/files":
|
|
902
|
+
entry = self._entry(url)
|
|
903
|
+
if entry is None:
|
|
904
|
+
self.send_error(404)
|
|
905
|
+
return
|
|
906
|
+
self._send(json.dumps(_associated_files(Path(entry.path))).encode(),
|
|
907
|
+
"application/json")
|
|
908
|
+
return
|
|
909
|
+
|
|
910
|
+
if url.path == "/api/image":
|
|
911
|
+
query = parse_qs(url.query)
|
|
912
|
+
index = (query.get("id") or [""])[0]
|
|
913
|
+
name = (query.get("name") or [""])[0]
|
|
914
|
+
with self.lock:
|
|
915
|
+
item = self.media.get(index, {}).get(name)
|
|
916
|
+
if item is None:
|
|
917
|
+
self.send_error(404)
|
|
918
|
+
return
|
|
919
|
+
self._send(item.data, item.media_type)
|
|
920
|
+
return
|
|
921
|
+
|
|
922
|
+
if url.path == "/api/trajectory":
|
|
923
|
+
entry = self._entry(url)
|
|
924
|
+
if entry is None:
|
|
925
|
+
self._send(json.dumps({"error": "no such session"}).encode(), "application/json")
|
|
926
|
+
return
|
|
927
|
+
index = entry.key
|
|
928
|
+
|
|
929
|
+
with self.lock:
|
|
930
|
+
cached = self.cache.get(index)
|
|
931
|
+
if cached is None:
|
|
932
|
+
try:
|
|
933
|
+
trajectory, _ = convert(Path(entry.path), entry.format)
|
|
934
|
+
store = trajectory.all_media()
|
|
935
|
+
if store:
|
|
936
|
+
_point_images_at_server(trajectory, index)
|
|
937
|
+
with self.lock:
|
|
938
|
+
self.media[index] = dict(store.items)
|
|
939
|
+
cached = trajectory.to_dict()
|
|
940
|
+
except Exception as exc: # a bad log should not kill the server
|
|
941
|
+
self._send(
|
|
942
|
+
json.dumps({"error": f"{type(exc).__name__}: {exc}"}).encode(),
|
|
943
|
+
"application/json",
|
|
944
|
+
)
|
|
945
|
+
return
|
|
946
|
+
with self.lock:
|
|
947
|
+
self.cache[index] = cached
|
|
948
|
+
self._send(json.dumps(cached).encode(), "application/json")
|
|
949
|
+
return
|
|
950
|
+
|
|
951
|
+
self.send_error(404)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _bind(port: int, handler, explicit: bool) -> ThreadingHTTPServer:
|
|
955
|
+
"""Bind to `port`, or to the next free one when the choice was ours.
|
|
956
|
+
|
|
957
|
+
Viewing a second session while a first is still open is normal, so a busy
|
|
958
|
+
default should not be an error. An explicitly requested port is honoured or
|
|
959
|
+
reported — silently moving it would be worse than failing.
|
|
960
|
+
"""
|
|
961
|
+
last: OSError | None = None
|
|
962
|
+
for candidate in range(port, port + (1 if explicit else 20)):
|
|
963
|
+
try:
|
|
964
|
+
return ThreadingHTTPServer(("127.0.0.1", candidate), handler)
|
|
965
|
+
except OSError as exc:
|
|
966
|
+
if exc.errno not in (errno.EADDRINUSE, errno.EACCES):
|
|
967
|
+
raise
|
|
968
|
+
last = exc
|
|
969
|
+
raise SystemExit(
|
|
970
|
+
f"transcript-viewer: port {port} is already in use"
|
|
971
|
+
+ ("" if explicit else f" (tried {port}-{port + 19})")
|
|
972
|
+
+ ".\nPass --port to choose another, or stop the running viewer."
|
|
973
|
+
) from last
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def serve(
|
|
977
|
+
entries: list[Entry] | None = None,
|
|
978
|
+
port: int = 7433,
|
|
979
|
+
open_browser: bool = True,
|
|
980
|
+
explicit_port: bool = False,
|
|
981
|
+
) -> None:
|
|
982
|
+
handler = partial(_Handler)
|
|
983
|
+
_Handler.entries = entries if entries is not None else scan()
|
|
984
|
+
_Handler.cache = {}
|
|
985
|
+
_Handler.media = {}
|
|
986
|
+
|
|
987
|
+
# Loopback only: these logs contain source code and tool output.
|
|
988
|
+
server = _bind(port, handler, explicit_port)
|
|
989
|
+
port = server.server_address[1]
|
|
990
|
+
url = f"http://127.0.0.1:{port}/"
|
|
991
|
+
print(f"transcript-viewer: {url} ({len(_Handler.entries)} sessions)")
|
|
992
|
+
print("Ctrl-C to stop.")
|
|
993
|
+
if open_browser:
|
|
994
|
+
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
|
995
|
+
try:
|
|
996
|
+
server.serve_forever()
|
|
997
|
+
except KeyboardInterrupt:
|
|
998
|
+
print("\nstopped.")
|
|
999
|
+
finally:
|
|
1000
|
+
server.server_close()
|