codebee 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +9 -0
- package/app/core/attachments.py +40 -0
- package/app/core/bookmeta.py +157 -52
- package/app/core/bookmeta_catalog.py +67 -90
- package/app/core/builtin_agent.py +190 -8
- package/app/core/flows.py +328 -328
- package/app/core/gitmod.py +82 -8
- package/app/core/history.py +8 -2
- package/app/core/jobs.py +9 -2
- package/app/core/manager.py +151 -1
- package/app/core/modelhub.py +86 -4
- package/app/core/pipeline.py +2326 -2267
- package/app/core/router.py +8 -3
- package/app/core/runner.py +185 -21
- package/app/core/selfupdate.py +54 -11
- package/app/core/store.py +4 -2
- package/app/main.py +101 -7
- package/app/ui/app.js +279 -49
- package/app/ui/i18n.js +19 -5
- package/app/ui/index.html +6 -2
- package/app/ui/style.css +3379 -2764
- package/package.json +2 -1
package/app/core/gitmod.py
CHANGED
|
@@ -66,6 +66,11 @@ def repo_info(workdir):
|
|
|
66
66
|
if h.strip():
|
|
67
67
|
recent.append({"hash": h.strip(), "subject": subj.strip()[:120]})
|
|
68
68
|
br = (branch["stdout"] or "").strip() if branch["ok"] else ""
|
|
69
|
+
if br == "HEAD":
|
|
70
|
+
# 游离 HEAD 时 abbrev-ref 返回字面量 "HEAD"——统一成哨兵值。
|
|
71
|
+
# 否则「切回 from_branch」会把 "HEAD" 当分支名原样 checkout,
|
|
72
|
+
# 而 git checkout HEAD 是原地空转(退出码 0 但不换位),分支永远切不走。
|
|
73
|
+
br = "(游离 HEAD)"
|
|
69
74
|
return {
|
|
70
75
|
"repo": True,
|
|
71
76
|
"branch": br or "(游离 HEAD)",
|
|
@@ -408,7 +413,7 @@ def finalize_run(workdir, gitinfo, message):
|
|
|
408
413
|
# 切回原分支;原为游离 HEAD 时回到基线提交(同样游离)
|
|
409
414
|
back = (gitinfo or {}).get("from_branch") or ""
|
|
410
415
|
base = (gitinfo or {}).get("base_commit") or ""
|
|
411
|
-
target = back if (back and back
|
|
416
|
+
target = back if (back and back not in ("(游离 HEAD)", "HEAD") and back != branch) else base
|
|
412
417
|
if target:
|
|
413
418
|
co = _git(workdir, "checkout", "--quiet", target, timeout=60)
|
|
414
419
|
if not co["ok"]:
|
|
@@ -472,6 +477,12 @@ def merge_task_branch(workdir, task):
|
|
|
472
477
|
out = {"restore_error": ""}
|
|
473
478
|
_restore_stash(wd, stash_sha, out)
|
|
474
479
|
return refuse("无法确定任务分支的基线分支(找不到带检出信息的运行记录),请手工合并")
|
|
480
|
+
if origin in ("HEAD", "(游离 HEAD)"):
|
|
481
|
+
# 老数据里游离基线存的是字面量 "HEAD":合并没有分支可落,显式拒绝
|
|
482
|
+
if stash_sha:
|
|
483
|
+
out = {"restore_error": ""}
|
|
484
|
+
_restore_stash(wd, stash_sha, out)
|
|
485
|
+
return refuse("任务分支从游离 HEAD 检出,没有可合并的基线分支;请手工合并")
|
|
475
486
|
if cur != origin:
|
|
476
487
|
if stash_sha:
|
|
477
488
|
out = {"restore_error": ""}
|
|
@@ -516,11 +527,56 @@ def merge_task_branch(workdir, task):
|
|
|
516
527
|
return True, "", info_out
|
|
517
528
|
|
|
518
529
|
|
|
530
|
+
def _worktrees_on_branch(workdir, br):
|
|
531
|
+
"""git worktree list --porcelain → 检出 br 的工作树路径列表。
|
|
532
|
+
|
|
533
|
+
porcelain 自 git 2.7 起可用;解析失败返回 None(调用方显式拒绝,
|
|
534
|
+
绝不带着「分支可能仍被占用」硬删)。
|
|
535
|
+
"""
|
|
536
|
+
w = _git(workdir, "worktree", "list", "--porcelain")
|
|
537
|
+
if not w["ok"]:
|
|
538
|
+
return None
|
|
539
|
+
entries, cur = [], None
|
|
540
|
+
for ln in (w["stdout"] or "").splitlines():
|
|
541
|
+
if ln.startswith("worktree "):
|
|
542
|
+
cur = {"path": ln[len("worktree "):].strip(), "branch": ""}
|
|
543
|
+
entries.append(cur)
|
|
544
|
+
elif cur is not None and ln.startswith("branch refs/heads/"):
|
|
545
|
+
cur["branch"] = ln[len("branch refs/heads/"):].strip()
|
|
546
|
+
return [x for x in entries if x["branch"] == br]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _switch_off_branch(workdir, br, target):
|
|
550
|
+
"""把工作树从 br 上切走,返回错误信息(成功为空串)。
|
|
551
|
+
|
|
552
|
+
先尝试切到 target;两种失败形态都兜底成游离:
|
|
553
|
+
- checkout 直接失败(如目标分支被别的工作树占用)→ --detach target;
|
|
554
|
+
- checkout「成功」却没换位(target 是 HEAD 这类原地引用,实测
|
|
555
|
+
git checkout HEAD 退出码 0 且停在原分支)→ 复查后强制游离。
|
|
556
|
+
最终以 rev-parse 复核 br 真的腾空为准,不信 checkout 的退出码。
|
|
557
|
+
"""
|
|
558
|
+
co = _git(workdir, "checkout", "--quiet", target, timeout=60) if target \
|
|
559
|
+
else _git(workdir, "checkout", "--quiet", "--detach", timeout=60)
|
|
560
|
+
cur = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD")
|
|
561
|
+
if (cur["stdout"] or "").strip() == br:
|
|
562
|
+
co = _git(workdir, "checkout", "--quiet", "--detach", target, timeout=60) \
|
|
563
|
+
if target else _git(workdir, "checkout", "--quiet", "--detach", timeout=60)
|
|
564
|
+
if not co["ok"]:
|
|
565
|
+
return "切离任务分支失败:%s" % (co["stderr"] or "")[-120:]
|
|
566
|
+
elif not co["ok"]:
|
|
567
|
+
return "切回 %s 失败:%s" % (target, (co["stderr"] or "")[-120:])
|
|
568
|
+
return ""
|
|
569
|
+
|
|
570
|
+
|
|
519
571
|
def discard_task_branch(workdir, task):
|
|
520
572
|
"""丢弃任务分支(人审后的「否决」出口,不可恢复)。
|
|
521
573
|
|
|
522
574
|
守卫同 merge(活跃任务/仓库缺失/分支不存在拒绝);若工作区恰好停在
|
|
523
575
|
任务分支上且干净(收尾出错的遗留现场),先切回基线再删分支。
|
|
576
|
+
分支也可能被**其它** worktree 检出(run 收尾被中断、任务分支滞留在
|
|
577
|
+
另一个工作树里)——git 拒绝删除被检出的分支,所以删前逐个清理:
|
|
578
|
+
干净的工作树切回基线(目标分支被占用就游离在那),有未提交改动的
|
|
579
|
+
不动、显式拒绝;目录已消失的残留登记由 worktree prune 清掉。
|
|
524
580
|
返回 (ok, 错误信息)。
|
|
525
581
|
"""
|
|
526
582
|
def refuse(msg):
|
|
@@ -536,19 +592,37 @@ def discard_task_branch(workdir, task):
|
|
|
536
592
|
br = branch_name(tid)
|
|
537
593
|
if not _git(wd, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)["ok"]:
|
|
538
594
|
return refuse("任务分支 %s 不存在(可能已被合并或丢弃)" % br)
|
|
595
|
+
latest = _latest_run_git(tid) or {}
|
|
596
|
+
back = latest.get("from_branch") or ""
|
|
597
|
+
base = latest.get("base_commit") or ""
|
|
598
|
+
target = back if (back and back not in ("(游离 HEAD)", "HEAD") and back != br) else base
|
|
539
599
|
cur = info.get("branch") or ""
|
|
540
600
|
if cur == br:
|
|
541
601
|
if info.get("dirty"):
|
|
542
602
|
return refuse("工作区停在任务分支上且有未提交改动,请先处理再丢弃")
|
|
543
|
-
latest = _latest_run_git(tid) or {}
|
|
544
|
-
back = latest.get("from_branch") or ""
|
|
545
|
-
base = latest.get("base_commit") or ""
|
|
546
|
-
target = back if (back and back != "(游离 HEAD)" and back != br) else base
|
|
547
603
|
if not target:
|
|
548
604
|
return refuse("工作区停在任务分支上且找不到可切回的基线,请手工切走后再丢弃")
|
|
549
|
-
|
|
550
|
-
if
|
|
551
|
-
return refuse(
|
|
605
|
+
err = _switch_off_branch(wd, br, target)
|
|
606
|
+
if err:
|
|
607
|
+
return refuse(err)
|
|
608
|
+
wts = _worktrees_on_branch(wd, br)
|
|
609
|
+
if wts is None:
|
|
610
|
+
return refuse("无法枚举 worktree,请手工处理占用任务分支的工作树后再丢弃")
|
|
611
|
+
for wt in wts:
|
|
612
|
+
p = wt["path"]
|
|
613
|
+
if not os.path.isdir(p):
|
|
614
|
+
continue
|
|
615
|
+
st = _git(p, "status", "--porcelain")
|
|
616
|
+
dirty = [ln for ln in (st["stdout"] or "").splitlines()
|
|
617
|
+
if ln.strip() and not _attach_entry(ln.strip())] if st["ok"] else []
|
|
618
|
+
if dirty:
|
|
619
|
+
return refuse("任务分支仍被工作树 %s 占用且其中有未提交改动,"
|
|
620
|
+
"请先处理后再丢弃" % p)
|
|
621
|
+
err = _switch_off_branch(p, br, target)
|
|
622
|
+
if err:
|
|
623
|
+
return refuse("工作树 %s %s" % (p, err))
|
|
624
|
+
if wts:
|
|
625
|
+
_git(wd, "worktree", "prune")
|
|
552
626
|
d = _git(wd, "branch", "-D", br)
|
|
553
627
|
if not d["ok"]:
|
|
554
628
|
return refuse("删除任务分支失败:%s" % (d["stderr"] or "")[-160:])
|
package/app/core/history.py
CHANGED
|
@@ -18,14 +18,20 @@ def agent_stats(limit=200):
|
|
|
18
18
|
s["wins"] += 1
|
|
19
19
|
|
|
20
20
|
for run in store.list_runs(limit):
|
|
21
|
-
if run.get("kind") != "orchestration"
|
|
21
|
+
if run.get("kind") != "orchestration":
|
|
22
|
+
continue
|
|
23
|
+
# 失败运行也要进历史(记为全负):以前只数 done——常挂的 CLI 永远
|
|
24
|
+
# 「无历史记录」满血参选,2026-09-17 opencode 当日秒挂 3 次仍被换将
|
|
25
|
+
# 选中。cancelled 不算(用户取消非智能体之过)。
|
|
26
|
+
if run.get("status") not in ("done", "failed"):
|
|
22
27
|
continue
|
|
23
28
|
verdict = run.get("verdict") or {}
|
|
24
29
|
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
25
30
|
ttype = (task or {}).get("type") or verdict.get("type")
|
|
26
31
|
if not ttype:
|
|
27
32
|
continue
|
|
28
|
-
win =
|
|
33
|
+
win = run.get("status") == "done" and bool(
|
|
34
|
+
verdict.get("pass") or verdict.get("publishable"))
|
|
29
35
|
for s in run.get("steps") or []:
|
|
30
36
|
if s.get("role") in ("implement", "draft"):
|
|
31
37
|
bump(s.get("agent"), ttype, win)
|
package/app/core/jobs.py
CHANGED
|
@@ -205,6 +205,9 @@ def resume_interrupted(limit=3):
|
|
|
205
205
|
continue
|
|
206
206
|
if run.get("cancelled_by_user"):
|
|
207
207
|
continue
|
|
208
|
+
if run.get("paused"):
|
|
209
|
+
continue # 用户主动暂停的运行(重启收尸后 paused 标志保留)不自动续跑——
|
|
210
|
+
# 「继续」由用户点「继续任务」决定,不替用户做主
|
|
208
211
|
if not _recent(run):
|
|
209
212
|
continue
|
|
210
213
|
if latest_by_task.get(run["task_id"]) != run["id"]:
|
|
@@ -320,8 +323,12 @@ def _do_mgmt(job, ev):
|
|
|
320
323
|
store.finish_step(run_id, step["n"], "failed", summary="该智能体未安装或未启用编排")
|
|
321
324
|
store.update_run(run_id, status="failed", error="未启用", ended_at=_now())
|
|
322
325
|
return
|
|
323
|
-
res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK",
|
|
324
|
-
|
|
326
|
+
res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK",
|
|
327
|
+
readonly=True,
|
|
328
|
+
# 300s:codex CLI 启动要拉 5 个 MCP 服务器 + 注入约 13 万
|
|
329
|
+
# token 技能上下文,首 token 常超 180s(2026-09-17 实测
|
|
330
|
+
# 网关裸探 8s 就回,慢在 CLI 自身启动与上下文)。
|
|
331
|
+
timeout=300, cancel_event=ev, log_path=str(log_abs))
|
|
325
332
|
ok = res["ok"] and "OK" in (res.get("text") or "").upper()
|
|
326
333
|
try:
|
|
327
334
|
_usage.record(source="smoke", run_id=run_id, step=step["n"], role="smoke",
|
package/app/core/manager.py
CHANGED
|
@@ -69,6 +69,52 @@ def detect_entry(entry):
|
|
|
69
69
|
return {"installed": False, "detail": ""}
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def sweep_orphan_cli_processes():
|
|
73
|
+
"""启动清扫:服务重启会孤儿化正在跑的 CLI 孙进程(外部只杀服务 PID,不带
|
|
74
|
+
/T),僵尸 opencode 更会劫持后续会话——opencode 是客户端-服务端架构,新
|
|
75
|
+
`opencode run` 连上僵尸实例后 shell 全在僵尸的项目根里跑(2026-09-17
|
|
76
|
+
mo-so 实测:agent 在 Temp 里找代码,汇报「工作目录没有源码」)。
|
|
77
|
+
|
|
78
|
+
按「Tutti 调用签名 + 父进程已死」双条件匹配,不误杀用户自己在用的 CLI:
|
|
79
|
+
opencode:命令行含 opencode + --model(同步写入的 provider 固定 orch)
|
|
80
|
+
codex:命令行含 codex + --skip-git-repo-check(Tutti 专属 flag 组合)
|
|
81
|
+
claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
|
|
82
|
+
ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
|
|
83
|
+
"System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
84
|
+
if not os.path.isfile(ps_exe):
|
|
85
|
+
ps_exe = "powershell"
|
|
86
|
+
ps = ("Get-CimInstance Win32_Process | "
|
|
87
|
+
"Where-Object { $_.Name -match '^(opencode|codex|node|cmd)\\.exe$' } | "
|
|
88
|
+
"Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
|
|
89
|
+
try:
|
|
90
|
+
r = subprocess.run([ps_exe, "-NoProfile", "-Command", ps],
|
|
91
|
+
capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=60)
|
|
92
|
+
import json as _json
|
|
93
|
+
raw = r.stdout.decode("utf-8", "replace").strip()
|
|
94
|
+
items = _json.loads(raw) if raw else []
|
|
95
|
+
if isinstance(items, dict):
|
|
96
|
+
items = [items]
|
|
97
|
+
live = {i.get("ProcessId") for i in items}
|
|
98
|
+
killed = 0
|
|
99
|
+
for i in items:
|
|
100
|
+
cl = str(i.get("CommandLine") or "").lower()
|
|
101
|
+
ppid = i.get("ParentProcessId")
|
|
102
|
+
is_ours = (("opencode" in cl and "--model" in cl)
|
|
103
|
+
or ("codex" in cl and "--skip-git-repo-check" in cl))
|
|
104
|
+
if not is_ours or ppid in live or not i.get("ProcessId"):
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
|
|
108
|
+
capture_output=True, creationflags=CREATE_NO_WINDOW,
|
|
109
|
+
timeout=15)
|
|
110
|
+
killed += 1
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
return killed
|
|
114
|
+
except Exception:
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
72
118
|
def detect_all(force=False):
|
|
73
119
|
"""检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile 在
|
|
74
120
|
Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
|
|
@@ -1091,10 +1137,18 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1091
1137
|
全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
|
|
1092
1138
|
文本级就地 patch(保留注释)。返回错误串或 None。"""
|
|
1093
1139
|
npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
|
|
1140
|
+
base = prov.get("base_url") or ""
|
|
1141
|
+
if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
|
|
1142
|
+
# @ai-sdk/anthropic 在 baseURL 后只拼 /messages(官方默认 baseURL 本身带
|
|
1143
|
+
# /v1),而 models.json 里 anthropic 供应商的 base 不带 /v1(modelhub
|
|
1144
|
+
# 发请求时自己补)——不补会打到 <host>/messages,网关回 "Not Allowed"
|
|
1145
|
+
# (2026-09-17 公司Anthropic 实测)。
|
|
1146
|
+
base = base.rstrip("/") + "/v1"
|
|
1094
1147
|
block = {"npm": npm, "name": prov.get("name") or "CodeBee 绑定",
|
|
1095
|
-
"options": {"baseURL":
|
|
1148
|
+
"options": {"baseURL": base,
|
|
1096
1149
|
"apiKey": prov.get("api_key") or ""},
|
|
1097
1150
|
"models": {model: {"name": model}} if model else {}}
|
|
1151
|
+
top_perms = {"edit": "allow", "bash": "allow", "webfetch": "allow"}
|
|
1098
1152
|
top_model = ("orch/" + model) if model else ""
|
|
1099
1153
|
targets = [p for p in _opencode_config_candidates(entry) if os.path.isfile(p)] \
|
|
1100
1154
|
or [_opencode_config_candidates(entry)[0]]
|
|
@@ -1130,6 +1184,10 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1130
1184
|
data["provider"] = provs
|
|
1131
1185
|
if top_model:
|
|
1132
1186
|
data["model"] = top_model
|
|
1187
|
+
# 无人值守必配:headless 下 opencode 工具调用默认要审批,全部被
|
|
1188
|
+
# 拒("The user rejected permission...",2026-09-17 实测)——
|
|
1189
|
+
# 按用户拍板的「默认给全部权限」写入放行段
|
|
1190
|
+
data["permission"] = top_perms
|
|
1133
1191
|
new_text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
1134
1192
|
else: # JSONC(带注释):文本级 patch
|
|
1135
1193
|
block_json = json.dumps(block, ensure_ascii=False)
|
|
@@ -1140,6 +1198,8 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1140
1198
|
if top_model:
|
|
1141
1199
|
new_text, _ = _jsonc_set(new_text, ("model",),
|
|
1142
1200
|
json.dumps(top_model))
|
|
1201
|
+
new_text, _ = _jsonc_set(new_text, ("permission",),
|
|
1202
|
+
json.dumps(top_perms))
|
|
1143
1203
|
if os.path.isfile(path):
|
|
1144
1204
|
shutil.copyfile(path, path + ".bak")
|
|
1145
1205
|
Path(path).write_bytes(new_text.encode("utf-8"))
|
|
@@ -1148,12 +1208,102 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1148
1208
|
return ";".join(errs) or None
|
|
1149
1209
|
|
|
1150
1210
|
|
|
1211
|
+
def _kimi_render(prov, model):
|
|
1212
|
+
"""渲染 kimi-code 的 CodeBee 托管块(顶层键在前,表在后——TOML 语义)。"""
|
|
1213
|
+
import re as _re
|
|
1214
|
+
ptype = "anthropic" if prov.get("protocol") == "anthropic" else "openai"
|
|
1215
|
+
base = prov.get("base_url") or ""
|
|
1216
|
+
alias = model or "default"
|
|
1217
|
+
pname = (prov.get("name") or "CodeBee").replace("\"", "")
|
|
1218
|
+
return (
|
|
1219
|
+
"# >>> CodeBee managed (do not edit between markers) >>>\n"
|
|
1220
|
+
"defaultProvider = \"orch\"\n"
|
|
1221
|
+
"defaultModel = \"%s\"\n"
|
|
1222
|
+
"yolo = true\n"
|
|
1223
|
+
"defaultPermissionMode = \"yolo\"\n"
|
|
1224
|
+
"\n"
|
|
1225
|
+
"[providers.orch]\n"
|
|
1226
|
+
"type = \"%s\"\n"
|
|
1227
|
+
"name = \"%s\"\n"
|
|
1228
|
+
"baseUrl = \"%s\"\n"
|
|
1229
|
+
"apiKey = \"%s\"\n"
|
|
1230
|
+
"\n"
|
|
1231
|
+
"[models.\"%s\"]\n"
|
|
1232
|
+
"provider = \"orch\"\n"
|
|
1233
|
+
"model = \"%s\"\n"
|
|
1234
|
+
"maxContextSize = 131072\n"
|
|
1235
|
+
"displayName = \"%s · %s\"\n"
|
|
1236
|
+
"# <<< CodeBee managed <<<\n"
|
|
1237
|
+
% (alias, ptype, pname, base,
|
|
1238
|
+
(prov.get("api_key") or "").replace("\"", ""),
|
|
1239
|
+
alias, alias, pname, alias))
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _sync_kimi_settings(entry, model, prov):
|
|
1243
|
+
"""kimi-code 专属:把绑定供应商写进 ~/.kimi-code/config.toml。
|
|
1244
|
+
|
|
1245
|
+
schema 从官方 bundle 反推(2026-09-17):顶层 defaultProvider/defaultModel/
|
|
1246
|
+
yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
|
|
1247
|
+
baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
|
|
1248
|
+
要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
|
|
1249
|
+
之间)幂等重写,用户自有内容保留在外。"""
|
|
1250
|
+
import re as _re
|
|
1251
|
+
top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
|
|
1252
|
+
targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
|
|
1253
|
+
errs = []
|
|
1254
|
+
for path in targets:
|
|
1255
|
+
try:
|
|
1256
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
1257
|
+
text = ""
|
|
1258
|
+
if os.path.isfile(path):
|
|
1259
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
1260
|
+
shutil.copyfile(path, path + ".bak")
|
|
1261
|
+
# 移除旧托管块与旧顶层键(防重复/防键落进别的表)
|
|
1262
|
+
text = _re.sub(r"# >>> CodeBee managed.*?# <<< CodeBee managed <<<\n?",
|
|
1263
|
+
"", text, flags=_re.S)
|
|
1264
|
+
for k in top_keys:
|
|
1265
|
+
text = _re.sub(r"(?m)^%s\s*=.*$\n?" % k, "", text)
|
|
1266
|
+
body = _re.sub(r"\n+$", "\n", text).lstrip("\n")
|
|
1267
|
+
managed = _kimi_render(prov, model)
|
|
1268
|
+
has_table = _re.search(r"(?m)^\[", body) is not None
|
|
1269
|
+
top = "".join("%s\n" % ln for ln in managed.split("\n")
|
|
1270
|
+
if _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
|
|
1271
|
+
tables = "\n".join(ln for ln in managed.split("\n")
|
|
1272
|
+
if not _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
|
|
1273
|
+
if has_table:
|
|
1274
|
+
new_text = top + "\n" + body + "\n" + tables
|
|
1275
|
+
else:
|
|
1276
|
+
new_text = (body + "\n" if body else "") + managed
|
|
1277
|
+
Path(path).write_bytes(new_text.encode("utf-8"))
|
|
1278
|
+
except Exception as e:
|
|
1279
|
+
errs.append("%s: %r" % (path, e))
|
|
1280
|
+
return ";".join(errs) or None
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def sync_cli_config_now(agent_id):
|
|
1284
|
+
"""运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
|
|
1285
|
+
绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
|
|
1286
|
+
try:
|
|
1287
|
+
from . import modelhub
|
|
1288
|
+
entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
|
|
1289
|
+
if not entry:
|
|
1290
|
+
return ""
|
|
1291
|
+
b = modelhub.bindings().get(agent_id) or {}
|
|
1292
|
+
note = _sync_agent_injection(entry, b)
|
|
1293
|
+
if note:
|
|
1294
|
+
return "已自动注入 %s 配置:%s" % (agent_id, note)
|
|
1295
|
+
return ""
|
|
1296
|
+
except Exception as e:
|
|
1297
|
+
return "自动注入失败: %r" % e
|
|
1298
|
+
|
|
1299
|
+
|
|
1151
1300
|
# 打开前专属注入通道:{agent_id: (可注入协议, 注入器)}。交互 TUI 脱离编排链路,
|
|
1152
1301
|
# 只认自家配置文件里的凭据,编排降级给的 env(ORCH_API_KEY 等)对它们无效。
|
|
1153
1302
|
_AGENT_INJECTORS = {
|
|
1154
1303
|
"claude-code": (("anthropic",), _sync_claude_settings),
|
|
1155
1304
|
"opencode": (("anthropic", "openai"), _sync_opencode_settings),
|
|
1156
1305
|
"qwencode": (("openai",), _sync_qwen_settings),
|
|
1306
|
+
"kimi-code": (("openai", "anthropic"), _sync_kimi_settings),
|
|
1157
1307
|
}
|
|
1158
1308
|
|
|
1159
1309
|
# 无专属注入通道的专有协议 CLI:env 注入大概率无效,打开时明确告知而非静默废
|
package/app/core/modelhub.py
CHANGED
|
@@ -114,6 +114,22 @@ def _normalize_ids(data):
|
|
|
114
114
|
_LITE = ("mini", "flash", "lite", "nano", "small", "tiny", "8b", "7b", "4b")
|
|
115
115
|
_HEAVY = ("opus", "pro", "max", "ultra", "plus", "heavy", "codex")
|
|
116
116
|
|
|
117
|
+
# 图片输入命名惯例:vision/omni 词段、glm-4v 式「数字+v」段;vl 子串另判
|
|
118
|
+
# (qwen-vl / internvl2 / cogvlm 等视觉家族的通用命名根,文本模型名含 vl 的极罕见)
|
|
119
|
+
_IMAGE_IN_RE = re.compile(
|
|
120
|
+
r"(?:^|[-_.])(?:vision|visual|omni)(?:[-_.0-9]|$)|[-_.]\dv(?:[-_.]|$)")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _auto_image_in(name):
|
|
124
|
+
"""按模型名启发式预填「支持图片输入」。claude/gemini 全系原生多模态直接标;
|
|
125
|
+
其余只认显式命名(vision 词段、vl 子串、数字+v),宁缺勿滥——漏标的明模型
|
|
126
|
+
用户手动打开即可,误标只是运行时网关显式报错、开关改回即恢复。
|
|
127
|
+
仅用于新模型的初始声明。"""
|
|
128
|
+
n = (name or "").lower()
|
|
129
|
+
if n.startswith(("claude", "gemini")):
|
|
130
|
+
return True
|
|
131
|
+
return bool(_IMAGE_IN_RE.search(n)) or "vl" in n
|
|
132
|
+
|
|
117
133
|
|
|
118
134
|
def _auto_priority(name):
|
|
119
135
|
"""按模型名启发式估强弱:分越高越强(优先级越靠前)。仅用于新模型的初始排序。"""
|
|
@@ -251,11 +267,14 @@ def refresh_models(provider_id):
|
|
|
251
267
|
# 保留 hidden:用户删掉的模型刷新时不能被重新带回
|
|
252
268
|
item = {"name": n, "enabled": bool(o.get("enabled", True)),
|
|
253
269
|
"priority": o.get("priority", 0),
|
|
254
|
-
"hidden": bool(o.get("hidden"))
|
|
270
|
+
"hidden": bool(o.get("hidden")),
|
|
271
|
+
# 模态声明同理:用户/预填设过的 image_in 刷新时不能被抹掉
|
|
272
|
+
"image_in": bool(o.get("image_in"))}
|
|
255
273
|
(hidden if item["hidden"] else existing).append(item)
|
|
256
274
|
else:
|
|
257
275
|
fresh.append({"name": n, "enabled": True,
|
|
258
|
-
"auto": _auto_priority(n)
|
|
276
|
+
"auto": _auto_priority(n),
|
|
277
|
+
"image_in": _auto_image_in(n)})
|
|
259
278
|
# 已删除但本次未返回的条目也保留,否则下次拉取会当作新模型“复活”
|
|
260
279
|
hidden += [dict(o) for n, o in old.items()
|
|
261
280
|
if o.get("hidden") and n not in names]
|
|
@@ -729,15 +748,48 @@ def note_key_ok(provider_id, key_id):
|
|
|
729
748
|
with _LOCK:
|
|
730
749
|
data = _load()
|
|
731
750
|
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
732
|
-
if not prov
|
|
751
|
+
if not prov:
|
|
733
752
|
return
|
|
734
|
-
target = next((k for k in prov
|
|
753
|
+
target = next((k for k in (prov.get("keys") or []) if str(k.get("id")) == str(key_id)), None)
|
|
735
754
|
if target is None or not target.get("last_error"):
|
|
736
755
|
return
|
|
737
756
|
target.pop("last_error", None)
|
|
738
757
|
_save(data)
|
|
739
758
|
|
|
740
759
|
|
|
760
|
+
def _is_codex_target(target):
|
|
761
|
+
return (target or "").strip().lower() in ("codex-cli", "codex", "codex-code")
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def note_codex_wire_dead(provider_id, minutes=30):
|
|
765
|
+
"""codex 撞上 wire 不兼容的供应商 → 供应商级冷却(自动绕开的记账位)。
|
|
766
|
+
|
|
767
|
+
codex 0.154 起只支持 responses wire(chat 被官方移除),讯飞等只有
|
|
768
|
+
chat completions 的 MaaS 每次 404/启动即拒。runner 按 404+no Route
|
|
769
|
+
matched / wire_api no longer supported 特征自动调用本函数;链展开与
|
|
770
|
+
路由绑定加分据此自动绕开,无需人工改绑定(2026-09-17 mo-so 实测)。
|
|
771
|
+
"""
|
|
772
|
+
import time as _t
|
|
773
|
+
if not provider_id:
|
|
774
|
+
return
|
|
775
|
+
with _LOCK:
|
|
776
|
+
data = _load()
|
|
777
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
778
|
+
if not prov:
|
|
779
|
+
return
|
|
780
|
+
prov["codex_wire_dead_until"] = _t.time() + max(1, int(minutes)) * 60
|
|
781
|
+
_save(data)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def codex_wire_blocked(prov):
|
|
785
|
+
"""该供应商是否处于 codex wire 不兼容冷却期。"""
|
|
786
|
+
import time as _t
|
|
787
|
+
try:
|
|
788
|
+
return float((prov or {}).get("codex_wire_dead_until") or 0) > _t.time()
|
|
789
|
+
except Exception:
|
|
790
|
+
return False
|
|
791
|
+
|
|
792
|
+
|
|
741
793
|
def _is_private_host(url):
|
|
742
794
|
"""主机是私网/环回 IP 字面量或 localhost 时返回 True(自动放行内网网关)。"""
|
|
743
795
|
import urllib.parse
|
|
@@ -1813,6 +1865,14 @@ def _model_bindable(prov, model):
|
|
|
1813
1865
|
return True
|
|
1814
1866
|
|
|
1815
1867
|
|
|
1868
|
+
def _model_image_in(prov, model):
|
|
1869
|
+
"""模型是否声明支持图片输入:models[] 条目 image_in;缺省/查不到=False(纯文本)。"""
|
|
1870
|
+
for m in ((prov or {}).get("models") or []):
|
|
1871
|
+
if isinstance(m, dict) and m.get("name") == model:
|
|
1872
|
+
return bool(m.get("image_in"))
|
|
1873
|
+
return False
|
|
1874
|
+
|
|
1875
|
+
|
|
1816
1876
|
def bind_agent(agent, difficulty="default"):
|
|
1817
1877
|
"""按绑定生成应用了供应商/模型覆盖的 agent 副本;无绑定时原样返回。"""
|
|
1818
1878
|
r = resolve_binding(agent.get("id"), difficulty) or resolve_binding(agent.get("kind"), difficulty)
|
|
@@ -1958,6 +2018,8 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
1958
2018
|
continue # 原生协议与适配过的 wire 都不匹配:跳过
|
|
1959
2019
|
if prov.get("name") in down_set:
|
|
1960
2020
|
continue # 健康监测判定 down:跳过,省掉无效等待
|
|
2021
|
+
if _is_codex_target(agent_kind_or_id) and codex_wire_blocked(prov):
|
|
2022
|
+
continue # codex 撞过该供应商 wire 不兼容(chat-only):冷却中自动绕开
|
|
1961
2023
|
if model and not _model_bindable(prov, model):
|
|
1962
2024
|
continue # 模型被停用/删除:该条跳过(2026-09-15 告警弹框「禁用该模型」)
|
|
1963
2025
|
# 多 KEY:同一厂商按 KEY 展开成多条,顺序即调用顺序。欠费的 KEY 被
|
|
@@ -1993,6 +2055,8 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
1993
2055
|
prov = provs.get(pid)
|
|
1994
2056
|
if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
|
|
1995
2057
|
return None
|
|
2058
|
+
if _is_codex_target(agent_kind_or_id) and codex_wire_blocked(prov):
|
|
2059
|
+
return None # codex wire 不兼容冷却中:解析为空 → 路由绑定分自动转负
|
|
1996
2060
|
ep = _entry_endpoint(prov, allowed)
|
|
1997
2061
|
if not ep:
|
|
1998
2062
|
return None # google 只登记;dsh 只接受 OpenAI 兼容端点;未适配的不硬塞
|
|
@@ -2173,6 +2237,24 @@ def reorder_models(provider_id, ordered_names):
|
|
|
2173
2237
|
return None
|
|
2174
2238
|
|
|
2175
2239
|
|
|
2240
|
+
def set_model_caps(provider_id, name, image_in):
|
|
2241
|
+
"""声明单个模型的模态能力(当前仅 image_in 图片输入)。返回错误文案或 None。"""
|
|
2242
|
+
with _LOCK:
|
|
2243
|
+
data = _load()
|
|
2244
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
2245
|
+
if not prov:
|
|
2246
|
+
return "供应商不存在"
|
|
2247
|
+
target = next((m for m in (prov.get("models") or [])
|
|
2248
|
+
if m.get("name") == name), None)
|
|
2249
|
+
if target is None:
|
|
2250
|
+
return "模型不存在: %s" % name
|
|
2251
|
+
image_in = bool(image_in)
|
|
2252
|
+
if bool(target.get("image_in")) != image_in: # 有差异才落盘
|
|
2253
|
+
target["image_in"] = image_in
|
|
2254
|
+
_save(data)
|
|
2255
|
+
return None
|
|
2256
|
+
|
|
2257
|
+
|
|
2176
2258
|
def _post_json_http(url, headers, body, allow_private, timeout=20):
|
|
2177
2259
|
"""带 SSRF 防护的 POST。返回 (status, json_obj|None, err)。"""
|
|
2178
2260
|
import urllib.parse
|