dsh-project-deck 0.1.0 → 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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-project-deck
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/dsh-project-deck.svg)](https://www.npmjs.com/package/dsh-project-deck)
4
+
3
5
  **把你的仓库、目录和半途项目,变成一块看得见进度的甲板。**
4
6
  A project deck for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):项目注册表 + 可视化面板 + 定时微信播报 + 自动发现 + 多机扫描。
5
7
 
@@ -29,11 +31,12 @@ python3 backend/deck demo # 生成 4 个假项目,起面板
29
31
 
30
32
  ```bash
31
33
  # 1. 后端(CLI + 面板 + 定时器)
32
- ./install.sh # 装到 ~/.project-deck,macOS 下注册 launchd
34
+ git clone https://github.com/peng456/dsh-project-deck && cd dsh-project-deck && ./install.sh
33
35
 
34
- # 2. DSH 插件
35
- dsh plugin --profile desktop add github:peng456/dsh-project-deck
36
- # 或 Desktop → 插件 → 安装 github:peng456/dsh-project-deck
36
+ # 2. DSH 插件(npm 或 GitHub 二选一)
37
+ dsh plugin --profile desktop add dsh-project-deck
38
+ # dsh plugin --profile desktop add github:peng456/dsh-project-deck
39
+ # 或 Desktop → 插件 → 搜索/安装 dsh-project-deck
37
40
  ```
38
41
 
39
42
  重启 DSH 后左下角出现 📋,**可以拖到页面任意位置**,位置会被记住;点开就是面板。
@@ -75,6 +78,18 @@ deck demo # 演示环境
75
78
  | `schedule.quiet` | 00:30–06:30 | 安静时段,不播报不扫描 |
76
79
  | `panel.allowLanRead` | true | 允许手机通过局域网看面板(只读) |
77
80
 
81
+ ## 自主推进(可选)
82
+
83
+ 每个项目可设 `off` / `assist` / `auto`:`assist` 只给选项和推荐,`auto` 会自己查事实、自己选方案、做可逆的事,把 push/删除/发布这类不可逆动作放进审批队列等你一句话。
84
+
85
+ ```bash
86
+ deck autonomy my-app auto
87
+ deck approvals # 等你拍板
88
+ deck decisions # 我替你做过的决定
89
+ ```
90
+
91
+ 规则写在 [docs/AUTONOMY.md](docs/AUTONOMY.md)。
92
+
78
93
  ## 多机扫描
79
94
 
80
95
  节点只读上报,不传文件内容:
package/backend/deck CHANGED
@@ -174,6 +174,190 @@ def session_activity(root):
174
174
  return latest
175
175
 
176
176
 
