chatdeck 0.1.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.
- chatdeck/__init__.py +2 -0
- chatdeck/__main__.py +3 -0
- chatdeck/autostart.py +91 -0
- chatdeck/cli.py +152 -0
- chatdeck/config.py +34 -0
- chatdeck/demo.py +141 -0
- chatdeck/desktop.py +93 -0
- chatdeck/hooks.py +131 -0
- chatdeck/server.py +345 -0
- chatdeck/static/index.html +342 -0
- chatdeck/static/logo-192.png +0 -0
- chatdeck/static/logo-512.png +0 -0
- chatdeck/static/logo.ico +0 -0
- chatdeck/static/logo.svg +13 -0
- chatdeck/terminals.py +218 -0
- chatdeck/transcripts.py +216 -0
- chatdeck/usage.py +96 -0
- chatdeck/windows/bubble.ps1 +303 -0
- chatdeck/windows/focus_wt.ps1 +68 -0
- chatdeck/windows/start.ps1 +35 -0
- chatdeck-0.1.0.dist-info/METADATA +164 -0
- chatdeck-0.1.0.dist-info/RECORD +26 -0
- chatdeck-0.1.0.dist-info/WHEEL +5 -0
- chatdeck-0.1.0.dist-info/entry_points.txt +2 -0
- chatdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
- chatdeck-0.1.0.dist-info/top_level.txt +1 -0
chatdeck/server.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""chatdeck: a local dashboard for Claude Code chats.
|
|
2
|
+
Lists running sessions (claude agents --json) and saved transcripts under
|
|
3
|
+
~/.claude/projects, and can view, search, rename, export and delete saved chats, stop
|
|
4
|
+
sessions, and open chats in a terminal (see terminals.py). Binds 127.0.0.1 only; every API call needs
|
|
5
|
+
the per-run token embedded in the page (custom header => cross-site pages can't call it)."""
|
|
6
|
+
import json, os, glob, secrets, shlex, signal, subprocess, threading, time
|
|
7
|
+
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
8
|
+
from urllib.parse import urlparse, parse_qs, quote
|
|
9
|
+
|
|
10
|
+
from . import config, terminals, transcripts, usage
|
|
11
|
+
from .transcripts import UUID
|
|
12
|
+
|
|
13
|
+
HOME = config.HOME
|
|
14
|
+
PROJECTS = os.path.join(HOME, ".claude", "projects")
|
|
15
|
+
DATA = config.DATA
|
|
16
|
+
STATUS, WAITING = os.path.join(DATA, "status"), os.path.join(DATA, "waiting")
|
|
17
|
+
TOKEN = secrets.token_hex(16)
|
|
18
|
+
ASSETS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
|
19
|
+
PAGE = os.path.join(ASSETS, "index.html")
|
|
20
|
+
STATIC = {"/logo.svg": ("logo.svg", "image/svg+xml"), "/logo-192.png": ("logo-192.png", "image/png"),
|
|
21
|
+
"/logo-512.png": ("logo-512.png", "image/png")}
|
|
22
|
+
MANIFEST = {"name": "chatdeck", "short_name": "chatdeck", "display": "standalone", "background_color": "#0f1115",
|
|
23
|
+
"theme_color": "#0f1115", "start_url": "/",
|
|
24
|
+
"icons": [{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
|
|
25
|
+
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"},
|
|
26
|
+
{"src": "/logo.svg", "sizes": "any", "type": "image/svg+xml"}]}
|
|
27
|
+
INDEX = transcripts.Index(PROJECTS)
|
|
28
|
+
LIMITS = usage.LimitHistory(os.path.join(DATA, "limits-history.jsonl"))
|
|
29
|
+
|
|
30
|
+
_agents = (0.0, {})
|
|
31
|
+
_agents_lock = threading.Lock()
|
|
32
|
+
def _fetch_agents():
|
|
33
|
+
global _agents
|
|
34
|
+
try:
|
|
35
|
+
if config.DEMO:
|
|
36
|
+
with open(os.path.join(DATA, "demo-agents.json")) as f: out = f.read()
|
|
37
|
+
else:
|
|
38
|
+
out = subprocess.run(["claude", "agents", "--json"], capture_output=True, text=True, timeout=30).stdout
|
|
39
|
+
res = {s["sessionId"]: s for s in json.loads(out or "[]") if s.get("sessionId")}
|
|
40
|
+
with _agents_lock: _agents = (time.time(), res)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
return _agents[1]
|
|
44
|
+
|
|
45
|
+
def _agents_loop():
|
|
46
|
+
while True:
|
|
47
|
+
_fetch_agents(); time.sleep(4)
|
|
48
|
+
|
|
49
|
+
def agents(fresh=False):
|
|
50
|
+
"""Running sessions. Normal reads use the list a background thread keeps fresh, so pages never wait;
|
|
51
|
+
destructive actions (delete/stop) ask for a fresh copy first."""
|
|
52
|
+
return _fetch_agents() if fresh else _agents[1]
|
|
53
|
+
|
|
54
|
+
def waiting(act, chats):
|
|
55
|
+
"""{sid: message} for running chats stuck on a permission prompt or question. A mark from the
|
|
56
|
+
waiting hook counts until the transcript is written to after it (the prompt was answered)."""
|
|
57
|
+
out = {}
|
|
58
|
+
for f in glob.glob(os.path.join(WAITING, "*.json")):
|
|
59
|
+
sid = os.path.basename(f)[:-5]
|
|
60
|
+
try:
|
|
61
|
+
if sid not in act:
|
|
62
|
+
if os.path.getmtime(f) < time.time() - 3600: os.remove(f)
|
|
63
|
+
continue
|
|
64
|
+
with open(f) as fh: w = json.load(fh)
|
|
65
|
+
hit = chats.get(sid)
|
|
66
|
+
if not hit or hit[1].mtime <= w["at"] + 1.5:
|
|
67
|
+
out[sid] = w.get("message") or "Waiting for you"
|
|
68
|
+
except (OSError, ValueError, KeyError):
|
|
69
|
+
pass
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
def _as_written(cwd, real):
|
|
73
|
+
"""git answers with symlinks resolved (macOS: /var is /private/var); give the folder as cwd spells it."""
|
|
74
|
+
d = cwd
|
|
75
|
+
while True:
|
|
76
|
+
if os.path.realpath(d) == real: return d
|
|
77
|
+
up = os.path.dirname(d)
|
|
78
|
+
if up == d: return real
|
|
79
|
+
d = up
|
|
80
|
+
|
|
81
|
+
_projects = {}
|
|
82
|
+
def project(cwd):
|
|
83
|
+
"""The project a folder belongs to: the main checkout of its git repo (so subfolders and
|
|
84
|
+
worktrees count as the repo itself), or the folder when it isn't in a repo."""
|
|
85
|
+
if not cwd: return ""
|
|
86
|
+
if cwd not in _projects:
|
|
87
|
+
root = cwd.split(os.sep + ".claude" + os.sep + "worktrees" + os.sep)[0] # Claude's worktrees, even once removed
|
|
88
|
+
try:
|
|
89
|
+
r = subprocess.run(["git", "-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
90
|
+
capture_output=True, text=True, timeout=5)
|
|
91
|
+
common = r.stdout.strip()
|
|
92
|
+
if r.returncode == 0 and os.path.basename(common) == ".git": root = _as_written(cwd, os.path.dirname(common))
|
|
93
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
94
|
+
pass
|
|
95
|
+
_projects[cwd] = root
|
|
96
|
+
return _projects[cwd].replace(HOME, "~", 1)
|
|
97
|
+
|
|
98
|
+
def chat_project(e, start_cwd=None):
|
|
99
|
+
"""The project a chat belongs to: the one it did most of its work in. A chat started in your home
|
|
100
|
+
folder that then worked in ~/code/app belongs to app, not to the home folder."""
|
|
101
|
+
totals = {}
|
|
102
|
+
for cwd, n in (e.cwds if e else {}).items():
|
|
103
|
+
p = project(cwd); totals[p] = totals.get(p, 0) + n
|
|
104
|
+
return max(totals, key=totals.get) if totals else project(start_cwd or (e.cwd if e else ""))
|
|
105
|
+
|
|
106
|
+
# Names given to chats while they were running. Claude Code keeps a running chat's name in memory
|
|
107
|
+
# and rewrites it, so the new name is shown from here and written into the chat once it ends.
|
|
108
|
+
PENDING = os.path.join(DATA, "pending-names.json")
|
|
109
|
+
_pending_lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
def pending_names():
|
|
112
|
+
try:
|
|
113
|
+
with open(PENDING) as f: return json.load(f)
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
def _save_pending(names):
|
|
118
|
+
os.makedirs(DATA, exist_ok=True)
|
|
119
|
+
with open(PENDING + ".tmp", "w") as f: json.dump(names, f)
|
|
120
|
+
os.replace(PENDING + ".tmp", PENDING)
|
|
121
|
+
|
|
122
|
+
def apply_pending(act, paths):
|
|
123
|
+
"""Write pending names into chats that have stopped ({sid: transcript path}); returns {sid: name} still pending.
|
|
124
|
+
A name is dropped instead if the chat was renamed in Claude Code meanwhile (/rename there wins)."""
|
|
125
|
+
with _pending_lock:
|
|
126
|
+
names = pending_names(); changed = False
|
|
127
|
+
for sid, p in list(names.items()):
|
|
128
|
+
renamed_there = sid in act and act[sid].get("name") not in (p["was"], p["title"])
|
|
129
|
+
if sid in act and not renamed_there: continue
|
|
130
|
+
if not renamed_there and sid in paths: transcripts.rename(paths[sid], sid, p["title"])
|
|
131
|
+
del names[sid]; changed = True
|
|
132
|
+
if changed: _save_pending(names)
|
|
133
|
+
return {sid: p["title"] for sid, p in names.items()}
|
|
134
|
+
|
|
135
|
+
def state():
|
|
136
|
+
act = agents(); pend = apply_pending(act, INDEX.files()) # before reading chats, so a name just written shows
|
|
137
|
+
chats = INDEX.all(); wait = waiting(act, chats); out = []
|
|
138
|
+
for sid, (p, e) in chats.items():
|
|
139
|
+
a = act.get(sid, {})
|
|
140
|
+
out.append({"sid": sid, "name": pend.get(sid) or a.get("name") or e.title or "(empty)",
|
|
141
|
+
"cwd": (a.get("cwd") or e.cwd or "").replace(HOME, "~"), "project": chat_project(e, a.get("cwd")),
|
|
142
|
+
"ctx": e.ctx, "msgs": e.msgs, "mtime": e.mtime, "size": e.size, "cost": round(e.cost, 2),
|
|
143
|
+
"status": a.get("status"), "kind": a.get("kind"), "started": a.get("startedAt"), "waiting": wait.get(sid)})
|
|
144
|
+
for sid, a in act.items(): # running but no transcript yet
|
|
145
|
+
if sid not in chats:
|
|
146
|
+
out.append({"sid": sid, "name": pend.get(sid) or a.get("name") or "(new chat)", "cwd": (a.get("cwd") or "").replace(HOME, "~"),
|
|
147
|
+
"project": project(a.get("cwd")),
|
|
148
|
+
"ctx": 0, "msgs": 0, "mtime": time.time(), "size": 0, "cost": 0, "status": a.get("status"),
|
|
149
|
+
"kind": a.get("kind"), "started": a.get("startedAt"), "waiting": wait.get(sid)})
|
|
150
|
+
out.sort(key=lambda x: -x["mtime"])
|
|
151
|
+
return {"chats": out, "now": time.time(), "usage": usage.summary([e for _, e in chats.values()], STATUS, LIMITS, project=chat_project)}
|
|
152
|
+
|
|
153
|
+
def manual(cwd, command):
|
|
154
|
+
"""What to paste into a terminal when no terminal could be opened for the person."""
|
|
155
|
+
return f"cd {shlex.quote(cwd)} && {command}"
|
|
156
|
+
|
|
157
|
+
DEMO_OFF = {"ok": False, "error": "this is the demo - it doesn't open terminals or touch real chats"}
|
|
158
|
+
|
|
159
|
+
def open_running(sid):
|
|
160
|
+
if config.DEMO: return DEMO_OFF
|
|
161
|
+
a = agents(fresh=True).get(sid)
|
|
162
|
+
if not a: return {"ok": False, "error": "not running any more"}
|
|
163
|
+
cwd = a.get("cwd") if os.path.isdir(a.get("cwd") or "") else HOME
|
|
164
|
+
if a.get("kind") == "background":
|
|
165
|
+
cmd = f"claude attach {a.get('id') or sid}"
|
|
166
|
+
if terminals.launch(cwd, cmd): return {"ok": True, "how": "attached in a new terminal tab"}
|
|
167
|
+
return {"ok": False, "copy": manual(cwd, cmd)}
|
|
168
|
+
if a.get("pid") and terminals.focus(int(a["pid"]), a.get("name")):
|
|
169
|
+
return {"ok": True, "how": "switched to its terminal"}
|
|
170
|
+
return {"ok": False, "error": "couldn't find its terminal tab - switch to it yourself (tabs with a locked title can't be found)"}
|
|
171
|
+
|
|
172
|
+
def transcript(sid):
|
|
173
|
+
if not UUID.match(sid or ""): return None
|
|
174
|
+
hits = glob.glob(os.path.join(PROJECTS, "*", sid + ".jsonl"))
|
|
175
|
+
if not hits or not os.path.realpath(hits[0]).startswith(os.path.realpath(PROJECTS) + os.sep): return None
|
|
176
|
+
return hits[0]
|
|
177
|
+
|
|
178
|
+
def delete(sids):
|
|
179
|
+
act = agents(fresh=True); done, refused = [], []
|
|
180
|
+
for sid in sids:
|
|
181
|
+
p = transcript(sid)
|
|
182
|
+
if not p or sid in act:
|
|
183
|
+
refused.append(sid); continue
|
|
184
|
+
os.remove(p); INDEX.forget(p)
|
|
185
|
+
side = p[:-6] # sibling folder with this chat's tool output / subagents
|
|
186
|
+
if os.path.isdir(side):
|
|
187
|
+
subprocess.run(["rm", "-rf", "--", side])
|
|
188
|
+
done.append(sid)
|
|
189
|
+
return {"deleted": done, "refused": refused}
|
|
190
|
+
|
|
191
|
+
def _demo_rename(sid, title):
|
|
192
|
+
path = os.path.join(DATA, "demo-agents.json")
|
|
193
|
+
with open(path) as f: running = json.load(f)
|
|
194
|
+
for x in running:
|
|
195
|
+
if x["sessionId"] == sid: x["name"] = title
|
|
196
|
+
with open(path, "w") as f: json.dump(running, f)
|
|
197
|
+
_fetch_agents()
|
|
198
|
+
return {"ok": True, "title": title, "typed": True}
|
|
199
|
+
|
|
200
|
+
def auto_rename(sid):
|
|
201
|
+
"""Run Claude Code's /rename with no name in a running chat: Claude names it after what it's about."""
|
|
202
|
+
act = agents(fresh=True); a = act.get(sid)
|
|
203
|
+
if not a: return {"ok": False, "error": "it isn't running - open it and type /rename, or type a name for it here"}
|
|
204
|
+
if a.get("status") != "idle" or sid in waiting(act, INDEX.all()):
|
|
205
|
+
return {"ok": False, "error": "it's busy - try again when it's waiting for your next message"}
|
|
206
|
+
if config.DEMO: return DEMO_OFF
|
|
207
|
+
if a.get("kind") != "interactive" or not a.get("pid") or not terminals.type_into(int(a["pid"]), a.get("name"), "/rename"):
|
|
208
|
+
return {"ok": False, "error": "couldn't find its terminal tab (tabs with a locked title can't be found) - type /rename in it"}
|
|
209
|
+
return {"ok": True}
|
|
210
|
+
|
|
211
|
+
def rename(sid, title):
|
|
212
|
+
title = " ".join(str(title or "").split())[:80]
|
|
213
|
+
p = transcript(sid)
|
|
214
|
+
if not p or not title: return {"ok": False, "error": "no such chat"}
|
|
215
|
+
act = agents(fresh=True)
|
|
216
|
+
if sid in act:
|
|
217
|
+
a = act[sid]
|
|
218
|
+
# Idle at its prompt in a terminal: run Claude Code's own /rename in it, so the name changes everywhere.
|
|
219
|
+
idle = a.get("status") == "idle" and a.get("kind") == "interactive" and sid not in waiting(act, INDEX.all())
|
|
220
|
+
if config.DEMO: return _demo_rename(sid, title)
|
|
221
|
+
if idle and a.get("pid") and terminals.type_into(int(a["pid"]), a.get("name"), f"/rename {title}"):
|
|
222
|
+
return {"ok": True, "title": title, "typed": True}
|
|
223
|
+
with _pending_lock: # busy, waiting on you, or not in a terminal we can reach: see PENDING
|
|
224
|
+
names = pending_names(); names[sid] = {"title": title, "was": a.get("name")}; _save_pending(names)
|
|
225
|
+
return {"ok": True, "title": title, "pending": True, "busy": not idle}
|
|
226
|
+
transcripts.rename(p, sid, title)
|
|
227
|
+
return {"ok": True, "title": title}
|
|
228
|
+
|
|
229
|
+
def compact(sid):
|
|
230
|
+
"""Run Claude Code's /compact in a running chat, the same way rename types /rename."""
|
|
231
|
+
act = agents(fresh=True); a = act.get(sid)
|
|
232
|
+
if not a: return {"ok": False, "error": "it isn't running"}
|
|
233
|
+
if a.get("status") != "idle" or sid in waiting(act, INDEX.all()):
|
|
234
|
+
return {"ok": False, "error": "it's busy - try again when it's waiting for your next message"}
|
|
235
|
+
if config.DEMO: return DEMO_OFF
|
|
236
|
+
if a.get("kind") != "interactive" or not a.get("pid") or not terminals.type_into(int(a["pid"]), a.get("name"), "/compact"):
|
|
237
|
+
return {"ok": False, "error": "couldn't reach its terminal - type /compact in it"}
|
|
238
|
+
return {"ok": True}
|
|
239
|
+
|
|
240
|
+
def stop(sid):
|
|
241
|
+
if config.DEMO: return DEMO_OFF
|
|
242
|
+
a = agents(fresh=True).get(sid)
|
|
243
|
+
if not a or not a.get("pid"): return {"ok": False, "error": "not running"}
|
|
244
|
+
pid = int(a["pid"])
|
|
245
|
+
cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True).stdout
|
|
246
|
+
if "claude" not in cmd: return {"ok": False, "error": "pid is not a Claude process"}
|
|
247
|
+
os.kill(pid, signal.SIGTERM)
|
|
248
|
+
return {"ok": True}
|
|
249
|
+
|
|
250
|
+
def open_chat(sid, view=False):
|
|
251
|
+
if config.DEMO: return DEMO_OFF
|
|
252
|
+
if view:
|
|
253
|
+
inner = "claude agents"; cwd = HOME
|
|
254
|
+
else:
|
|
255
|
+
p = transcript(sid)
|
|
256
|
+
if not p: return {"ok": False}
|
|
257
|
+
cwd = INDEX.get(p).cwd or HOME
|
|
258
|
+
inner = f"claude --resume {sid}"
|
|
259
|
+
cwd = cwd if os.path.isdir(cwd) else HOME
|
|
260
|
+
if terminals.launch(cwd, inner): return {"ok": True}
|
|
261
|
+
return {"ok": False, "copy": manual(cwd, inner)}
|
|
262
|
+
|
|
263
|
+
def new_chat(proj):
|
|
264
|
+
"""Start a new Claude Code chat in a project folder. Only folders chatdeck lists as projects are allowed."""
|
|
265
|
+
if config.DEMO: return DEMO_OFF
|
|
266
|
+
known = {chat_project(e) for _, e in INDEX.all().values()} | {project(a.get("cwd")) for a in agents().values()}
|
|
267
|
+
if proj not in known: return {"ok": False, "error": "unknown project"}
|
|
268
|
+
cwd = os.path.join(HOME, proj[2:]) if proj.startswith("~/") else HOME if proj == "~" else proj
|
|
269
|
+
if not os.path.isdir(cwd): return {"ok": False, "error": f"{proj} doesn't exist any more"}
|
|
270
|
+
if terminals.launch(cwd, "claude"): return {"ok": True}
|
|
271
|
+
return {"ok": False, "copy": manual(cwd, "claude")}
|
|
272
|
+
|
|
273
|
+
def chat(sid):
|
|
274
|
+
p = transcript(sid)
|
|
275
|
+
if not p: return None
|
|
276
|
+
e = INDEX.get(p); a = agents().get(sid, {})
|
|
277
|
+
return dict(transcripts.messages(p), sid=sid, name=(pending_names().get(sid) or {}).get("title") or a.get("name") or e.title or "(empty)", running=bool(a),
|
|
278
|
+
cwd=(e.cwd or "").replace(HOME, "~"), cost=round(e.cost, 2), size=e.size, mtime=e.mtime)
|
|
279
|
+
|
|
280
|
+
class H(BaseHTTPRequestHandler):
|
|
281
|
+
def log_message(self, *a): pass
|
|
282
|
+
def send(self, code, body, ctype="application/json", extra=()):
|
|
283
|
+
b = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
284
|
+
self.send_response(code); self.send_header("Content-Type", ctype)
|
|
285
|
+
self.send_header("Cache-Control", "no-store")
|
|
286
|
+
for k, v in extra: self.send_header(k, v)
|
|
287
|
+
self.end_headers(); self.wfile.write(b)
|
|
288
|
+
def authed(self):
|
|
289
|
+
return self.headers.get("X-Token") == TOKEN and self.headers.get("Host", "").split(":")[0] in ("localhost", "127.0.0.1")
|
|
290
|
+
def do_GET(self):
|
|
291
|
+
u = urlparse(self.path); q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
|
292
|
+
if u.path == "/":
|
|
293
|
+
with open(PAGE) as f:
|
|
294
|
+
return self.send(200, f.read().replace("__TOKEN__", TOKEN).encode(), "text/html; charset=utf-8")
|
|
295
|
+
if u.path in STATIC: # the logo and app manifest are public; they give nothing away
|
|
296
|
+
name, ctype = STATIC[u.path]
|
|
297
|
+
with open(os.path.join(ASSETS, name), "rb") as f:
|
|
298
|
+
return self.send(200, f.read(), ctype)
|
|
299
|
+
if u.path == "/manifest.webmanifest":
|
|
300
|
+
return self.send(200, json.dumps(MANIFEST).encode(), "application/manifest+json")
|
|
301
|
+
if not self.authed(): return self.send(404, {"error": "not found"})
|
|
302
|
+
if u.path == "/api/state": return self.send(200, state())
|
|
303
|
+
if u.path == "/api/summary": # small payload for the desktop bubble's badge
|
|
304
|
+
act = agents(); w = waiting(act, INDEX.all())
|
|
305
|
+
return self.send(200, {"running": len(act), "busy": sum(a.get("status") == "busy" for a in act.values()),
|
|
306
|
+
"waiting": [act[sid].get("name") or "a chat" for sid in w if sid in act]})
|
|
307
|
+
if u.path == "/api/search": return self.send(200, INDEX.search(q.get("q", "")[:200]))
|
|
308
|
+
if u.path == "/api/chat":
|
|
309
|
+
c = chat(q.get("sid", ""))
|
|
310
|
+
return self.send(200, c) if c else self.send(404, {"error": "no such chat"})
|
|
311
|
+
if u.path == "/api/export":
|
|
312
|
+
c = transcript(q.get("sid", ""))
|
|
313
|
+
if not c: return self.send(404, {"error": "no such chat"})
|
|
314
|
+
title = INDEX.get(c).title or "chat"
|
|
315
|
+
name = "".join(ch if ch.isalnum() or ch in "-_ " else "-" for ch in title).strip()[:60] or "chat"
|
|
316
|
+
return self.send(200, transcripts.markdown(c, title).encode(), "text/markdown; charset=utf-8",
|
|
317
|
+
[("Content-Disposition", f"attachment; filename*=UTF-8''{quote(name)}.md")])
|
|
318
|
+
self.send(404, {"error": "not found"})
|
|
319
|
+
def do_POST(self):
|
|
320
|
+
if not self.authed(): return self.send(403, {"error": "forbidden"})
|
|
321
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
322
|
+
body = json.loads(self.rfile.read(n) or b"{}")
|
|
323
|
+
if self.path == "/api/delete": return self.send(200, delete(body.get("sids", [])))
|
|
324
|
+
if self.path == "/api/rename":
|
|
325
|
+
if body.get("auto"): return self.send(200, auto_rename(body.get("sid", "")))
|
|
326
|
+
return self.send(200, rename(body.get("sid", ""), body.get("title", "")))
|
|
327
|
+
if self.path == "/api/stop": return self.send(200, stop(body.get("sid", "")))
|
|
328
|
+
if self.path == "/api/compact": return self.send(200, compact(body.get("sid", "")))
|
|
329
|
+
if self.path == "/api/open": return self.send(200, open_chat(body.get("sid", ""), body.get("view", False)))
|
|
330
|
+
if self.path == "/api/new": return self.send(200, new_chat(body.get("project", "")))
|
|
331
|
+
if self.path == "/api/open-running": return self.send(200, open_running(body.get("sid", "")))
|
|
332
|
+
self.send(404, {"error": "not found"})
|
|
333
|
+
|
|
334
|
+
def serve(port):
|
|
335
|
+
"""Run the dashboard until stopped. Raises OSError if the port is taken."""
|
|
336
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", port), H)
|
|
337
|
+
os.makedirs(DATA, exist_ok=True)
|
|
338
|
+
pidfile = os.path.join(DATA, "server.pid")
|
|
339
|
+
with open(pidfile, "w") as f: f.write(str(os.getpid()))
|
|
340
|
+
threading.Thread(target=_agents_loop, daemon=True).start()
|
|
341
|
+
try:
|
|
342
|
+
httpd.serve_forever()
|
|
343
|
+
finally:
|
|
344
|
+
try: os.remove(pidfile)
|
|
345
|
+
except OSError: pass
|