dsh-project-deck 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,953 @@
1
+ #!/usr/bin/env python3
2
+ """项目甲板 管理面板 —— 本地 Web 控制台。
3
+
4
+ python3 ~/.project-deck/bin/panel.py # 默认 127.0.0.1:9199
5
+ DECK_PANEL_HOST=0.0.0.0 DECK_PANEL_PORT=9199 python3 panel.py
6
+
7
+ 数据全部来自 ~/.project-deck(registry / config / report-state / 播报历史),
8
+ 与 deck CLI、launchd 守护进程共用同一份真相,不另存一份。
9
+ """
10
+ import glob
11
+ import http.server
12
+ import importlib.util
13
+ import json
14
+ import os
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import time
20
+ import urllib.parse
21
+
22
+ BASE = os.path.expanduser(os.environ.get('DECK_HOME', '~/.project-deck'))
23
+ DECK_BIN = os.environ.get('DECK_BIN') or os.path.join(BASE, 'bin', 'deck')
24
+ if not os.path.exists(DECK_BIN):
25
+ DECK_BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deck')
26
+ PAGENT = DECK_BIN
27
+ HTML = os.path.join(BASE, 'panel.html')
28
+ if not os.path.exists(HTML):
29
+ HTML = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'panel.html')
30
+ PORT = int(os.environ.get('DECK_PANEL_PORT', '9199'))
31
+ HOST = os.environ.get('DECK_PANEL_HOST', '127.0.0.1')
32
+
33
+ KINDS = {'code': '代码', 'content': '内容', 'ops': '运维', 'life': '生活', 'research': '研究'}
34
+ STATUSES = {'active': '活跃', 'paused': '暂停', 'blocked': '阻塞', 'archived': '归档'}
35
+
36
+
37
+ def load_deck():
38
+ from importlib.machinery import SourceFileLoader
39
+ loader = SourceFileLoader('deck', PAGENT)
40
+ spec = importlib.util.spec_from_loader('deck', loader)
41
+ mod = importlib.util.module_from_spec(spec)
42
+ loader.exec_module(mod)
43
+ return mod
44
+
45
+
46
+ P = load_deck()
47
+
48
+
49
+ # ── 数据汇总 ────────────────────────────────────────────────
50
+ def wechat_state():
51
+ try:
52
+ r = subprocess.run([P.WXSEND, '--status'], capture_output=True, text=True, timeout=10)
53
+ ok = r.returncode == 0
54
+ return {'ok': ok, 'raw': r.stdout.strip(), 'code': r.returncode}
55
+ except (OSError, subprocess.SubprocessError):
56
+ return {'ok': False, 'raw': 'wxsend 不可用', 'code': 3}
57
+
58
+
59
+ def project_view(p):
60
+ info = P.collect(p)
61
+ return {
62
+ 'id': p['id'], 'name': p.get('name', p['id']),
63
+ 'kind': p.get('kind', 'code'), 'kindLabel': KINDS.get(p.get('kind', 'code'), p.get('kind')),
64
+ 'status': p.get('status', 'active'),
65
+ 'statusLabel': STATUSES.get(p.get('status', 'active'), p.get('status')),
66
+ 'priority': p.get('priority', 50), 'root': p.get('root', ''),
67
+ 'note': p.get('note', ''), 'next': info['next'] or p.get('note') or None,
68
+ 'exists': info['exists'], 'memory': info['memory'],
69
+ 'activity': info['activity'], 'activityText': P.rel_time(info['activity']),
70
+ 'git': info['git'], 'board': info['board'],
71
+ 'autonomy': P.autonomy_of(p),
72
+ 'sessions': project_sessions(p.get('root', ''), p.get('sessions') or []),
73
+ 'boundSessions': p.get('sessions') or [],
74
+ }
75
+
76
+
77
+ def snapshot():
78
+ cfg = P.load_json(P.CONFIG, {})
79
+ reg = P.load_json(P.REGISTRY, {'projects': []})
80
+ st = P.load_json(P.STATE, {})
81
+ rep = cfg.get('report', {})
82
+ last = st.get('last_run') or 0
83
+ interval = float(rep.get('intervalHours', 4))
84
+ history = []
85
+ if os.path.exists(P.HISTORY):
86
+ try:
87
+ lines = open(P.HISTORY, encoding='utf-8', errors='replace').read().strip().splitlines()[-8:]
88
+ for line in lines:
89
+ try:
90
+ h = json.loads(line)
91
+ history.append({'at': h.get('at', 0), 'exit': h.get('exit', -1),
92
+ 'text': h.get('text', '')})
93
+ except json.JSONDecodeError:
94
+ continue
95
+ except OSError:
96
+ pass
97
+ history.reverse()
98
+ projects = [project_view(p) for p in reg.get('projects', [])]
99
+ projects.sort(key=lambda p: (-p['priority'], p['id']))
100
+ return {
101
+ 'now': time.time(),
102
+ 'config': {'intervalHours': interval, 'enabled': rep.get('enabled', True),
103
+ 'maxChars': rep.get('maxChars', 1200),
104
+ 'format': rep.get('format', 'markdown'),
105
+ 'image': bool(rep.get('image', False)),
106
+ 'web': cfg.get('web', {}).get('baseUrl', 'http://127.0.0.1:43120'),
107
+ 'scan': cfg.get('scan', {})},
108
+ 'daemon': {'running': bool(P.daemon_status()), 'pid': P.daemon_status() or 0,
109
+ 'lastRun': last, 'nextRun': (last + interval * 3600) if last else 0},
110
+ 'wechat': dict(wechat_state(),
111
+ stale=P.channel_stale(),
112
+ fails=P.channel_state().get('fails', 0),
113
+ lastError=P.channel_state().get('lastError', '')),
114
+ 'counts': {
115
+ 'total': len(projects),
116
+ 'active': sum(1 for p in projects if p['status'] == 'active'),
117
+ 'blocked': sum(1 for p in projects if p['status'] == 'blocked'),
118
+ 'recent': sum(1 for p in projects if p['activity'] and time.time() - p['activity'] < 6 * 3600),
119
+ 'noMemory': sum(1 for p in projects if not p['memory']),
120
+ },
121
+ 'projects': projects,
122
+ 'history': history,
123
+ 'nodes': nodes_view(),
124
+ 'approvals': [x for x in P.load_approvals().get('items', []) if x.get('status') == 'pending'],
125
+ 'decisions': decisions_view(10),
126
+ }
127
+
128
+
129
+ def nodes_view():
130
+ nodes = P.load_nodes().get('nodes', [])
131
+ out = []
132
+ for n in nodes:
133
+ snap = P.load_json(os.path.join(P.NODE_SNAPS, n['id'] + '.json'), None)
134
+ out.append({
135
+ 'id': n['id'], 'name': n.get('name', n['id']), 'platform': n.get('platform', ''),
136
+ 'address': n.get('address', ''), 'lastSeen': n.get('lastSeen'),
137
+ 'runtimes': n.get('runtimes', ''), 'note': n.get('note', ''),
138
+ 'scannedAt': min(snap.get('scanned_at'), time.time()) if snap else None,
139
+ 'projects': len(snap.get('projects', [])) if snap else 0,
140
+ 'online': bool(n.get('local')) or bool(snap and time.time() - snap.get('scanned_at', 0) < 86400),
141
+ })
142
+ return out
143
+
144
+
145
+ def latest_image():
146
+ files = sorted(glob.glob(os.path.join(BASE, 'graphics', 'board-*.png')))
147
+ return files[-1] if files else None
148
+
149
+
150
+ def send_image_now(dry=False):
151
+ cfg = P.load_json(P.CONFIG, {})
152
+ png = P.render_image(cfg)
153
+ if dry:
154
+ return {'code': 0, 'message': '仅渲染(未发送)',
155
+ 'url': '/api/image/latest?t=' + str(int(time.time()))}
156
+ reg = P.load_json(P.REGISTRY, {'projects': []})
157
+ n = len([p for p in reg.get('projects', []) if p.get('status') != 'archived'])
158
+ caption = f'项目播报 · {n} 个项目 · ' + time.strftime('%m-%d %H:%M')
159
+ code, msg = P.send_wechat_image(png, caption)
160
+ return {'code': code, 'message': msg,
161
+ 'url': '/api/image/latest?t=' + str(int(time.time()))}
162
+
163
+
164
+ SESSION_ROOT = os.path.join(os.path.expanduser('~'), '.dsh', 'sessions')
165
+ PROJCACHE_ROOT = os.path.join(os.path.expanduser('~'), '.dsh', 'storages',
166
+ 'session_projcache', 'sessions')
167
+ SESSION_CACHE = os.path.join(BASE, 'sessions-cache.json')
168
+ INDEX_CACHE_VERSION = 2 # 缓存结构版本;旧版(按日志文件路径做键)遇到即重建
169
+ DISPLAY_CHARS = 120 # 列表 / 提示行展示用的首条消息长度
170
+ PROMPT_CHARS = 600 # 索引里保留的首条消息正文长度(供搜索与归类)
171
+ _INDEX_LOCK = threading.Lock() # 预热线程与 HTTP 请求会并发调 session_index,串行化扫描与落盘
172
+ INDEX_MEMO_TTL = 2.0 # 一次快照会按项目重复调 9 次索引,短 TTL 内存缓存合并成一次扫描
173
+ _INDEX_MEMO = {'at': 0.0, 'data': {}}
174
+ _WORKSPACE_STORE = os.path.join(os.path.expanduser('~'), '.dsh', 'storages', 'workspace.json')
175
+
176
+
177
+ def project_key(cwd):
178
+ """与 DSH 的 projectKey() 同算法:分隔符→'-',非安全字符→~XXXX,前后加 --。"""
179
+ out, sep = [], False
180
+ for ch in cwd:
181
+ code = ord(ch)
182
+ if ch in '/\\:':
183
+ if not sep:
184
+ out.append('-')
185
+ sep = True
186
+ elif ch != '~' and (ch.isalnum() or ch in '._-') and code < 128:
187
+ out.append(ch)
188
+ sep = False
189
+ else:
190
+ out.append('~%04X' % code)
191
+ sep = False
192
+ s = ''.join(out).lstrip('-') or 'root'
193
+ return '--' + s[:251] + '--'
194
+
195
+
196
+ def _squash(text):
197
+ """把多行消息压成单行空白,便于列表展示与关键字搜索。"""
198
+ return re.sub(r'\s+', ' ', text or '').strip()
199
+
200
+
201
+ def _session_log(session_dir):
202
+ """返回 (权威日志路径, 最近活跃 mtime)。
203
+
204
+ DSH 迁移后 session.v3.jsonl.zstd 是权威副本,未迁移的老会话只有
205
+ session.jsonl.zstd;新代码不能只认前者,否则老会话永远进不了索引。
206
+ """
207
+ v3 = os.path.join(session_dir, 'session.v3.jsonl.zstd')
208
+ legacy = os.path.join(session_dir, 'session.jsonl.zstd')
209
+ logs = [p for p in (v3, legacy) if os.path.exists(p)]
210
+ if not logs:
211
+ return None, 0
212
+ at = 0
213
+ for p in logs:
214
+ try:
215
+ at = max(at, os.path.getmtime(p))
216
+ except OSError:
217
+ pass
218
+ return logs[0], at
219
+
220
+
221
+ def _pc_value(rows, key):
222
+ row = rows.get(key)
223
+ return row.get('val') if isinstance(row, dict) else None
224
+
225
+
226
+ def _read_projcache(sid):
227
+ """读 DSH 的 projection cache(物化索引):标题、首条消息全文、cwd。
228
+
229
+ DSH 已把每个会话的 title / titleInput / cwd 折叠好落在
230
+ ~/.dsh/storages/session_projcache/sessions/<id>.json,读它比逐个解压
231
+ 日志便宜得多。文件不存在或暂时读不出(写入中 / 损坏)返回 {}。
232
+ """
233
+ path = os.path.join(PROJCACHE_ROOT, sid + '.json')
234
+ try:
235
+ with open(path, encoding='utf-8') as f:
236
+ raw = json.load(f)
237
+ except (OSError, ValueError):
238
+ return {}
239
+ rec = raw.get('record') or {}
240
+ ident = rec.get('identity') or {}
241
+ rows = rec.get('rows') or {}
242
+ first = ''
243
+ ti = _pc_value(rows, 'titleInput')
244
+ if isinstance(ti, dict):
245
+ first = (ti.get('first') or {}).get('text') or ''
246
+ title = _pc_value(rows, 'title') or ''
247
+ if not title and not first and not ident.get('cwd'):
248
+ return {}
249
+ return {'cwd': ident.get('cwd') or '', 'createdAt': ident.get('createdAt') or 0,
250
+ 'title': title, 'first': first}
251
+
252
+
253
+ def _read_session_head(path):
254
+ """兜底:只解开头几十行,拿会话头与首条用户消息(不解压整个日志)。"""
255
+ try:
256
+ proc = subprocess.Popen(['zstd', '-dc', path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
257
+ except (OSError, subprocess.SubprocessError):
258
+ return None
259
+ raw = b''
260
+ try:
261
+ while raw.count(b'\n') < 25 and len(raw) < 200000:
262
+ chunk = proc.stdout.read(65536)
263
+ if not chunk:
264
+ break
265
+ raw += chunk
266
+ finally:
267
+ try:
268
+ proc.kill()
269
+ except OSError:
270
+ pass
271
+ proc.wait(timeout=5)
272
+ head, first = None, ''
273
+ for line in raw.splitlines()[:25]:
274
+ try:
275
+ j = json.loads(line)
276
+ except Exception:
277
+ continue
278
+ if j.get('type') == 'session' and head is None:
279
+ head = j
280
+ elif j.get('type') == 'user/message' and not first:
281
+ content = (j.get('data') or {}).get('content') or []
282
+ first = ' '.join(c.get('text', '') for c in content if isinstance(c, dict))
283
+ if head is not None and first:
284
+ break
285
+ if head is None:
286
+ return None
287
+ return {'id': head.get('id'), 'cwd': head.get('cwd'), 'createdAt': head.get('createdAt'),
288
+ 'first': first}
289
+
290
+
291
+ def _public_record(ent):
292
+ """缓存条目去掉签名后的对外记录。"""
293
+ return {k: ent.get(k) for k in ('id', 'ws', 'at', 'cwd', 'createdAt',
294
+ 'title', 'first', 'prompt')}
295
+
296
+
297
+ def _lookup(idx, sid):
298
+ """按会话 id 查索引;兼容历史绑定的 header id(如 im-connect 的 im:… 会话)。"""
299
+ rec = idx.get(sid)
300
+ if rec:
301
+ return rec
302
+ for rec in idx.values():
303
+ if rec.get('id') == sid:
304
+ return rec
305
+ return None
306
+
307
+
308
+ def session_index(refresh=False, fresh=False):
309
+ """会话索引入口:进程内串行化(预热线程与并发请求共用一份缓存文件)。
310
+
311
+ 面板一次刷新会按项目重复调用本函数,用短 TTL 的内存快照合并成一次磁盘扫描;
312
+ `fresh=True` 绕过内存快照(会话选择器用,保证刚建的会话立刻可见)。
313
+ 调用方只读,不要修改返回的 dict。
314
+ """
315
+ with _INDEX_LOCK:
316
+ now = time.time()
317
+ if (not refresh and not fresh and _INDEX_MEMO['at']
318
+ and now - _INDEX_MEMO['at'] < INDEX_MEMO_TTL):
319
+ return _INDEX_MEMO['data']
320
+ data = _index_sessions(refresh)
321
+ _INDEX_MEMO['at'] = now
322
+ _INDEX_MEMO['data'] = data
323
+ return data
324
+
325
+
326
+ def _index_sessions(refresh=False):
327
+ """扫描全部会话目录,返回 {id: {id, cwd, at, createdAt, title, first, prompt, ws}}。
328
+
329
+ 标题与首条消息优先取自 DSH 的 session_projcache(物化索引,不必解压日志);
330
+ 只有缓存缺失时才回退到「只解头部」的日志读取。按日志与缓存文件的 mtime/size
331
+ 做签名缓存,未变化的会话不重复解析,已消失的会话从索引里清掉。
332
+ """
333
+ entries = {}
334
+ if not refresh:
335
+ raw = P.load_json(SESSION_CACHE, {})
336
+ if isinstance(raw, dict) and raw.get('v') == INDEX_CACHE_VERSION:
337
+ entries = raw.get('entries') or {}
338
+ out, seen, changed = {}, set(), False
339
+ if not os.path.isdir(SESSION_ROOT):
340
+ return {}
341
+ for ws in os.listdir(SESSION_ROOT):
342
+ wsd = os.path.join(SESSION_ROOT, ws)
343
+ if not os.path.isdir(wsd):
344
+ continue
345
+ for sid in os.listdir(wsd):
346
+ sd = os.path.join(wsd, sid)
347
+ if not os.path.isdir(sd):
348
+ continue
349
+ log, at = _session_log(sd)
350
+ if not log:
351
+ continue
352
+ try:
353
+ lst = os.stat(log)
354
+ except OSError:
355
+ continue
356
+ seen.add(sid)
357
+ try:
358
+ pst = os.stat(os.path.join(PROJCACHE_ROOT, sid + '.json'))
359
+ pc_sig = (pst.st_mtime, pst.st_size)
360
+ except OSError:
361
+ pc_sig = (0, 0)
362
+ sig = [lst.st_mtime, lst.st_size, pc_sig[0], pc_sig[1]]
363
+ ent = entries.get(sid)
364
+ if isinstance(ent, dict) and ent.get('sig') == sig:
365
+ out[sid] = _public_record(ent)
366
+ continue
367
+ pc = _read_projcache(sid)
368
+ head = None
369
+ # 只有 title 不够:mesh 任务会话的标题是插件写死的「Mesh 任务 t-xxx」,
370
+ # titleInput 里没有符合条件的首条消息,正文得回日志头部补。
371
+ if not pc.get('cwd') or not pc.get('first'):
372
+ head = _read_session_head(log)
373
+ if head and head.get('id') and head['id'] != sid:
374
+ # 目录名是编码后的 projectKey(im-connect 会话就是这样),
375
+ # projcache 以真实 session id 落盘,再按 header id 试一次。
376
+ alt = _read_projcache(head['id'])
377
+ if alt.get('title') or alt.get('first'):
378
+ pc = alt
379
+ if not pc and not head:
380
+ continue
381
+ rec = {'id': sid, 'ws': ws, 'at': at,
382
+ 'cwd': pc.get('cwd') or (head or {}).get('cwd') or '',
383
+ 'createdAt': pc.get('createdAt') or (head or {}).get('createdAt') or 0,
384
+ 'title': _squash(pc.get('title')),
385
+ 'prompt': _squash(pc.get('first') or (head or {}).get('first'))[:PROMPT_CHARS]}
386
+ rec['first'] = rec['prompt'][:DISPLAY_CHARS]
387
+ out[sid] = dict(rec)
388
+ if pc_sig != (0, 0) and not pc:
389
+ changed = True # 缓存文件在却读不出(写入中 / 损坏):这次不落缓存,下次重试
390
+ continue
391
+ entries[sid] = dict(rec, sig=sig)
392
+ changed = True
393
+ for sid in list(entries):
394
+ if sid not in seen:
395
+ del entries[sid]
396
+ changed = True
397
+ if changed:
398
+ P.save_json(SESSION_CACHE, {'v': INDEX_CACHE_VERSION, 'entries': entries})
399
+ return out
400
+
401
+
402
+ def project_sessions(root, bound=None, limit=12):
403
+ """cwd 匹配的会话 + 手工绑定的会话,合并去重后按最近活跃排序。"""
404
+ idx = session_index()
405
+ seen, out = set(), []
406
+ for s in _cwd_sessions(root, limit=200):
407
+ if s['id'] in seen:
408
+ continue
409
+ seen.add(s['id'])
410
+ meta = _lookup(idx, s['id']) or {}
411
+ out.append({'id': s['id'], 'at': s['at'], 'bound': False,
412
+ 'title': meta.get('title', ''), 'first': meta.get('first', ''),
413
+ 'cwd': meta.get('cwd', '')})
414
+ for sid in (bound or []):
415
+ if sid in seen:
416
+ continue
417
+ m = _lookup(idx, sid)
418
+ if not m:
419
+ continue
420
+ seen.add(sid)
421
+ out.append({'id': m.get('id') or sid, 'at': m['at'], 'bound': True,
422
+ 'title': m.get('title', ''), 'first': m.get('first', ''),
423
+ 'cwd': m.get('cwd', '')})
424
+ out.sort(key=lambda x: -x['at'])
425
+ return out[:limit]
426
+
427
+
428
+ def _cwd_sessions(root, limit=12):
429
+ """按 projectKey 找到该项目的会话目录,按最近修改时间倒序。"""
430
+ if not root:
431
+ return []
432
+ d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root)))
433
+ if not os.path.isdir(d):
434
+ return []
435
+ out = []
436
+ try:
437
+ names = os.listdir(d)
438
+ except OSError:
439
+ return []
440
+ for name in names:
441
+ sd = os.path.join(d, name)
442
+ if not os.path.isdir(sd):
443
+ continue
444
+ # 目录名就是会话 id(session-<uuid> / mesh-<uuid> / 裸 uuid 都见过),
445
+ # 必须原样传给 uiWorkspace.openSession,不能剥前缀。
446
+ log, latest = _session_log(sd)
447
+ if not log:
448
+ continue
449
+ if latest:
450
+ out.append({'id': name, 'at': latest})
451
+ out.sort(key=lambda x: -x['at'])
452
+ return out[:limit]
453
+
454
+
455
+ def goal_list(root):
456
+ gdir = os.path.join(root, '.dsh-graph', 'goals')
457
+ out = []
458
+ if not os.path.isdir(gdir):
459
+ return out
460
+ for name in sorted(os.listdir(gdir)):
461
+ f = os.path.join(gdir, name, 'goal.md')
462
+ if not os.path.isfile(f):
463
+ continue
464
+ try:
465
+ head = open(f, encoding='utf-8', errors='replace').read(3000)
466
+ except OSError:
467
+ continue
468
+ title = re.search(r'^\s+title:\s*["\']?(.+?)["\']?\s*$', head, re.M)
469
+ status = re.search(r'^\s+status:\s*["\']?([a-z_]+)', head, re.M)
470
+ out.append({'id': name, 'title': (title.group(1) if title else name)[:60],
471
+ 'status': status.group(1) if status else 'unknown'})
472
+ return out[:12]
473
+
474
+
475
+ def git_log(root, n=5):
476
+ try:
477
+ r = subprocess.run(['git', '-C', root, 'log', '-' + str(n), '--format=%h%x1f%ar%x1f%s'],
478
+ capture_output=True, text=True, timeout=6)
479
+ if r.returncode != 0:
480
+ return []
481
+ rows = []
482
+ for line in r.stdout.strip().splitlines():
483
+ parts = line.split('\x1f', 2)
484
+ if len(parts) == 3:
485
+ rows.append({'sha': parts[0], 'when': parts[1], 'subject': parts[2][:90]})
486
+ return rows
487
+ except (OSError, subprocess.SubprocessError):
488
+ return []
489
+
490
+
491
+ def project_detail(pid):
492
+ reg = P.load_json(P.REGISTRY, {'projects': []})
493
+ proj = next((p for p in reg['projects'] if p['id'] == pid), None)
494
+ if not proj:
495
+ raise ValueError('项目不存在')
496
+ view = project_view(proj)
497
+ root = proj.get('root', '')
498
+ detail = {'project': view, 'gitLog': git_log(root) if os.path.isdir(root) else [],
499
+ 'goals': goal_list(root) if os.path.isdir(root) else [], 'state': None}
500
+ sf = P.state_file(proj)
501
+ if sf and os.path.exists(sf):
502
+ try:
503
+ text = open(sf, encoding='utf-8', errors='replace').read(4000)
504
+ except OSError:
505
+ text = ''
506
+ nxt, st = P.parse_state(sf)
507
+ detail['state'] = {'path': sf, 'next': nxt, 'status': st,
508
+ 'size': os.path.getsize(sf), 'excerpt': text[:2000]}
509
+ return detail
510
+
511
+
512
+ def image_history(limit=8):
513
+ files = sorted(glob.glob(os.path.join(BASE, 'graphics', 'board-*.png')), reverse=True)[:limit]
514
+ return [{'name': os.path.basename(f), 'at': os.path.getmtime(f),
515
+ 'url': '/api/image/file?name=' + urllib.parse.quote(os.path.basename(f))} for f in files]
516
+
517
+
518
+ def scan_view():
519
+ st = P.load_json(P.SCAN_STATE, {})
520
+ return {'lastRun': st.get('lastRun'), 'added': st.get('added', []),
521
+ 'candidateCount': len(st.get('candidates', []))}
522
+
523
+
524
+ def scan_candidates():
525
+ return P.discover(P.load_json(P.CONFIG, {}))
526
+
527
+
528
+ def run_scan():
529
+ cfg = P.load_json(P.CONFIG, {})
530
+ res = P.auto_scan(cfg)
531
+ return {'added': res.get('added', []), 'candidates': P.discover(cfg), 'state': snapshot()}
532
+
533
+
534
+ def add_candidates(paths):
535
+ cfg = P.load_json(P.CONFIG, {})
536
+ cands = {c['path']: c for c in P.discover(cfg)}
537
+ added, errors = [], []
538
+ for path in paths:
539
+ c = cands.get(path)
540
+ if not c:
541
+ errors.append(f'{path} 已失效')
542
+ continue
543
+ try:
544
+ add_project({'id': c['id'], 'name': c['name'], 'root': c['path'],
545
+ 'kind': c['kind'], 'priority': 40,
546
+ 'note': '自动发现:' + ';'.join(c['reasons'][:3])})
547
+ added.append(c['id'])
548
+ except ValueError as e:
549
+ errors.append(str(e))
550
+ return {'added': added, 'errors': errors, 'candidates': P.discover(cfg), 'state': snapshot()}
551
+
552
+
553
+ # ── 写操作 ──────────────────────────────────────────────────
554
+ def set_config(payload):
555
+ cfg = P.load_json(P.CONFIG, {})
556
+ rep = cfg.setdefault('report', {})
557
+ if 'intervalHours' in payload:
558
+ try:
559
+ h = float(payload['intervalHours'])
560
+ except (TypeError, ValueError):
561
+ raise ValueError('间隔必须是数字')
562
+ if not (0 < h <= 720):
563
+ raise ValueError('间隔需在 0 到 720 小时之间')
564
+ rep['intervalHours'] = h
565
+ if 'enabled' in payload:
566
+ rep['enabled'] = bool(payload['enabled'])
567
+ if 'maxChars' in payload:
568
+ rep['maxChars'] = int(payload['maxChars'])
569
+ if 'image' in payload:
570
+ rep['image'] = bool(payload['image'])
571
+ if 'scan' in payload and isinstance(payload['scan'], dict):
572
+ sc = cfg.setdefault('scan', {})
573
+ for k in ('enabled', 'autoAdd'):
574
+ if k in payload['scan']:
575
+ sc[k] = bool(payload['scan'][k])
576
+ for k in ('intervalHours', 'threshold', 'depth'):
577
+ if k in payload['scan']:
578
+ try:
579
+ sc[k] = float(payload['scan'][k]) if k == 'intervalHours' else int(payload['scan'][k])
580
+ except (TypeError, ValueError):
581
+ raise ValueError(k + ' 必须是数字')
582
+ if 'roots' in payload['scan']:
583
+ roots = payload['scan']['roots']
584
+ if isinstance(roots, str):
585
+ roots = [x.strip() for x in roots.splitlines() if x.strip()]
586
+ sc['roots'] = [str(x) for x in roots][:12]
587
+ if 'format' in payload:
588
+ if payload['format'] not in ('markdown', 'plain'):
589
+ raise ValueError('版式只支持 markdown / plain')
590
+ rep['format'] = payload['format']
591
+ P.save_json(P.CONFIG, cfg)
592
+ P.log(f'[panel] config interval={rep.get("intervalHours")} enabled={rep.get("enabled")}')
593
+ return snapshot()
594
+
595
+
596
+ def bind_session(payload):
597
+ pid = payload.get('project')
598
+ sid = str(payload.get('sessionId') or '').strip()
599
+ if not pid or not sid:
600
+ raise ValueError('project 与 sessionId 必填')
601
+ reg = P.load_json(P.REGISTRY, {'projects': []})
602
+ proj = next((x for x in reg['projects'] if x['id'] == pid), None)
603
+ if not proj:
604
+ raise ValueError('项目不存在')
605
+ idx = session_index()
606
+ rec = _lookup(idx, sid)
607
+ if not rec:
608
+ raise ValueError('会话不存在(索引里没有这个 id)')
609
+ sid = rec.get('id') or sid # 统一落成索引里的规范 id(目录名)
610
+ cur = list(proj.get('sessions') or [])
611
+ if payload.get('bind', True):
612
+ if sid not in cur:
613
+ cur.append(sid)
614
+ else:
615
+ cur = [x for x in cur if x != sid]
616
+ proj['sessions'] = cur
617
+ P.save_json(P.REGISTRY, reg)
618
+ P.log(f'[panel] bind session {sid} -> {pid} ({len(cur)})')
619
+ return snapshot()
620
+
621
+
622
+ def set_autonomy(project, mode, max_per_day=None):
623
+ if mode not in ('off', 'assist', 'auto'):
624
+ raise ValueError('mode 只能是 off / assist / auto')
625
+ reg = P.load_json(P.REGISTRY, {'projects': []})
626
+ proj = next((x for x in reg['projects'] if x['id'] == project), None)
627
+ if not proj:
628
+ raise ValueError('项目不存在')
629
+ a = P.autonomy_of(proj)
630
+ a['mode'] = mode
631
+ if max_per_day is not None:
632
+ a['maxPerDay'] = int(max_per_day)
633
+ proj['autonomy'] = a
634
+ P.save_json(P.REGISTRY, reg)
635
+ P.log(f'[panel] autonomy {project} -> {mode}')
636
+ return snapshot()
637
+
638
+
639
+ def resolve_approval(approval_id, action, choice=''):
640
+ it = P.resolve_approval(approval_id, action, choice)
641
+ if not it:
642
+ raise ValueError('待批项不存在或已处理')
643
+ return {'item': it, 'state': snapshot()}
644
+
645
+
646
+ def decisions_view(limit=20):
647
+ rows = []
648
+ if os.path.exists(P.DECISIONS):
649
+ for line in open(P.DECISIONS):
650
+ try:
651
+ rows.append(json.loads(line))
652
+ except json.JSONDecodeError:
653
+ continue
654
+ return rows[-limit:][::-1]
655
+
656
+
657
+ def ignore_candidate(path, days):
658
+ if not path:
659
+ raise ValueError('缺少 path')
660
+ ig = P.load_ignore()
661
+ days = int(days)
662
+ if days <= 0:
663
+ if path not in ig['blocked']:
664
+ ig['blocked'].append(path)
665
+ ig.get('ignore', {}).pop(path, None)
666
+ else:
667
+ ig.setdefault('ignore', {})[path] = time.time() + days * 86400
668
+ P.save_json(P.SCAN_IGNORE, ig)
669
+ P.log(f'[scan] 忽略候选 {path} ({days} 天)')
670
+ return {'candidates': P.discover(P.load_json(P.CONFIG, {})), 'state': snapshot()}
671
+
672
+
673
+ def all_sessions(q='', limit=300):
674
+ idx = session_index(fresh=True) # 选择器要立刻看到刚建的会话,不吃内存快照
675
+ rows = list(idx.values())
676
+ if q:
677
+ ql = q.lower()
678
+ rows = [r for r in rows
679
+ if ql in (r.get('title') or '').lower()
680
+ or ql in (r.get('prompt') or r.get('first') or '').lower()
681
+ or ql in (r.get('cwd') or '').lower()]
682
+ rows.sort(key=lambda r: -r['at'])
683
+ return rows[:limit]
684
+
685
+
686
+ def add_project(payload):
687
+ pid = str(payload.get('id', '')).strip()
688
+ root = os.path.realpath(os.path.expanduser(str(payload.get('root', '')).strip()))
689
+ if not pid or not root:
690
+ raise ValueError('id 与 root 必填')
691
+ if not os.path.isdir(root):
692
+ raise ValueError(f'目录不存在: {root}')
693
+ reg = P.load_json(P.REGISTRY, {'projects': []})
694
+ for p in reg['projects']:
695
+ if p['id'] == pid:
696
+ raise ValueError(f'项目 {pid} 已存在')
697
+ if os.path.realpath(p.get('root', '')) == root:
698
+ raise ValueError(f'该目录已绑定项目 {p["id"]}')
699
+ reg['projects'].append({
700
+ 'id': pid, 'name': payload.get('name') or pid, 'aliases': [pid],
701
+ 'kind': payload.get('kind', 'code'), 'status': payload.get('status', 'active'),
702
+ 'priority': int(payload.get('priority', 50)), 'root': root,
703
+ 'memory': None, 'note': payload.get('note', ''),
704
+ 'created_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
705
+ })
706
+ P.save_json(P.REGISTRY, reg)
707
+ P.log(f'[panel] add project {pid} -> {root}')
708
+ return snapshot()
709
+
710
+
711
+ def update_project(payload):
712
+ pid = payload.get('id')
713
+ reg = P.load_json(P.REGISTRY, {'projects': []})
714
+ for p in reg['projects']:
715
+ if p['id'] != pid:
716
+ continue
717
+ for key in ('name', 'note', 'kind', 'status'):
718
+ if key in payload:
719
+ p[key] = payload[key]
720
+ if 'aliases' in payload and isinstance(payload['aliases'], list):
721
+ p['aliases'] = [str(a).strip() for a in payload['aliases'] if str(a).strip()][:20]
722
+ if 'priority' in payload:
723
+ p['priority'] = int(payload['priority'])
724
+ P.save_json(P.REGISTRY, reg)
725
+ P.log(f'[panel] update project {pid}: ' + ', '.join(k for k in payload if k != 'id'))
726
+ return snapshot()
727
+ raise ValueError(f'没有项目 {pid}')
728
+
729
+
730
+ def remove_project(payload):
731
+ pid = payload.get('id')
732
+ reg = P.load_json(P.REGISTRY, {'projects': []})
733
+ before = len(reg['projects'])
734
+ reg['projects'] = [p for p in reg['projects'] if p['id'] != pid]
735
+ if len(reg['projects']) == before:
736
+ raise ValueError(f'没有项目 {pid}')
737
+ P.save_json(P.REGISTRY, reg)
738
+ P.log(f'[panel] remove project {pid}')
739
+ return snapshot()
740
+
741
+
742
+ def send_report():
743
+ code, text, msg = P.do_report_with_image(P.load_json(P.CONFIG, {}), quiet=True)
744
+ st = P.load_json(P.STATE, {})
745
+ st['last_run'] = time.time()
746
+ P.save_json(P.STATE, st)
747
+ return {'code': code, 'message': msg, 'text': text, 'state': snapshot()}
748
+
749
+
750
+ # ── HTTP ────────────────────────────────────────────────────
751
+ class Handler(http.server.BaseHTTPRequestHandler):
752
+ server_version = 'deck-panel/1.0'
753
+
754
+ def log_message(self, fmt, *args):
755
+ pass
756
+
757
+ def _json(self, obj, status=200):
758
+ body = json.dumps(obj, ensure_ascii=False).encode()
759
+ self.send_response(status)
760
+ self.send_header('Content-Type', 'application/json; charset=utf-8')
761
+ self.send_header('Content-Length', str(len(body)))
762
+ self.send_header('Access-Control-Allow-Origin', '*')
763
+ self.end_headers()
764
+ self.wfile.write(body)
765
+
766
+ def _html(self, path):
767
+ try:
768
+ body = open(path, 'rb').read()
769
+ except OSError:
770
+ self._json({'ok': False, 'error': 'panel.html 缺失'}, 500)
771
+ return
772
+ self.send_response(200)
773
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
774
+ self.send_header('Content-Length', str(len(body)))
775
+ self.end_headers()
776
+ self.wfile.write(body)
777
+
778
+ def _local(self):
779
+ if self.client_address[0] in ('127.0.0.1', '::1', '::ffff:127.0.0.1'):
780
+ return True
781
+ cfg = P.load_json(P.CONFIG, {})
782
+ return bool((cfg.get('panel') or {}).get('allowLanRead'))
783
+
784
+ def do_GET(self):
785
+ path = urllib.parse.urlparse(self.path).path
786
+ if not self._local() and path not in ('/api/node/scanner.py', '/api/node/scanner.js', '/api/node/scanner.ps1'):
787
+ return self._json({'ok': False, 'error': 'forbidden'}, 403)
788
+ if path in ('/', '/index.html'):
789
+ return self._html(HTML)
790
+ if path == '/api/state':
791
+ try:
792
+ return self._json(snapshot())
793
+ except Exception as e:
794
+ return self._json({'ok': False, 'error': repr(e)}, 500)
795
+ if path == '/api/all-sessions':
796
+ qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
797
+ return self._json({'ok': True, 'sessions': all_sessions(qs.get('q', [''])[0], int(qs.get('limit', ['300'])[0]))})
798
+ if path == '/api/sessions':
799
+ qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
800
+ pid = qs.get('id', [''])[0]
801
+ reg = P.load_json(P.REGISTRY, {'projects': []})
802
+ proj = next((x for x in reg['projects'] if x['id'] == pid), None)
803
+ if not proj:
804
+ return self._json({'ok': False, 'error': '项目不存在'}, 404)
805
+ return self._json({'ok': True, 'id': pid, 'sessions': project_sessions(proj.get('root', ''), 20)})
806
+ if path == '/api/decisions':
807
+ return self._json({'ok': True, 'decisions': decisions_view(30)})
808
+ if path == '/api/approvals':
809
+ return self._json({'ok': True, 'items': [x for x in P.load_approvals().get('items', []) if x.get('status') == 'pending']})
810
+ if path == '/api/nodes':
811
+ return self._json({'ok': True, 'nodes': nodes_view()})
812
+ if path == '/api/node/scanner.ps1':
813
+ fp = os.path.join(BASE, 'bin', 'deck-node.ps1')
814
+ if not os.path.isfile(fp):
815
+ return self._json({'ok': False, 'error': 'scanner 不存在'}, 404)
816
+ body = open(fp, 'rb').read()
817
+ self.send_response(200)
818
+ self.send_header('Content-Type', 'text/plain; charset=utf-8')
819
+ self.send_header('Content-Length', str(len(body)))
820
+ self.end_headers()
821
+ self.wfile.write(body)
822
+ return
823
+ if path == '/api/node/scanner.js':
824
+ fp = os.path.join(BASE, 'bin', 'deck-node.js')
825
+ if not os.path.isfile(fp):
826
+ return self._json({'ok': False, 'error': 'scanner 不存在'}, 404)
827
+ body = open(fp, 'rb').read()
828
+ self.send_response(200)
829
+ self.send_header('Content-Type', 'text/plain; charset=utf-8')
830
+ self.send_header('Content-Length', str(len(body)))
831
+ self.end_headers()
832
+ self.wfile.write(body)
833
+ return
834
+ if path == '/api/node/scanner.py':
835
+ fp = os.path.join(BASE, 'bin', 'deck-node')
836
+ if not os.path.isfile(fp):
837
+ return self._json({'ok': False, 'error': 'scanner 不存在'}, 404)
838
+ body = open(fp, 'rb').read()
839
+ self.send_response(200)
840
+ self.send_header('Content-Type', 'text/plain; charset=utf-8')
841
+ self.send_header('Content-Length', str(len(body)))
842
+ self.end_headers()
843
+ self.wfile.write(body)
844
+ return
845
+ if path == '/api/project':
846
+ qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
847
+ try:
848
+ return self._json({'ok': True, 'detail': project_detail(qs.get('id', [''])[0])})
849
+ except ValueError as e:
850
+ return self._json({'ok': False, 'error': str(e)}, 404)
851
+ if path == '/api/image/history':
852
+ return self._json({'ok': True, 'images': image_history()})
853
+ if path == '/api/image/file':
854
+ qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
855
+ name = os.path.basename(qs.get('name', [''])[0])
856
+ fp = os.path.join(BASE, 'graphics', name)
857
+ if not name.endswith('.png') or not os.path.isfile(fp):
858
+ return self._json({'ok': False, 'error': '图片不存在'}, 404)
859
+ body = open(fp, 'rb').read()
860
+ self.send_response(200)
861
+ self.send_header('Content-Type', 'image/png')
862
+ self.send_header('Content-Length', str(len(body)))
863
+ self.end_headers()
864
+ self.wfile.write(body)
865
+ return
866
+ if path == '/api/image/latest':
867
+ f = latest_image()
868
+ if not f:
869
+ return self._json({'ok': False, 'error': '还没有图片'}, 404)
870
+ body = open(f, 'rb').read()
871
+ self.send_response(200)
872
+ self.send_header('Content-Type', 'image/png')
873
+ self.send_header('Cache-Control', 'no-store')
874
+ self.send_header('Content-Length', str(len(body)))
875
+ self.end_headers()
876
+ self.wfile.write(body)
877
+ return
878
+ if path == '/api/scan':
879
+ try:
880
+ return self._json({'ok': True, 'candidates': scan_candidates(), **scan_view()})
881
+ except Exception as e:
882
+ return self._json({'ok': False, 'error': repr(e)}, 500)
883
+ return self._json({'ok': False, 'error': 'not found'}, 404)
884
+
885
+ def do_POST(self):
886
+ path = urllib.parse.urlparse(self.path).path
887
+ if not self._local() and path != '/api/node-scan':
888
+ return self._json({'ok': False, 'error': 'forbidden'}, 403)
889
+ try:
890
+ length = int(self.headers.get('Content-Length') or 0)
891
+ payload = json.loads(self.rfile.read(length) or b'{}')
892
+ except (ValueError, json.JSONDecodeError):
893
+ return self._json({'ok': False, 'error': '请求体不是合法 JSON'}, 400)
894
+
895
+ try:
896
+ if path == '/api/autonomy':
897
+ return self._json({'ok': True, 'state': set_autonomy(payload.get('project'), payload.get('mode'), payload.get('maxPerDay'))})
898
+ if path == '/api/approval':
899
+ return self._json({'ok': True, **resolve_approval(payload.get('id'), payload.get('action'), payload.get('choice') or '')})
900
+ if path == '/api/scan/ignore':
901
+ return self._json({'ok': True, **ignore_candidate(payload.get('path'), payload.get('days', 7))})
902
+ if path == '/api/bind':
903
+ return self._json({'ok': True, 'state': bind_session(payload)})
904
+ if path == '/api/node-scan':
905
+ try:
906
+ snap = P.save_node_scan(payload)
907
+ return self._json({'ok': True, 'node': snap['node'],
908
+ 'received': len(snap.get('projects', []))})
909
+ except ValueError as e:
910
+ return self._json({'ok': False, 'error': str(e)}, 400)
911
+ if path == '/api/image':
912
+ return self._json({'ok': True, **send_image_now(dry=bool(payload.get('dry')))})
913
+ if path == '/api/report':
914
+ return self._json({'ok': True, **send_report()})
915
+ if path == '/api/config':
916
+ return self._json({'ok': True, 'state': set_config(payload)})
917
+ if path == '/api/scan':
918
+ action = payload.get('action')
919
+ if action == 'run':
920
+ return self._json({'ok': True, **run_scan()})
921
+ if action == 'add':
922
+ return self._json({'ok': True, **add_candidates(payload.get('paths') or [])})
923
+ return self._json({'ok': False, 'error': '未知 action'}, 400)
924
+ if path == '/api/project':
925
+ action = payload.get('action')
926
+ if action == 'add':
927
+ return self._json({'ok': True, 'state': add_project(payload)})
928
+ if action == 'update':
929
+ return self._json({'ok': True, 'state': update_project(payload)})
930
+ if action == 'remove':
931
+ return self._json({'ok': True, 'state': remove_project(payload)})
932
+ return self._json({'ok': False, 'error': '未知 action'}, 400)
933
+ except ValueError as e:
934
+ return self._json({'ok': False, 'error': str(e)}, 400)
935
+ except Exception as e:
936
+ P.log(f'[panel] error {path}: {e!r}')
937
+ return self._json({'ok': False, 'error': repr(e)}, 500)
938
+ return self._json({'ok': False, 'error': 'not found'}, 404)
939
+
940
+
941
+ def main():
942
+ import threading
943
+ threading.Thread(target=lambda: session_index(), daemon=True).start() # 预热会话索引
944
+ httpd = http.server.ThreadingHTTPServer((HOST, PORT), Handler)
945
+ P.log(f'[panel] listening http://{HOST}:{PORT}')
946
+ try:
947
+ httpd.serve_forever()
948
+ except KeyboardInterrupt:
949
+ pass
950
+
951
+
952
+ if __name__ == '__main__':
953
+ sys.exit(main())