177
+ def autonomy_of(p):
178
+ a = p.get('autonomy') or {}
179
+ mode = a.get('mode', 'off')
180
+ if mode not in AUTONOMY_MODES:
181
+ mode = 'off'
182
+ return {'mode': mode, 'maxPerDay': int(a.get('maxPerDay', 3)),
183
+ 'day': a.get('day', ''), 'actionsToday': int(a.get('actionsToday', 0))}
184
+
185
+
186
+ def _today():
187
+ return datetime.now().strftime('%Y-%m-%d')
188
+
189
+
190
+ def can_act(p):
191
+ a = autonomy_of(p)
192
+ if a['mode'] == 'off':
193
+ return False, 'off'
194
+ if a['day'] != _today():
195
+ return a['mode'] != 'off', 'new-day'
196
+ if a['actionsToday'] >= a['maxPerDay']:
197
+ return False, f"预算用尽({a['actionsToday']}/{a['maxPerDay']})"
198
+ return True, 'ok'
199
+
200
+
201
+ def bump_actions(pid):
202
+ reg = load_json(REGISTRY, {'projects': []})
203
+ for p in reg['projects']:
204
+ if p['id'] == pid:
205
+ a = autonomy_of(p)
206
+ if a['day'] != _today():
207
+ a['day'] = _today(); a['actionsToday'] = 0
208
+ a['actionsToday'] += 1
209
+ p['autonomy'] = a
210
+ save_json(REGISTRY, reg)
211
+
212
+
213
+ def record_decision(pid, question, options, choice, why, reversible=True, mode=None):
214
+ entry = {'at': time.time(), 'project': pid, 'question': question,
215
+ 'options': options, 'choice': choice, 'why': why,
216
+ 'reversible': bool(reversible), 'mode': mode or ''}
217
+ with open(DECISIONS, 'a') as f:
218
+ f.write(json.dumps(entry, ensure_ascii=False) + '\n')
219
+ log(f'[autonomy] decision {pid}: {choice} ({question[:40]})')
220
+ return entry
221
+
222
+
223
+ def load_approvals():
224
+ return load_json(APPROVALS, {'items': []})
225
+
226
+
227
+ def add_approval(pid, question, options, recommend='', irreversible=True):
228
+ d = load_approvals()
229
+ item = {'id': 'ap-' + secrets.token_hex(4), 'project': pid, 'question': question,
230
+ 'options': options, 'recommend': recommend, 'irreversible': bool(irreversible),
231
+ 'createdAt': time.time(), 'status': 'pending'}
232
+ d.setdefault('items', []).append(item)
233
+ save_json(APPROVALS, d)
234
+ log(f'[autonomy] approval {item["id"]} {pid}: {question[:50]}')
235
+ return item
236
+
237
+
238
+ def resolve_approval(aid, decision, choice='', why=''):
239
+ d = load_approvals()
240
+ for it in d.get('items', []):
241
+ if it['id'] == aid and it['status'] == 'pending':
242
+ it['status'] = 'approved' if decision == 'approve' else 'rejected'
243
+ it['resolvedAt'] = time.time()
244
+ it['humanChoice'] = choice or it.get('recommend', '')
245
+ if why:
246
+ it['note'] = why
247
+ save_json(APPROVALS, d)
248
+ record_decision(it['project'], it['question'], it.get('options', []),
249
+ it['humanChoice'] if decision == 'approve' else 'rejected',
250
+ 'human: ' + (why or decision), True, 'human')
251
+ return it
252
+ return None
253
+
254
+
255
+ def cmd_autonomy(argv):
256
+ if not argv:
257
+ reg = load_json(REGISTRY, {'projects': []})
258
+ rows = [(p['id'], autonomy_of(p)) for p in reg['projects']]
259
+ for pid, a in rows:
260
+ print(f"{pid:22} {a['mode']:7} 今日 {a['actionsToday']}/{a['maxPerDay']}")
261
+ return 0
262
+ pid = argv[0]
263
+ reg = load_json(REGISTRY, {'projects': []})
264
+ proj = next((p for p in reg['projects'] if p['id'] == pid), None)
265
+ if not proj:
266
+ print('项目不存在'); return 1
267
+ if len(argv) < 2:
268
+ a = autonomy_of(proj); print(f"{pid}: {a['mode']} 今日 {a['actionsToday']}/{a['maxPerDay']}"); return 0
269
+ mode = argv[1]
270
+ if mode not in AUTONOMY_MODES:
271
+ print('模式只能是 off / assist / auto'); return 1
272
+ a = autonomy_of(proj)
273
+ a['mode'] = mode
274
+ if '--max' in argv:
275
+ try: a['maxPerDay'] = int(argv[argv.index('--max') + 1])
276
+ except (IndexError, ValueError): pass
277
+ proj['autonomy'] = a
278
+ save_json(REGISTRY, reg)
279
+ print(f"{pid}: 自主模式 → {mode}(每日上限 {a['maxPerDay']})")
280
+ return 0
281
+
282
+
283
+ def cmd_decide(argv):
284
+ if not argv:
285
+ print('用法: deck decide <id> --question Q --options A|B|C --choice B --why "..." [--irreversible]')
286
+ return 1
287
+ pid = argv[0]
288
+ opts = {}
289
+ i = 1
290
+ while i < len(argv):
291
+ if argv[i].startswith('--') and i + 1 < len(argv):
292
+ opts[argv[i][2:]] = argv[i + 1]; i += 2
293
+ else: i += 1
294
+ q = opts.get('question', ''); choice = opts.get('choice', '')
295
+ if not q or not choice:
296
+ print('缺少 --question / --choice'); return 1
297
+ record_decision(pid, q, opts.get('options', ''), choice, opts.get('why', ''),
298
+ not ('irreversible' in opts or '--irreversible' in argv))
299
+ bump_actions(pid)
300
+ print('已记录决策')
301
+ return 0
302
+
303
+
304
+ def cmd_ask(argv):
305
+ if not argv:
306
+ print('用法: deck ask <id> --question Q --options A,B [--recommend A]')
307
+ return 1
308
+ pid = argv[0]; opts = {}
309
+ i = 1
310
+ while i < len(argv):
311
+ if argv[i].startswith('--') and i + 1 < len(argv):
312
+ opts[argv[i][2:]] = argv[i + 1]; i += 2
313
+ else: i += 1
314
+ q = opts.get('question', '')
315
+ if not q: print('缺少 --question'); return 1
316
+ it = add_approval(pid, q, opts.get('options', ''), opts.get('recommend', ''))
317
+ print('已加入审批队列:', it['id'])
318
+ return 0
319
+
320
+
321
+ def cmd_approvals(argv):
322
+ d = load_approvals()
323
+ pend = [x for x in d.get('items', []) if x.get('status') == 'pending']
324
+ if not argv:
325
+ if not pend: print('没有待批事项'); return 0
326
+ for x in pend:
327
+ age = rel_time(x['createdAt'])
328
+ print(f"{x['id']} {x['project']:18} {age:8} {x['question'][:60]}")
329
+ if x.get('options'): print(' 选项:', x['options'], '| 推荐:', x.get('recommend') or '-')
330
+ return 0
331
+ if argv[0] in ('--approve', '--reject') and len(argv) > 1:
332
+ aid = argv[1]
333
+ choice = ''
334
+ if '--choice' in argv: choice = argv[argv.index('--choice') + 1]
335
+ why = ''
336
+ if '--why' in argv: why = argv[argv.index('--why') + 1]
337
+ it = resolve_approval(aid, 'approve' if argv[0] == '--approve' else 'reject', choice, why)
338
+ print('已处理' if it else '找不到该待批项')
339
+ return 0
340
+ print('用法: deck approvals [--approve ID [--choice X] | --reject ID [--why "..."]]')
341
+ return 1
342
+
343
+
344
+ def cmd_decisions(argv):
345
+ limit = 20
346
+ if '--limit' in argv:
347
+ try: limit = int(argv[argv.index('--limit') + 1])
348
+ except (IndexError, ValueError): pass
349
+ if not os.path.exists(DECISIONS):
350
+ print('还没有决策记录'); return 0
351
+ rows = []
352
+ for line in open(DECISIONS):
353
+ try: rows.append(json.loads(line))
354
+ except json.JSONDecodeError: pass
355
+ for r in rows[-limit:]:
356
+ flag = '' if r.get('reversible', True) else ' [不可逆]'
357
+ print(f"{datetime.fromtimestamp(r['at']):%m-%d %H:%M} {r['project']:18} {r['choice']}{flag} ← {r['question'][:50]}")
358
+ return 0
359
+
360
+
177
361
  def in_quiet(cfg, ts=None):
