codebee 0.1.2 → 0.1.4

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/app/main.py CHANGED
@@ -1,1448 +1,1478 @@
1
- # -*- coding: utf-8 -*-
2
- """CodeBee(多智能体编排台):纯标准库 HTTP 服务(零依赖,Python 3.8+)。
3
-
4
- 启动:python app/main.py [端口],默认 8765,自动打开浏览器。
5
- """
6
- from __future__ import annotations
7
-
8
- import argparse
9
- import json
10
- import os
11
- import re
12
- import socket
13
- import subprocess
14
- import sys
15
- import threading
16
- import time
17
- import webbrowser
18
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
- from pathlib import Path
20
- from urllib.parse import parse_qs, unquote, urlparse
21
-
22
- from core import automation, catalog, flows, jobs, manager, market, market_remote, registry, remote, settings, store
23
- from core import paths
24
- from core import health
25
-
26
- MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
27
- ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
28
- ".ico": "image/x-icon", ".json": "application/manifest+json; charset=utf-8",
29
- ".webmanifest": "application/manifest+json; charset=utf-8"}
30
-
31
-
32
- def _utf8_bytes(data):
33
- """文本预览出口的编码守卫:字节流非合法 UTF-8 时按 GBK 解码后重编码为
34
- UTF-8 再发。子代理在中文 Windows 上可能把工作区文件落成 GBK,直接透传
35
- 会让声明 charset=utf-8 的浏览器预览整片乱码(与 pipeline 读防线同一问题
36
- 的服务端出口面)。"""
37
- try:
38
- data.decode("utf-8")
39
- return data
40
- except UnicodeDecodeError:
41
- pass
42
- try:
43
- return data.decode("gbk").encode("utf-8")
44
- except (UnicodeDecodeError, UnicodeEncodeError):
45
- return data.decode("utf-8", "replace").encode("utf-8")
46
-
47
- PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
48
-
49
- # 侧栏「查看文件」/「目录浏览」跳过的噪音目录(与 store 的习惯一致)
50
- _SKIP_DIRS_SHARE = {".git", "node_modules", "__pycache__", ".venv", "venv",
51
- ".idea", ".vscode", "_attachments"}
52
-
53
-
54
- class Handler(BaseHTTPRequestHandler):
55
- server_version = "CodeBee/1.0"
56
-
57
- # ------------------------------------------------------------ 基础
58
- def log_message(self, fmt, *args):
59
- pass # 安静模式;异常仍会记录到 run 目录
60
-
61
- def _send(self, code, body, ctype="application/json; charset=utf-8", headers=None):
62
- if isinstance(body, str):
63
- body = body.encode("utf-8")
64
- self.send_response(code)
65
- self.send_header("Content-Type", ctype)
66
- self.send_header("Content-Length", str(len(body)))
67
- self.send_header("Cache-Control", "no-store")
68
- for k, v in (headers or {}).items():
69
- self.send_header(k, v)
70
- self.end_headers()
71
- try:
72
- self.wfile.write(body)
73
- except Exception:
74
- pass
75
-
76
- def _json(self, code, obj):
77
- self._send(code, json.dumps(obj, ensure_ascii=False))
78
-
79
- def _body(self):
80
- try:
81
- n = int(self.headers.get("Content-Length") or 0)
82
- if n <= 0:
83
- return {}
84
- return json.loads(self.rfile.read(n).decode("utf-8"))
85
- except Exception:
86
- return {}
87
-
88
- # ------------------------------------------------------------ 远程访问
89
- def _forwarded_ip(self):
90
- """经代理进来的真实客户端 IP:X-Forwarded-For 首跳,缺省 CF-Connecting-IP。"""
91
- fw = self.headers.get("X-Forwarded-For") or ""
92
- if not fw:
93
- fw = self.headers.get("CF-Connecting-IP") or ""
94
- return remote.effective_ip(self.client_address[0], fw), fw
95
-
96
- def _authed(self):
97
- q = parse_qs(urlparse(self.path).query)
98
- ip, fw = self._forwarded_ip()
99
- return remote.request_authed(ip, fw,
100
- (q.get("token") or [""])[0],
101
- self.headers.get("X-CodeBee-Token") or "")
102
-
103
- def _client_id(self):
104
- cid = (self.headers.get("X-CodeBee-Client") or "").strip()
105
- if not cid: # EventSource 带不了自定义头,身份从 query 兜底
106
- cid = (parse_qs(urlparse(self.path).query).get("client") or [""])[0].strip()
107
- if cid:
108
- return cid[:64]
109
- # 无头请求(curl/旧脚本):真本机统一算"本机",远程各自匿名且无持久身份
110
- ip, fw = self._forwarded_ip()
111
- return "local" if (ip in ("127.0.0.1", "::1") and not fw) else ""
112
-
113
- def _client_name(self):
114
- name = (self.headers.get("X-CodeBee-Name") or "").strip()
115
- if not name:
116
- name = (parse_qs(urlparse(self.path).query).get("name") or [""])[0].strip()
117
- if name:
118
- try:
119
- name = unquote(name) # 前端对非 ASCII 设备名做了 encodeURIComponent
120
- except Exception:
121
- pass
122
- return name[:24]
123
- return "本机" if self._client_id() == "local" else "其他设备"
124
-
125
- def _deny_control(self):
126
- ok, view = remote.acquire(self._client_id(), self._client_name())
127
- if ok:
128
- if view.get("mine"):
129
- store.bump_state() # 空闲自动接管:让其他端立即看到
130
- return None
131
- return self._json(423, {
132
- "error": "「%s」正在控制,请先在右上角接管控制权" % view.get("holder", "其他设备"),
133
- "control": view})
134
-
135
- # ------------------------------------------------------------ 路由
136
- def do_GET(self):
137
- path = urlparse(self.path).path
138
- m = None
139
- # 静态页不设防(无敏感信息):远程裸地址打开时由前端令牌门引导输入
140
- if path in ("/", "/index.html"):
141
- return self._static("index.html")
142
- if path.startswith("/api/"):
143
- if not self._authed():
144
- return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
145
- if path == "/api/state":
146
- return self._json(200, _state_payload(self._client_id()))
147
- if path == "/api/browse":
148
- return self._api_browse()
149
- if path == "/api/dir/scan":
150
- # 侧栏文件夹「查看文件」:列出该工作目录的文件(跳过噪音目录,只列文件)
151
- return self._api_dir_scan()
152
- if path == "/api/dir/file":
153
- return self._api_dir_file()
154
- if path == "/api/git/info":
155
- return self._api_git_info()
156
- if path == "/api/events":
157
- return self._api_events()
158
- if path == "/api/control":
159
- return self._json(200, {"control": remote.control_view(self._client_id())})
160
- if path == "/api/health":
161
- from core import health
162
- return self._json(200, health.snapshot())
163
- if path == "/api/connect":
164
- # 供设置页「手机连接」弹框生成二维码;远程打开需令牌,天然受保护
165
- return self._json(200, {"urls": remote.build_connect_urls(PORT),
166
- "port": PORT})
167
- if path == "/api/models":
168
- from core import modelhub
169
- return self._json(200, {"providers": modelhub.provider_view(),
170
- "bindings": modelhub.bindings(),
171
- "catalog": modelhub.models_view(),
172
- "source_names": modelhub.source_names()})
173
- if path == "/api/models/sources":
174
- from core import modelhub
175
- return self._json(200, {"sources": modelhub.sources()})
176
- if path == "/api/flows":
177
- return self._json(200, {"flows": flows.list_flows()})
178
- if path == "/api/skills":
179
- from core import skills
180
- return self._json(200, skills.view())
181
- if path == "/api/settings":
182
- return self._json(200, dict(settings.load(), **jobs.workers_info()))
183
- if path == "/api/selfupdate":
184
- from core import selfupdate
185
- return self._json(200, selfupdate.check(
186
- force=bool(parse_qs(urlparse(self.path).query).get("force"))))
187
- if path == "/api/orchestrator":
188
- from core import modelhub
189
- return self._json(200, {"orchestrator": modelhub.orchestrator_view()})
190
- if path == "/api/catalog":
191
- return self._json(200, {"catalog": manager.catalog_view(),
192
- "checking": manager.updates_checking()})
193
- if path == "/api/sessions":
194
- from core import sessions
195
- return self._json(200, {"sessions": sessions.scan(
196
- force="force=1" in urlparse(self.path).query)})
197
- if path == "/api/catalog/reload":
198
- catalog.load(force=True)
199
- manager.detect_all(force=True)
200
- return self._json(200, {"ok": True, "catalog": manager.catalog_view()})
201
- if path == "/api/runs":
202
- return self._json(200, {"runs": store.list_runs()})
203
- if path == "/api/usage":
204
- from core import usage
205
- q = parse_qs(urlparse(self.path).query)
206
- try:
207
- days = max(0, min(3650, int((q.get("days") or ["30"])[0])))
208
- except ValueError:
209
- days = 30
210
- return self._json(200, usage.summary(days=days))
211
- m = re.match(r"^/api/runs/([^/]+)$", path)
212
- if m:
213
- run = store.get_run(m.group(1))
214
- return self._json(200, {"run": run}) if run else self._json(404, {"error": "not found"})
215
- m = re.match(r"^/api/runs/([^/]+)/report$", path)
216
- if m:
217
- run = store.get_run(m.group(1))
218
- if not run:
219
- return self._json(404, {"error": "not found"})
220
- p = paths.RUNS_DIR / m.group(1) / "report.md"
221
- if not p.is_file():
222
- return self._send(200, "(报告尚未生成)", "text/markdown; charset=utf-8")
223
- return self._send(200, p.read_text(encoding="utf-8", errors="replace"),
224
- "text/markdown; charset=utf-8")
225
- m = re.match(r"^/api/runs/([^/]+)/files$", path)
226
- if m:
227
- run = store.get_run(m.group(1))
228
- if not run:
229
- return self._json(404, {"error": "not found"})
230
- wd, files = store.run_artifacts(m.group(1))
231
- # 任务一步都没跑出来过(如历次都在检出前失败)→ 工作目录里的
232
- # 文件变动是并行活动的噪音,不算这个任务的成品
233
- if files and not store.task_step_count(run.get("task_id") or ""):
234
- files = []
235
- return self._json(200, {"workdir": wd, "files": files,
236
- "task_id": run.get("task_id") or ""})
237
- m = re.match(r"^/api/tasks/([^/]+)/side$", path)
238
- if m:
239
- # 任务检查器(右缘停靠列)专用:轻量聚合、可轮询,不带 diff 文本
240
- return self._api_task_side(m.group(1))
241
- m = re.match(r"^/api/tasks/([^/]+)/runs$", path)
242
- if m:
243
- # 任务级详情用:该任务全部 run(含 steps),不受前端 run 窗口限制
244
- if not store.get_task(m.group(1)):
245
- return self._json(404, {"error": "not found"})
246
- return self._json(200, {"runs": store.task_runs(m.group(1))})
247
- m = re.match(r"^/api/tasks/([^/]+)/bible$", path)
248
- if m:
249
- # 故事圣经:查看(无令牌豁免走 _authed 已过;本机免令牌)
250
- fp, text, err = store.read_story_bible(m.group(1))
251
- if err:
252
- return self._json(400, {"error": err})
253
- return self._json(200, {"path": fp, "text": text})
254
- m = re.match(r"^/api/tasks/([^/]+)/book-meta$", path)
255
- if m:
256
- # 作品信息(番茄/七猫建书表单资料):读取任务上的生成状态与结果
257
- task = store.get_task(m.group(1))
258
- if not task:
259
- return self._json(404, {"error": "not found"})
260
- return self._json(200, {"book_meta": task.get("book_meta") or {}})
261
- m = re.match(r"^/api/tasks/([^/]+)/git$", path)
262
- if m:
263
- # GIT 工作台全貌(详情页「版本」页签):仓库状态聚合 + 任务隔离态
264
- return self._api_git_wb(m.group(1))
265
- m = re.match(r"^/api/tasks/([^/]+)/git/diff$", path)
266
- if m:
267
- # 工作台单文件实时 diff(?path= 仓库内相对路径)
268
- return self._api_git_wb_diff(m.group(1))
269
- m = re.match(r"^/api/tasks/([^/]+)/continue-info$", path)
270
- if m:
271
- # 「继续连载」弹框数据:能否续、已写到第几章、默认续几章
272
- info = store.continue_info(m.group(1))
273
- if info is None:
274
- return self._json(404, {"error": "not found"})
275
- return self._json(200, info)
276
- m = re.match(r"^/api/runs/([^/]+)/file$", path)
277
- if m:
278
- if not store.get_run(m.group(1)):
279
- return self._json(404, {"error": "not found"})
280
- rel = (parse_qs(urlparse(self.path).query).get("name") or [""])[0]
281
- data, err = store.read_run_file(m.group(1), unquote(rel))
282
- if err:
283
- return self._json(404, {"error": err})
284
- ext = Path(rel.lower()).suffix
285
- ctype = MIME.get(ext, "application/octet-stream")
286
- # 文本类扩展名直接在浏览器里看内容;未知二进制才触发下载
287
- if ext in (".md", ".txt", ".log", ".csv", ".yml", ".yaml", ".ini", ".toml"):
288
- ctype = "text/plain; charset=utf-8"
289
- if ctype.startswith("text/"):
290
- data = _utf8_bytes(data)
291
- return self._send(200, data, ctype)
292
- m = re.match(r"^/api/runs/([^/]+)/log$", path)
293
- if m:
294
- # step 由前端 encodeURIComponent 编码("steps/x.log" → "steps%2Fx.log"),
295
- # 必须解码后再拼路径,否则永远找不到日志文件。read_step_log 内部已防目录穿越。
296
- q = parse_qs(urlparse(self.path).query)
297
- rel = (q.get("step") or [""])[0]
298
- rel = rel.replace("\\", "/").lstrip("/")
299
- # tail:蜂巢卡片实时尾巴轮询用小窗口;日志面板用默认(4000)。
300
- # 先取默认/解析再夹紧:此前 max(200, min(40000, 0)) 恒为 200,
301
- # 不带 tail 的日志抽屉一直只读到末尾 200 字节(2026-09-15 实测暴露)
302
- try:
303
- tail = int((q.get("tail") or [""])[0] or 0) or paths.LOG_TAIL_CHARS
304
- except ValueError:
305
- tail = paths.LOG_TAIL_CHARS
306
- tail = max(200, min(40000, tail))
307
- run = store.get_run(m.group(1))
308
- if not run:
309
- return self._json(404, {"error": "not found"})
310
- # pretty=1:日志抽屉等人类视图,把 codex JSONL 事件流翻译成可读行;
311
- # 蜂巢卡片等机器视图可不带,只享受重复行折叠
312
- pretty = (q.get("pretty") or [""])[0] in ("1", "true")
313
- text = store.read_step_log(m.group(1), rel, tail=tail, pretty=pretty)
314
- # 带上步骤/运行状态:日志面板靠它区分「实时刷新中」和「已结束」,
315
- # 结束即停轮询(否则用户盯着不动的日志以为刷新坏了)
316
- st = next((s.get("status") or "" for s in (run.get("steps") or [])
317
- if s.get("log") == rel), "")
318
- return self._json(200, {"log": text, "step_status": st,
319
- "run_status": run.get("status") or ""})
320
- m = re.match(r"^/api/runs/([^/]+)/timeline$", path)
321
- if m:
322
- return self._api_run_timeline(m.group(1))
323
- if path == "/api/automation":
324
- return self._json(200, {"tasks": automation.list_tasks(),
325
- "templates": automation.templates()})
326
- m = re.match(r"^/api/automation/([^/]+)$", path)
327
- if m:
328
- t = automation.get_task(m.group(1))
329
- return self._json(200, {"task": t}) if t else self._json(404, {"error": "not found"})
330
- if path == "/api/market":
331
- return self._json(200, market.view())
332
- if path == "/api/market/remote":
333
- q = parse_qs(urlparse(self.path).query)
334
- return self._json(200, market_remote.view(
335
- offset=(q.get("offset") or ["0"])[0],
336
- limit=(q.get("limit") or [""])[0],
337
- source=(q.get("source") or [""])[0],
338
- q=(q.get("q") or [""])[0]))
339
- return self._json(404, {"error": "unknown api"})
340
- # 静态文件:单文件或 UI 子目录文件(如 icons/icon-192.png);
341
- # _static 内的 parents 校验确保解析后仍在 UI_DIR 内,防穿越
342
- name = path.lstrip("/")
343
- if re.match(r"^[\w.-]+(/[\w.-]+)*$", name):
344
- return self._static(name)
345
- return self._json(404, {"error": "not found"})
346
-
347
- def do_POST(self):
348
- path = urlparse(self.path).path
349
- m = None
350
- if not self._authed():
351
- return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
352
- if path == "/api/control":
353
- return self._api_control()
354
- if path == "/api/control/heartbeat":
355
- ok, view = remote.heartbeat(self._client_id())
356
- return self._json(200, {"ok": ok, "control": view})
357
- # 写操作需要控制权:空闲自动接管;他人持有时 423,由前端引导抢夺。
358
- # 例外(配置管理类操作全局生效,不被「哪台设备在操作」挡住,
359
- # 否则告警弹框里的按钮在多端场景会静默 423 失败):
360
- if path in ("/api/health/op", "/api/models/provider-op", "/api/models/model-op",
361
- "/api/models/test-provider", "/api/models/test-model",
362
- "/api/models/probe-wire", "/api/models/key-op"):
363
- pass # 落到下方各自路由
364
- else:
365
- deny = self._deny_control()
366
- if deny:
367
- return deny
368
- if path == "/api/tasks":
369
- return self._api_create_task()
370
- if path == "/api/health/op":
371
- # 供应商健康告警的手动操作(silence 静默 / reset 手动恢复)
372
- from core import health
373
- body = self._body()
374
- prov = (body.get("provider") or "").strip()
375
- if not prov:
376
- return self._json(400, {"error": "provider 必填"})
377
- op = body.get("op") or ""
378
- if op == "silence":
379
- ok, err = health.silence(prov, minutes=int(body.get("minutes") or 0))
380
- elif op == "reset":
381
- ok, err = health.reset(prov)
382
- else:
383
- return self._json(400, {"error": "op 必须是 silence 或 reset"})
384
- if not ok:
385
- return self._json(404, {"error": err})
386
- store.bump_state()
387
- return self._json(200, {"ok": True, "health": health.snapshot()})
388
- if path == "/api/attachments":
389
- return self._api_add_attachment()
390
- if path == "/api/dir/save":
391
- # 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
392
- return self._api_dir_save()
393
- m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue)$", path)
394
- if m:
395
- if m.group(2) == "archive":
396
- body = self._body()
397
- ok, err = store.archive_task(m.group(1), bool(body.get("archived", True)))
398
- elif m.group(2) == "retry":
399
- ok, err, run = store.retry_task(m.group(1))
400
- if not ok:
401
- return self._json(400, {"error": err})
402
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": m.group(1)})
403
- return self._json(200, {"ok": True, "run_id": run["id"]})
404
- elif m.group(2) == "continue":
405
- # 继续连载:在旧任务基础上新建任务(沿用目标/目录/评审设置,章节号衔接)
406
- ok, err, new_task = store.continue_task(
407
- m.group(1), (self._body() or {}).get("chapters"))
408
- if not ok:
409
- return self._json(400, {"error": err})
410
- run = store.create_run("orchestration", new_task["title"],
411
- task_id=new_task["id"])
412
- store.update_task_status(new_task["id"], "queued")
413
- jobs.enqueue({"kind": "orchestration",
414
- "run_id": run["id"], "task_id": new_task["id"]})
415
- return self._json(200, {"ok": True, "task_id": new_task["id"],
416
- "run_id": run["id"]})
417
- elif m.group(2) == "rename":
418
- ok, err = store.rename_task(m.group(1), self._body().get("title"))
419
- else:
420
- ok, err = store.delete_task(m.group(1))
421
- return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
422
- m = re.match(r"^/api/tasks/([^/]+)/bible$", path)
423
- if m:
424
- # 故事圣经:编辑保存(运行中由 store 拒绝,防打碎前缀缓存)
425
- body = self._body()
426
- ok, err = store.write_story_bible(m.group(1), body.get("text") or "")
427
- return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
428
- m = re.match(r"^/api/tasks/([^/]+)/book-meta$", path)
429
- if m:
430
- return self._api_book_meta_generate(m.group(1))
431
- m = re.match(r"^/api/tasks/([^/]+)/(git-merge|git-discard)$", path)
432
- if m:
433
- return self._api_git_verdict(m.group(1), m.group(2))
434
- m = re.match(r"^/api/tasks/([^/]+)/git$", path)
435
- if m:
436
- # GIT 工作台写操作:{action, path?, message?, branch?, confirm?}
437
- return self._api_git_wb_op(m.group(1))
438
- m = re.match(r"^/api/(tasks|runs)/([^/]+)/reveal$", path)
439
- if m:
440
- return self._api_reveal(m.group(1), m.group(2))
441
- m = re.match(r"^/api/runs/([^/]+)/messages$", path)
442
- if m:
443
- return self._api_add_message(m.group(1))
444
- m = re.match(r"^/api/runs/([^/]+)/chat$", path)
445
- if m:
446
- return self._api_direct_chat(m.group(1))
447
- m = re.match(r"^/api/runs/([^/]+)/messages/retract$", path)
448
- if m:
449
- return self._api_retract_message(m.group(1))
450
- m = re.match(r"^/api/runs/([^/]+)/pause$", path)
451
- if m:
452
- # 暂停/放行:标志位挂在下一个步骤开始前;取消不必先解除暂停
453
- body = self._body() or {}
454
- if not store.set_paused(m.group(1), bool(body.get("paused"))):
455
- return self._json(404, {"error": "not found"})
456
- return self._json(200, {"ok": True, "paused": bool(body.get("paused"))})
457
- m = re.match(r"^/api/runs/([^/]+)/cancel$", path)
458
- if m:
459
- # 先落「用户主动取消」标记再置事件:自动续跑必须尊重这个意图不得续上;
460
- # 顺序保证起跑方读到已置位事件时,标记一定已写入(排队取消的兜底依赖它)。
461
- store.update_run(m.group(1), cancelled_by_user=True)
462
- ok = jobs.cancel(m.group(1))
463
- return self._json(200, {"ok": ok})
464
- m = re.match(r"^/api/runs/([^/]+)/delete$", path)
465
- if m:
466
- ok, err = store.delete_run(m.group(1))
467
- return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
468
- if path == "/api/runs/delete":
469
- n, skipped, err = store.delete_runs(self._body().get("ids") or [])
470
- if err and not n:
471
- return self._json(400, {"error": err})
472
- return self._json(200, {"ok": True, "count": n, "skipped": skipped, "message": err})
473
- if path == "/api/runs/clear":
474
- n, skipped = store.clear_runs()
475
- return self._json(200, {"ok": True, "count": n, "skipped": skipped})
476
- if path == "/api/orchestration":
477
- return self._api_set_preference()
478
- if path == "/api/catalog/reset":
479
- catalog.reset_to_default()
480
- catalog.load(force=True)
481
- return self._json(200, {"ok": True})
482
- if path == "/api/catalog/reload":
483
- catalog.load(force=True)
484
- manager.detect_all(force=True)
485
- return self._json(200, {"ok": True})
486
- if path == "/api/catalog/check-updates":
487
- # 进入智能体目录页时前端自动调用;后台逐条查远端版本,立即返回
488
- force = bool(self._body().get("force"))
489
- n = manager.check_updates_async(force=force)
490
- return self._json(200, {"ok": True, "count": n,
491
- "checking": manager.updates_checking()})
492
- if path == "/api/selfupdate/apply":
493
- from core import selfupdate
494
- res = selfupdate.apply_upgrade()
495
- return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
496
- if path == "/api/selfupdate/restart":
497
- from core import selfupdate
498
- global PORT
499
- if not selfupdate.relaunch(PORT):
500
- return self._json(400, {"error": "重启参数非法"})
501
- def _bye():
502
- time.sleep(0.8)
503
- selfupdate.self_quit()
504
- threading.Thread(target=_bye, daemon=True).start()
505
- return self._json(200, {"ok": True, "message": "服务正在重启,几秒后自动恢复"})
506
- if path == "/api/models/provider/delete":
507
- from core import modelhub
508
- body = self._body()
509
- n, err = modelhub.providers_op([body.get("id") or ""], "delete")
510
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True, "count": n})
511
- if path == "/api/models/provider-op":
512
- from core import modelhub
513
- body = self._body()
514
- n, err = modelhub.providers_op(body.get("ids") or [], body.get("op") or "")
515
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True, "count": n})
516
- if path == "/api/models/key-op":
517
- # 多 KEY:增删改 / 启停 / 排序 / 重置冷却(欠费充值后手动恢复)
518
- from core import modelhub
519
- body = self._body()
520
- err = modelhub.key_op(body.get("provider_id") or "",
521
- body.get("op") or "",
522
- key_id=body.get("key_id") or "",
523
- key=body.get("key") or "",
524
- label=body.get("label") or "",
525
- enabled=body.get("enabled"),
526
- ids=body.get("ids"))
527
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
528
- if path == "/api/models/provider":
529
- from core import modelhub
530
- err = modelhub.upsert_provider(self._body())
531
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
532
- if path == "/api/models/import":
533
- from core import modelhub
534
- body = self._body()
535
- ids = body.get("sources")
536
- res = modelhub.import_sources(ids if isinstance(ids, list) else None)
537
- if res["imported"]:
538
- modelhub.refresh_all_async() # 导入后自动拉取各供应商可用模型
539
- res["message"] += ";正在后台获取模型列表…"
540
- return self._json(200, dict(res, ok=res["imported"] > 0))
541
- if path == "/api/models/import-ccswitch":
542
- from core import modelhub
543
- n, msg = modelhub.import_ccswitch()
544
- if n:
545
- modelhub.refresh_all_async() # 导入后自动拉取各供应商可用模型
546
- msg += ";正在后台获取模型列表…"
547
- return self._json(200, {"ok": n > 0, "imported": n, "message": msg})
548
- if path == "/api/models/refresh":
549
- from core import modelhub
550
- n, err = modelhub.refresh_models(self._body().get("id") or "")
551
- return self._json(200, {"ok": bool(n), "count": n, "message": err})
552
- if path == "/api/models/refresh-all":
553
- from core import modelhub
554
- n = modelhub.refresh_all_async()
555
- return self._json(200, {"ok": True, "count": n,
556
- "message": "后台刷新 %d 个供应商…" % n})
557
- if path == "/api/models/model-op":
558
- from core import modelhub
559
- body = self._body()
560
- pid = body.get("provider_id") or ""
561
- op = body.get("op") or ""
562
- names = body.get("names")
563
- if names is not None: # 批量:names 数组
564
- n, err = modelhub.model_ops(pid, names, op)
565
- return self._json(400, {"error": err}) if err else self._json(
566
- 200, {"ok": True, "count": n})
567
- err = modelhub.model_op(pid, body.get("name") or "", op)
568
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
569
- if path == "/api/models/reorder":
570
- from core import modelhub
571
- body = self._body()
572
- err = modelhub.reorder_models(body.get("provider_id") or "",
573
- body.get("names") or [])
574
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
575
- if path == "/api/models/test-provider":
576
- from core import modelhub
577
- return self._json(200, modelhub.test_provider(self._body().get("id") or ""))
578
- if path == "/api/models/probe-wire":
579
- # 适配测试:实测供应商另一条 wire 协议(同密钥),结果存 wire_caps
580
- from core import modelhub
581
- caps, note = modelhub.probe_wire_caps(self._body().get("id") or "")
582
- return self._json(200, {"ok": True, "wire_caps": caps, "note": note})
583
- if path == "/api/models/test-model":
584
- from core import modelhub
585
- body = self._body()
586
- return self._json(200, modelhub.test_model(body.get("provider_id") or "",
587
- body.get("name") or "",
588
- key_id=body.get("key_id") or ""))
589
- if path == "/api/models/binding":
590
- from core import modelhub
591
- body = self._body()
592
- b = modelhub.set_binding(body.get("agent_id") or "",
593
- provider_id=body.get("provider_id"),
594
- model=body.get("model"),
595
- models=body.get("models"),
596
- difficulty_routing=body.get("difficulty_routing"),
597
- chain=body.get("chain"))
598
- return self._json(200, {"ok": True, "binding": b})
599
- if path == "/api/flows":
600
- flow, err = flows.upsert_flow(self._body())
601
- if err:
602
- return self._json(400, {"error": err})
603
- return self._json(200, {"ok": True, "flow": flow})
604
- if path == "/api/skills/lesson-op":
605
- from core import skills
606
- body = self._body()
607
- err = skills.lesson_op(body.get("id") or "", body.get("op") or "")
608
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
609
- if path == "/api/skills/pack-op":
610
- from core import skills
611
- body = self._body()
612
- err = skills.pack_op(body.get("id") or "", body.get("op") or "")
613
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
614
- if path == "/api/skills/learn":
615
- from core import skills
616
- n = skills.learn_from_run((self._body().get("run_id") or ""))
617
- return self._json(200, {"ok": True, "learned": n})
618
- m = re.match(r"^/api/flows/([^/]+)/reset$", path)
619
- if m:
620
- err = flows.reset_flow(m.group(1))
621
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
622
- m = re.match(r"^/api/flows/([^/]+)/delete$", path)
623
- if m:
624
- err = flows.delete_flow(m.group(1))
625
- return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
626
- if path == "/api/settings":
627
- view, err = settings.save(self._body())
628
- if err:
629
- return self._json(400, {"error": err, "settings": view})
630
- n = jobs.configure(view["max_concurrent_jobs"])
631
- return self._json(200, {"ok": True, "settings": view, "workers": n})
632
- if path == "/api/settings/default-workdir":
633
- body = self._body()
634
- old = settings.default_workdir()
635
- view, err = settings.save({"default_workdir": body.get("path") or ""})
636
- if err:
637
- return self._json(400, {"error": err, "settings": view})
638
- new = settings.default_workdir()
639
- moved = skipped = 0
640
- if body.get("migrate") and old != new:
641
- try:
642
- Path(new).mkdir(parents=True, exist_ok=True)
643
- except Exception as e:
644
- return self._json(400, {"error": "新默认路径不可创建: %s" % e})
645
- moved, skipped = store.migrate_task_workdirs(old, new)
646
- return self._json(200, {"ok": True, "settings": view, "moved": moved, "skipped": skipped})
647
- if path == "/api/orchestrator":
648
- from core import modelhub
649
- body = self._body()
650
- err = modelhub.set_orchestrator(body.get("provider_id"),
651
- model=body.get("model"),
652
- enabled=body.get("enabled"))
653
- return self._json(400, {"error": err}) if err else self._json(
654
- 200, {"ok": True, "orchestrator": modelhub.orchestrator_view()})
655
- if path == "/api/orchestrator/test":
656
- from core import modelhub, usage
657
- orch = modelhub.resolve_orchestrator()
658
- if not orch:
659
- return self._json(400, {"ok": False, "error": "编排者未启用或配置失效"})
660
- prov, model = orch
661
- res = modelhub.chat(prov["id"], model,
662
- "请只回复两个字:收到", max_tokens=64, timeout=30,
663
- cache_ttl=86400) # §07 T2.2:连通测试幂等,24h 精确缓存
664
- try:
665
- usage.record(source="test", role="orch-test", agent="orchestrator",
666
- agent_label="编排者", tool="orchestrator", model=model,
667
- provider=prov.get("name", prov.get("id", "")),
668
- ok=bool(res.get("ok")), usage=res.get("usage"))
669
- except Exception:
670
- pass
671
- return self._json(200, dict(res, model=model,
672
- provider=prov.get("name", prov["id"])))
673
- m = re.match(r"^/api/catalog/([^/]+)/(install|upgrade|uninstall|smoke)$", path)
674
- if m:
675
- entry = catalog.by_id(m.group(1))
676
- if not entry:
677
- return self._json(404, {"error": "catalog 中无此条目"})
678
- op = m.group(2)
679
- titles = {"install": "安装", "upgrade": "升级", "uninstall": "卸载",
680
- "smoke": "冒烟测试"}
681
- if op == "uninstall" and not catalog.uninstall_command(entry):
682
- return self._json(400, {
683
- "error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
684
- % entry["id"]})
685
- run = store.create_run("mgmt", "%s %s" % (titles[op], entry.get("name", entry["id"])),
686
- entry_id=entry["id"], op=op)
687
- jobs.enqueue({"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
688
- return self._json(200, {"run_id": run["id"]})
689
- m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
690
- if m:
691
- # 一键打开(web 类起服务+开浏览器 / console 类新终端窗口)。
692
- # 即时返回不走任务队列;body 可传 {"open": false} 供测试免开浏览器
693
- entry = catalog.by_id(m.group(1))
694
- if not entry:
695
- return self._json(404, {"error": "catalog 中无此条目"})
696
- res = manager.launch(entry, open_browser=bool(self._body().get("open", True)))
697
- return self._json(200 if res.get("ok") else 400, res)
698
- m = re.match(r"^/api/catalog/([^/]+)/check-update$", path)
699
- if m:
700
- entry = catalog.by_id(m.group(1))
701
- if not entry:
702
- return self._json(404, {"error": "catalog 中无此条目"})
703
- return self._json(200, manager.check_update(entry))
704
- m = re.match(r"^/api/catalog/([^/]+)/model$", path)
705
- if m:
706
- entry = catalog.by_id(m.group(1))
707
- if not entry:
708
- return self._json(404, {"error": "catalog 中无此条目"})
709
- body = self._body()
710
- res = manager.write_model(entry, body.get("model"))
711
- return self._json(200 if res["ok"] else 400, res)
712
- if path == "/api/automation":
713
- try:
714
- t = automation.create(self._body())
715
- except ValueError as e:
716
- return self._json(400, {"error": str(e)})
717
- return self._json(200, {"ok": True, "task": t})
718
- m = re.match(r"^/api/automation/([^/]+)$", path)
719
- if m:
720
- try:
721
- t = automation.update(m.group(1), self._body())
722
- except ValueError as e:
723
- return self._json(400, {"error": str(e)})
724
- return self._json(200, {"ok": True, "task": t}) if t else self._json(404, {"error": "not found"})
725
- m = re.match(r"^/api/automation/([^/]+)/(toggle|run|delete)$", path)
726
- if m:
727
- tid, op = m.group(1), m.group(2)
728
- if op == "toggle":
729
- t = automation.set_enabled(tid, bool((self._body() or {}).get("enabled", True)))
730
- return self._json(200, {"ok": True, "task": t}) if t else self._json(404, {"error": "not found"})
731
- if op == "run":
732
- t, run_id = automation.run_now(tid)
733
- if t is None:
734
- return self._json(404, {"error": "not found"})
735
- return self._json(200, {"ok": True, "task": t, "run_id": run_id or ""})
736
- ok = automation.delete(tid)
737
- return self._json(200, {"ok": True}) if ok else self._json(404, {"error": "not found"})
738
- # 外部目录路由必须在通配的 market/<id>/(install|remove) 之前——
739
- # 否则 "remote" 会被当成包名吞掉
740
- if path == "/api/market/remote/refresh":
741
- return self._json(200, market_remote.refresh((self._body() or {}).get("source")))
742
- m = re.match(r"^/api/market/remote/install$", path)
743
- if m:
744
- res, err = market_remote.install_remote((self._body() or {}).get("id") or "")
745
- return self._json(400, {"error": err}) if err else self._json(200, res)
746
- m = re.match(r"^/api/market/([^/]+)/(install|remove)$", path)
747
- if m:
748
- if m.group(2) == "install":
749
- res, err = market.install(m.group(1))
750
- else:
751
- err = market.remove(m.group(1))
752
- res = {"ok": True, "id": m.group(1)}
753
- return self._json(400, {"error": err}) if err else self._json(200, res)
754
- return self._json(404, {"error": "unknown api"})
755
-
756
- # ------------------------------------------------------------ 业务
757
- def _api_reveal(self, kind, res_id):
758
- """打开/返回资源所在目录:任务=工作目录,运行=记录目录(步骤日志、报告都在里面)。
759
- 路径一律由服务端按 id 推导,不接受客户端传任意路径——本服务监听局域网,
760
- 不能变成远程探测文件系统的口子。open=false 只回路径给前端复制。"""
761
- if kind == "tasks":
762
- task = store.get_task(res_id)
763
- if not task:
764
- return self._json(404, {"error": "任务不存在"})
765
- p = Path(task.get("workdir") or "")
766
- else:
767
- run = store.get_run(res_id)
768
- if not run:
769
- return self._json(404, {"error": "运行记录不存在"})
770
- p = paths.RUNS_DIR / res_id
771
- if not p.is_dir():
772
- return self._json(404, {"error": "目录不存在: %s" % p})
773
- if self._body().get("open"):
774
- try:
775
- if sys.platform == "win32":
776
- subprocess.Popen(["explorer", str(p)])
777
- elif sys.platform == "darwin":
778
- subprocess.Popen(["open", str(p)])
779
- else:
780
- subprocess.Popen(["xdg-open", str(p)])
781
- except Exception as e:
782
- return self._json(500, {"error": "无法打开目录: %s" % e})
783
- return self._json(200, {"ok": True, "path": str(p)})
784
-
785
- def _api_git_verdict(self, task_id, op):
786
- """任务分支裁决:git-merge 合并回原分支(采纳)/ git-discard 删除分支(否决)。
787
-
788
- 丢弃不可恢复,必须显式 confirm=true;两边的守卫与冲突回滚都在
789
- gitmod 里(见 merge_task_branch / discard_task_branch)。
790
- """
791
- from core import gitmod
792
- task = store.get_task(task_id)
793
- if not task:
794
- return self._json(404, {"error": "任务不存在"})
795
- if op == "git-merge":
796
- ok, err, info = gitmod.merge_task_branch(task.get("workdir"), task)
797
- if not ok:
798
- return self._json(400, {"error": err})
799
- store.set_task_git_state(task_id, "merged")
800
- return self._json(200, {"ok": True, **(info or {})})
801
- body = self._body()
802
- if not body.get("confirm"):
803
- return self._json(400, {"error": "丢弃任务分支不可恢复,需要 confirm=true 二次确认"})
804
- ok, err = gitmod.discard_task_branch(task.get("workdir"), task)
805
- if not ok:
806
- return self._json(400, {"error": err})
807
- store.set_task_git_state(task_id, "discarded")
808
- return self._json(200, {"ok": True})
809
-
810
- def _api_book_meta_generate(self, task_id):
811
- """作品信息一键生成(POST /api/tasks/<id>/book-meta,body: {platform})。
812
-
813
- 后台线程跑(编排者→作者CLI→模板的降级链可能数分钟),请求立即返回;
814
- 前端靠任务 book_meta 状态(SSE 全量状态里带)轮进度。已有 running 时
815
- 幂等拒绝,不重复起线程。"""
816
- from core import bookmeta
817
- task = store.get_task(task_id)
818
- if not task:
819
- return self._json(404, {"error": "任务不存在"})
820
- body = self._body() or {}
821
- platform = (body.get("platform") or "").strip()
822
- if platform not in bookmeta.PLATFORMS:
823
- return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
824
- if not bookmeta.needs_book_meta(task):
825
- return self._json(400, {"error": "只有连载首批任务需要作品信息;续写批次沿用第一批的开书资料"})
826
- if task.get("status") in ("queued", "running"):
827
- return self._json(400, {"error": "任务正在运行,请等本轮结束后再生成作品信息"})
828
- cur = ((task.get("book_meta") or {}).get(platform) or {})
829
- if cur.get("status") == "running":
830
- return self._json(200, {"ok": True, "already": True})
831
- if not store.set_book_meta(task_id, platform,
832
- {"status": "running",
833
- "at": time.strftime("%Y-%m-%d %H:%M:%S")}):
834
- return self._json(404, {"error": "任务不存在"})
835
- author = bookmeta._resolve_author(task)
836
- threading.Thread(target=bookmeta.generate_async, daemon=True,
837
- name="book-meta-%s" % platform,
838
- args=(task_id, platform, author)).start()
839
- return self._json(200, {"ok": True, "started": True})
840
-
841
- def _api_task_side(self, task_id):
842
- """任务检查器(右缘停靠列)的轻量聚合端点。聚合逻辑在 store.task_side
843
- (可单测、单实例状态);这里只做 404 转换。"""
844
- d = store.task_side(task_id)
845
- return self._json(404, {"error": "任务不存在"}) if d is None else self._json(200, d)
846
-
847
- # ------------------------------------------------------ GIT 工作台(详情页版本页签)
848
- # 与 /api/git/info 不同:workdir 由任务 id 服务端推导,不接受客户端传任意路径,
849
- # 所以不必限本机——局域网端(手机)打开详情页同样能看变更、做日常 git 操作。
850
-
851
- def _api_git_wb(self, task_id):
852
- from core import gitmod
853
- task = store.get_task(task_id)
854
- if not task:
855
- return self._json(404, {"error": "任务不存在"})
856
- d = gitmod.workbench_status(str(task.get("workdir") or ""))
857
- d["isolation"] = {"state": task.get("git_state") or "",
858
- "rev": task.get("git_rev") or "",
859
- "branch": gitmod.branch_name(task_id)}
860
- d["task"] = {"id": task_id, "status": task.get("status") or "",
861
- "title": task.get("title") or ""}
862
- return self._json(200, d)
863
-
864
- def _api_git_wb_diff(self, task_id):
865
- from core import gitmod
866
- task = store.get_task(task_id)
867
- if not task:
868
- return self._json(404, {"error": "任务不存在"})
869
- qs = parse_qs(urlparse(self.path).query)
870
- d = gitmod.file_diff(str(task.get("workdir") or ""), (qs.get("path") or [""])[0])
871
- if d.get("error"):
872
- return self._json(400, {"error": d["error"]})
873
- return self._json(200, d)
874
-
875
- def _api_git_wb_op(self, task_id):
876
- from core import gitmod
877
- task = store.get_task(task_id)
878
- if not task:
879
- return self._json(404, {"error": "任务不存在"})
880
- if task.get("status") in ("queued", "running"):
881
- return self._json(409, {"error": "任务正在运行:智能体正在工作目录里产出,"
882
- "Git 写操作等任务结束再进行(只读查看不受限)"})
883
- body = self._body()
884
- action = str(body.get("action") or "")
885
- ok, err, data = gitmod.workbench_op(str(task.get("workdir") or ""), action, body)
886
- if not ok:
887
- return self._json(400, {"error": err})
888
- return self._json(200, {"ok": True, "action": action, **(data or {})})
889
-
890
- def _api_browse(self):
891
- """本机目录浏览(工作目录「选择…」弹框用)。仅限本机请求:目录枚举是信息
892
- 泄露面,手机/局域网端不提供、继续手填。path 缺省=用户主目录;__drives__=盘符
893
- 列表(Windows 从「此电脑」开始选)。只列目录不列文件,纯只读。"""
894
- # 本机判定按来源 IP,不能按 client_id:页面请求带 X-CodeBee-Client,
895
- # 本机页面的 client_id 也不是 "local",会被误拒
896
- ip, fw = self._forwarded_ip()
897
- if ip not in ("127.0.0.1", "::1") or fw:
898
- return self._json(403, {"error": "目录选择仅限本机使用,请手动输入路径"})
899
- qs = parse_qs(urlparse(self.path).query)
900
- raw = (qs.get("path") or [""])[0].strip()
901
- if raw == "__drives__":
902
- drives = [c + ":\\" for c in "CDEFGHIJKLMNOPQRSTUVWXYZ"
903
- if Path(c + ":\\").exists()]
904
- return self._json(200, {"path": "此电脑", "parent": "", "dirs": drives})
905
- try:
906
- p = Path(raw).expanduser() if raw else Path.home()
907
- except Exception:
908
- return self._json(400, {"error": "非法路径"})
909
- if not p.exists():
910
- return self._json(404, {"error": "目录不存在: %s" % p})
911
- if not p.is_dir():
912
- return self._json(400, {"error": "不是目录: %s" % p})
913
- try:
914
- dirs = sorted((d.name for d in p.iterdir() if d.is_dir()), key=str.lower)
915
- except PermissionError:
916
- dirs = [] # 无权限的目录按空目录处理,可继续选它本身
917
- parent = "" if p.parent == p else str(p.parent)
918
- return self._json(200, {"path": str(p), "parent": parent, "dirs": dirs})
919
-
920
- def _api_dir_scan(self):
921
- """侧栏文件夹「查看文件」:递归列出该工作目录下的全部文件(左侧文件页树形展示)。
922
-
923
- 只读;仅限本机请求(同 /api/browse 的信息泄露口径)。name 为相对路径
924
- ("/" 分隔,如 "docs/readme.md"),前端按目录层级渲染成树;跳过
925
- .git / node_modules 等噪音目录;不设文件数上限,全量列出。
926
- """
927
- ip, fw = self._forwarded_ip()
928
- if ip not in ("127.0.0.1", "::1") or fw:
929
- return self._json(403, {"error": "文件浏览仅限本机使用"})
930
- qs = parse_qs(urlparse(self.path).query)
931
- raw = (qs.get("path") or [""])[0].strip()
932
- if not raw:
933
- return self._json(400, {"error": "缺少 path"})
934
- try:
935
- p = Path(raw).expanduser()
936
- except Exception:
937
- return self._json(400, {"error": "非法路径"})
938
- if not p.is_dir():
939
- return self._json(404, {"error": "目录不存在: %s" % p})
940
- files = []
941
- try:
942
- for cur, dirs, names in os.walk(str(p)):
943
- # os.walk 默认不跟随目录符号链接:环形目录不会死循环
944
- dirs[:] = [d for d in dirs if d not in _SKIP_DIRS_SHARE]
945
- for n in names:
946
- try:
947
- st = (Path(cur) / n).stat()
948
- except OSError:
949
- continue # 断链/权限:跳过单个文件
950
- files.append({"name": (Path(cur) / n).relative_to(p).as_posix(),
951
- "size": st.st_size, "mtime": int(st.st_mtime)})
952
- except (PermissionError, OSError):
953
- return self._json(403, {"error": "无权限读取该目录"})
954
- files.sort(key=lambda f: (-f["mtime"], f["name"].lower()))
955
- return self._json(200, {"path": str(p), "files": files})
956
-
957
- def _api_dir_file(self):
958
- """侧栏「查看文件」里点文件 chip:返回该文件内容(浏览器新页打开/下载)。
959
- 只限本机(同 browse 口径);name 必须相对,解析后仍落在 dir 内,防穿越。"""
960
- ip, fw = self._forwarded_ip()
961
- if ip not in ("127.0.0.1", "::1") or fw:
962
- return self._json(403, {"error": "文件查看仅限本机使用"})
963
- qs = parse_qs(urlparse(self.path).query)
964
- raw_dir = (qs.get("dir") or [""])[0].strip()
965
- raw_name = (qs.get("name") or [""])[0].strip()
966
- if not raw_dir or not raw_name:
967
- return self._json(400, {"error": "缺少 dir/name"})
968
- try:
969
- base = Path(raw_dir).expanduser().resolve()
970
- except Exception:
971
- return self._json(400, {"error": "非法路径"})
972
- if not base.is_dir():
973
- return self._json(404, {"error": "目录不存在: %s" % base})
974
- name = unquote(raw_name).replace("\\", "/")
975
- parts = name.split("/")
976
- if not name or not all(p and p != ".." for p in parts):
977
- return self._json(400, {"error": "非法文件名"})
978
- fp = (base / name).resolve()
979
- # 嵌套路径(docs/x.md)也要放行:Windows 下 str(fp) 是反斜杠,
980
- # startswith(base+"/") 会把合法子目录文件误判越界,改用 relative_to 判定
981
- try:
982
- fp.relative_to(base)
983
- except ValueError:
984
- return self._json(403, {"error": "越界"})
985
- if not fp.is_file():
986
- return self._json(404, {"error": "文件不存在"})
987
- try:
988
- data = fp.read_bytes()
989
- except OSError:
990
- return self._json(403, {"error": "无法读取"})
991
- ext = Path(name.lower()).suffix
992
- ctype = MIME.get(ext, "application/octet-stream")
993
- if ext in (".md", ".txt", ".log", ".csv", ".yml", ".yaml", ".json", ".ini", ".toml",
994
- ".py", ".js", ".ts", ".html", ".css", ".svg"):
995
- ctype = "text/plain; charset=utf-8"
996
- if ctype.startswith("text/"):
997
- data = _utf8_bytes(data)
998
- # mtime 供前端「编辑保存」做冲突检测:文件被任务/外部改动后拒绝覆盖(409)
999
- return self._send(200, data, ctype,
1000
- {"X-Tutti-Mtime": str(int(fp.stat().st_mtime))})
1001
-
1002
- def _api_dir_save(self):
1003
- """「查看文件」弹窗的编辑保存:{dir, name, content, mtime} → 覆写该文件。
1004
-
1005
- 仅限本机(同 /api/dir/file 口径,写操作还过全局控制权门槛);name 必须
1006
- 相对且解析后落在 dir 内(与读取端同一套防穿越判定);内容按 UTF-8 落盘
1007
- (读取端本就把文本按 UTF-8 下发,GBK 老文件保存一次即归一化为 UTF-8)。
1008
- mtime 是打开预览时响应头 X-Tutti-Mtime 带回的旧值:不等 → 文件已被
1009
- 任务/外部改动过,回 409 拒绝覆盖,让用户重开预览确认后再改。
1010
- """
1011
- ip, fw = self._forwarded_ip()
1012
- if ip not in ("127.0.0.1", "::1") or fw:
1013
- return self._json(403, {"error": "文件保存仅限本机使用"})
1014
- body = self._body()
1015
- raw_dir = (body.get("dir") or "").strip()
1016
- raw_name = (body.get("name") or "").strip()
1017
- content = body.get("content")
1018
- if not raw_dir or not raw_name:
1019
- return self._json(400, {"error": "缺少 dir/name"})
1020
- if not isinstance(content, str):
1021
- return self._json(400, {"error": "content 必须是文本"})
1022
- if len(content.encode("utf-8")) > 4 * 1024 * 1024:
1023
- return self._json(400, {"error": "内容超过 4MB,请在编辑器里改大文件"})
1024
- try:
1025
- base = Path(raw_dir).expanduser().resolve()
1026
- except Exception:
1027
- return self._json(400, {"error": "非法路径"})
1028
- if not base.is_dir():
1029
- return self._json(404, {"error": "目录不存在: %s" % base})
1030
- name = raw_name.replace("\\", "/")
1031
- parts = name.split("/")
1032
- if not name or not all(p and p != ".." for p in parts):
1033
- return self._json(400, {"error": "非法文件名"})
1034
- fp = (base / name).resolve()
1035
- try:
1036
- fp.relative_to(base)
1037
- except ValueError:
1038
- return self._json(403, {"error": "越界"})
1039
- if fp.exists() and not fp.is_file():
1040
- return self._json(400, {"error": "同名路径不是文件"})
1041
- old_mtime = body.get("mtime")
1042
- if fp.exists() and old_mtime is not None:
1043
- try:
1044
- if abs(int(fp.stat().st_mtime) - int(old_mtime)) > 1:
1045
- return self._json(409, {"error": "文件已被外部修改,请关闭弹窗重新打开确认后再编辑"})
1046
- except OSError:
1047
- pass
1048
- try:
1049
- fp.parent.mkdir(parents=True, exist_ok=True)
1050
- fp.write_bytes(content.encode("utf-8"))
1051
- except OSError:
1052
- return self._json(403, {"error": "无法写入(只读/被占用?)"})
1053
- return self._json(200, {"ok": True, "size": fp.stat().st_size,
1054
- "mtime": int(fp.stat().st_mtime)})
1055
-
1056
- def _api_git_info(self):
1057
- """探测工作目录是否为 git 仓库,返回分支/标签/最近提交供「代码版本」下拉。
1058
- 只读(rev-parse / status / log),不改仓库。仅限本机请求(同目录浏览的口径)。"""
1059
- ip, fw = self._forwarded_ip()
1060
- if ip not in ("127.0.0.1", "::1") or fw:
1061
- return self._json(403, {"error": "代码版本探测仅限本机使用"})
1062
- from core import gitmod
1063
- qs = parse_qs(urlparse(self.path).query)
1064
- raw = (qs.get("workdir") or [""])[0].strip()
1065
- if not raw:
1066
- return self._json(200, {"repo": False})
1067
- try:
1068
- p = Path(raw).expanduser()
1069
- except Exception:
1070
- return self._json(400, {"error": "非法路径"})
1071
- if not p.is_dir():
1072
- return self._json(200, {"repo": False})
1073
- try:
1074
- return self._json(200, gitmod.repo_info(str(p)))
1075
- except Exception as e:
1076
- return self._json(200, {"repo": False, "error": str(e)[:200]})
1077
-
1078
- def _api_add_attachment(self):
1079
- """上传一个任务附件(截图/文件)到待提交区:{name, data: base64} → 返回附件 id。
1080
- 创建任务时把 id 列表放进 payload.attachments,落盘到工作目录 _attachments/。"""
1081
- from core import attachments
1082
- # 防超大声明:普通附件 8MB(base64 约 11MB),Office 文档 24MB(约 32MB),
1083
- # 门限取上限加余量;按扩展名的精确校验在 save_pending 里
1084
- try:
1085
- if int(self.headers.get("Content-Length") or 0) > 34 * 1024 * 1024:
1086
- return self._json(413, {"error": "附件过大(普通 8MB / Word·Excel·PPT 24MB)"})
1087
- except ValueError:
1088
- return self._json(400, {"error": "非法 Content-Length"})
1089
- body = self._body()
1090
- try:
1091
- meta = attachments.save_pending(body.get("name"), body.get("data"))
1092
- except ValueError as e:
1093
- return self._json(400, {"error": str(e)})
1094
- return self._json(200, {"ok": True, "attachment": meta})
1095
-
1096
- def _api_add_message(self, run_id):
1097
- """运行中指挥:往该 run 的信箱追加一条用户指令(文字 + 附件)。
1098
- 附件走待提交区(前端先 POST /api/attachments 拿 id)→ commit 进 workdir,
1099
- 把落盘相对路径随消息入箱,供下一个步骤 drain 时注入。写接口已在 do_POST
1100
- 统一做过设备控制(_deny_control),此处不重复。"""
1101
- run = store.get_run(run_id)
1102
- if not run:
1103
- return self._json(404, {"error": "not found"})
1104
- body = self._body() or {}
1105
- text = body.get("text") or ""
1106
- att_ids = body.get("attachments") or []
1107
- workdir = store.run_workdir(run_id)
1108
- saved = []
1109
- if att_ids and workdir:
1110
- from core import attachments
1111
- try:
1112
- saved = attachments.commit_to_workdir(workdir, att_ids)
1113
- except Exception:
1114
- saved = []
1115
- saved_paths = [a.get("path") for a in saved if a.get("path")]
1116
- if not text.strip() and not saved_paths:
1117
- return self._json(400, {"error": "消息为空"})
1118
- msg = store.add_message(run_id, text, sender=self._client_name(),
1119
- attachments=saved_paths)
1120
- if not msg:
1121
- return self._json(400, {"error": "运行不存在或消息非法"})
1122
- return self._json(200, {"ok": True, "message": msg})
1123
-
1124
- def _api_run_timeline(self, run_id):
1125
- """直连对话视图的时间线:用户消息与各步 CLI 输出按序合并成一个气泡流。
1126
-
1127
- 步骤正文取自该步日志文件(CLI 原始输出),按 created/序号排序:
1128
- [user 消息] → [assistant 输出] → [user 追问] → …。供详情页「对话」分区直读,
1129
- 前端不必逐条拉日志。非 direct 任务也能取(返回步骤流),只是视图不启用。"""
1130
- run = store.get_run(run_id)
1131
- if not run:
1132
- return self._json(404, {"error": "not found"})
1133
- items = []
1134
- for m in (run.get("messages") or []):
1135
- items.append({
1136
- "kind": "user",
1137
- "at": m.get("created_at") or "",
1138
- "who": m.get("sender") or "",
1139
- "text": m.get("text") or "",
1140
- "attachments": m.get("attachments") or [],
1141
- "consumed": bool(m.get("consumed")),
1142
- "id": m.get("id"),
1143
- })
1144
- for s in (run.get("steps") or []):
1145
- body = ""
1146
- if s.get("log"):
1147
- body = store.read_step_log(run_id, s["log"], tail=20000, pretty=True)
1148
- if not body:
1149
- body = s.get("summary") or ""
1150
- items.append({
1151
- "kind": "agent",
1152
- "at": s.get("ended_at") or s.get("started_at") or "",
1153
- "who": s.get("agent_label") or s.get("agent") or "",
1154
- "role": s.get("role") or "",
1155
- "n": s.get("n"),
1156
- "status": s.get("status") or "",
1157
- "text": body,
1158
- "note": s.get("note") or "",
1159
- })
1160
- return self._json(200, {
1161
- "run_id": run_id, "status": run.get("status") or "",
1162
- "engine": (store.get_task(run.get("task_id") or "") or {}).get("engine") or "",
1163
- "items": items,
1164
- })
1165
-
1166
- def _api_direct_chat(self, run_id):
1167
- """直连对话追话:往已结束的 direct run 追加一条消息并自动续跑。
1168
-
1169
- 机制:消息入旧 run 信箱 → retry_task 起新 run(未消费消息自动继承)
1170
- → 入队编排 → _run_direct 看到信箱积压走续轮档(DIRECT_FOLLOWUP)。
1171
- direct 引擎任务可用;运行中的 run 走既有 /messages(轮间注入)。
1172
- 写接口已在 do_POST 统一做过设备控制。"""
1173
- run = store.get_run(run_id)
1174
- if not run:
1175
- return self._json(404, {"error": "not found"})
1176
- task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
1177
- if not task or task.get("engine") != "direct":
1178
- return self._json(400, {"error": "该任务不是直连任务,请用「下达指令」"})
1179
- if (run.get("status") or "") in ("queued", "running"):
1180
- return self._json(400, {"error": "运行中:消息会随下一步自动送达,无需追话"})
1181
- body = self._body() or {}
1182
- text = body.get("text") or ""
1183
- att_ids = body.get("attachments") or []
1184
- workdir = store.run_workdir(run_id) or task.get("workdir") or ""
1185
- saved = []
1186
- if att_ids and workdir:
1187
- from core import attachments
1188
- try:
1189
- saved = attachments.commit_to_workdir(workdir, att_ids)
1190
- except Exception:
1191
- saved = []
1192
- saved_paths = [a.get("path") for a in saved if a.get("path")]
1193
- if not text.strip() and not saved_paths:
1194
- return self._json(400, {"error": "消息为空"})
1195
- msg = store.add_message(run_id, text, sender=self._client_name(),
1196
- attachments=saved_paths)
1197
- if not msg:
1198
- return self._json(400, {"error": "消息非法"})
1199
- ok, err, new_run = store.retry_task(task["id"])
1200
- if not ok:
1201
- return self._json(400, {"error": err or "无法续跑"})
1202
- jobs.enqueue({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
1203
- return self._json(200, {"ok": True, "run_id": new_run["id"]})
1204
-
1205
- def _api_retract_message(self, run_id):
1206
- """撤回一条尚未下达的指令(drain 前从信箱删除)。已送达的撤不回——
1207
- 那是审计事实。写接口已在 do_POST 统一做过设备控制。"""
1208
- body = self._body() or {}
1209
- ok, err = store.retract_message(run_id, body.get("id"))
1210
- if not ok:
1211
- return self._json(400, {"error": err})
1212
- return self._json(200, {"ok": True})
1213
-
1214
- def _api_control(self):
1215
- body = self._body()
1216
- action = body.get("action") or ""
1217
- if action == "acquire":
1218
- ok, view = remote.acquire(self._client_id(), self._client_name(),
1219
- force=bool(body.get("force")))
1220
- if not ok:
1221
- return self._json(423, {"error": "「%s」正在控制" % view.get("holder", "其他设备"),
1222
- "control": view})
1223
- store.bump_state() # 控制权交接:让其他端立即看到
1224
- return self._json(200, {"ok": True, "control": view})
1225
- if action == "release":
1226
- view = remote.release(self._client_id())
1227
- store.bump_state()
1228
- return self._json(200, {"ok": True, "control": view})
1229
- return self._json(400, {"error": "action 必须是 acquire 或 release"})
1230
-
1231
- def _api_events(self):
1232
- """SSE 事件驱动:等 store 状态版本变化才构建/推送,空闲连接几乎零开销
1233
- (此前每连接每 0.8s 盲构建全量 payload,多端并发会把 detect 的慢 IO
1234
- 放大成服务假死)。每 ~2s 醒一次顺带检查控制权变化。"""
1235
- self.send_response(200)
1236
- self.send_header("Content-Type", "text/event-stream; charset=utf-8")
1237
- self.send_header("Cache-Control", "no-store")
1238
- self.send_header("X-Accel-Buffering", "no") # 防 nginx/隧道缓冲 SSE
1239
- self.end_headers()
1240
- cid = self._client_id()
1241
- ver = store.state_version()
1242
- sent_ctrl = None
1243
- n = 0
1244
- try:
1245
- while True:
1246
- ver = store.wait_state_change(ver, 2.0)
1247
- ctrl = remote.control_view(cid)
1248
- if ver == store.state_version() and ctrl == sent_ctrl:
1249
- continue # 超时醒来且无变化
1250
- payload = json.dumps(_state_payload(cid, ver), ensure_ascii=False)
1251
- self.wfile.write(("data: " + payload + "\n\n").encode("utf-8"))
1252
- self.wfile.flush()
1253
- sent_ctrl = ctrl
1254
- n += 1
1255
- if n % 20 == 0: # ~40s 一次注释行:探活兼防中间层断开空闲连接
1256
- self.wfile.write(b": ping\n\n")
1257
- self.wfile.flush()
1258
- except Exception:
1259
- pass # 客户端断开是常态,线程随进程退出
1260
-
1261
- def _api_create_task(self):
1262
- body = self._body()
1263
- try:
1264
- task = store.create_task(body)
1265
- except ValueError as e:
1266
- return self._json(400, {"error": str(e)})
1267
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
1268
- store.update_task_status(task["id"], "queued")
1269
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1270
- return self._json(200, {"task_id": task["id"], "run_id": run["id"]})
1271
-
1272
- def _api_set_preference(self):
1273
- body = self._body()
1274
- agent_id = body.get("agent_id")
1275
- if not agent_id:
1276
- return self._json(400, {"error": "agent_id 必填"})
1277
- # 模型链已并入「CLI 绑定」(modelhub bindings),这里只管参与编排开关
1278
- pref = registry.set_preference(agent_id, enabled=body.get("enabled"))
1279
- return self._json(200, {"ok": True, "preference": pref})
1280
-
1281
- # ------------------------------------------------------------ 静态
1282
- def _static(self, name):
1283
- p = (paths.UI_DIR / name).resolve()
1284
- if not p.is_file() or paths.UI_DIR.resolve() not in p.parents:
1285
- return self._json(404, {"error": "not found"})
1286
- suffix = p.suffix.lower()
1287
- self._send(200, p.read_bytes(), MIME.get(suffix, "application/octet-stream"))
1288
-
1289
-
1290
- def _state_payload(client_id="", ver=None):
1291
- agents = registry.effective_agents(catalog.load(), manager.detect_all())
1292
- if ver is None:
1293
- ver = store.state_version()
1294
- return {
1295
- "v": ver,
1296
- "agents": agents,
1297
- "tasks": store.list_tasks(30, archived=False),
1298
- "archived_tasks": store.list_tasks(30, archived=True),
1299
- # 每个任务的最近一次运行(不受 runs 窗口限制):侧栏靠它展示各任务真实近况
1300
- "task_latest": store.latest_run_by_task(),
1301
- # 每个任务的运行次数/步骤总数(全量):侧栏「查看全部」的计数来源
1302
- "task_stats": store.task_run_stats(),
1303
- "runs": store.list_runs(40),
1304
- "control": remote.control_view(client_id),
1305
- # 供应商健康/告警(顶栏横幅数据源;有告警时 bump_state 会推给所有端)
1306
- "health": health.snapshot(),
1307
- }
1308
-
1309
-
1310
- class ThreadedServer(ThreadingHTTPServer):
1311
- """Windows 下 SO_REUSEADDR 允许两个进程同时 LISTEN 同一端口(请求随机
1312
- 分发到其中一个——服务"时好时坏"的根源)。独占锁让第二个实例在这里
1313
- 干净失败,而不是静默双绑。正常重启不受影响(监听 socket 关闭不进
1314
- TIME_WAIT;已建立连接的 TIME_WAIT 不阻止重新 LISTEN 同端口)。"""
1315
- allow_reuse_address = False
1316
-
1317
- def server_bind(self):
1318
- if os.name == "nt":
1319
- self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
1320
- super().server_bind()
1321
-
1322
-
1323
- def main():
1324
- parser = argparse.ArgumentParser(description="CodeBee 多智能体编排台")
1325
- parser.add_argument("--host", default="0.0.0.0",
1326
- help="监听地址;0.0.0.0 允许手机/局域网访问(默认),127.0.0.1 仅本机")
1327
- parser.add_argument("--port", type=int, default=8765)
1328
- parser.add_argument("--no-browser", action="store_true")
1329
- parser.add_argument("--trusted-proxy", action="store_true",
1330
- help="服务跑在 Cloudflare Tunnel/frp 等反代后面时开启:"
1331
- "带转发头的回源请求必须带令牌,防止本机回源被当成 127.0.0.1 豁免")
1332
- parser.add_argument("--public-url", default="",
1333
- help="固定公网地址(如 https://codebee.example.com),扫码弹框优先展示")
1334
- parser.add_argument("--no-public-tunnel", action="store_true",
1335
- help="不自动开 Cloudflare 快速隧道(默认:检测到 cloudflared 就自动开,"
1336
- "获得随机 *.trycloudflare.com 公网地址,重启会变)")
1337
- parser.add_argument("--wait-port", action="store_true",
1338
- help="自更新重启用:先等旧实例释放端口再启动(Windows LISTEN 防护)")
1339
- args = parser.parse_args()
1340
-
1341
- paths.ensure_dirs()
1342
- from core import attachments
1343
- n_pc = attachments.cleanup_stale() # 待提交附件残留清理(崩溃/弃单不堆积)
1344
- if n_pc:
1345
- print("[CodeBee] 附件待提交区:清理过期残留 %d 个" % n_pc)
1346
- catalog.load()
1347
- store.load_all()
1348
- health.init() # 供应商健康/告警:恢复落盘状态 + 启动探针线程
1349
- from core import modelhub
1350
- modelhub.migrate_orch_models() # 旧「编排模型」偏好并入 CLI 绑定(幂等,带备份)
1351
- modelhub.migrate_chains() # 旧单供应商模型链升级为跨厂商 chain(幂等,带备份)
1352
- try:
1353
- from core import settings_schema
1354
- settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)
1355
- except Exception:
1356
- pass
1357
- from core import skills
1358
- n_lc = skills.migrate_lesson_categories() # 分类字段上线前的教训按关键词回填(幂等,带备份)
1359
- if n_lc:
1360
- print("[CodeBee] 经验库:%d 条历史教训已自动归类" % n_lc)
1361
- from core import usage
1362
- n_bf = usage.backfill_from_runs() # 历史运行 token 回填台账(幂等,仅补缺失步骤)
1363
- if n_bf:
1364
- print("[CodeBee] 用量台账:已从历史运行回填 %d 条记录" % n_bf)
1365
- # dsh-migration §1E:崩溃遗留的 running run 标记为 failed(在 jobs.resume_interrupted
1366
- # 之前执行,否则续跑逻辑会把僵尸 run 当成正常中断接手)
1367
- n_rc = store.recover_orphaned_runs()
1368
- if n_rc:
1369
- print("[CodeBee] 崩溃恢复:%d 个遗留运行标记为 failed(interrupted at startup)" % n_rc)
1370
- try:
1371
- from core import settings_schema
1372
- settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)
1373
- except Exception:
1374
- pass
1375
- jobs.start_worker()
1376
- n_resume = jobs.resume_interrupted() # 启动恢复:服务被杀中断的连载任务自动续跑
1377
- if n_resume:
1378
- print("[CodeBee] 已自动恢复 %d 个中断的连载任务(断点续跑)" % n_resume)
1379
- n_auto = automation.start() # 自动化:加载定时任务并拉起调度线程(错过的一次性任务不补跑)
1380
- if n_auto:
1381
- print("[CodeBee] 自动化:%d 个定时任务已加载" % n_auto)
1382
- tok = remote.token()
1383
- import atexit
1384
- import os as _os
1385
- remote.set_trusted_proxy(args.trusted_proxy or _os.environ.get("TUTTI_TRUST_PROXY") == "1",
1386
- args.public_url)
1387
-
1388
- global PORT
1389
- PORT = args.port
1390
- if args.wait_port:
1391
- from core import selfupdate
1392
- selfupdate.wait_port_before_bind(args.port)
1393
- try:
1394
- httpd = ThreadedServer((args.host, args.port), Handler)
1395
- except OSError as e:
1396
- # Windows 的 SO_REUSEADDR 允许两个进程同时 LISTEN 同一端口(请求随机
1397
- # 分发,表现为"时好时坏");加独占锁后双起在这里干净失败并指路。
1398
- import os
1399
- hint = ""
1400
- if os.name == "nt":
1401
- hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
1402
- "tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
1403
- % args.port)
1404
- raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
1405
- % (args.port, e, hint))
1406
-
1407
- def _announce_public(url):
1408
- print("[CodeBee] 公网 %s/?token=%s" % (url, tok))
1409
- print("[CodeBee] ← 任何网络可访问;临时地址重启会变,手机重新扫码即可。"
1410
- "\n[CodeBee] 要固定域名:cloudflared tunnel login 后参考 README 公网章节。")
1411
- store.bump_state() # 扫码弹框下次打开即可拿到公网地址
1412
-
1413
- print("[CodeBee] 本机 http://127.0.0.1:%d" % args.port)
1414
- print("[CodeBee] 数据目录 %s" % paths.DATA_DIR)
1415
- if remote.PUBLIC_URL:
1416
- print("[CodeBee] 公网 %s/?token=%s ← 任何网络可访问(反代回源已强制校验令牌)"
1417
- % (remote.PUBLIC_URL, tok))
1418
- elif not args.no_public_tunnel and args.host != "127.0.0.1":
1419
- if remote.has_local_creds():
1420
- print("[CodeBee] (检测到已有 Cloudflare 隧道凭据:临时隧道在此类机器上不可用,"
1421
- "请用 --public-url 配固定域名,或 start-public.bat)")
1422
- elif remote.start_quick_tunnel(args.port, _announce_public):
1423
- print("[CodeBee] 正在建立 Cloudflare 快速隧道(公网地址几秒后打印;"
1424
- "若打不开说明当前网络不支持,可改用固定域名或 Tailscale)…")
1425
- else:
1426
- print("[CodeBee] (未检测到 cloudflared,跳过公网隧道;"
1427
- "winget install Cloudflare.cloudflared 后重启即可获得公网地址)")
1428
- atexit.register(remote.stop_quick_tunnel)
1429
- if args.host != "127.0.0.1":
1430
- lan = remote.lan_ip()
1431
- if lan:
1432
- print("[CodeBee] 局域网 http://%s:%d/?token=%s ← 手机同一 WiFi 直接打开" % (lan, args.port, tok))
1433
- ts = remote.tailscale_ip()
1434
- if ts:
1435
- print("[CodeBee] Tailscale http://%s:%d/?token=%s ← 外网随时随地访问" % (ts, args.port, tok))
1436
- elif not remote.PUBLIC_URL:
1437
- print("[CodeBee] (未检测到 Tailscale;安装后重启本服务即可获得外网地址)")
1438
- print("[CodeBee] 远程访问受令牌保护;手机打开一次带 token 的地址后会记住。")
1439
- if not args.no_browser:
1440
- threading.Timer(1.0, lambda: webbrowser.open("http://127.0.0.1:%d" % args.port)).start()
1441
- try:
1442
- httpd.serve_forever()
1443
- except KeyboardInterrupt:
1444
- print("\n[CodeBee] 已退出")
1445
-
1446
-
1447
- if __name__ == "__main__":
1448
- main()
1
+ # -*- coding: utf-8 -*-
2
+ """CodeBee(多智能体编排台):纯标准库 HTTP 服务(零依赖,Python 3.8+)。
3
+
4
+ 启动:python app/main.py [端口],默认 8765,自动打开浏览器。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import re
12
+ import socket
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ import webbrowser
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from pathlib import Path
20
+ from urllib.parse import parse_qs, unquote, urlparse
21
+
22
+ from core import automation, catalog, flows, jobs, manager, market, market_remote, registry, remote, settings, store
23
+ from core import paths
24
+ from core import health
25
+
26
+ MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
27
+ ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
28
+ ".ico": "image/x-icon", ".json": "application/manifest+json; charset=utf-8",
29
+ ".webmanifest": "application/manifest+json; charset=utf-8"}
30
+
31
+
32
+ def _utf8_bytes(data):
33
+ """文本预览出口的编码守卫:字节流非合法 UTF-8 时按 GBK 解码后重编码为
34
+ UTF-8 再发。子代理在中文 Windows 上可能把工作区文件落成 GBK,直接透传
35
+ 会让声明 charset=utf-8 的浏览器预览整片乱码(与 pipeline 读防线同一问题
36
+ 的服务端出口面)。"""
37
+ try:
38
+ data.decode("utf-8")
39
+ return data
40
+ except UnicodeDecodeError:
41
+ pass
42
+ try:
43
+ return data.decode("gbk").encode("utf-8")
44
+ except (UnicodeDecodeError, UnicodeEncodeError):
45
+ return data.decode("utf-8", "replace").encode("utf-8")
46
+
47
+ PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
48
+
49
+ # 侧栏「查看文件」/「目录浏览」跳过的噪音目录(与 store 的习惯一致)
50
+ _SKIP_DIRS_SHARE = {".git", "node_modules", "__pycache__", ".venv", "venv",
51
+ ".idea", ".vscode", "_attachments"}
52
+
53
+
54
+ class Handler(BaseHTTPRequestHandler):
55
+ server_version = "CodeBee/1.0"
56
+
57
+ # ------------------------------------------------------------ 基础
58
+ def log_message(self, fmt, *args):
59
+ pass # 安静模式;异常仍会记录到 run 目录
60
+
61
+ def _send(self, code, body, ctype="application/json; charset=utf-8", headers=None):
62
+ if isinstance(body, str):
63
+ body = body.encode("utf-8")
64
+ self.send_response(code)
65
+ self.send_header("Content-Type", ctype)
66
+ self.send_header("Content-Length", str(len(body)))
67
+ self.send_header("Cache-Control", "no-store")
68
+ for k, v in (headers or {}).items():
69
+ self.send_header(k, v)
70
+ self.end_headers()
71
+ try:
72
+ self.wfile.write(body)
73
+ except Exception:
74
+ pass
75
+
76
+ def _json(self, code, obj):
77
+ self._send(code, json.dumps(obj, ensure_ascii=False))
78
+
79
+ def _body(self):
80
+ try:
81
+ n = int(self.headers.get("Content-Length") or 0)
82
+ if n <= 0:
83
+ return {}
84
+ return json.loads(self.rfile.read(n).decode("utf-8"))
85
+ except Exception:
86
+ return {}
87
+
88
+ # ------------------------------------------------------------ 远程访问
89
+ def _forwarded_ip(self):
90
+ """经代理进来的真实客户端 IP:X-Forwarded-For 首跳,缺省 CF-Connecting-IP。"""
91
+ fw = self.headers.get("X-Forwarded-For") or ""
92
+ if not fw:
93
+ fw = self.headers.get("CF-Connecting-IP") or ""
94
+ return remote.effective_ip(self.client_address[0], fw), fw
95
+
96
+ def _authed(self):
97
+ q = parse_qs(urlparse(self.path).query)
98
+ ip, fw = self._forwarded_ip()
99
+ return remote.request_authed(ip, fw,
100
+ (q.get("token") or [""])[0],
101
+ self.headers.get("X-CodeBee-Token") or "")
102
+
103
+ def _client_id(self):
104
+ cid = (self.headers.get("X-CodeBee-Client") or "").strip()
105
+ if not cid: # EventSource 带不了自定义头,身份从 query 兜底
106
+ cid = (parse_qs(urlparse(self.path).query).get("client") or [""])[0].strip()
107
+ if cid:
108
+ return cid[:64]
109
+ # 无头请求(curl/旧脚本):真本机统一算"本机",远程各自匿名且无持久身份
110
+ ip, fw = self._forwarded_ip()
111
+ return "local" if (ip in ("127.0.0.1", "::1") and not fw) else ""
112
+
113
+ def _client_name(self):
114
+ name = (self.headers.get("X-CodeBee-Name") or "").strip()
115
+ if not name:
116
+ name = (parse_qs(urlparse(self.path).query).get("name") or [""])[0].strip()
117
+ if name:
118
+ try:
119
+ name = unquote(name) # 前端对非 ASCII 设备名做了 encodeURIComponent
120
+ except Exception:
121
+ pass
122
+ return name[:24]
123
+ return "本机" if self._client_id() == "local" else "其他设备"
124
+
125
+ def _deny_control(self):
126
+ ok, view = remote.acquire(self._client_id(), self._client_name())
127
+ if ok:
128
+ if view.get("mine"):
129
+ store.bump_state() # 空闲自动接管:让其他端立即看到
130
+ return None
131
+ return self._json(423, {
132
+ "error": "「%s」正在控制,请先在右上角接管控制权" % view.get("holder", "其他设备"),
133
+ "control": view})
134
+
135
+ # ------------------------------------------------------------ 路由
136
+ def do_GET(self):
137
+ path = urlparse(self.path).path
138
+ m = None
139
+ # 静态页不设防(无敏感信息):远程裸地址打开时由前端令牌门引导输入
140
+ if path in ("/", "/index.html"):
141
+ return self._static("index.html")
142
+ if path.startswith("/api/"):
143
+ if not self._authed():
144
+ return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
145
+ if path == "/api/state":
146
+ return self._json(200, _state_payload(self._client_id()))
147
+ if path == "/api/browse":
148
+ return self._api_browse()
149
+ if path == "/api/dir/scan":
150
+ # 侧栏文件夹「查看文件」:列出该工作目录的文件(跳过噪音目录,只列文件)
151
+ return self._api_dir_scan()
152
+ if path == "/api/dir/file":
153
+ return self._api_dir_file()
154
+ if path == "/api/git/info":
155
+ return self._api_git_info()
156
+ if path == "/api/events":
157
+ return self._api_events()
158
+ if path == "/api/control":
159
+ return self._json(200, {"control": remote.control_view(self._client_id())})
160
+ if path == "/api/health":
161
+ from core import health
162
+ return self._json(200, health.snapshot())
163
+ if path == "/api/connect":
164
+ # 供设置页「手机连接」弹框生成二维码;远程打开需令牌,天然受保护
165
+ return self._json(200, {"urls": remote.build_connect_urls(PORT),
166
+ "port": PORT})
167
+ if path == "/api/models":
168
+ from core import modelhub
169
+ return self._json(200, {"providers": modelhub.provider_view(),
170
+ "bindings": modelhub.bindings(),
171
+ "catalog": modelhub.models_view(),
172
+ "source_names": modelhub.source_names()})
173
+ if path == "/api/models/sources":
174
+ from core import modelhub
175
+ return self._json(200, {"sources": modelhub.sources()})
176
+ if path == "/api/flows":
177
+ return self._json(200, {"flows": flows.list_flows()})
178
+ if path == "/api/skills":
179
+ from core import skills
180
+ return self._json(200, skills.view())
181
+ if path == "/api/settings":
182
+ return self._json(200, dict(settings.load(), **jobs.workers_info()))
183
+ if path == "/api/selfupdate":
184
+ from core import selfupdate
185
+ return self._json(200, selfupdate.check(
186
+ force=bool(parse_qs(urlparse(self.path).query).get("force"))))
187
+ if path == "/api/orchestrator":
188
+ from core import modelhub
189
+ return self._json(200, {"orchestrator": modelhub.orchestrator_view()})
190
+ if path == "/api/catalog":
191
+ return self._json(200, {"catalog": manager.catalog_view(),
192
+ "checking": manager.updates_checking()})
193
+ if path == "/api/sessions":
194
+ from core import sessions
195
+ return self._json(200, {"sessions": sessions.scan(
196
+ force="force=1" in urlparse(self.path).query)})
197
+ if path == "/api/catalog/reload":
198
+ catalog.load(force=True)
199
+ manager.detect_all(force=True)
200
+ return self._json(200, {"ok": True, "catalog": manager.catalog_view()})
201
+ if path == "/api/runs":
202
+ return self._json(200, {"runs": store.list_runs()})
203
+ if path == "/api/usage":
204
+ from core import usage
205
+ q = parse_qs(urlparse(self.path).query)
206
+ try:
207
+ days = max(0, min(3650, int((q.get("days") or ["30"])[0])))
208
+ except ValueError:
209
+ days = 30
210
+ return self._json(200, usage.summary(days=days))
211
+ m = re.match(r"^/api/runs/([^/]+)$", path)
212
+ if m:
213
+ run = store.get_run(m.group(1))
214
+ return self._json(200, {"run": run}) if run else self._json(404, {"error": "not found"})
215
+ m = re.match(r"^/api/runs/([^/]+)/report$", path)
216
+ if m:
217
+ run = store.get_run(m.group(1))
218
+ if not run:
219
+ return self._json(404, {"error": "not found"})
220
+ p = paths.RUNS_DIR / m.group(1) / "report.md"
221
+ if not p.is_file():
222
+ return self._send(200, "(报告尚未生成)", "text/markdown; charset=utf-8")
223
+ return self._send(200, p.read_text(encoding="utf-8", errors="replace"),
224
+ "text/markdown; charset=utf-8")
225
+ m = re.match(r"^/api/runs/([^/]+)/files$", path)
226
+ if m:
227
+ run = store.get_run(m.group(1))
228
+ if not run:
229
+ return self._json(404, {"error": "not found"})
230
+ wd, files = store.run_artifacts(m.group(1))
231
+ # 任务一步都没跑出来过(如历次都在检出前失败)→ 工作目录里的
232
+ # 文件变动是并行活动的噪音,不算这个任务的成品
233
+ if files and not store.task_step_count(run.get("task_id") or ""):
234
+ files = []
235
+ return self._json(200, {"workdir": wd, "files": files,
236
+ "task_id": run.get("task_id") or ""})
237
+ m = re.match(r"^/api/tasks/([^/]+)/side$", path)
238
+ if m:
239
+ # 任务检查器(右缘停靠列)专用:轻量聚合、可轮询,不带 diff 文本
240
+ return self._api_task_side(m.group(1))
241
+ m = re.match(r"^/api/tasks/([^/]+)/runs$", path)
242
+ if m:
243
+ # 任务级详情用:该任务全部 run(含 steps),不受前端 run 窗口限制
244
+ if not store.get_task(m.group(1)):
245
+ return self._json(404, {"error": "not found"})
246
+ return self._json(200, {"runs": store.task_runs(m.group(1))})
247
+ m = re.match(r"^/api/tasks/([^/]+)/bible$", path)
248
+ if m:
249
+ # 故事圣经:查看(无令牌豁免走 _authed 已过;本机免令牌)
250
+ fp, text, err = store.read_story_bible(m.group(1))
251
+ if err:
252
+ return self._json(400, {"error": err})
253
+ return self._json(200, {"path": fp, "text": text})
254
+ m = re.match(r"^/api/tasks/([^/]+)/book-meta$", path)
255
+ if m:
256
+ # 作品信息(番茄/七猫建书表单资料):读取任务上的生成状态与结果
257
+ task = store.get_task(m.group(1))
258
+ if not task:
259
+ return self._json(404, {"error": "not found"})
260
+ return self._json(200, {"book_meta": task.get("book_meta") or {}})
261
+ m = re.match(r"^/api/tasks/([^/]+)/git$", path)
262
+ if m:
263
+ # GIT 工作台全貌(详情页「版本」页签):仓库状态聚合 + 任务隔离态
264
+ return self._api_git_wb(m.group(1))
265
+ m = re.match(r"^/api/tasks/([^/]+)/git/diff$", path)
266
+ if m:
267
+ # 工作台单文件实时 diff(?path= 仓库内相对路径)
268
+ return self._api_git_wb_diff(m.group(1))
269
+ m = re.match(r"^/api/tasks/([^/]+)/continue-info$", path)
270
+ if m:
271
+ # 「继续连载」弹框数据:能否续、已写到第几章、默认续几章
272
+ info = store.continue_info(m.group(1))
273
+ if info is None:
274
+ return self._json(404, {"error": "not found"})
275
+ return self._json(200, info)
276
+ m = re.match(r"^/api/runs/([^/]+)/file$", path)
277
+ if m:
278
+ if not store.get_run(m.group(1)):
279
+ return self._json(404, {"error": "not found"})
280
+ rel = (parse_qs(urlparse(self.path).query).get("name") or [""])[0]
281
+ data, err = store.read_run_file(m.group(1), unquote(rel))
282
+ if err:
283
+ return self._json(404, {"error": err})
284
+ ext = Path(rel.lower()).suffix
285
+ ctype = MIME.get(ext, "application/octet-stream")
286
+ # 文本类扩展名直接在浏览器里看内容;未知二进制才触发下载
287
+ if ext in (".md", ".txt", ".log", ".csv", ".yml", ".yaml", ".ini", ".toml"):
288
+ ctype = "text/plain; charset=utf-8"
289
+ if ctype.startswith("text/"):
290
+ data = _utf8_bytes(data)
291
+ return self._send(200, data, ctype)
292
+ m = re.match(r"^/api/runs/([^/]+)/log$", path)
293
+ if m:
294
+ # step 由前端 encodeURIComponent 编码("steps/x.log" → "steps%2Fx.log"),
295
+ # 必须解码后再拼路径,否则永远找不到日志文件。read_step_log 内部已防目录穿越。
296
+ q = parse_qs(urlparse(self.path).query)
297
+ rel = (q.get("step") or [""])[0]
298
+ rel = rel.replace("\\", "/").lstrip("/")
299
+ # tail:蜂巢卡片实时尾巴轮询用小窗口;日志面板用默认(4000)。
300
+ # 先取默认/解析再夹紧:此前 max(200, min(40000, 0)) 恒为 200,
301
+ # 不带 tail 的日志抽屉一直只读到末尾 200 字节(2026-09-15 实测暴露)
302
+ try:
303
+ tail = int((q.get("tail") or [""])[0] or 0) or paths.LOG_TAIL_CHARS
304
+ except ValueError:
305
+ tail = paths.LOG_TAIL_CHARS
306
+ tail = max(200, min(40000, tail))
307
+ run = store.get_run(m.group(1))
308
+ if not run:
309
+ return self._json(404, {"error": "not found"})
310
+ # pretty=1:日志抽屉等人类视图,把 codex JSONL 事件流翻译成可读行;
311
+ # 蜂巢卡片等机器视图可不带,只享受重复行折叠
312
+ pretty = (q.get("pretty") or [""])[0] in ("1", "true")
313
+ text = store.read_step_log(m.group(1), rel, tail=tail, pretty=pretty)
314
+ # 带上步骤/运行状态:日志面板靠它区分「实时刷新中」和「已结束」,
315
+ # 结束即停轮询(否则用户盯着不动的日志以为刷新坏了)
316
+ st = next((s.get("status") or "" for s in (run.get("steps") or [])
317
+ if s.get("log") == rel), "")
318
+ return self._json(200, {"log": text, "step_status": st,
319
+ "run_status": run.get("status") or ""})
320
+ m = re.match(r"^/api/runs/([^/]+)/timeline$", path)
321
+ if m:
322
+ return self._api_run_timeline(m.group(1))
323
+ if path == "/api/automation":
324
+ return self._json(200, {"tasks": automation.list_tasks(),
325
+ "templates": automation.templates()})
326
+ m = re.match(r"^/api/automation/([^/]+)$", path)
327
+ if m:
328
+ t = automation.get_task(m.group(1))
329
+ return self._json(200, {"task": t}) if t else self._json(404, {"error": "not found"})
330
+ if path == "/api/market":
331
+ return self._json(200, market.view())
332
+ if path == "/api/market/remote":
333
+ q = parse_qs(urlparse(self.path).query)
334
+ return self._json(200, market_remote.view(
335
+ offset=(q.get("offset") or ["0"])[0],
336
+ limit=(q.get("limit") or [""])[0],
337
+ source=(q.get("source") or [""])[0],
338
+ q=(q.get("q") or [""])[0]))
339
+ return self._json(404, {"error": "unknown api"})
340
+ # 静态文件:单文件或 UI 子目录文件(如 icons/icon-192.png);
341
+ # _static 内的 parents 校验确保解析后仍在 UI_DIR 内,防穿越
342
+ name = path.lstrip("/")
343
+ if re.match(r"^[\w.-]+(/[\w.-]+)*$", name):
344
+ return self._static(name)
345
+ return self._json(404, {"error": "not found"})
346
+
347
+ def do_POST(self):
348
+ path = urlparse(self.path).path
349
+ m = None
350
+ if not self._authed():
351
+ return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
352
+ if path == "/api/control":
353
+ return self._api_control()
354
+ if path == "/api/control/heartbeat":
355
+ ok, view = remote.heartbeat(self._client_id())
356
+ return self._json(200, {"ok": ok, "control": view})
357
+ # 写操作需要控制权:空闲自动接管;他人持有时 423,由前端引导抢夺。
358
+ # 例外(配置管理类操作全局生效,不被「哪台设备在操作」挡住,
359
+ # 否则告警弹框里的按钮在多端场景会静默 423 失败):
360
+ if path in ("/api/health/op", "/api/models/provider-op", "/api/models/model-op",
361
+ "/api/models/test-provider", "/api/models/test-model",
362
+ "/api/models/probe-wire", "/api/models/key-op"):
363
+ pass # 落到下方各自路由
364
+ else:
365
+ deny = self._deny_control()
366
+ if deny:
367
+ return deny
368
+ if path == "/api/tasks":
369
+ return self._api_create_task()
370
+ if path == "/api/health/op":
371
+ # 供应商健康告警的手动操作(silence 静默 / reset 手动恢复)
372
+ from core import health
373
+ body = self._body()
374
+ prov = (body.get("provider") or "").strip()
375
+ if not prov:
376
+ return self._json(400, {"error": "provider 必填"})
377
+ op = body.get("op") or ""
378
+ if op == "silence":
379
+ ok, err = health.silence(prov, minutes=int(body.get("minutes") or 0))
380
+ elif op == "reset":
381
+ ok, err = health.reset(prov)
382
+ else:
383
+ return self._json(400, {"error": "op 必须是 silence 或 reset"})
384
+ if not ok:
385
+ return self._json(404, {"error": err})
386
+ store.bump_state()
387
+ return self._json(200, {"ok": True, "health": health.snapshot()})
388
+ if path == "/api/attachments":
389
+ return self._api_add_attachment()
390
+ if path == "/api/dir/save":
391
+ # 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
392
+ return self._api_dir_save()
393
+ m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue)$", path)
394
+ if m:
395
+ if m.group(2) == "archive":
396
+ body = self._body()
397
+ ok, err = store.archive_task(m.group(1), bool(body.get("archived", True)))
398
+ elif m.group(2) == "retry":
399
+ ok, err, run = store.retry_task(m.group(1))
400
+ if not ok:
401
+ return self._json(400, {"error": err})
402
+ jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": m.group(1)})
403
+ return self._json(200, {"ok": True, "run_id": run["id"]})
404
+ elif m.group(2) == "continue":
405
+ # 继续连载:在旧任务基础上新建任务(沿用目标/目录/评审设置,章节号衔接)
406
+ ok, err, new_task = store.continue_task(
407
+ m.group(1), (self._body() or {}).get("chapters"))
408
+ if not ok:
409
+ return self._json(400, {"error": err})
410
+ run = store.create_run("orchestration", new_task["title"],
411
+ task_id=new_task["id"])
412
+ store.update_task_status(new_task["id"], "queued")
413
+ jobs.enqueue({"kind": "orchestration",
414
+ "run_id": run["id"], "task_id": new_task["id"]})
415
+ return self._json(200, {"ok": True, "task_id": new_task["id"],
416
+ "run_id": run["id"]})
417
+ elif m.group(2) == "rename":
418
+ ok, err = store.rename_task(m.group(1), self._body().get("title"))
419
+ else:
420
+ ok, err = store.delete_task(m.group(1))
421
+ return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
422
+ m = re.match(r"^/api/tasks/([^/]+)/bible$", path)
423
+ if m:
424
+ # 故事圣经:编辑保存(运行中由 store 拒绝,防打碎前缀缓存)
425
+ body = self._body()
426
+ ok, err = store.write_story_bible(m.group(1), body.get("text") or "")
427
+ return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
428
+ m = re.match(r"^/api/tasks/([^/]+)/book-meta$", path)
429
+ if m:
430
+ return self._api_book_meta_generate(m.group(1))
431
+ m = re.match(r"^/api/tasks/([^/]+)/(git-merge|git-discard)$", path)
432
+ if m:
433
+ return self._api_git_verdict(m.group(1), m.group(2))
434
+ m = re.match(r"^/api/tasks/([^/]+)/git$", path)
435
+ if m:
436
+ # GIT 工作台写操作:{action, path?, message?, branch?, confirm?}
437
+ return self._api_git_wb_op(m.group(1))
438
+ m = re.match(r"^/api/(tasks|runs)/([^/]+)/reveal$", path)
439
+ if m:
440
+ return self._api_reveal(m.group(1), m.group(2))
441
+ m = re.match(r"^/api/runs/([^/]+)/messages$", path)
442
+ if m:
443
+ return self._api_add_message(m.group(1))
444
+ m = re.match(r"^/api/runs/([^/]+)/chat$", path)
445
+ if m:
446
+ return self._api_direct_chat(m.group(1))
447
+ m = re.match(r"^/api/runs/([^/]+)/messages/retract$", path)
448
+ if m:
449
+ return self._api_retract_message(m.group(1))
450
+ m = re.match(r"^/api/runs/([^/]+)/pause$", path)
451
+ if m:
452
+ # 暂停/放行:标志位挂在下一个步骤开始前;取消不必先解除暂停
453
+ body = self._body() or {}
454
+ if not store.set_paused(m.group(1), bool(body.get("paused"))):
455
+ return self._json(404, {"error": "not found"})
456
+ return self._json(200, {"ok": True, "paused": bool(body.get("paused"))})
457
+ m = re.match(r"^/api/runs/([^/]+)/cancel$", path)
458
+ if m:
459
+ # 先落「用户主动取消」标记再置事件:自动续跑必须尊重这个意图不得续上;
460
+ # 顺序保证起跑方读到已置位事件时,标记一定已写入(排队取消的兜底依赖它)。
461
+ store.update_run(m.group(1), cancelled_by_user=True)
462
+ ok = jobs.cancel(m.group(1))
463
+ return self._json(200, {"ok": ok})
464
+ m = re.match(r"^/api/runs/([^/]+)/delete$", path)
465
+ if m:
466
+ ok, err = store.delete_run(m.group(1))
467
+ return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
468
+ if path == "/api/runs/delete":
469
+ n, skipped, err = store.delete_runs(self._body().get("ids") or [])
470
+ if err and not n:
471
+ return self._json(400, {"error": err})
472
+ return self._json(200, {"ok": True, "count": n, "skipped": skipped, "message": err})
473
+ if path == "/api/runs/clear":
474
+ n, skipped = store.clear_runs()
475
+ return self._json(200, {"ok": True, "count": n, "skipped": skipped})
476
+ if path == "/api/orchestration":
477
+ return self._api_set_preference()
478
+ if path == "/api/catalog/reset":
479
+ catalog.reset_to_default()
480
+ catalog.load(force=True)
481
+ return self._json(200, {"ok": True})
482
+ if path == "/api/catalog/reload":
483
+ catalog.load(force=True)
484
+ manager.detect_all(force=True)
485
+ return self._json(200, {"ok": True})
486
+ if path == "/api/catalog/check-updates":
487
+ # 进入智能体目录页时前端自动调用;后台逐条查远端版本,立即返回
488
+ force = bool(self._body().get("force"))
489
+ n = manager.check_updates_async(force=force)
490
+ return self._json(200, {"ok": True, "count": n,
491
+ "checking": manager.updates_checking()})
492
+ if path == "/api/selfupdate/apply":
493
+ from core import selfupdate
494
+ res = selfupdate.apply_upgrade()
495
+ return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
496
+ if path == "/api/selfupdate/restart":
497
+ from core import selfupdate
498
+ global PORT
499
+ if not selfupdate.relaunch(PORT):
500
+ return self._json(400, {"error": "重启参数非法"})
501
+ def _bye():
502
+ time.sleep(0.8)
503
+ selfupdate.self_quit()
504
+ threading.Thread(target=_bye, daemon=True).start()
505
+ return self._json(200, {"ok": True, "message": "服务正在重启,几秒后自动恢复"})
506
+ if path == "/api/models/provider/delete":
507
+ from core import modelhub
508
+ body = self._body()
509
+ n, err = modelhub.providers_op([body.get("id") or ""], "delete")
510
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True, "count": n})
511
+ if path == "/api/models/provider-op":
512
+ from core import modelhub
513
+ body = self._body()
514
+ n, err = modelhub.providers_op(body.get("ids") or [], body.get("op") or "")
515
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True, "count": n})
516
+ if path == "/api/models/key-op":
517
+ # 多 KEY:增删改 / 启停 / 排序 / 重置冷却(欠费充值后手动恢复)
518
+ from core import modelhub
519
+ body = self._body()
520
+ err = modelhub.key_op(body.get("provider_id") or "",
521
+ body.get("op") or "",
522
+ key_id=body.get("key_id") or "",
523
+ key=body.get("key") or "",
524
+ label=body.get("label") or "",
525
+ enabled=body.get("enabled"),
526
+ ids=body.get("ids"))
527
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
528
+ if path == "/api/models/provider":
529
+ from core import modelhub
530
+ err = modelhub.upsert_provider(self._body())
531
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
532
+ if path == "/api/models/import":
533
+ from core import modelhub
534
+ body = self._body()
535
+ ids = body.get("sources")
536
+ res = modelhub.import_sources(ids if isinstance(ids, list) else None)
537
+ if res["imported"]:
538
+ modelhub.refresh_all_async() # 导入后自动拉取各供应商可用模型
539
+ res["message"] += ";正在后台获取模型列表…"
540
+ return self._json(200, dict(res, ok=res["imported"] > 0))
541
+ if path == "/api/models/import-ccswitch":
542
+ from core import modelhub
543
+ n, msg = modelhub.import_ccswitch()
544
+ if n:
545
+ modelhub.refresh_all_async() # 导入后自动拉取各供应商可用模型
546
+ msg += ";正在后台获取模型列表…"
547
+ return self._json(200, {"ok": n > 0, "imported": n, "message": msg})
548
+ if path == "/api/models/refresh":
549
+ from core import modelhub
550
+ n, err = modelhub.refresh_models(self._body().get("id") or "")
551
+ return self._json(200, {"ok": bool(n), "count": n, "message": err})
552
+ if path == "/api/models/refresh-all":
553
+ from core import modelhub
554
+ n = modelhub.refresh_all_async()
555
+ return self._json(200, {"ok": True, "count": n,
556
+ "message": "后台刷新 %d 个供应商…" % n})
557
+ if path == "/api/models/model-op":
558
+ from core import modelhub
559
+ body = self._body()
560
+ pid = body.get("provider_id") or ""
561
+ op = body.get("op") or ""
562
+ names = body.get("names")
563
+ if names is not None: # 批量:names 数组
564
+ n, err = modelhub.model_ops(pid, names, op)
565
+ return self._json(400, {"error": err}) if err else self._json(
566
+ 200, {"ok": True, "count": n})
567
+ err = modelhub.model_op(pid, body.get("name") or "", op)
568
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
569
+ if path == "/api/models/reorder":
570
+ from core import modelhub
571
+ body = self._body()
572
+ err = modelhub.reorder_models(body.get("provider_id") or "",
573
+ body.get("names") or [])
574
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
575
+ if path == "/api/models/test-provider":
576
+ from core import modelhub
577
+ return self._json(200, modelhub.test_provider(self._body().get("id") or ""))
578
+ if path == "/api/models/probe-wire":
579
+ # 适配测试:实测供应商另一条 wire 协议(同密钥),结果存 wire_caps
580
+ from core import modelhub
581
+ caps, note = modelhub.probe_wire_caps(self._body().get("id") or "")
582
+ return self._json(200, {"ok": True, "wire_caps": caps, "note": note})
583
+ if path == "/api/models/test-model":
584
+ from core import modelhub
585
+ body = self._body()
586
+ return self._json(200, modelhub.test_model(body.get("provider_id") or "",
587
+ body.get("name") or "",
588
+ key_id=body.get("key_id") or ""))
589
+ if path == "/api/models/binding":
590
+ from core import modelhub
591
+ body = self._body()
592
+ b = modelhub.set_binding(body.get("agent_id") or "",
593
+ provider_id=body.get("provider_id"),
594
+ model=body.get("model"),
595
+ models=body.get("models"),
596
+ difficulty_routing=body.get("difficulty_routing"),
597
+ chain=body.get("chain"))
598
+ return self._json(200, {"ok": True, "binding": b})
599
+ if path == "/api/flows":
600
+ flow, err = flows.upsert_flow(self._body())
601
+ if err:
602
+ return self._json(400, {"error": err})
603
+ return self._json(200, {"ok": True, "flow": flow})
604
+ if path == "/api/skills/lesson-op":
605
+ from core import skills
606
+ body = self._body()
607
+ err = skills.lesson_op(body.get("id") or "", body.get("op") or "")
608
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
609
+ if path == "/api/skills/pack-op":
610
+ from core import skills
611
+ body = self._body()
612
+ err = skills.pack_op(body.get("id") or "", body.get("op") or "")
613
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
614
+ if path == "/api/skills/learn":
615
+ from core import skills
616
+ n = skills.learn_from_run((self._body().get("run_id") or ""))
617
+ return self._json(200, {"ok": True, "learned": n})
618
+ m = re.match(r"^/api/flows/([^/]+)/reset$", path)
619
+ if m:
620
+ err = flows.reset_flow(m.group(1))
621
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
622
+ m = re.match(r"^/api/flows/([^/]+)/delete$", path)
623
+ if m:
624
+ err = flows.delete_flow(m.group(1))
625
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
626
+ if path == "/api/settings":
627
+ view, err = settings.save(self._body())
628
+ if err:
629
+ return self._json(400, {"error": err, "settings": view})
630
+ n = jobs.configure(view["max_concurrent_jobs"])
631
+ return self._json(200, {"ok": True, "settings": view, "workers": n})
632
+ if path == "/api/settings/default-workdir":
633
+ body = self._body()
634
+ old = settings.default_workdir()
635
+ view, err = settings.save({"default_workdir": body.get("path") or ""})
636
+ if err:
637
+ return self._json(400, {"error": err, "settings": view})
638
+ new = settings.default_workdir()
639
+ moved = skipped = 0
640
+ if body.get("migrate") and old != new:
641
+ try:
642
+ Path(new).mkdir(parents=True, exist_ok=True)
643
+ except Exception as e:
644
+ return self._json(400, {"error": "新默认路径不可创建: %s" % e})
645
+ moved, skipped = store.migrate_task_workdirs(old, new)
646
+ return self._json(200, {"ok": True, "settings": view, "moved": moved, "skipped": skipped})
647
+ if path == "/api/orchestrator":
648
+ from core import modelhub
649
+ body = self._body()
650
+ err = modelhub.set_orchestrator(body.get("provider_id"),
651
+ model=body.get("model"),
652
+ enabled=body.get("enabled"))
653
+ return self._json(400, {"error": err}) if err else self._json(
654
+ 200, {"ok": True, "orchestrator": modelhub.orchestrator_view()})
655
+ if path == "/api/orchestrator/test":
656
+ from core import modelhub, usage
657
+ orch = modelhub.resolve_orchestrator()
658
+ if not orch:
659
+ return self._json(400, {"ok": False, "error": "编排者未启用或配置失效"})
660
+ prov, model = orch
661
+ res = modelhub.chat(prov["id"], model,
662
+ "请只回复两个字:收到", max_tokens=64, timeout=30,
663
+ cache_ttl=86400) # §07 T2.2:连通测试幂等,24h 精确缓存
664
+ try:
665
+ usage.record(source="test", role="orch-test", agent="orchestrator",
666
+ agent_label="编排者", tool="orchestrator", model=model,
667
+ provider=prov.get("name", prov.get("id", "")),
668
+ ok=bool(res.get("ok")), usage=res.get("usage"))
669
+ except Exception:
670
+ pass
671
+ return self._json(200, dict(res, model=model,
672
+ provider=prov.get("name", prov["id"])))
673
+ m = re.match(r"^/api/catalog/([^/]+)/(install|upgrade|uninstall|smoke)$", path)
674
+ if m:
675
+ entry = catalog.by_id(m.group(1))
676
+ if not entry:
677
+ return self._json(404, {"error": "catalog 中无此条目"})
678
+ op = m.group(2)
679
+ titles = {"install": "安装", "upgrade": "升级", "uninstall": "卸载",
680
+ "smoke": "冒烟测试"}
681
+ if op == "uninstall" and not catalog.uninstall_command(entry):
682
+ return self._json(400, {
683
+ "error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
684
+ % entry["id"]})
685
+ run = store.create_run("mgmt", "%s %s" % (titles[op], entry.get("name", entry["id"])),
686
+ entry_id=entry["id"], op=op)
687
+ jobs.enqueue({"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
688
+ return self._json(200, {"run_id": run["id"]})
689
+ m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
690
+ if m:
691
+ # 一键打开(web 类起服务+开浏览器 / console 类新终端窗口)。
692
+ # 即时返回不走任务队列;body 可传 {"open": false} 供测试免开浏览器
693
+ entry = catalog.by_id(m.group(1))
694
+ if not entry:
695
+ return self._json(404, {"error": "catalog 中无此条目"})
696
+ res = manager.launch(entry, open_browser=bool(self._body().get("open", True)))
697
+ return self._json(200 if res.get("ok") else 400, res)
698
+ m = re.match(r"^/api/catalog/([^/]+)/check-update$", path)
699
+ if m:
700
+ entry = catalog.by_id(m.group(1))
701
+ if not entry:
702
+ return self._json(404, {"error": "catalog 中无此条目"})
703
+ return self._json(200, manager.check_update(entry))
704
+ m = re.match(r"^/api/catalog/([^/]+)/model$", path)
705
+ if m:
706
+ entry = catalog.by_id(m.group(1))
707
+ if not entry:
708
+ return self._json(404, {"error": "catalog 中无此条目"})
709
+ body = self._body()
710
+ res = manager.write_model(entry, body.get("model"))
711
+ return self._json(200 if res["ok"] else 400, res)
712
+ if path == "/api/automation":
713
+ try:
714
+ t = automation.create(self._body())
715
+ except ValueError as e:
716
+ return self._json(400, {"error": str(e)})
717
+ return self._json(200, {"ok": True, "task": t})
718
+ m = re.match(r"^/api/automation/([^/]+)$", path)
719
+ if m:
720
+ try:
721
+ t = automation.update(m.group(1), self._body())
722
+ except ValueError as e:
723
+ return self._json(400, {"error": str(e)})
724
+ return self._json(200, {"ok": True, "task": t}) if t else self._json(404, {"error": "not found"})
725
+ m = re.match(r"^/api/automation/([^/]+)/(toggle|run|delete)$", path)
726
+ if m:
727
+ tid, op = m.group(1), m.group(2)
728
+ if op == "toggle":
729
+ t = automation.set_enabled(tid, bool((self._body() or {}).get("enabled", True)))
730
+ return self._json(200, {"ok": True, "task": t}) if t else self._json(404, {"error": "not found"})
731
+ if op == "run":
732
+ t, run_id = automation.run_now(tid)
733
+ if t is None:
734
+ return self._json(404, {"error": "not found"})
735
+ return self._json(200, {"ok": True, "task": t, "run_id": run_id or ""})
736
+ ok = automation.delete(tid)
737
+ return self._json(200, {"ok": True}) if ok else self._json(404, {"error": "not found"})
738
+ # 外部目录路由必须在通配的 market/<id>/(install|remove) 之前——
739
+ # 否则 "remote" 会被当成包名吞掉
740
+ if path == "/api/market/remote/refresh":
741
+ return self._json(200, market_remote.refresh((self._body() or {}).get("source")))
742
+ m = re.match(r"^/api/market/remote/install$", path)
743
+ if m:
744
+ res, err = market_remote.install_remote((self._body() or {}).get("id") or "")
745
+ return self._json(400, {"error": err}) if err else self._json(200, res)
746
+ m = re.match(r"^/api/market/([^/]+)/(install|remove)$", path)
747
+ if m:
748
+ if m.group(2) == "install":
749
+ res, err = market.install(m.group(1))
750
+ else:
751
+ err = market.remove(m.group(1))
752
+ res = {"ok": True, "id": m.group(1)}
753
+ return self._json(400, {"error": err}) if err else self._json(200, res)
754
+ return self._json(404, {"error": "unknown api"})
755
+
756
+ # ------------------------------------------------------------ 业务
757
+ def _api_reveal(self, kind, res_id):
758
+ """打开/返回资源所在目录:任务=工作目录,运行=记录目录(步骤日志、报告都在里面)。
759
+ 路径一律由服务端按 id 推导,不接受客户端传任意路径——本服务监听局域网,
760
+ 不能变成远程探测文件系统的口子。open=false 只回路径给前端复制。"""
761
+ if kind == "tasks":
762
+ task = store.get_task(res_id)
763
+ if not task:
764
+ return self._json(404, {"error": "任务不存在"})
765
+ p = Path(task.get("workdir") or "")
766
+ else:
767
+ run = store.get_run(res_id)
768
+ if not run:
769
+ return self._json(404, {"error": "运行记录不存在"})
770
+ p = paths.RUNS_DIR / res_id
771
+ if not p.is_dir():
772
+ return self._json(404, {"error": "目录不存在: %s" % p})
773
+ if self._body().get("open"):
774
+ try:
775
+ if sys.platform == "win32":
776
+ subprocess.Popen(["explorer", str(p)])
777
+ elif sys.platform == "darwin":
778
+ subprocess.Popen(["open", str(p)])
779
+ else:
780
+ subprocess.Popen(["xdg-open", str(p)])
781
+ except Exception as e:
782
+ return self._json(500, {"error": "无法打开目录: %s" % e})
783
+ return self._json(200, {"ok": True, "path": str(p)})
784
+
785
+ def _api_git_verdict(self, task_id, op):
786
+ """任务分支裁决:git-merge 合并回原分支(采纳)/ git-discard 删除分支(否决)。
787
+
788
+ 丢弃不可恢复,必须显式 confirm=true;两边的守卫与冲突回滚都在
789
+ gitmod 里(见 merge_task_branch / discard_task_branch)。
790
+ """
791
+ from core import gitmod
792
+ task = store.get_task(task_id)
793
+ if not task:
794
+ return self._json(404, {"error": "任务不存在"})
795
+ if op == "git-merge":
796
+ ok, err, info = gitmod.merge_task_branch(task.get("workdir"), task)
797
+ if not ok:
798
+ return self._json(400, {"error": err})
799
+ store.set_task_git_state(task_id, "merged")
800
+ return self._json(200, {"ok": True, **(info or {})})
801
+ body = self._body()
802
+ if not body.get("confirm"):
803
+ return self._json(400, {"error": "丢弃任务分支不可恢复,需要 confirm=true 二次确认"})
804
+ ok, err = gitmod.discard_task_branch(task.get("workdir"), task)
805
+ if not ok:
806
+ return self._json(400, {"error": err})
807
+ store.set_task_git_state(task_id, "discarded")
808
+ return self._json(200, {"ok": True})
809
+
810
+ def _api_book_meta_generate(self, task_id):
811
+ """作品信息一键生成(POST /api/tasks/<id>/book-meta,body: {platform})。
812
+
813
+ 后台线程跑(编排者→作者CLI→模板的降级链可能数分钟),请求立即返回;
814
+ 前端靠任务 book_meta 状态(SSE 全量状态里带)轮进度。已有 running 时
815
+ 幂等拒绝,不重复起线程。"""
816
+ from core import bookmeta
817
+ task = store.get_task(task_id)
818
+ if not task:
819
+ return self._json(404, {"error": "任务不存在"})
820
+ body = self._body() or {}
821
+ platform = (body.get("platform") or "").strip()
822
+ if platform not in bookmeta.PLATFORMS:
823
+ return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
824
+ if not bookmeta.needs_book_meta(task):
825
+ return self._json(400, {"error": "只有连载首批任务需要作品信息;续写批次沿用第一批的开书资料"})
826
+ if task.get("status") in ("queued", "running"):
827
+ return self._json(400, {"error": "任务正在运行,请等本轮结束后再生成作品信息"})
828
+ cur = ((task.get("book_meta") or {}).get(platform) or {})
829
+ if cur.get("status") == "running":
830
+ return self._json(200, {"ok": True, "already": True})
831
+ if not store.set_book_meta(task_id, platform,
832
+ {"status": "running",
833
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}):
834
+ return self._json(404, {"error": "任务不存在"})
835
+ author = bookmeta._resolve_author(task)
836
+ threading.Thread(target=bookmeta.generate_async, daemon=True,
837
+ name="book-meta-%s" % platform,
838
+ args=(task_id, platform, author)).start()
839
+ return self._json(200, {"ok": True, "started": True})
840
+
841
+ def _api_task_side(self, task_id):
842
+ """任务检查器(右缘停靠列)的轻量聚合端点。聚合逻辑在 store.task_side
843
+ (可单测、单实例状态);这里只做 404 转换。"""
844
+ d = store.task_side(task_id)
845
+ return self._json(404, {"error": "任务不存在"}) if d is None else self._json(200, d)
846
+
847
+ # ------------------------------------------------------ GIT 工作台(详情页版本页签)
848
+ # 与 /api/git/info 不同:workdir 由任务 id 服务端推导,不接受客户端传任意路径,
849
+ # 所以不必限本机——局域网端(手机)打开详情页同样能看变更、做日常 git 操作。
850
+
851
+ def _api_git_wb(self, task_id):
852
+ from core import gitmod
853
+ task = store.get_task(task_id)
854
+ if not task:
855
+ return self._json(404, {"error": "任务不存在"})
856
+ d = gitmod.workbench_status(str(task.get("workdir") or ""))
857
+ d["isolation"] = {"state": task.get("git_state") or "",
858
+ "rev": task.get("git_rev") or "",
859
+ "branch": gitmod.branch_name(task_id)}
860
+ d["task"] = {"id": task_id, "status": task.get("status") or "",
861
+ "title": task.get("title") or ""}
862
+ return self._json(200, d)
863
+
864
+ def _api_git_wb_diff(self, task_id):
865
+ from core import gitmod
866
+ task = store.get_task(task_id)
867
+ if not task:
868
+ return self._json(404, {"error": "任务不存在"})
869
+ qs = parse_qs(urlparse(self.path).query)
870
+ d = gitmod.file_diff(str(task.get("workdir") or ""), (qs.get("path") or [""])[0])
871
+ if d.get("error"):
872
+ return self._json(400, {"error": d["error"]})
873
+ return self._json(200, d)
874
+
875
+ def _api_git_wb_op(self, task_id):
876
+ from core import gitmod
877
+ task = store.get_task(task_id)
878
+ if not task:
879
+ return self._json(404, {"error": "任务不存在"})
880
+ if task.get("status") in ("queued", "running"):
881
+ return self._json(409, {"error": "任务正在运行:智能体正在工作目录里产出,"
882
+ "Git 写操作等任务结束再进行(只读查看不受限)"})
883
+ body = self._body()
884
+ action = str(body.get("action") or "")
885
+ ok, err, data = gitmod.workbench_op(str(task.get("workdir") or ""), action, body)
886
+ if not ok:
887
+ return self._json(400, {"error": err})
888
+ return self._json(200, {"ok": True, "action": action, **(data or {})})
889
+
890
+ def _api_browse(self):
891
+ """本机目录浏览(工作目录「选择…」弹框用)。仅限本机请求:目录枚举是信息
892
+ 泄露面,手机/局域网端不提供、继续手填。path 缺省=用户主目录;__drives__=盘符
893
+ 列表(Windows 从「此电脑」开始选)。只列目录不列文件,纯只读。"""
894
+ # 本机判定按来源 IP,不能按 client_id:页面请求带 X-CodeBee-Client,
895
+ # 本机页面的 client_id 也不是 "local",会被误拒
896
+ ip, fw = self._forwarded_ip()
897
+ if ip not in ("127.0.0.1", "::1") or fw:
898
+ return self._json(403, {"error": "目录选择仅限本机使用,请手动输入路径"})
899
+ qs = parse_qs(urlparse(self.path).query)
900
+ raw = (qs.get("path") or [""])[0].strip()
901
+ if raw == "__drives__":
902
+ drives = [c + ":\\" for c in "CDEFGHIJKLMNOPQRSTUVWXYZ"
903
+ if Path(c + ":\\").exists()]
904
+ return self._json(200, {"path": "此电脑", "parent": "", "dirs": drives})
905
+ try:
906
+ p = Path(raw).expanduser() if raw else Path.home()
907
+ except Exception:
908
+ return self._json(400, {"error": "非法路径"})
909
+ if not p.exists():
910
+ return self._json(404, {"error": "目录不存在: %s" % p})
911
+ if not p.is_dir():
912
+ return self._json(400, {"error": "不是目录: %s" % p})
913
+ try:
914
+ dirs = sorted((d.name for d in p.iterdir() if d.is_dir()), key=str.lower)
915
+ except PermissionError:
916
+ dirs = [] # 无权限的目录按空目录处理,可继续选它本身
917
+ parent = "" if p.parent == p else str(p.parent)
918
+ return self._json(200, {"path": str(p), "parent": parent, "dirs": dirs})
919
+
920
+ def _api_dir_scan(self):
921
+ """侧栏文件夹「查看文件」:递归列出该工作目录下的全部文件(左侧文件页树形展示)。
922
+
923
+ 只读;仅限本机请求(同 /api/browse 的信息泄露口径)。name 为相对路径
924
+ ("/" 分隔,如 "docs/readme.md"),前端按目录层级渲染成树;跳过
925
+ .git / node_modules 等噪音目录;不设文件数上限,全量列出。
926
+ """
927
+ ip, fw = self._forwarded_ip()
928
+ if ip not in ("127.0.0.1", "::1") or fw:
929
+ return self._json(403, {"error": "文件浏览仅限本机使用"})
930
+ qs = parse_qs(urlparse(self.path).query)
931
+ raw = (qs.get("path") or [""])[0].strip()
932
+ if not raw:
933
+ return self._json(400, {"error": "缺少 path"})
934
+ try:
935
+ p = Path(raw).expanduser()
936
+ except Exception:
937
+ return self._json(400, {"error": "非法路径"})
938
+ if not p.is_dir():
939
+ return self._json(404, {"error": "目录不存在: %s" % p})
940
+ files = []
941
+ try:
942
+ for cur, dirs, names in os.walk(str(p)):
943
+ # os.walk 默认不跟随目录符号链接:环形目录不会死循环
944
+ dirs[:] = [d for d in dirs if d not in _SKIP_DIRS_SHARE]
945
+ for n in names:
946
+ try:
947
+ st = (Path(cur) / n).stat()
948
+ except OSError:
949
+ continue # 断链/权限:跳过单个文件
950
+ files.append({"name": (Path(cur) / n).relative_to(p).as_posix(),
951
+ "size": st.st_size, "mtime": int(st.st_mtime)})
952
+ except (PermissionError, OSError):
953
+ return self._json(403, {"error": "无权限读取该目录"})
954
+ files.sort(key=lambda f: (-f["mtime"], f["name"].lower()))
955
+ return self._json(200, {"path": str(p), "files": files})
956
+
957
+ def _api_dir_file(self):
958
+ """侧栏「查看文件」里点文件 chip:返回该文件内容(浏览器新页打开/下载)。
959
+ 只限本机(同 browse 口径);name 必须相对,解析后仍落在 dir 内,防穿越。"""
960
+ ip, fw = self._forwarded_ip()
961
+ if ip not in ("127.0.0.1", "::1") or fw:
962
+ return self._json(403, {"error": "文件查看仅限本机使用"})
963
+ qs = parse_qs(urlparse(self.path).query)
964
+ raw_dir = (qs.get("dir") or [""])[0].strip()
965
+ raw_name = (qs.get("name") or [""])[0].strip()
966
+ if not raw_dir or not raw_name:
967
+ return self._json(400, {"error": "缺少 dir/name"})
968
+ try:
969
+ base = Path(raw_dir).expanduser().resolve()
970
+ except Exception:
971
+ return self._json(400, {"error": "非法路径"})
972
+ if not base.is_dir():
973
+ return self._json(404, {"error": "目录不存在: %s" % base})
974
+ name = unquote(raw_name).replace("\\", "/")
975
+ parts = name.split("/")
976
+ if not name or not all(p and p != ".." for p in parts):
977
+ return self._json(400, {"error": "非法文件名"})
978
+ fp = (base / name).resolve()
979
+ # 嵌套路径(docs/x.md)也要放行:Windows 下 str(fp) 是反斜杠,
980
+ # startswith(base+"/") 会把合法子目录文件误判越界,改用 relative_to 判定
981
+ try:
982
+ fp.relative_to(base)
983
+ except ValueError:
984
+ return self._json(403, {"error": "越界"})
985
+ if not fp.is_file():
986
+ return self._json(404, {"error": "文件不存在"})
987
+ try:
988
+ data = fp.read_bytes()
989
+ except OSError:
990
+ return self._json(403, {"error": "无法读取"})
991
+ ext = Path(name.lower()).suffix
992
+ ctype = MIME.get(ext, "application/octet-stream")
993
+ if ext in (".md", ".txt", ".log", ".csv", ".yml", ".yaml", ".json", ".ini", ".toml",
994
+ ".py", ".js", ".ts", ".html", ".css", ".svg"):
995
+ ctype = "text/plain; charset=utf-8"
996
+ if ctype.startswith("text/"):
997
+ data = _utf8_bytes(data)
998
+ # mtime 供前端「编辑保存」做冲突检测:文件被任务/外部改动后拒绝覆盖(409)
999
+ return self._send(200, data, ctype,
1000
+ {"X-Tutti-Mtime": str(int(fp.stat().st_mtime))})
1001
+
1002
+ def _api_dir_save(self):
1003
+ """「查看文件」弹窗的编辑保存:{dir, name, content, mtime} → 覆写该文件。
1004
+
1005
+ 仅限本机(同 /api/dir/file 口径,写操作还过全局控制权门槛);name 必须
1006
+ 相对且解析后落在 dir 内(与读取端同一套防穿越判定);内容按 UTF-8 落盘
1007
+ (读取端本就把文本按 UTF-8 下发,GBK 老文件保存一次即归一化为 UTF-8)。
1008
+ mtime 是打开预览时响应头 X-Tutti-Mtime 带回的旧值:不等 → 文件已被
1009
+ 任务/外部改动过,回 409 拒绝覆盖,让用户重开预览确认后再改。
1010
+ """
1011
+ ip, fw = self._forwarded_ip()
1012
+ if ip not in ("127.0.0.1", "::1") or fw:
1013
+ return self._json(403, {"error": "文件保存仅限本机使用"})
1014
+ body = self._body()
1015
+ raw_dir = (body.get("dir") or "").strip()
1016
+ raw_name = (body.get("name") or "").strip()
1017
+ content = body.get("content")
1018
+ if not raw_dir or not raw_name:
1019
+ return self._json(400, {"error": "缺少 dir/name"})
1020
+ if not isinstance(content, str):
1021
+ return self._json(400, {"error": "content 必须是文本"})
1022
+ if len(content.encode("utf-8")) > 4 * 1024 * 1024:
1023
+ return self._json(400, {"error": "内容超过 4MB,请在编辑器里改大文件"})
1024
+ try:
1025
+ base = Path(raw_dir).expanduser().resolve()
1026
+ except Exception:
1027
+ return self._json(400, {"error": "非法路径"})
1028
+ if not base.is_dir():
1029
+ return self._json(404, {"error": "目录不存在: %s" % base})
1030
+ name = raw_name.replace("\\", "/")
1031
+ parts = name.split("/")
1032
+ if not name or not all(p and p != ".." for p in parts):
1033
+ return self._json(400, {"error": "非法文件名"})
1034
+ fp = (base / name).resolve()
1035
+ try:
1036
+ fp.relative_to(base)
1037
+ except ValueError:
1038
+ return self._json(403, {"error": "越界"})
1039
+ if fp.exists() and not fp.is_file():
1040
+ return self._json(400, {"error": "同名路径不是文件"})
1041
+ old_mtime = body.get("mtime")
1042
+ if fp.exists() and old_mtime is not None:
1043
+ try:
1044
+ if abs(int(fp.stat().st_mtime) - int(old_mtime)) > 1:
1045
+ return self._json(409, {"error": "文件已被外部修改,请关闭弹窗重新打开确认后再编辑"})
1046
+ except OSError:
1047
+ pass
1048
+ try:
1049
+ fp.parent.mkdir(parents=True, exist_ok=True)
1050
+ fp.write_bytes(content.encode("utf-8"))
1051
+ except OSError:
1052
+ return self._json(403, {"error": "无法写入(只读/被占用?)"})
1053
+ return self._json(200, {"ok": True, "size": fp.stat().st_size,
1054
+ "mtime": int(fp.stat().st_mtime)})
1055
+
1056
+ def _api_git_info(self):
1057
+ """探测工作目录是否为 git 仓库,返回分支/标签/最近提交供「代码版本」下拉。
1058
+ 只读(rev-parse / status / log),不改仓库。仅限本机请求(同目录浏览的口径)。"""
1059
+ ip, fw = self._forwarded_ip()
1060
+ if ip not in ("127.0.0.1", "::1") or fw:
1061
+ return self._json(403, {"error": "代码版本探测仅限本机使用"})
1062
+ from core import gitmod
1063
+ qs = parse_qs(urlparse(self.path).query)
1064
+ raw = (qs.get("workdir") or [""])[0].strip()
1065
+ if not raw:
1066
+ return self._json(200, {"repo": False})
1067
+ try:
1068
+ p = Path(raw).expanduser()
1069
+ except Exception:
1070
+ return self._json(400, {"error": "非法路径"})
1071
+ if not p.is_dir():
1072
+ return self._json(200, {"repo": False})
1073
+ try:
1074
+ return self._json(200, gitmod.repo_info(str(p)))
1075
+ except Exception as e:
1076
+ return self._json(200, {"repo": False, "error": str(e)[:200]})
1077
+
1078
+ def _api_add_attachment(self):
1079
+ """上传一个任务附件(截图/文件)到待提交区:{name, data: base64} → 返回附件 id。
1080
+ 创建任务时把 id 列表放进 payload.attachments,落盘到工作目录 _attachments/。"""
1081
+ from core import attachments
1082
+ # 防超大声明:普通附件 8MB(base64 约 11MB),Office 文档 24MB(约 32MB),
1083
+ # 门限取上限加余量;按扩展名的精确校验在 save_pending 里
1084
+ try:
1085
+ if int(self.headers.get("Content-Length") or 0) > 34 * 1024 * 1024:
1086
+ return self._json(413, {"error": "附件过大(普通 8MB / Word·Excel·PPT 24MB)"})
1087
+ except ValueError:
1088
+ return self._json(400, {"error": "非法 Content-Length"})
1089
+ body = self._body()
1090
+ try:
1091
+ meta = attachments.save_pending(body.get("name"), body.get("data"))
1092
+ except ValueError as e:
1093
+ return self._json(400, {"error": str(e)})
1094
+ return self._json(200, {"ok": True, "attachment": meta})
1095
+
1096
+ def _api_add_message(self, run_id):
1097
+ """运行中指挥:往该 run 的信箱追加一条用户指令(文字 + 附件)。
1098
+ 附件走待提交区(前端先 POST /api/attachments 拿 id)→ commit 进 workdir,
1099
+ 把落盘相对路径随消息入箱,供下一个步骤 drain 时注入。写接口已在 do_POST
1100
+ 统一做过设备控制(_deny_control),此处不重复。"""
1101
+ run = store.get_run(run_id)
1102
+ if not run:
1103
+ return self._json(404, {"error": "not found"})
1104
+ body = self._body() or {}
1105
+ text = body.get("text") or ""
1106
+ att_ids = body.get("attachments") or []
1107
+ workdir = store.run_workdir(run_id)
1108
+ saved = []
1109
+ if att_ids and workdir:
1110
+ from core import attachments
1111
+ try:
1112
+ saved = attachments.commit_to_workdir(workdir, att_ids)
1113
+ except Exception:
1114
+ saved = []
1115
+ saved_paths = [a.get("path") for a in saved if a.get("path")]
1116
+ if not text.strip() and not saved_paths:
1117
+ return self._json(400, {"error": "消息为空"})
1118
+ msg = store.add_message(run_id, text, sender=self._client_name(),
1119
+ attachments=saved_paths)
1120
+ if not msg:
1121
+ return self._json(400, {"error": "运行不存在或消息非法"})
1122
+ return self._json(200, {"ok": True, "message": msg})
1123
+
1124
+ def _api_run_timeline(self, run_id):
1125
+ """直连对话视图的时间线:用户消息与各步 CLI 输出按序合并成一个气泡流。
1126
+
1127
+ 步骤正文取自该步日志文件(CLI 原始输出),按 created/序号排序:
1128
+ [user 消息] → [assistant 输出] → [user 追问] → …。供详情页「对话」分区直读,
1129
+ 前端不必逐条拉日志。非 direct 任务也能取(返回步骤流),只是视图不启用。"""
1130
+ run = store.get_run(run_id)
1131
+ if not run:
1132
+ return self._json(404, {"error": "not found"})
1133
+ items = []
1134
+ for m in (run.get("messages") or []):
1135
+ items.append({
1136
+ "kind": "user",
1137
+ "at": m.get("created_at") or "",
1138
+ "who": m.get("sender") or "",
1139
+ "text": m.get("text") or "",
1140
+ "attachments": m.get("attachments") or [],
1141
+ "consumed": bool(m.get("consumed")),
1142
+ "id": m.get("id"),
1143
+ })
1144
+ for s in (run.get("steps") or []):
1145
+ # 正文只认 output(runner 抽好的最终回答)→ summary。绝不回退读原始
1146
+ # 日志:日志是全量事件流(下发提示词回显 + CLI 报错),塞进气泡就成了
1147
+ # 「看日志」(2026-09-17 实测症状);运行中的步骤两者都还没有,留空给
1148
+ # 前端显示「正在执行」占位。
1149
+ body = s.get("output") or ""
1150
+ if not body:
1151
+ body = s.get("summary") or ""
1152
+ items.append({
1153
+ "kind": "agent",
1154
+ "at": s.get("ended_at") or s.get("started_at") or "",
1155
+ "who": s.get("agent_label") or s.get("agent") or "",
1156
+ "role": s.get("role") or "",
1157
+ "n": s.get("n"),
1158
+ "status": s.get("status") or "",
1159
+ "text": body,
1160
+ "note": s.get("note") or "",
1161
+ })
1162
+ return self._json(200, {
1163
+ "run_id": run_id, "status": run.get("status") or "",
1164
+ "engine": (store.get_task(run.get("task_id") or "") or {}).get("engine") or "",
1165
+ "items": items,
1166
+ })
1167
+
1168
+ def _api_direct_chat(self, run_id):
1169
+ """直连对话追话:往已结束的 direct run 追加一条消息并自动续跑。
1170
+
1171
+ 机制:消息入旧 run 信箱 retry_task 起新 run(未消费消息自动继承)
1172
+ 入队编排 → _run_direct 看到信箱积压走续轮档(DIRECT_FOLLOWUP)。
1173
+ 仅 direct 引擎任务可用;运行中的 run 走既有 /messages(轮间注入)。
1174
+ 写接口已在 do_POST 统一做过设备控制。"""
1175
+ run = store.get_run(run_id)
1176
+ if not run:
1177
+ return self._json(404, {"error": "not found"})
1178
+ task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
1179
+ if not task or task.get("engine") != "direct":
1180
+ return self._json(400, {"error": "该任务不是直连任务,请用「下达指令」"})
1181
+ if (run.get("status") or "") in ("queued", "running"):
1182
+ return self._json(400, {"error": "运行中:消息会随下一步自动送达,无需追话"})
1183
+ body = self._body() or {}
1184
+ text = body.get("text") or ""
1185
+ att_ids = body.get("attachments") or []
1186
+ workdir = store.run_workdir(run_id) or task.get("workdir") or ""
1187
+ saved = []
1188
+ if att_ids and workdir:
1189
+ from core import attachments
1190
+ try:
1191
+ saved = attachments.commit_to_workdir(workdir, att_ids)
1192
+ except Exception:
1193
+ saved = []
1194
+ saved_paths = [a.get("path") for a in saved if a.get("path")]
1195
+ if not text.strip() and not saved_paths:
1196
+ return self._json(400, {"error": "消息为空"})
1197
+ msg = store.add_message(run_id, text, sender=self._client_name(),
1198
+ attachments=saved_paths)
1199
+ if not msg:
1200
+ return self._json(400, {"error": "消息非法"})
1201
+ ok, err, new_run = store.retry_task(task["id"])
1202
+ if not ok:
1203
+ return self._json(400, {"error": err or "无法续跑"})
1204
+ jobs.enqueue({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
1205
+ return self._json(200, {"ok": True, "run_id": new_run["id"]})
1206
+
1207
+ def _api_retract_message(self, run_id):
1208
+ """撤回一条尚未下达的指令(drain 前从信箱删除)。已送达的撤不回——
1209
+ 那是审计事实。写接口已在 do_POST 统一做过设备控制。"""
1210
+ body = self._body() or {}
1211
+ ok, err = store.retract_message(run_id, body.get("id"))
1212
+ if not ok:
1213
+ return self._json(400, {"error": err})
1214
+ return self._json(200, {"ok": True})
1215
+
1216
+ def _api_control(self):
1217
+ body = self._body()
1218
+ action = body.get("action") or ""
1219
+ if action == "acquire":
1220
+ ok, view = remote.acquire(self._client_id(), self._client_name(),
1221
+ force=bool(body.get("force")))
1222
+ if not ok:
1223
+ return self._json(423, {"error": "「%s」正在控制" % view.get("holder", "其他设备"),
1224
+ "control": view})
1225
+ store.bump_state() # 控制权交接:让其他端立即看到
1226
+ return self._json(200, {"ok": True, "control": view})
1227
+ if action == "release":
1228
+ view = remote.release(self._client_id())
1229
+ store.bump_state()
1230
+ return self._json(200, {"ok": True, "control": view})
1231
+ return self._json(400, {"error": "action 必须是 acquire 或 release"})
1232
+
1233
+ def _api_events(self):
1234
+ """SSE 事件驱动:等 store 状态版本变化才构建/推送,空闲连接几乎零开销
1235
+ (此前每连接每 0.8s 盲构建全量 payload,多端并发会把 detect 的慢 IO
1236
+ 放大成服务假死)。每 ~2s 醒一次顺带检查控制权变化。"""
1237
+ self.send_response(200)
1238
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
1239
+ self.send_header("Cache-Control", "no-store")
1240
+ self.send_header("X-Accel-Buffering", "no") # 防 nginx/隧道缓冲 SSE
1241
+ self.end_headers()
1242
+ cid = self._client_id()
1243
+ ver = store.state_version()
1244
+ sent_ctrl = None
1245
+ n = 0
1246
+ try:
1247
+ while True:
1248
+ ver = store.wait_state_change(ver, 2.0)
1249
+ ctrl = remote.control_view(cid)
1250
+ if ver == store.state_version() and ctrl == sent_ctrl:
1251
+ continue # 超时醒来且无变化
1252
+ payload = json.dumps(_state_payload(cid, ver), ensure_ascii=False)
1253
+ self.wfile.write(("data: " + payload + "\n\n").encode("utf-8"))
1254
+ self.wfile.flush()
1255
+ sent_ctrl = ctrl
1256
+ n += 1
1257
+ if n % 20 == 0: # ~40s 一次注释行:探活兼防中间层断开空闲连接
1258
+ self.wfile.write(b": ping\n\n")
1259
+ self.wfile.flush()
1260
+ except Exception:
1261
+ pass # 客户端断开是常态,线程随进程退出
1262
+
1263
+ def _api_create_task(self):
1264
+ body = self._body()
1265
+ try:
1266
+ task = store.create_task(body)
1267
+ except ValueError as e:
1268
+ return self._json(400, {"error": str(e)})
1269
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
1270
+ store.update_task_status(task["id"], "queued")
1271
+ jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1272
+ return self._json(200, {"task_id": task["id"], "run_id": run["id"]})
1273
+
1274
+ def _api_set_preference(self):
1275
+ body = self._body()
1276
+ agent_id = body.get("agent_id")
1277
+ if not agent_id:
1278
+ return self._json(400, {"error": "agent_id 必填"})
1279
+ # 模型链已并入「CLI 绑定」(modelhub bindings),这里只管参与编排开关
1280
+ pref = registry.set_preference(agent_id, enabled=body.get("enabled"))
1281
+ return self._json(200, {"ok": True, "preference": pref})
1282
+
1283
+ # ------------------------------------------------------------ 静态
1284
+ def _static(self, name):
1285
+ p = (paths.UI_DIR / name).resolve()
1286
+ if not p.is_file() or paths.UI_DIR.resolve() not in p.parents:
1287
+ return self._json(404, {"error": "not found"})
1288
+ suffix = p.suffix.lower()
1289
+ self._send(200, p.read_bytes(), MIME.get(suffix, "application/octet-stream"))
1290
+
1291
+
1292
+ def _state_payload(client_id="", ver=None):
1293
+ agents = registry.effective_agents(catalog.load(), manager.detect_all())
1294
+ if ver is None:
1295
+ ver = store.state_version()
1296
+ return {
1297
+ "v": ver,
1298
+ "agents": agents,
1299
+ "tasks": store.list_tasks(30, archived=False),
1300
+ "archived_tasks": store.list_tasks(30, archived=True),
1301
+ # 每个任务的最近一次运行(不受 runs 窗口限制):侧栏靠它展示各任务真实近况
1302
+ "task_latest": store.latest_run_by_task(),
1303
+ # 每个任务的运行次数/步骤总数(全量):侧栏「查看全部」的计数来源
1304
+ "task_stats": store.task_run_stats(),
1305
+ "runs": store.list_runs(40),
1306
+ "control": remote.control_view(client_id),
1307
+ # 供应商健康/告警(顶栏横幅数据源;有告警时 bump_state 会推给所有端)
1308
+ "health": health.snapshot(),
1309
+ }
1310
+
1311
+
1312
+ class ThreadedServer(ThreadingHTTPServer):
1313
+ """Windows SO_REUSEADDR 允许两个进程同时 LISTEN 同一端口(请求随机
1314
+ 分发到其中一个——服务"时好时坏"的根源)。独占锁让第二个实例在这里
1315
+ 干净失败,而不是静默双绑。正常重启不受影响(监听 socket 关闭不进
1316
+ TIME_WAIT;已建立连接的 TIME_WAIT 不阻止重新 LISTEN 同端口)。"""
1317
+ allow_reuse_address = False
1318
+
1319
+ def server_bind(self):
1320
+ if os.name == "nt":
1321
+ self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
1322
+ super().server_bind()
1323
+
1324
+
1325
+ def main():
1326
+ parser = argparse.ArgumentParser(description="CodeBee 多智能体编排台")
1327
+ parser.add_argument("--host", default="0.0.0.0",
1328
+ help="监听地址;0.0.0.0 允许手机/局域网访问(默认),127.0.0.1 仅本机")
1329
+ parser.add_argument("--port", type=int, default=8765)
1330
+ parser.add_argument("--no-browser", action="store_true")
1331
+ parser.add_argument("--trusted-proxy", action="store_true",
1332
+ help="服务跑在 Cloudflare Tunnel/frp 等反代后面时开启:"
1333
+ "带转发头的回源请求必须带令牌,防止本机回源被当成 127.0.0.1 豁免")
1334
+ parser.add_argument("--public-url", default="",
1335
+ help="固定公网地址(如 https://codebee.example.com),扫码弹框优先展示")
1336
+ parser.add_argument("--no-public-tunnel", action="store_true",
1337
+ help="不自动开 Cloudflare 快速隧道(默认:检测到 cloudflared 就自动开,"
1338
+ "获得随机 *.trycloudflare.com 公网地址,重启会变)")
1339
+ parser.add_argument("--wait-port", action="store_true",
1340
+ help="自更新重启用:先等旧实例释放端口再启动(Windows 双 LISTEN 防护)")
1341
+ args = parser.parse_args()
1342
+
1343
+ # 启动每步都落一行进度(flush 强制落屏):冷启动在慢盘/杀软扫描下可能几十秒,
1344
+ # 不打印会让用户以为卡死(真实案例:npm 装完首启只看到横幅像挂了)。
1345
+ # 看门狗:任何阶段卡超过 20 秒,自动把所有线程堆栈打到控制台(每 20s 重复)——
1346
+ # 用户截图即可精确定位卡点,不用猜。
1347
+ try:
1348
+ import faulthandler
1349
+ faulthandler.dump_traceback_later(20, repeat=True, file=sys.stderr)
1350
+ except Exception:
1351
+ pass
1352
+ _t0 = time.time()
1353
+
1354
+ def _step(msg):
1355
+ print("[CodeBee] %s (%.1fs)" % (msg, time.time() - _t0), flush=True)
1356
+
1357
+ _step("正在准备数据目录…")
1358
+ paths.ensure_dirs()
1359
+ from core import attachments
1360
+ n_pc = attachments.cleanup_stale() # 待提交附件残留清理(崩溃/弃单不堆积)
1361
+ if n_pc:
1362
+ print("[CodeBee] 附件待提交区:清理过期残留 %d 个" % n_pc)
1363
+ _step("正在加载任务目录…")
1364
+ catalog.load()
1365
+ store.load_all()
1366
+ _step("正在启动健康探针…")
1367
+ health.init() # 供应商健康/告警:恢复落盘状态 + 启动探针线程
1368
+ from core import modelhub
1369
+ _step("正在迁移模型绑定…")
1370
+ modelhub.migrate_orch_models() # 旧「编排模型」偏好并入 CLI 绑定(幂等,带备份)
1371
+ modelhub.migrate_chains() # 旧单供应商模型链升级为跨厂商 chain(幂等,带备份)
1372
+ try:
1373
+ from core import settings_schema
1374
+ settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)
1375
+ except Exception:
1376
+ pass
1377
+ from core import skills
1378
+ _step("正在整理经验库…")
1379
+ n_lc = skills.migrate_lesson_categories() # 分类字段上线前的教训按关键词回填(幂等,带备份)
1380
+ if n_lc:
1381
+ print("[CodeBee] 经验库:%d 条历史教训已自动归类" % n_lc)
1382
+ from core import usage
1383
+ _step("正在回填用量台账…")
1384
+ n_bf = usage.backfill_from_runs() # 历史运行 token 回填台账(幂等,仅补缺失步骤)
1385
+ if n_bf:
1386
+ print("[CodeBee] 用量台账:已从历史运行回填 %d 条记录" % n_bf)
1387
+ # dsh-migration §1E:崩溃遗留的 running run 标记为 failed(在 jobs.resume_interrupted
1388
+ # 之前执行,否则续跑逻辑会把僵尸 run 当成正常中断接手)
1389
+ n_rc = store.recover_orphaned_runs()
1390
+ if n_rc:
1391
+ print("[CodeBee] 崩溃恢复:%d 个遗留运行标记为 failed(interrupted at startup)" % n_rc)
1392
+ try:
1393
+ from core import settings_schema
1394
+ settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)
1395
+ except Exception:
1396
+ pass
1397
+ _step("正在启动任务队列…")
1398
+ jobs.start_worker()
1399
+ n_resume = jobs.resume_interrupted() # 启动恢复:服务被杀中断的连载任务自动续跑
1400
+ if n_resume:
1401
+ print("[CodeBee] 已自动恢复 %d 个中断的连载任务(断点续跑)" % n_resume)
1402
+ _step("正在启动自动化调度…")
1403
+ n_auto = automation.start() # 自动化:加载定时任务并拉起调度线程(错过的一次性任务不补跑)
1404
+ if n_auto:
1405
+ print("[CodeBee] 自动化:%d 个定时任务已加载" % n_auto)
1406
+ tok = remote.token()
1407
+ import atexit
1408
+ import os as _os
1409
+ remote.set_trusted_proxy(args.trusted_proxy or _os.environ.get("TUTTI_TRUST_PROXY") == "1",
1410
+ args.public_url)
1411
+
1412
+ global PORT
1413
+ PORT = args.port
1414
+ if args.wait_port:
1415
+ from core import selfupdate
1416
+ selfupdate.wait_port_before_bind(args.port)
1417
+ try:
1418
+ httpd = ThreadedServer((args.host, args.port), Handler)
1419
+ except OSError as e:
1420
+ # Windows SO_REUSEADDR 允许两个进程同时 LISTEN 同一端口(请求随机
1421
+ # 分发,表现为"时好时坏");加独占锁后双起在这里干净失败并指路。
1422
+ import os
1423
+ hint = ""
1424
+ if os.name == "nt":
1425
+ hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
1426
+ "tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
1427
+ % args.port)
1428
+ raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
1429
+ % (args.port, e, hint))
1430
+
1431
+ def _announce_public(url):
1432
+ print("[CodeBee] 公网 %s/?token=%s" % (url, tok))
1433
+ print("[CodeBee] ← 任何网络可访问;临时地址重启会变,手机重新扫码即可。"
1434
+ "\n[CodeBee] 要固定域名:cloudflared tunnel login 后参考 README 公网章节。")
1435
+ store.bump_state() # 扫码弹框下次打开即可拿到公网地址
1436
+
1437
+ print("[CodeBee] 本机 http://127.0.0.1:%d" % args.port, flush=True)
1438
+ print("[CodeBee] 数据目录 %s" % paths.DATA_DIR, flush=True)
1439
+ print("[CodeBee] 已就绪,浏览器即将自动打开;不要关闭本窗口。", flush=True)
1440
+ try:
1441
+ import faulthandler
1442
+ faulthandler.cancel_dump_traceback_later() # 启动完成,看门狗退役(否则运行期每 20s 误报堆栈)
1443
+ except Exception:
1444
+ pass
1445
+ if remote.PUBLIC_URL:
1446
+ print("[CodeBee] 公网 %s/?token=%s ← 任何网络可访问(反代回源已强制校验令牌)"
1447
+ % (remote.PUBLIC_URL, tok))
1448
+ elif not args.no_public_tunnel and args.host != "127.0.0.1":
1449
+ if remote.has_local_creds():
1450
+ print("[CodeBee] (检测到已有 Cloudflare 隧道凭据:临时隧道在此类机器上不可用,"
1451
+ "请用 --public-url 配固定域名,或 start-public.bat)")
1452
+ elif remote.start_quick_tunnel(args.port, _announce_public):
1453
+ print("[CodeBee] 正在建立 Cloudflare 快速隧道(公网地址几秒后打印;"
1454
+ "若打不开说明当前网络不支持,可改用固定域名或 Tailscale)…")
1455
+ else:
1456
+ print("[CodeBee] (未检测到 cloudflared,跳过公网隧道;"
1457
+ "winget install Cloudflare.cloudflared 后重启即可获得公网地址)")
1458
+ atexit.register(remote.stop_quick_tunnel)
1459
+ if args.host != "127.0.0.1":
1460
+ lan = remote.lan_ip()
1461
+ if lan:
1462
+ print("[CodeBee] 局域网 http://%s:%d/?token=%s ← 手机同一 WiFi 直接打开" % (lan, args.port, tok))
1463
+ ts = remote.tailscale_ip()
1464
+ if ts:
1465
+ print("[CodeBee] Tailscale http://%s:%d/?token=%s ← 外网随时随地访问" % (ts, args.port, tok))
1466
+ elif not remote.PUBLIC_URL:
1467
+ print("[CodeBee] (未检测到 Tailscale;安装后重启本服务即可获得外网地址)")
1468
+ print("[CodeBee] 远程访问受令牌保护;手机打开一次带 token 的地址后会记住。")
1469
+ if not args.no_browser:
1470
+ threading.Timer(1.0, lambda: webbrowser.open("http://127.0.0.1:%d" % args.port)).start()
1471
+ try:
1472
+ httpd.serve_forever()
1473
+ except KeyboardInterrupt:
1474
+ print("\n[CodeBee] 已退出")
1475
+
1476
+
1477
+ if __name__ == "__main__":
1478
+ main()