codebee 0.1.21 → 0.1.23
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/CHANGELOG.md +183 -171
- package/README.md +32 -6
- package/app/core/aiflavor.py +63 -9
- package/app/core/knowledge.py +19 -7
- package/app/core/pipeline.py +146 -6
- package/app/core/portscan.py +188 -0
- package/app/core/router.py +57 -5
- package/app/core/skills.py +26 -47
- package/app/core/task_compile.py +87 -0
- package/app/main.py +42 -0
- package/app/pet.py +125 -28
- package/app/ui/app.js +264 -18
- package/app/ui/i18n.js +35 -0
- package/app/ui/index.html +1143 -1137
- package/app/ui/style.css +64 -0
- package/package.json +1 -1
package/app/core/skills.py
CHANGED
|
@@ -38,6 +38,7 @@ def _user_pack_dir():
|
|
|
38
38
|
|
|
39
39
|
MAX_INJECT_CHARS = 9000 # 单次注入上限(防止提示词爆炸;七猫+番茄双平台包并存后上调)
|
|
40
40
|
MAX_LESSONS_INJECT = 8 # 注入的自动教训条数上限
|
|
41
|
+
WILDCARD_PACK_CHAR_CAP = 2400 # wildcard(scope=*)包单包注入预算:市场通配技能动辄数万字,全文注入会挤掉项目教训
|
|
41
42
|
|
|
42
43
|
# 自动教训的问题分类:闭集枚举,对齐评审维度。沉淀时由复盘官归类(兜底路径按评审
|
|
43
44
|
# 维度关键词映射),UI 据此分类过滤查看。刻意保持小而稳,避免类别爆炸让过滤失去意义。
|
|
@@ -390,54 +391,16 @@ def relevance_top(lessons, task, limit):
|
|
|
390
391
|
if not probe:
|
|
391
392
|
return lessons[:limit]
|
|
392
393
|
|
|
393
|
-
def rank(x):
|
|
394
|
-
grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
|
|
395
|
-
return (-len(probe & grams), x.get("id") or "")
|
|
396
|
-
|
|
397
394
|
# 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
|
|
398
|
-
hits = _load_hits()
|
|
399
395
|
def rank(x):
|
|
400
396
|
grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
|
|
401
397
|
overlap = -len(probe & grams)
|
|
402
398
|
lid = x.get("id") or ""
|
|
403
|
-
return (overlap, -
|
|
399
|
+
return (overlap, -int(x.get("hits") or 0), lid)
|
|
404
400
|
|
|
405
401
|
return sorted(lessons, key=rank)[:limit]
|
|
406
402
|
|
|
407
403
|
|
|
408
|
-
def _hits_path():
|
|
409
|
-
return paths.DATA_DIR / "skill_hits.json"
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def _load_hits():
|
|
413
|
-
"""读取教训使用计数({lesson_id: 次数})。文件缺失/损坏返回空 dict。"""
|
|
414
|
-
try:
|
|
415
|
-
p = _hits_path()
|
|
416
|
-
if p.is_file():
|
|
417
|
-
import json
|
|
418
|
-
return json.loads(p.read_text(encoding="utf-8"))
|
|
419
|
-
except Exception:
|
|
420
|
-
pass
|
|
421
|
-
return {}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
def _bump_hits(ids):
|
|
425
|
-
"""注入后递增使用计数并持久化(fire-and-forget,失败静默)。"""
|
|
426
|
-
try:
|
|
427
|
-
import json
|
|
428
|
-
p = _hits_path()
|
|
429
|
-
hits = _load_hits()
|
|
430
|
-
for lid in ids:
|
|
431
|
-
if lid:
|
|
432
|
-
hits[lid] = hits.get(lid, 0) + 1
|
|
433
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
434
|
-
tmp = p.with_suffix(".tmp")
|
|
435
|
-
tmp.write_text(json.dumps(hits, ensure_ascii=False), encoding="utf-8")
|
|
436
|
-
tmp.replace(p)
|
|
437
|
-
except Exception:
|
|
438
|
-
pass
|
|
439
|
-
|
|
440
|
-
|
|
441
404
|
def block_for(task, scope_override=None, *, stable_order=False):
|
|
442
405
|
"""生成注入提示词的经验块。命中即计数。返回 (文本, 命中的 id 列表)。
|
|
443
406
|
|
|
@@ -446,9 +409,14 @@ def block_for(task, scope_override=None, *, stable_order=False):
|
|
|
446
409
|
stable_order=True(docs/migration/07-token-cost.md T1.2'):教训按 id 排序
|
|
447
410
|
而非 hits——hits 在任务中途变化会让技能块字节级不稳定,打碎供应商的
|
|
448
411
|
前缀缓存(同一任务 8 章应看到完全相同的技能块)。内容不变,只稳排序。
|
|
412
|
+
|
|
413
|
+
预算纪律(2026-09-21 巡检实锤引入):wildcard(scope=*)包动辄数万字,
|
|
414
|
+
39 个全文注入会先把 9000 字全局上限吃光,项目教训排在末尾被整段截掉。
|
|
415
|
+
两道预算:①wildcard 包单包限额(定向命中的包不受限);②教训保底——
|
|
416
|
+
包区最多吃到「全局上限 − 教训长度」,教训永远完整注入。
|
|
449
417
|
"""
|
|
450
418
|
scope = scope_override or task.get("type") or "*"
|
|
451
|
-
parts, used = [], []
|
|
419
|
+
parts, used, lesson_ids = [], [], []
|
|
452
420
|
|
|
453
421
|
for p in all_packs():
|
|
454
422
|
if scope not in p["scopes"] and "*" not in p["scopes"]:
|
|
@@ -458,6 +426,9 @@ def block_for(task, scope_override=None, *, stable_order=False):
|
|
|
458
426
|
txt = pack_text(p).strip()
|
|
459
427
|
if not txt and not p.get("persona"):
|
|
460
428
|
continue
|
|
429
|
+
# wildcard 包(仅靠 * 命中,非定向)单包限预算;定向命中不限,走全局
|
|
430
|
+
if scope not in p["scopes"] and len(txt) > WILDCARD_PACK_CHAR_CAP:
|
|
431
|
+
txt = txt[:WILDCARD_PACK_CHAR_CAP] + "\n…(本包超出通配注入预算已截断,完整内容见技能库)"
|
|
461
432
|
# 3B:persona 独立成块(角色设定与规范正文分开,模型更易区分 obey 层级)
|
|
462
433
|
if p.get("persona"):
|
|
463
434
|
parts.append("### 【角色设定:%s】\n%s" % (p["name"], str(p["persona"]).strip()))
|
|
@@ -467,6 +438,7 @@ def block_for(task, scope_override=None, *, stable_order=False):
|
|
|
467
438
|
|
|
468
439
|
lessons = relevance_top(list_lessons(scope, only_enabled=True), task,
|
|
469
440
|
MAX_LESSONS_INJECT)
|
|
441
|
+
lesson_part = ""
|
|
470
442
|
if lessons:
|
|
471
443
|
if stable_order:
|
|
472
444
|
lessons.sort(key=lambda x: x.get("id") or "")
|
|
@@ -474,17 +446,24 @@ def block_for(task, scope_override=None, *, stable_order=False):
|
|
|
474
446
|
for x in lessons:
|
|
475
447
|
lines.append("- **%s**:%s" % (x["title"], x["content"]))
|
|
476
448
|
used.append(x["id"])
|
|
477
|
-
|
|
449
|
+
lesson_ids.append(x["id"])
|
|
450
|
+
lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n"
|
|
451
|
+
+ "\n".join(lines))
|
|
478
452
|
|
|
479
|
-
if not parts:
|
|
453
|
+
if not parts and not lesson_part:
|
|
480
454
|
return "", []
|
|
481
|
-
|
|
455
|
+
|
|
456
|
+
header = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n"
|
|
457
|
+
budget = max(600, MAX_INJECT_CHARS - len(lesson_part))
|
|
458
|
+
body = "\n\n".join(parts)
|
|
459
|
+
if len(body) > budget:
|
|
460
|
+
body = body[:budget] + "\n…(包区已按预算截断,优先保住项目教训)"
|
|
461
|
+
body = (body + "\n\n" + lesson_part) if (body and lesson_part) else (body or lesson_part)
|
|
462
|
+
text = header + body
|
|
482
463
|
if len(text) > MAX_INJECT_CHARS:
|
|
483
464
|
text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
|
|
484
|
-
if
|
|
485
|
-
|
|
486
|
-
if used:
|
|
487
|
-
bump_hits(used)
|
|
465
|
+
if lesson_ids:
|
|
466
|
+
bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
|
|
488
467
|
return text, used
|
|
489
468
|
|
|
490
469
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""任务编译器:把用户任务和预置流程编译成统一、可审计的运行规格。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from . import dispatch, flows
|
|
8
|
+
|
|
9
|
+
SCHEMA_VERSION = 1
|
|
10
|
+
DIFFICULTIES = ("easy", "default", "hard")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _difficulty(task, flow):
|
|
14
|
+
explicit = str((task or {}).get("difficulty") or "").strip().lower()
|
|
15
|
+
if explicit in DIFFICULTIES:
|
|
16
|
+
return explicit, "用户指定"
|
|
17
|
+
try:
|
|
18
|
+
threshold = float((task or {}).get("threshold")
|
|
19
|
+
if (task or {}).get("threshold") is not None
|
|
20
|
+
else (flow or {}).get("threshold") or 7.0)
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
threshold = 7.0
|
|
23
|
+
if threshold >= 8.5:
|
|
24
|
+
return "hard", "高质量门槛"
|
|
25
|
+
if threshold <= 6.0:
|
|
26
|
+
return "easy", "快速交付门槛"
|
|
27
|
+
if str((task or {}).get("type") or "").lower() in (
|
|
28
|
+
"research", "tech_proposal", "rank_scan"):
|
|
29
|
+
return "hard", "研究/方案类默认需要深度"
|
|
30
|
+
return "default", "标准难度"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _capabilities(task, dimension, engine):
|
|
34
|
+
caps = [dimension]
|
|
35
|
+
if engine == "code" or (task or {}).get("verify_command"):
|
|
36
|
+
caps.extend(["filesystem", "verification"])
|
|
37
|
+
if (task or {}).get("serial"):
|
|
38
|
+
caps.extend(["long_context", "continuity"])
|
|
39
|
+
if (task or {}).get("attachments"):
|
|
40
|
+
caps.append("attachments")
|
|
41
|
+
return list(dict.fromkeys(caps))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def compile_task(task):
|
|
45
|
+
"""返回统一任务规格;输入缺失或字段异常时保持可编排的安全兜底。"""
|
|
46
|
+
raw = dict(task) if isinstance(task, Mapping) else {}
|
|
47
|
+
ttype = str(raw.get("type") or "direct").strip().lower()
|
|
48
|
+
flow = flows.get_flow(ttype) or {}
|
|
49
|
+
engine = str(raw.get("engine") or flow.get("engine") or
|
|
50
|
+
("code" if ttype == "code" else "review")).strip()
|
|
51
|
+
if engine not in flows.ENGINES:
|
|
52
|
+
engine = flow.get("engine") or ("code" if ttype == "code" else "review")
|
|
53
|
+
difficulty, difficulty_reason = _difficulty(raw, flow)
|
|
54
|
+
dimension = dispatch.task_dimension(ttype, raw.get("role") or "")
|
|
55
|
+
rubric = raw.get("rubric") or flow.get("rubric") or []
|
|
56
|
+
if isinstance(rubric, str):
|
|
57
|
+
rubric = [x.strip() for x in rubric.replace(",", ",").split(",") if x.strip()]
|
|
58
|
+
elif not isinstance(rubric, (list, tuple)):
|
|
59
|
+
rubric = flow.get("rubric") or []
|
|
60
|
+
rubric = [str(x).strip() for x in rubric if str(x).strip()][:8]
|
|
61
|
+
deliverable = str(raw.get("manuscript") or flow.get("manuscript") or "").strip()
|
|
62
|
+
serial = raw.get("serial") if isinstance(raw.get("serial"), dict) else None
|
|
63
|
+
return {
|
|
64
|
+
"schema_version": SCHEMA_VERSION,
|
|
65
|
+
"type": ttype,
|
|
66
|
+
"title": str(raw.get("title") or "")[:120],
|
|
67
|
+
"goal": str(raw.get("goal") or "")[:2000],
|
|
68
|
+
"engine": engine,
|
|
69
|
+
"dimension": dimension,
|
|
70
|
+
"difficulty": difficulty,
|
|
71
|
+
"difficulty_reason": difficulty_reason,
|
|
72
|
+
"capabilities": _capabilities(raw, dimension, engine),
|
|
73
|
+
"deliverable": deliverable,
|
|
74
|
+
"quality_dimensions": rubric,
|
|
75
|
+
"serial": serial,
|
|
76
|
+
"explicit_agent": str(raw.get("implementer") or "").strip(),
|
|
77
|
+
"has_verification": bool(raw.get("verify_command")),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def summary(spec):
|
|
82
|
+
"""给运行日志/UI 的短说明,不包含用户正文。"""
|
|
83
|
+
spec = spec or {}
|
|
84
|
+
return "%s/%s/%s(能力:%s)" % (
|
|
85
|
+
spec.get("type") or "direct", spec.get("engine") or "direct",
|
|
86
|
+
spec.get("difficulty") or "default",
|
|
87
|
+
"、".join(spec.get("capabilities") or []) or "通用")
|
package/app/main.py
CHANGED
|
@@ -296,6 +296,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
296
296
|
# 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
|
|
297
297
|
from core import telemetry
|
|
298
298
|
return self._json(200, telemetry.issue_report(days=30))
|
|
299
|
+
if path == "/api/ports":
|
|
300
|
+
# 端口占用诊断(借鉴 leftopen):谁在听、PID/进程/项目归属、
|
|
301
|
+
# 是否仅本机。?port=N 只看单端口。只读,不碰任何进程。
|
|
302
|
+
from core import portscan
|
|
303
|
+
q = parse_qs(urlparse(self.path).query)
|
|
304
|
+
ports = portscan.listening_ports()
|
|
305
|
+
focus = (q.get("port") or [""])[0]
|
|
306
|
+
if focus.isdigit():
|
|
307
|
+
ports = [p for p in ports if p["port"] == int(focus)]
|
|
308
|
+
for p in ports:
|
|
309
|
+
p["self"] = p.get("pid") == os.getpid()
|
|
310
|
+
return self._json(200, {"ports": ports})
|
|
299
311
|
m = re.match(r"^/api/runs/([^/]+)$", path)
|
|
300
312
|
if m:
|
|
301
313
|
run = store.get_run(m.group(1))
|
|
@@ -560,6 +572,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
560
572
|
return self._json(200, {"ok": True, "health": health.snapshot()})
|
|
561
573
|
if path == "/api/attachments":
|
|
562
574
|
return self._api_add_attachment()
|
|
575
|
+
if path == "/api/ports/close":
|
|
576
|
+
# 温和关闭端口占用进程(借鉴 leftopen):SIGTERM only、关前重验
|
|
577
|
+
# PID 绑定;系统进程/自身服务在 portscan 内拒关。设备控制权守卫
|
|
578
|
+
# 已在上方统一生效(不在豁免清单里)。
|
|
579
|
+
from core import portscan
|
|
580
|
+
body = self._body()
|
|
581
|
+
try:
|
|
582
|
+
cport = int(body.get("port") or 0)
|
|
583
|
+
except (TypeError, ValueError):
|
|
584
|
+
return self._json(400, {"error": "port 必须是数字"})
|
|
585
|
+
if not (1 <= cport <= 65535):
|
|
586
|
+
return self._json(400, {"error": "port 越界"})
|
|
587
|
+
ok, msg = portscan.close_port(cport)
|
|
588
|
+
return self._json(200 if ok else 409,
|
|
589
|
+
{"ok": ok, "message": msg,
|
|
590
|
+
"error": None if ok else msg})
|
|
563
591
|
if path == "/api/dir/save":
|
|
564
592
|
# 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
|
|
565
593
|
return self._api_dir_save()
|
|
@@ -2412,6 +2440,20 @@ def main():
|
|
|
2412
2440
|
hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
|
|
2413
2441
|
"tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
|
|
2414
2442
|
% args.port)
|
|
2443
|
+
# 端口占用自动指认(借鉴 leftopen 38★):直接报出 PID/进程/项目归属,
|
|
2444
|
+
# 用户不用再手跑 netstat+tasklist 两连。识别不出时回落上面的手工指路。
|
|
2445
|
+
try:
|
|
2446
|
+
from core import portscan as _ps
|
|
2447
|
+
for _h in _ps.listening_ports():
|
|
2448
|
+
if _h.get("port") != args.port:
|
|
2449
|
+
continue
|
|
2450
|
+
_who = _h.get("process") or "未知进程"
|
|
2451
|
+
_proj = (",项目 %s" % _h["project"]) if _h.get("project") else ""
|
|
2452
|
+
hint = ("占用者:PID %d(%s%s);旧进程杀掉或换 --port 重启"
|
|
2453
|
+
% (_h.get("pid") or 0, _who, _proj))
|
|
2454
|
+
break
|
|
2455
|
+
except Exception:
|
|
2456
|
+
pass
|
|
2415
2457
|
raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
|
|
2416
2458
|
% (args.port, e, hint))
|
|
2417
2459
|
|
package/app/pet.py
CHANGED
|
@@ -30,8 +30,10 @@ import http.client
|
|
|
30
30
|
import json
|
|
31
31
|
import math
|
|
32
32
|
import os
|
|
33
|
+
import queue
|
|
33
34
|
import random
|
|
34
35
|
import sys
|
|
36
|
+
import threading
|
|
35
37
|
import time
|
|
36
38
|
import webbrowser
|
|
37
39
|
from pathlib import Path
|
|
@@ -39,7 +41,8 @@ from pathlib import Path
|
|
|
39
41
|
# --------------------------------------------------------------- 纯逻辑(可测试)
|
|
40
42
|
|
|
41
43
|
ACTIVE_STATUSES = ("queued", "running")
|
|
42
|
-
|
|
44
|
+
GOOD_STATUSES = ("done",)
|
|
45
|
+
BAD_STATUSES = ("failed", "cancelled", "timeout")
|
|
43
46
|
POLL_MS = 4000 # 轮询间隔
|
|
44
47
|
POLL_TIMEOUT = 2.5 # 单次拉取超时(秒)
|
|
45
48
|
MAX_MISS = 15 # 连续拉不到服务 N 次后自离(约 1 分钟,防孤儿常驻)
|
|
@@ -49,20 +52,32 @@ HOVER_MS = 250 # 指针轮询周期
|
|
|
49
52
|
HOVER_DELAY_MS = 350 # 悬停多久后才弹任务清单(扫过不弹)
|
|
50
53
|
|
|
51
54
|
|
|
55
|
+
def _safe_int(value, default=0, minimum=0):
|
|
56
|
+
"""外部状态里的计数可能是空值或脏字符串;统一兜底并限制为非负数。"""
|
|
57
|
+
try:
|
|
58
|
+
number = int(value)
|
|
59
|
+
except (TypeError, ValueError, OverflowError):
|
|
60
|
+
number = int(default)
|
|
61
|
+
return max(minimum, number)
|
|
62
|
+
|
|
63
|
+
|
|
52
64
|
def parse_snapshot(raw):
|
|
53
65
|
"""把 /api/pet_state 响应裁成蜜蜂关心的最小集。字段缺失一律兜底,不抛。"""
|
|
54
66
|
raw = raw if isinstance(raw, dict) else {}
|
|
55
|
-
st = raw.get("settings")
|
|
67
|
+
st = raw.get("settings") if isinstance(raw.get("settings"), dict) else {}
|
|
68
|
+
workers = raw.get("workers") if isinstance(raw.get("workers"), dict) else {}
|
|
69
|
+
digest = raw.get("digest") if isinstance(raw.get("digest"), dict) else {}
|
|
70
|
+
task_rows = raw.get("tasks") if isinstance(raw.get("tasks"), list) else []
|
|
56
71
|
tasks = []
|
|
57
|
-
for t in
|
|
72
|
+
for t in task_rows[:300]:
|
|
58
73
|
if not isinstance(t, dict):
|
|
59
74
|
continue
|
|
60
75
|
tasks.append({
|
|
61
76
|
"id": str(t.get("id") or ""),
|
|
62
77
|
"title": str(t.get("title") or t.get("id") or ""),
|
|
63
78
|
"run_status": str(t.get("run_status") or ""),
|
|
64
|
-
"steps_done":
|
|
65
|
-
"steps_total":
|
|
79
|
+
"steps_done": _safe_int(t.get("steps_done")),
|
|
80
|
+
"steps_total": _safe_int(t.get("steps_total")),
|
|
66
81
|
"step_current": str(t.get("step_current") or ""),
|
|
67
82
|
"error": str(t.get("error") or ""),
|
|
68
83
|
})
|
|
@@ -73,15 +88,14 @@ def parse_snapshot(raw):
|
|
|
73
88
|
"pet_skin": str(st.get("pet_skin") or DEFAULT_SKIN),
|
|
74
89
|
},
|
|
75
90
|
"workers": {
|
|
76
|
-
"running":
|
|
77
|
-
"queued":
|
|
91
|
+
"running": _safe_int(workers.get("running")),
|
|
92
|
+
"queued": _safe_int(workers.get("queued")),
|
|
78
93
|
},
|
|
79
94
|
"tasks": tasks,
|
|
80
95
|
"digest": {
|
|
81
|
-
"unseen":
|
|
82
|
-
"latest":
|
|
83
|
-
if isinstance(
|
|
84
|
-
else None,
|
|
96
|
+
"unseen": _safe_int(digest.get("unseen")),
|
|
97
|
+
"latest": digest.get("latest")
|
|
98
|
+
if isinstance(digest.get("latest"), dict) else None,
|
|
85
99
|
},
|
|
86
100
|
}
|
|
87
101
|
|
|
@@ -105,10 +119,12 @@ def derive_state(prev_active_ids, tasks):
|
|
|
105
119
|
by_id = {t["id"]: t for t in tasks}
|
|
106
120
|
for tid in prev_active_ids:
|
|
107
121
|
t = by_id.get(tid)
|
|
108
|
-
if t is None
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
if t is None:
|
|
123
|
+
continue
|
|
124
|
+
if t["run_status"] in BAD_STATUSES:
|
|
111
125
|
info["bad"].append(t)
|
|
126
|
+
elif t["run_status"] in GOOD_STATUSES:
|
|
127
|
+
info["good"].append(t)
|
|
112
128
|
if info["bad"]:
|
|
113
129
|
return "alert", info
|
|
114
130
|
if info["good"]:
|
|
@@ -276,7 +292,10 @@ def _http_json(port, path, body=None):
|
|
|
276
292
|
method = "POST"
|
|
277
293
|
conn.request(method, path, body=payload, headers=headers)
|
|
278
294
|
resp = conn.getresponse()
|
|
279
|
-
|
|
295
|
+
data = resp.read()
|
|
296
|
+
if not 200 <= resp.status < 300:
|
|
297
|
+
raise RuntimeError("CodeBee API 返回 HTTP %d" % resp.status)
|
|
298
|
+
return json.loads(data.decode("utf-8"))
|
|
280
299
|
finally:
|
|
281
300
|
conn.close()
|
|
282
301
|
|
|
@@ -405,6 +424,14 @@ class PetApp:
|
|
|
405
424
|
self.hop_until = 0.0 # 冒气泡时的蹦跶动作截止
|
|
406
425
|
self.next_chatter = time.time() + random.uniform(90, 240)
|
|
407
426
|
self.seen_digests = 0 # 本轮已通知过的摘要未读数(涨了才报,不重复轰炸)
|
|
427
|
+
self._polling = False
|
|
428
|
+
self._poll_results = queue.Queue(maxsize=1)
|
|
429
|
+
self._settings_lock = threading.Lock()
|
|
430
|
+
self._settings_pending = {}
|
|
431
|
+
self._settings_desired = {}
|
|
432
|
+
self._settings_confirmed = {}
|
|
433
|
+
self._settings_seq = 0
|
|
434
|
+
self._settings_worker_running = False
|
|
408
435
|
|
|
409
436
|
if self.frames:
|
|
410
437
|
self._build_sprite()
|
|
@@ -583,10 +610,7 @@ class PetApp:
|
|
|
583
610
|
return
|
|
584
611
|
self.skin = skin
|
|
585
612
|
self._save_cfg(skin=skin)
|
|
586
|
-
|
|
587
|
-
_http_json(self.port, "/api/settings", body={"pet_skin": skin})
|
|
588
|
-
except Exception:
|
|
589
|
-
pass
|
|
613
|
+
self._post_settings({"pet_skin": skin})
|
|
590
614
|
self._rebuild_sprite()
|
|
591
615
|
|
|
592
616
|
def _rebuild_sprite(self):
|
|
@@ -841,16 +865,92 @@ class PetApp:
|
|
|
841
865
|
else:
|
|
842
866
|
self._show([self.pbar_bg, self.pbar_fg], False)
|
|
843
867
|
|
|
844
|
-
|
|
868
|
+
def _post_settings(self, body):
|
|
869
|
+
"""合并并串行发送设置,保证快速连点时最后一次选择最终生效。"""
|
|
870
|
+
patch = dict(body or {})
|
|
871
|
+
if not patch:
|
|
872
|
+
return
|
|
873
|
+
with self._settings_lock:
|
|
874
|
+
self._settings_seq += 1
|
|
875
|
+
seq = self._settings_seq
|
|
876
|
+
for key, value in patch.items():
|
|
877
|
+
self._settings_pending[key] = (seq, value)
|
|
878
|
+
self._settings_desired[key] = (seq, value)
|
|
879
|
+
if self._settings_worker_running:
|
|
880
|
+
return
|
|
881
|
+
self._settings_worker_running = True
|
|
882
|
+
|
|
883
|
+
def send():
|
|
884
|
+
while True:
|
|
885
|
+
with self._settings_lock:
|
|
886
|
+
if not self._settings_pending:
|
|
887
|
+
self._settings_worker_running = False
|
|
888
|
+
return
|
|
889
|
+
current = dict(self._settings_pending)
|
|
890
|
+
self._settings_pending.clear()
|
|
891
|
+
try:
|
|
892
|
+
_http_json(self.port, "/api/settings",
|
|
893
|
+
body={key: item[1] for key, item in current.items()})
|
|
894
|
+
with self._settings_lock:
|
|
895
|
+
for key, item in current.items():
|
|
896
|
+
self._settings_confirmed[key] = max(
|
|
897
|
+
item[0], self._settings_confirmed.get(key, 0))
|
|
898
|
+
except Exception:
|
|
899
|
+
# 新值优先;失败的旧值只补回尚未被新点击覆盖的键。
|
|
900
|
+
with self._settings_lock:
|
|
901
|
+
for key, item in current.items():
|
|
902
|
+
self._settings_pending.setdefault(key, item)
|
|
903
|
+
time.sleep(1.0)
|
|
904
|
+
threading.Thread(target=send, name="pet-settings", daemon=True).start()
|
|
905
|
+
|
|
906
|
+
def _reconcile_desired_settings(self, snap, confirmed_at_poll_start):
|
|
907
|
+
"""只覆盖写入确认前已发出的旧轮询;确认后的快照以服务端为准。"""
|
|
908
|
+
with self._settings_lock:
|
|
909
|
+
for key, item in list(self._settings_desired.items()):
|
|
910
|
+
seq, value = item
|
|
911
|
+
if confirmed_at_poll_start.get(key, 0) >= seq:
|
|
912
|
+
del self._settings_desired[key]
|
|
913
|
+
else:
|
|
914
|
+
snap["settings"][key] = value
|
|
915
|
+
|
|
916
|
+
# ---- 数据轮询(4s 一拍;网络请求不占 Tk 主线程)----
|
|
845
917
|
def _tick(self):
|
|
846
918
|
self._touch_lock()
|
|
847
|
-
|
|
919
|
+
if self._polling:
|
|
920
|
+
return
|
|
921
|
+
self._polling = True
|
|
922
|
+
with self._settings_lock:
|
|
923
|
+
confirmed_at_poll_start = dict(self._settings_confirmed)
|
|
924
|
+
|
|
925
|
+
def fetch():
|
|
926
|
+
snap = None
|
|
927
|
+
error = None
|
|
928
|
+
try:
|
|
929
|
+
snap = parse_snapshot(_http_json(self.port, "/api/pet_state"))
|
|
930
|
+
except Exception as exc:
|
|
931
|
+
error = exc
|
|
932
|
+
try:
|
|
933
|
+
self._poll_results.put_nowait(
|
|
934
|
+
(snap, error, confirmed_at_poll_start))
|
|
935
|
+
except queue.Full:
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
threading.Thread(target=fetch, name="pet-poll", daemon=True).start()
|
|
939
|
+
self.root.after(50, self._collect_poll)
|
|
940
|
+
|
|
941
|
+
def _collect_poll(self):
|
|
848
942
|
try:
|
|
849
|
-
snap =
|
|
850
|
-
|
|
851
|
-
|
|
943
|
+
snap, error, confirmed_at_poll_start = self._poll_results.get_nowait()
|
|
944
|
+
except queue.Empty:
|
|
945
|
+
self.root.after(50, self._collect_poll)
|
|
946
|
+
return
|
|
947
|
+
self._polling = False
|
|
948
|
+
if error is not None:
|
|
852
949
|
self.miss += 1
|
|
950
|
+
else:
|
|
951
|
+
self.miss = 0
|
|
853
952
|
if snap is not None:
|
|
953
|
+
self._reconcile_desired_settings(snap, confirmed_at_poll_start)
|
|
854
954
|
if not snap["settings"]["pet_enabled"]:
|
|
855
955
|
return self._bye()
|
|
856
956
|
self.snap = snap
|
|
@@ -1187,10 +1287,7 @@ class PetApp:
|
|
|
1187
1287
|
return m
|
|
1188
1288
|
|
|
1189
1289
|
def _set_mode(self, mode):
|
|
1190
|
-
|
|
1191
|
-
_http_json(self.port, "/api/settings", body={"pet_mode": mode})
|
|
1192
|
-
except Exception:
|
|
1193
|
-
pass
|
|
1290
|
+
self._post_settings({"pet_mode": mode})
|
|
1194
1291
|
|
|
1195
1292
|
def _set_lang(self, lang):
|
|
1196
1293
|
self.lang = lang
|