178
362
  q = (cfg.get('schedule') or {}).get('quiet') or {}
179
363
  start, end = q.get('from'), q.get('to')
@@ -412,6 +596,21 @@ def build_report(cfg):
412
596
  if added or cands:
413
597
  lines.append('')
414
598
 
599
+ # 等你拍板 + 自主推进概览
600
+ pend = [x for x in load_approvals().get('items', []) if x.get('status') == 'pending']
601
+ if pend:
602
+ lines.append(f'**等你拍板**:{len(pend)} 项')
603
+ for x in pend[:3]:
604
+ opts = f"({x['options']},推荐 {x.get('recommend') or '-'})" if x.get('options') else ''
605
+ lines.append(f"- {x['project']}:{x['question'][:50]}{opts}")
606
+ lines.append('')
607
+ modes = {'auto': 0, 'assist': 0, 'off': 0}
608
+ for p in projects:
609
+ modes[autonomy_of(p)['mode']] = modes.get(autonomy_of(p)['mode'], 0) + 1
610
+ if modes['auto'] or modes['assist']:
611
+ lines.append(f"自主推进:auto {modes['auto']} · assist {modes['assist']} · off {modes['off']}")
612
+ lines.append('')
613
+
415
614
  # 节点离线提醒(超过 3 天没上报)
416
615
  try:
417
616
  off = []
@@ -455,6 +654,9 @@ def build_report(cfg):
455
654
  WS_STORE = os.path.join(HOME, '.dsh', 'storages', 'workspace.json')
456
655
  SCAN_STATE = os.path.join(BASE, 'scan-state.json')
457
656
  SCAN_IGNORE = os.path.join(BASE, 'scan-ignore.json')
657
+ DECISIONS = os.path.join(BASE, 'decisions.jsonl')
658
+ APPROVALS = os.path.join(BASE, 'approvals.json')
659
+ AUTONOMY_MODES = ('off', 'assist', 'auto')
458
660
  NODES = os.path.join(BASE, 'nodes.json')
459
661
  NODE_SNAPS = os.path.join(BASE, 'nodes')
460
662
 
@@ -1376,6 +1578,9 @@ def cmd_status():
1376
1578
  print(f"周期播报: {'开启' if r.get('enabled', True) else '关闭'},每 {r.get('intervalHours', 4)} 小时,"
1377
1579
  f"版式 {r.get('format', 'markdown')},{'附图' if r.get('image') else '不附图'}")
1378
1580
  print(f"守护进程: {'运行中 pid ' + str(pid) if pid else '未运行'}")
1581
+ pend_count = len([x for x in load_approvals().get('items', []) if x.get('status') == 'pending'])
1582
+ if pend_count:
1583
+ print(f"待批事项: {pend_count} 个")
1379
1584
  sc = load_json(SCAN_STATE, {})
1380
1585
  if sc.get('lastRun'):
1381
1586
  print(f"自动发现: 上次扫描 {datetime.fromtimestamp(sc['lastRun']):%m-%d %H:%M}"
@@ -1529,6 +1734,16 @@ def main():
1529
1734
  st['last_run'] = time.time()
1530
1735
  save_json(STATE, st)
1531
1736
  return code
1737
+ if cmd == 'autonomy':
1738
+ return cmd_autonomy(rest)
1739
+ if cmd == 'decide':
1740
+ return cmd_decide(rest)
1741
+ if cmd == 'ask':
1742
+ return cmd_ask(rest)
1743
+ if cmd == 'approvals':
1744
+ return cmd_approvals(rest)
1745
+ if cmd == 'decisions':
1746
+ return cmd_decisions(rest)
1532
1747
  if cmd == 'demo':
1533
1748
  return cmd_demo(rest)
1534
1749
  if cmd == 'panel':
@@ -155,6 +155,13 @@
155
155
  </label>
156
156
  <label>每 <input type="number" id="interval" min="0.1" max="720" step="0.5" style="width:78px"> 小时播报一次</label>
157
157
  <button onclick="applyInterval()">应用</button>
158
+ <label style="margin-left:6px">图标位置
159
+ <select onchange="setFabPos(this.value)" style="padding:5px 8px">
160
+ <option value="">默认</option><option value="right-bottom">右下</option>
161
+ <option value="left-bottom">左下</option><option value="right-top">右上</option>
162
+ <option value="center-right">右侧居中</option>
163
+ </select>
164
+ </label>
158
165
  <label style="margin-left:6px">版式
159
166
  <select id="format" onchange="setConfig({format:this.value})" style="padding:5px 8px">
160
167
  <option value="markdown">Markdown</option>
@@ -186,6 +193,11 @@
186
193
  <span class="hint" id="filterHint"></span>
187
194
  </div>
188
195
 
196
+ <div id="approvalsBox" style="display:none">
197
+ <h2>等你拍板 <span class="hint" id="apCount"></span></h2>
198
+ <div class="history" id="approvalsList"></div>
199
+ </div>
200
+
189
201
  <div class="grid" id="grid"></div>
190
202
 
191
203
  <details class="add">
@@ -341,6 +353,44 @@ function render(){
341
353
  if (!scanFormLoaded && S.config.scan) { loadConfigIntoForm(); }
342
354
  renderHistory();
343
355
  renderNodes();
356
+ renderApprovals();
357
+ }
358
+
359
+ function renderApprovals(){
360
+ const box = $('approvalsBox');
361
+ if (!box) return;
362
+ const items = S.approvals || [];
363
+ box.style.display = items.length ? 'block' : 'none';
364
+ if (!items.length) return;
365
+ $('apCount').textContent = items.length + ' 项';
366
+ $('approvalsList').innerHTML = items.map(x => `
367
+ <div class="hist">
368
+ <div class="top">
369
+ <span><span class="chip teal">${esc(x.project)}</span> ${esc(relText(x.createdAt))}</span>
370
+ <span style="display:flex;gap:6px">
371
+ <button style="font-size:11px;padding:2px 9px" onclick="resolveAp('${esc(x.id)}','approve','${esc(x.recommend || '')}')">采纳${x.recommend ? '推荐 ' + esc(x.recommend) : ''}</button>
372
+ <button class="danger" style="font-size:11px;padding:2px 9px" onclick="resolveAp('${esc(x.id)}','reject','')">拒绝</button>
373
+ </span>
374
+ </div>
375
+ <div style="margin-top:6px;color:#cfd9e3;font-size:12.5px">${esc(x.question)}</div>
376
+ ${x.options ? `<div class="hint" style="margin-top:4px">选项:${esc(x.options)}${x.recommend ? ' · 推荐 ' + esc(x.recommend) : ''}</div>` : ''}
377
+ </div>`).join('');
378
+ }
379
+
380
+ async function setAutonomy(id, mode){
381
+ try{
382
+ const r = await api('/api/autonomy', {project: id, mode});
383
+ S = r.state; render();
384
+ toast('自主模式 → ' + mode);
385
+ }catch(e){ toast('设置失败:' + e.message, true); }
386
+ }
387
+
388
+ async function resolveAp(id, action, choice){
389
+ try{
390
+ const r = await api('/api/approval', {id, action, choice});
391
+ S = r.state; render();
392
+ toast(action === 'approve' ? '已采纳' : '已拒绝');
393
+ }catch(e){ toast('处理失败:' + e.message, true); }
344
394
  }
345
395
 
346
396
  function renderNodes(){
@@ -381,6 +431,8 @@ function cardHtml(p){
381
431
  const board = p.board && p.board.total ? `<span class="chip">看板 ${p.board.total}</span>` : '<span class="chip">无看板</span>';
382
432
  const git = p.git ? `<span class="chip">git ${esc(p.git.sha)}${p.git.dirty ? ' <b style="color:var(--warn)">+' + p.git.dirty + '</b>' : ''}</span>` : '';
383
433
  const missing = p.exists ? '' : '<span class="chip bad">目录缺失</span>';
434
+ const aut = (p.autonomy && p.autonomy.mode) || 'off';
435
+ const autChip = `<span class="chip ${aut === 'auto' ? 'teal' : (aut === 'assist' ? 'warn' : '')}">🤖 ${aut}</span>`;
384
436
  const next = p.next || '<span style="color:var(--muted)">(未登记进展)</span>';
385
437
  return `<div class="card ${st}" id="card-${esc(p.id)}">
386
438
  <div class="cardhead">
@@ -389,13 +441,16 @@ function cardHtml(p){
389
441
  <button class="enter" onclick="enterProject('${esc(p.id)}')"
390
442
  title="${p.sessions && p.sessions.length ? '进入该项目最近一次会话' : '该项目还没有会话,点击新建一个'}">💬 进入会话</button>
391
443
  </div>
392
- <div class="chips">${kind}${statusChip(p)}${mem}${board}${git}${missing}</div>
444
+ <div class="chips">${kind}${statusChip(p)}${autChip}${mem}${board}${git}${missing}</div>
393
445
  <div class="next">下一步:${esc(p.next || '(未登记进展)')}</div>
394
446
  <div class="row"><span class="k">最近活动</span><span class="v">${esc(p.activityText)}</span></div>
395
447
  <div class="row"><span class="k">根目录</span><span class="v">${esc(p.root)}</span></div>
396
448
  ${p.note ? `<div class="row"><span class="k">说明</span><span class="v">${esc(p.note)}</span></div>` : ''}
397
449
  <footer>
398
450
  <button onclick="openDetail('${esc(p.id)}')" title="项目详情(会话列表 / git / 看板 / STATE)">详情</button>
451
+ <select onchange="setAutonomy('${esc(p.id)}', this.value)" title="自主推进模式" style="font-size:12px;padding:5px 8px">
452
+ ${['off','assist','auto'].map(m => `<option value="${m}" ${aut===m?'selected':''}>🤖 ${m}</option>`).join('')}
453
+ </select>
399
454
  <select onchange="setStatus('${esc(p.id)}', this.value)" style="font-size:12px;padding:5px 8px">
400
455
  ${['active','paused','blocked','archived'].map(s => `<option value="${s}" ${p.status===s?'selected':''}>${statusName(s)}</option>`).join('')}
401
456
  </select>
@@ -526,6 +581,13 @@ function sendToPlugin(payload){
526
581
  };
527
582
  }
528
583
 
584
+ function setFabPos(pos){
585
+ if (!inPluginFrame()) { toast('请在 DSH 内嵌面板里使用(浏览器打开时无效)', true); return; }
586
+ window.parent.postMessage({ type: 'deck:set-fab-pos', pos }, '*');
587
+ if (!pos) { toast('已重置为默认位置'); return; }
588
+ toast('图标位置已切换');
589
+ }
590
+
529
591
  function openDeepLink(hash){
530
592
  const base = (S && S.config && S.config.web) || 'http://127.0.0.1:43120';
531
593
  const url = base.replace(/\/+$/, '') + '/#' + hash;
package/backend/panel.py CHANGED
@@ -68,6 +68,7 @@ def project_view(p):
68
68
  'exists': info['exists'], 'memory': info['memory'],
69
69
  'activity': info['activity'], 'activityText': P.rel_time(info['activity']),
70
70
  'git': info['git'], 'board': info['board'],
71
+ 'autonomy': P.autonomy_of(p),
71
72
  'sessions': project_sessions(p.get('root', ''), p.get('sessions') or []),
72
73
  'boundSessions': p.get('sessions') or [],
73
74
  }
@@ -120,6 +121,8 @@ def snapshot():
120
121
  'projects': projects,
121
122
  'history': history,
122
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),
123
126
  }
124
127
 
125
128
 
@@ -616,6 +619,41 @@ def bind_session(payload):
616
619
  return snapshot()
617
620
 
618
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
+
619
657
  def ignore_candidate(path, days):
620
658
  if not path:
621
659
  raise ValueError('缺少 path')
@@ -721,6 +759,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
721
759
  self.send_response(status)
722
760
  self.send_header('Content-Type', 'application/json; charset=utf-8')
723
761
  self.send_header('Content-Length', str(len(body)))
762
+ self.send_header('Access-Control-Allow-Origin', '*')
724
763
  self.end_headers()
725
764
  self.wfile.write(body)
726
765
 
@@ -764,6 +803,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
764
803
  if not proj:
765
804
  return self._json({'ok': False, 'error': '项目不存在'}, 404)
766
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']})
767
810
  if path == '/api/nodes':
768
811
  return self._json({'ok': True, 'nodes': nodes_view()})
769
812
  if path == '/api/node/scanner.ps1':
@@ -850,6 +893,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
850
893
  return self._json({'ok': False, 'error': '请求体不是合法 JSON'}, 400)
851
894
 
852
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 '')})
853
900
  if path == '/api/scan/ignore':
854
901
  return self._json({'ok': True, **ignore_candidate(payload.get('path'), payload.get('days', 7))})
855
902
  if path == '/api/bind':
package/lib/client.js CHANGED
@@ -94,14 +94,19 @@ window.__ModuleLoader__.load({
94
94
  }
95
95
 
96
96
  const css = `
97
- .deck-fab{position:fixed;left:16px;bottom:18px;z-index:120;display:flex;align-items:center;justify-content:center;
97
+ .deck-fab{position:fixed;right:18px;bottom:112px;left:auto;z-index:120;position:fixed;pointer-events:auto;touch-action:none;cursor:grab;user-select:none;display:flex;align-items:center;justify-content:center;
98
98
  width:46px;height:46px;border-radius:50%;border:1px solid rgba(53,208,186,.45);cursor:pointer;
99
99
  background:linear-gradient(180deg,#12303a,#0f1e1d);color:#7ce7da;font-size:20px;line-height:1;
100
100
  box-shadow:0 10px 24px rgba(0,0,0,.35),0 2px 6px rgba(0,0,0,.2);transition:filter .12s,transform .12s;
101
101
  font-family:system-ui,-apple-system,"PingFang SC",sans-serif;padding:0}
102
102
  .deck-fab:hover{filter:brightness(1.12);transform:translateY(-1px)}
103
+ .deck-fab:active{cursor:grabbing}
104
+ .deck-fab svg{display:block}
105
+ .deck-fab .deck-badge{position:absolute;top:-4px;right:-4px;min-width:16px;height:16px;padding:0 4px;border-radius:9px;
106
+ background:#ef5b5b;color:#fff;font-size:10px;line-height:16px;text-align:center;font-weight:700;box-shadow:0 0 0 2px #0b0f14}
107
+ .deck-fab.ai-on{box-shadow:0 0 0 2px rgba(53,208,186,.55),0 10px 24px rgba(0,0,0,.35)}
103
108
  .deck-fab:active{transform:translateY(0)}
104
- .deck-mask{position:fixed;inset:0;z-index:130;background:rgba(4,8,12,.62);display:flex;align-items:center;justify-content:center;padding:24px}
109
+ .deck-mask{position:fixed;inset:0;z-index:130;background:rgba(4,8,12,.62);display:flex;align-items:center;justify-content:center;padding:24px;pointer-events:auto}
105
110
  .deck-box{width:min(1180px,94vw);height:min(860px,88vh);background:#0b0f14;border:1px solid #1f2a37;border-radius:14px;
106
111
  display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.45)}
107
112
  .deck-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #1f2a37;
@@ -132,72 +137,94 @@ window.__ModuleLoader__.load({
132
137
  const [pos, setPos] = React.useState(() => {
133
138
  try { const v = JSON.parse(localStorage.getItem('deck-fab-pos') || 'null'); return v && typeof v.x === 'number' ? v : null } catch { return null }
134
139
  })
135
- const drag = React.useRef(null)
136
- const moved = React.useRef(false)
140
+ const [summary, setSummary] = React.useState({ auto: 0, assist: 0, pending: 0 })
141
+ React.useEffect(() => {
142
+ let alive = true
143
+ const load = () => {
144
+ fetch('http://127.0.0.1:9199/api/state', { cache: 'no-store' })
145
+ .then((r) => r.json())
146
+ .then((d) => {
147
+ if (!alive) return
148
+ const ps = d.projects || []
149
+ setSummary({
150
+ auto: ps.filter((p) => p.autonomy && p.autonomy.mode === 'auto').length,
151
+ assist: ps.filter((p) => p.autonomy && p.autonomy.mode === 'assist').length,
152
+ pending: (d.approvals || []).length,
153
+ })
154
+ })
155
+ .catch(() => {})
156
+ }
157
+ load()
158
+ const t = setInterval(load, 60000)
159
+ return () => { alive = false; clearInterval(t) }
160
+ }, [])
161
+
162
+ const dragState = React.useRef(null)
163
+ const movedRef = React.useRef(false)
137
164
 
138
165
  const clamp = (x, y) => ({
139
166
  x: Math.max(6, Math.min(x, window.innerWidth - 54)),
140
167
  y: Math.max(6, Math.min(y, window.innerHeight - 54)),
141
168
  })
142
169
 
143
- const onPointerDown = (e) => {
144
- drag.current = { dx: e.clientX - (pos ? pos.x : 16), dy: e.clientY - (pos ? pos.y : window.innerHeight - 64) }
145
- moved.current = false
146
- e.currentTarget.setPointerCapture(e.pointerId)
147
- }
148
- const onPointerMove = (e) => {
149
- if (!drag.current) return
150
- const nx = e.clientX - drag.current.dx
151
- const ny = e.clientY - drag.current.dy
152
- if (Math.abs(nx - (pos ? pos.x : 16)) > 4 || !pos) moved.current = true
153
- setPos(clamp(nx, ny))
154
- }
155
- const onPointerUp = (e) => {
156
- if (!drag.current) return
157
- drag.current = null
158
- try { e.currentTarget.releasePointerCapture(e.pointerId) } catch {}
159
- setPos((cur) => {
160
- if (cur) { try { localStorage.setItem('deck-fab-pos', JSON.stringify(cur)) } catch {} }
161
- return cur
162
- })
170
+ // 用 window 级监听,避免指针移出按钮就丢事件(之前只能按住按钮原地挪)
171
+ React.useEffect(() => {
172
+ const onMove = (e) => {
173
+ const d = dragState.current
174
+ if (!d) return
175
+ if (!d.moved && (Math.abs(e.clientX - d.sx) > 3 || Math.abs(e.clientY - d.sy) > 3)) d.moved = true
176
+ setPos(clamp(e.clientX - d.dx, e.clientY - d.dy))
177
+ }
178
+ const onUp = () => {
179
+ const d = dragState.current
180
+ if (!d) return
181
+ dragState.current = null
182
+ movedRef.current = d.moved
183
+ setPos((cur) => { if (cur) { try { localStorage.setItem('deck-fab-pos', JSON.stringify(cur)) } catch {} } return cur })
184
+ }
185
+ window.addEventListener('pointermove', onMove, true)
186
+ window.addEventListener('pointerup', onUp, true)
187
+ window.addEventListener('mousemove', onMove, true)
188
+ window.addEventListener('mouseup', onUp, true)
189
+ return () => {
190
+ window.removeEventListener('pointermove', onMove, true)
191
+ window.removeEventListener('pointerup', onUp, true)
192
+ window.removeEventListener('mousemove', onMove, true)
193
+ window.removeEventListener('mouseup', onUp, true)
194
+ }
195
+ }, [])
196
+
197
+ const fabStyle = pos ? { left: pos.x + 'px', top: pos.y + 'px', right: 'auto', bottom: 'auto' } : undefined
198
+
199
+ const onDragStart = (e) => {
200
+ const r = e.currentTarget.getBoundingClientRect()
201
+ dragState.current = { dx: e.clientX - r.left, dy: e.clientY - r.top, sx: e.clientX, sy: e.clientY, moved: false }
202
+ movedRef.current = false
203
+ if (!pos) setPos({ x: r.left, y: r.top })
204
+ if (e.preventDefault) e.preventDefault()
163
205
  }
164
- const fabStyle = pos
165
- ? { left: pos.x + 'px', top: pos.y + 'px', bottom: 'auto', right: 'auto' }
166
- : undefined
167
206
 
168
- // 面板(iframe,127.0.0.1:9199)点「最近会话」时 postMessage 过来,在这里跳到对应会话
207
+ // 面板里也能改图标位置(拖拽失效时的兜底)
169
208
  React.useEffect(() => {
170
- const onMessage = (e) => {
209
+ const onPos = (e) => {
171
210
  if (e.origin !== PANEL) return
172
- const data = e.data || {}
173
- if (data.type === 'dsh-project-deck:open-project') {
174
- openProjectWith(appCtx, { root: String(data.root || ''), sessionId: data.sessionId ? String(data.sessionId) : null })
175
- .then((ok) => {
176
- console.log('[dsh-project-deck] open-project', data.project, 'ok=', ok)
177
- try {
178
- const frame = document.querySelector('iframe.deck-frame')
179
- if (frame && frame.contentWindow) {
180
- frame.contentWindow.postMessage({ type: 'dsh-project-deck:open-result', ok, project: data.project }, PANEL)
181
- }
182
- } catch (err) { /* 忽略 */ }
183
- if (ok) setOpen(false)
184
- })
185
- return
211
+ const d = e.data || {}
212
+ if (d.type !== 'deck:set-fab-pos') return
213
+ const W = window.innerWidth, H = window.innerHeight
214
+ const spots = {
215
+ 'right-bottom': { x: W - 60, y: H - 160 },
216
+ 'left-bottom': { x: 16, y: H - 160 },
217
+ 'right-top': { x: W - 60, y: 84 },
218
+ 'center-right': { x: W - 60, y: Math.round(H / 2) },
219
+ default: { x: W - 60, y: H - 160 },
186
220
  }
187
- if (data.type !== 'dsh-project-deck:open-session' || !data.sessionId) return
188
- const ok = openSessionWith(appCtx, String(data.sessionId))
189
- console.log('[dsh-project-deck] open-session request', data.sessionId, 'ok=', ok)
190
- try {
191
- const frame = document.querySelector('iframe.deck-frame')
192
- if (frame && frame.contentWindow) {
193
- frame.contentWindow.postMessage({ type: 'dsh-project-deck:open-result', ok, sessionId: data.sessionId }, PANEL)
194
- }
195
- } catch (e) { console.warn('[dsh-project-deck] 回传结果失败', e) }
196
- if (ok) setOpen(false)
221
+ const next = spots[d.pos] || spots.default
222
+ setPos(clamp(next.x, next.y))
223
+ try { localStorage.setItem('deck-fab-pos', JSON.stringify(clamp(next.x, next.y))) } catch {}
197
224
  }
198
- window.addEventListener('message', onMessage)
199
- return () => window.removeEventListener('message', onMessage)
200
- }, [appCtx])
225
+ window.addEventListener('message', onPos)
226
+ return () => window.removeEventListener('message', onPos)
227
+ }, [])
201
228
 
202
229
  React.useEffect(() => {
203
230
  if (!open) return undefined
@@ -210,10 +237,33 @@ window.__ModuleLoader__.load({
210
237
 
211
238
  return h(React.Fragment, null,
212
239
  h('button', {
213
- className: 'deck-fab',
214
- title: '项目甲板 Project Deck(项目 / 播报 / 节点)',
215
- onClick: openPanel,
216
- }, '\u{1F4CB}'),
240
+ className: 'deck-fab' + (summary.auto > 0 ? ' ai-on' : ''),
241
+ style: fabStyle,
242
+ title: '项目甲板 · AI 项目管理与自动推进(可拖动 v4)'
243
+ + (summary.auto ? `|auto ${summary.auto}` : '')
244
+ + (summary.pending ? `|待你拍板 ${summary.pending}` : ''),
245
+ onPointerDown: onDragStart,
246
+ onMouseDown: onDragStart,
247
+ onClick: () => { if (!movedRef.current) openPanel(); movedRef.current = false },
248
+ onDragStart: (e) => e.preventDefault(),
249
+ },
250
+ h('svg', { width: 26, height: 26, viewBox: '0 0 24 24', fill: 'none', stroke: '#7ce7da', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' },
251
+ // 左:AI 机器人
252
+ h('rect', { x: 1.6, y: 8.2, width: 8.2, height: 7.2, rx: 2.2 }),
253
+ h('path', { d: 'M5.7 8.2V6.2' }),
254
+ h('circle', { cx: 5.7, cy: 5, r: 1.05, fill: '#7ce7da', stroke: 'none' }),
255
+ h('circle', { cx: 4.1, cy: 11.5, r: 0.9, fill: '#7ce7da', stroke: 'none' }),
256
+ h('circle', { cx: 7.3, cy: 11.5, r: 0.9, fill: '#7ce7da', stroke: 'none' }),
257
+ h('path', { d: 'M4.5 13.7h2.4' }),
258
+ // 右:待办清单(两条已完成)
259
+ h('rect', { x: 11.8, y: 4.6, width: 10.4, height: 14.8, rx: 2.6 }),
260
+ h('path', { d: 'M13.9 9.4l1.1 1.1 1.9-2.1' }),
261
+ h('path', { d: 'M18.1 9.9h2.3' }),
262
+ h('path', { d: 'M13.9 14l1.1 1.1 1.9-2.1' }),
263
+ h('path', { d: 'M18.1 14.5h2.3' }),
264
+ ),
265
+ summary.pending > 0 ? h('span', { className: 'deck-badge' }, String(summary.pending)) : null,
266
+ ),
217
267
  open
218
268
  ? h('div', { className: 'deck-mask', onClick: (e) => { if (e.target === e.currentTarget) setOpen(false) } },
219
269
  h('div', { className: 'deck-box' },
@@ -242,6 +292,24 @@ window.__ModuleLoader__.load({
242
292
  )
243
293
  }
244
294
 
295
+ // 崩溃兜底:任何渲染错误都不让图标凭空消失
296
+ class Boundary extends React.Component {
297
+ constructor(props) { super(props); this.state = { err: null } }
298
+ static getDerivedStateFromError(err) { return { err } }
299
+ render() {
300
+ if (this.state.err) {
301
+ return h('div', {
302
+ title: String(this.state.err && this.state.err.message || this.state.err),
303
+ style: { position: 'fixed', right: 12, bottom: 100, zIndex: 121, background: '#2a1518',
304
+ color: '#ffb3b3', padding: '5px 9px', borderRadius: 8, fontSize: 11,
305
+ border: '1px solid #4a2226', cursor: 'pointer', pointerEvents: 'auto' },
306
+ onClick: () => this.setState({ err: null }),
307
+ }, '项目甲板加载失败(点我重试)')
308
+ }
309
+ return this.props.children
310
+ }
311
+ }
312
+
245
313
  exports.name = 'dsh-project-deck'
246
314
  exports.inject = ['slots']
247
315
  exports.apply = function apply(ctx) {
@@ -289,7 +357,7 @@ window.__ModuleLoader__.load({
289
357
 
290
358
  slots.inject('shell.overlay', () => slots.register(
291
359
  { name: 'shell.overlay', id: 'deck-panel', order: 6, label: '项目甲板' },
292
- (props) => h(Panel, Object.assign({}, props, { appCtx: ctx })),
360
+ (props) => h(Boundary, null, h(Panel, Object.assign({}, props, { appCtx: ctx }))),
293
361
  ))
294
362
  console.log('[dsh-project-deck] client ready')
295
363
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-project-deck",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DeepSeek Harness plugin: a project deck for your repos — registry, dashboard, scheduled WeChat reports, auto-discovery, multi-node scanning.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
47
- "url": "https://github.com/peng456/dsh-project-deck"
47
+ "url": "git+https://github.com/peng456/dsh-project-deck.git"
48
48
  },
49
49
  "author": "peng456",
50
50
  "homepage": "https://github.com/peng456/dsh-project-deck#readme",