codebee 0.1.66 → 0.1.67
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 +13 -1
- package/app/core/acceptance.py +100 -0
- package/app/core/assets.py +110 -0
- package/app/core/attachments.py +92 -1
- package/app/core/bookmeta.py +160 -26
- package/app/core/defectretro.py +43 -0
- package/app/core/flows.py +47 -1
- package/app/core/jobs.py +20 -4
- package/app/core/knowledge.py +50 -3
- package/app/core/manager.py +7 -0
- package/app/core/market_remote.py +124 -26
- package/app/core/modelhub.py +29 -0
- package/app/core/operations.py +228 -0
- package/app/core/paihang.py +58 -29
- package/app/core/pipeline.py +205 -11
- package/app/core/planner.py +4 -1
- package/app/core/publish/browser.py +6 -0
- package/app/core/publish/fanqie.py +31 -0
- package/app/core/publish/flow.py +9 -1
- package/app/core/publish/ledger.py +7 -2
- package/app/core/publish/manager.py +91 -22
- package/app/core/revisions.py +51 -0
- package/app/core/runner.py +27 -12
- package/app/core/skills.py +94 -1
- package/app/core/store.py +207 -11
- package/app/core/volumes.py +34 -0
- package/app/core/zentao.py +21 -3
- package/app/main.py +140 -21
- package/app/ui/app.js +407 -17
- package/app/ui/artdiff.js +74 -0
- package/app/ui/i18n.js +51 -1
- package/app/ui/index.html +6 -0
- package/app/ui/style.css +96 -24
- package/package.json +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""缺陷复盘报告(defect_retro,借鉴 zl2237/test-defect-retrospective)。
|
|
3
|
+
|
|
4
|
+
禅道/Jira 缺陷导出 → 产品/开发/测试三视角复盘报告。
|
|
5
|
+
数据不走网络:缺陷导出(CSV)作为任务附件注入上下文,这里只负责
|
|
6
|
+
组装分析框架提示词。无附件时提示词会点名要求补附件——报告框架
|
|
7
|
+
照常输出,不留静默空转。
|
|
8
|
+
|
|
9
|
+
对照参考实现的取舍:它是本地脚本确定性算指标(analyzer 同输入同输出),
|
|
10
|
+
我们是 direct 引擎一次性成稿——先跑通「有复盘产物」这一步,指标口径
|
|
11
|
+
固化(状态/严重度归一在提示词里给锚)后续按需升档。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def retro_prompt(goal):
|
|
17
|
+
"""组装缺陷复盘三视角提示词。纯框架无抓取,恒有返回值。"""
|
|
18
|
+
return (
|
|
19
|
+
"你是资深质量效能专家,负责输出一份缺陷复盘报告。\n\n"
|
|
20
|
+
"## 输入\n"
|
|
21
|
+
"用户会提供缺陷平台导出数据(推荐禅道/Jira 导出 CSV 作为任务附件;"
|
|
22
|
+
"Excel 二进制读不了,请导出 CSV)。字段口径以「标题、严重程度、"
|
|
23
|
+
"状态、创建日期、所属模块、解决方案、关闭原因、责任人」为锚,"
|
|
24
|
+
"缺失字段按实际有的算并在报告中声明口径。\n\n"
|
|
25
|
+
"## 用户关注点\n%s\n\n"
|
|
26
|
+
"## 你的产出(Markdown 报告)\n"
|
|
27
|
+
"1. **整体画像**:缺陷总量、严重度分布、状态分布、时间走势"
|
|
28
|
+
"(按创建日期聚合)、模块 Top5 集中度——只用表格+一句话结论,不复述原始数据\n"
|
|
29
|
+
"2. **产品视角**:需求类缺陷(描述不清/变更频繁/逻辑矛盾)占比与典型例;"
|
|
30
|
+
"类需求缺陷(被当 Bug 提的优化)点名\n"
|
|
31
|
+
"3. **开发视角**:根因聚类(编码疏忽/设计缺陷/环境/数据/回归)占比;"
|
|
32
|
+
"重复回归的模块点名;修复后又被激活的缺陷单列\n"
|
|
33
|
+
"4. **测试视角**:发现阶段分布(提测后/上线后/用户反馈);漏测推测"
|
|
34
|
+
"(上线后才暴露的缺陷缺什么用例);严重度与发现时长的错配\n"
|
|
35
|
+
"5. **改进动作**:每个视角 2-3 条,带建议责任角色与可验证的完成标准,"
|
|
36
|
+
"拒绝「加强沟通」类空话\n\n"
|
|
37
|
+
"## 纪律\n"
|
|
38
|
+
"- 所有数字必须从提供的导出数据里数出来,数不出的指标写「数据未提供」"
|
|
39
|
+
"而不是编造\n"
|
|
40
|
+
"- 若上下文里没有任何缺陷数据,直接说明需要附带导出 CSV 并给出导出步骤"
|
|
41
|
+
"(禅道:产品-缺陷-导出-CSV),不要硬写报告\n"
|
|
42
|
+
"- 直接输出报告,不要复述本提示词"
|
|
43
|
+
% (goal or "(用户未指定,按全量缺陷复盘)"))
|
package/app/core/flows.py
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"""
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import hashlib
|
|
12
13
|
import json
|
|
13
14
|
import re
|
|
14
15
|
import threading
|
|
@@ -76,7 +77,10 @@ BUILTIN_FLOWS = [
|
|
|
76
77
|
"note": "起草 → 多维评审 → 修订循环 → 发布门禁"},
|
|
77
78
|
{"id": "rank_scan", "name": "扫榜选材", "icon": "i-chart", "engine": "direct", "builtin": True,
|
|
78
79
|
"goal_hint": "想写哪个方向(一句话,可留空默认分析总榜热门题材)",
|
|
79
|
-
"note": "
|
|
80
|
+
"note": "抓取七猫/番茄/起点/纵横四平台排行榜公开数据 → AI 提炼跨平台热门题材/人设/差异化切入点(快档直出报告)"},
|
|
81
|
+
{"id": "defect_retro", "name": "缺陷复盘", "icon": "i-clipboard", "engine": "direct", "builtin": True,
|
|
82
|
+
"goal_hint": "复盘哪个版本/时间段的缺陷(建议附带禅道或 Jira 导出 CSV)",
|
|
83
|
+
"note": "读取附带的缺陷导出数据 → AI 输出产品/开发/测试三视角复盘报告:整体画像、根因聚类、漏测分析、改进动作(快档直出报告)"},
|
|
80
84
|
{"id": "research", "name": "调研报告", "icon": "i-file-search", "engine": "review", "builtin": True,
|
|
81
85
|
"manuscript": "report.md",
|
|
82
86
|
"rubric": ["全面性", "深度", "论据可靠", "可读性", "结论质量"],
|
|
@@ -250,6 +254,48 @@ def get_flow(flow_id):
|
|
|
250
254
|
return None
|
|
251
255
|
|
|
252
256
|
|
|
257
|
+
# 参与 digest 的语义字段:会进运行链的参数与提示词。icon/note/goal_hint/name 是
|
|
258
|
+
# 纯展示位——措辞变化不影响执行语义,不应制造「修订漂移」噪音。
|
|
259
|
+
_DIGEST_FIELDS = ("id", "engine", "manuscript", "rubric", "threshold", "rounds",
|
|
260
|
+
"best_of", "serial", "draft_prompt", "critique_prompt",
|
|
261
|
+
"verify_command")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def flow_content_sha256(flow):
|
|
265
|
+
"""Return the full semantic content hash for an immutable flow revision."""
|
|
266
|
+
if not isinstance(flow, dict):
|
|
267
|
+
return ""
|
|
268
|
+
basis = {k: flow.get(k) for k in _DIGEST_FIELDS}
|
|
269
|
+
blob = json.dumps(basis, ensure_ascii=False, sort_keys=True, default=str)
|
|
270
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def flow_digest(flow):
|
|
274
|
+
"""流程定义的内容指纹(sha256 前 16 位)——「这个任务当时用的是哪一版流程」
|
|
275
|
+
的对账真源(借鉴 WorkDSH ADR-0010 发布后不可变修订)。"""
|
|
276
|
+
return flow_content_sha256(flow)[:16]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def flow_drift(task):
|
|
280
|
+
"""任务流程修订漂移检测:任务创建时钉的 digest vs 当前流程定义。
|
|
281
|
+
|
|
282
|
+
任务参数在创建时已固化(改流程不影响固化字段),但 task_compile 对历史
|
|
283
|
+
任务缺失字段(threshold/rubric/manuscript)仍回落当前流程定义——续跑/
|
|
284
|
+
重试因此可能静默吃到新流程。这里只诊断不裁定:返回
|
|
285
|
+
{"flow", "pinned", "current"} 或 None(无快照/流程已删/无漂移)。"""
|
|
286
|
+
snap = (task or {}).get("flow_snapshot") or {}
|
|
287
|
+
pinned = str(snap.get("digest") or "")
|
|
288
|
+
if not pinned:
|
|
289
|
+
return None
|
|
290
|
+
cur = get_flow(str((task or {}).get("type") or ""))
|
|
291
|
+
if not cur:
|
|
292
|
+
return None
|
|
293
|
+
current = flow_digest(cur)
|
|
294
|
+
if not current or current == pinned:
|
|
295
|
+
return None
|
|
296
|
+
return {"flow": cur.get("id") or "", "pinned": pinned, "current": current}
|
|
297
|
+
|
|
298
|
+
|
|
253
299
|
def upsert_flow(payload):
|
|
254
300
|
"""新增/更新流程。返回 (flow, 错误)。
|
|
255
301
|
|
package/app/core/jobs.py
CHANGED
|
@@ -564,19 +564,32 @@ def _err_signature(err):
|
|
|
564
564
|
return re.sub(r"\s+", " ", s).strip()[:400]
|
|
565
565
|
|
|
566
566
|
|
|
567
|
+
def _serial_progress(run):
|
|
568
|
+
"""连载运行的已过审章数——超时续跑的进度锚点(chapter_scores 长度)。"""
|
|
569
|
+
try:
|
|
570
|
+
return len(run.get("chapter_scores") or [])
|
|
571
|
+
except Exception:
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
|
|
567
575
|
def _maybe_auto_resume(run_id):
|
|
568
|
-
"""
|
|
576
|
+
"""连载任务失败/超时自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
|
|
569
577
|
|
|
570
578
|
真实长篇单次运行常因供应商拥堵超时中断;这里在执行线程收尾时自动续跑一次
|
|
571
579
|
续跑(store.retry_task 会带上 inherit),让整个流程真正无人值守。
|
|
580
|
+
2026-09-24 戍边骑奴 9-20 案:「任务总时限已到」终态此前不续跑,而总时限
|
|
581
|
+
预算默认 1 小时、慢链天 12 章天然跑不完,用户只能守着手动点重试——超时
|
|
582
|
+
恰恰是最该自动接着写的一种中断,纳入。
|
|
572
583
|
同因连撞止损:续跑副本再失败时与本次失败的错误签名比对(2026-09-18
|
|
573
584
|
重写任务 kimi 403 欠费案),一模一样说明退避没换来不同结果,直接落
|
|
574
|
-
|
|
585
|
+
终态写明死因,不再烧剩余的退避次数。超时例外:错误签名恒为同一句
|
|
586
|
+
「任务总时限已到」,但每轮都在落章——本轮比上轮多过审了章就不算
|
|
587
|
+
「同因无效重试」,续跑交给次数上限兜底;两轮一章未进则照常止损。
|
|
575
588
|
"""
|
|
576
589
|
try:
|
|
577
590
|
from . import store
|
|
578
591
|
run = store.get_run(run_id)
|
|
579
|
-
if not run or run.get("status") not in ("failed", "cancelled"):
|
|
592
|
+
if not run or run.get("status") not in ("failed", "cancelled", "timeout"):
|
|
580
593
|
return False
|
|
581
594
|
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
582
595
|
if not task or not task.get("serial"):
|
|
@@ -589,8 +602,11 @@ def _maybe_auto_resume(run_id):
|
|
|
589
602
|
if prev_id:
|
|
590
603
|
prev = store.get_run(prev_id)
|
|
591
604
|
sig_now = _err_signature(run.get("error"))
|
|
605
|
+
timeout_progressed = (run.get("status") == "timeout"
|
|
606
|
+
and _serial_progress(run) > _serial_progress(prev or {}))
|
|
592
607
|
if (prev and sig_now and prev.get("error")
|
|
593
|
-
and sig_now == _err_signature(prev.get("error"))
|
|
608
|
+
and sig_now == _err_signature(prev.get("error"))
|
|
609
|
+
and not timeout_progressed):
|
|
594
610
|
store.update_run(run_id, auto_resume_stopped="same_cause",
|
|
595
611
|
error=(run.get("error") or "")
|
|
596
612
|
+ "|自动续跑止损:连续两次失败原因相同,不再重试")
|
package/app/core/knowledge.py
CHANGED
|
@@ -29,7 +29,7 @@ import threading
|
|
|
29
29
|
import time
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
|
-
from . import paths, skills
|
|
32
|
+
from . import paths, revisions, skills
|
|
33
33
|
|
|
34
34
|
_LOCK = threading.RLock()
|
|
35
35
|
_FILE = paths.DATA_DIR / "knowledge.json"
|
|
@@ -82,6 +82,29 @@ def list_entries(scope=None, status=None, tag=None, only_enabled=False):
|
|
|
82
82
|
return items
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def revision_for_task(task) -> dict:
|
|
86
|
+
"""Return approved knowledge references selected by task scope."""
|
|
87
|
+
scope = str((task or {}).get("type") or "*")
|
|
88
|
+
entries = list_entries(scope=scope, status="approved", only_enabled=True)
|
|
89
|
+
refs = []
|
|
90
|
+
for entry in entries:
|
|
91
|
+
body = entry.get("body") or ""
|
|
92
|
+
digest = revisions.content_sha256(body)
|
|
93
|
+
stored_digest = str(entry.get("content_sha256") or "")
|
|
94
|
+
drift = bool(stored_digest and stored_digest != digest)
|
|
95
|
+
refs.append({"id": entry.get("id") or "",
|
|
96
|
+
"revision_id": (entry.get("revision_id")
|
|
97
|
+
if stored_digest == digest else
|
|
98
|
+
revisions.revision_id("kbrev", digest)) or
|
|
99
|
+
revisions.revision_id("kbrev", digest),
|
|
100
|
+
"content_sha256": digest,
|
|
101
|
+
"revision_drift": drift})
|
|
102
|
+
payload = {"scope": scope, "entries": refs}
|
|
103
|
+
digest = revisions.stable_digest(payload)
|
|
104
|
+
return {"revision_id": revisions.revision_id("knowledge", digest),
|
|
105
|
+
"content_sha256": digest, "scope": scope, "entries": refs}
|
|
106
|
+
|
|
107
|
+
|
|
85
108
|
def _title_sim(a, b):
|
|
86
109
|
"""标题近似度:字符 bigram 的包含度(交集 / 较短者,中文友好零分词)。
|
|
87
110
|
用包含度而非 Jaccard:「X」vs「X 指南」的较短者完全被包含 →1.0,
|
|
@@ -131,13 +154,25 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
|
131
154
|
if hit is not None:
|
|
132
155
|
it = hit
|
|
133
156
|
it["seen"] = int(it.get("seen") or 1) + 1
|
|
157
|
+
old_body = str(it.get("body") or "")
|
|
158
|
+
old_rev = it.get("revision_id") or ""
|
|
159
|
+
new_rev = revisions.make_revision("kbrev", body, source=source)
|
|
134
160
|
if status == "approved" or it.get("status") != "approved":
|
|
161
|
+
if old_body and old_body != body:
|
|
162
|
+
it.setdefault("revisions", []).append(
|
|
163
|
+
revisions.revision_record(
|
|
164
|
+
"kbrev", old_body, source=it.get("source") or "",
|
|
165
|
+
extra={"body": old_body, "revision_id": old_rev or
|
|
166
|
+
revisions.revision_id("kbrev", revisions.content_sha256(old_body))}))
|
|
167
|
+
it["revisions"] = it["revisions"][-5:]
|
|
135
168
|
it["body"] = body
|
|
169
|
+
it.update({k: new_rev[k] for k in ("revision_id", "content_sha256")})
|
|
136
170
|
if status == "approved":
|
|
137
171
|
it["status"] = "approved"
|
|
138
172
|
else:
|
|
139
173
|
it.setdefault("revisions", []).append(
|
|
140
|
-
|
|
174
|
+
revisions.revision_record("kbrev", body, source=source,
|
|
175
|
+
extra={"body": body}))
|
|
141
176
|
it["revisions"] = it["revisions"][-5:] # 最多留 5 条修订候选
|
|
142
177
|
for tg in tags:
|
|
143
178
|
if tg not in (it.get("tags") or []):
|
|
@@ -154,6 +189,7 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
|
154
189
|
it["updated_at"] = _now()
|
|
155
190
|
_save(data)
|
|
156
191
|
return it
|
|
192
|
+
rev = revisions.make_revision("kbrev", body, source=source)
|
|
157
193
|
it = {"id": kid, "scope": scope, "title": title, "body": body,
|
|
158
194
|
"tags": tags, "status": status, "enabled": True,
|
|
159
195
|
"source": source, "source_file": source_file,
|
|
@@ -161,6 +197,7 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
|
161
197
|
"confidence": confidence or "medium",
|
|
162
198
|
"revisions": [], "hits": 0, "seen": 1,
|
|
163
199
|
"created_at": _now(), "updated_at": _now(), "kind": "knowledge"}
|
|
200
|
+
it.update({k: rev[k] for k in ("revision_id", "content_sha256")})
|
|
164
201
|
items.append(it)
|
|
165
202
|
_save(data)
|
|
166
203
|
return it
|
|
@@ -197,7 +234,17 @@ def entry_op(entry_id, op, fields=None):
|
|
|
197
234
|
if str(f.get("title") or "").strip():
|
|
198
235
|
hit["title"] = str(f["title"]).strip()[:80]
|
|
199
236
|
if str(f.get("body") or "").strip():
|
|
200
|
-
|
|
237
|
+
new_body = str(f["body"]).strip()[:1500]
|
|
238
|
+
old_body = str(hit.get("body") or "")
|
|
239
|
+
if old_body != new_body:
|
|
240
|
+
hit.setdefault("revisions", []).append(
|
|
241
|
+
revisions.revision_record(
|
|
242
|
+
"kbrev", old_body, source=hit.get("source") or "",
|
|
243
|
+
extra={"body": old_body}))
|
|
244
|
+
hit["revisions"] = hit["revisions"][-5:]
|
|
245
|
+
hit["body"] = new_body
|
|
246
|
+
hit.update({k: revisions.make_revision("kbrev", new_body)[k]
|
|
247
|
+
for k in ("revision_id", "content_sha256")})
|
|
201
248
|
if "tags" in f:
|
|
202
249
|
hit["tags"] = [str(t).strip()[:20]
|
|
203
250
|
for t in (f.get("tags") or []) if str(t).strip()][:8]
|
package/app/core/manager.py
CHANGED
|
@@ -1227,6 +1227,13 @@ def _sync_qwen_settings(entry, model, prov):
|
|
|
1227
1227
|
path = _guard_home(_config_path(entry))
|
|
1228
1228
|
if not path:
|
|
1229
1229
|
return "qwen 配置路径无效或越出用户主目录,已拒绝"
|
|
1230
|
+
# 内闸防 anthropic 面混进 OPENAI_BASE_URL(2026-09-24 戍边骑奴案:史上
|
|
1231
|
+
# 某版把 .../api/anthropic 写进去,qwen 拿 openai 协议打 anthropic 路由,
|
|
1232
|
+
# 网关恒回 404 被误判「网关限流」,每轮 run 起点白烧 2-3 次重试)。
|
|
1233
|
+
# auto 协议不拦——适配层探出的 openai 面由外层 launch_pick 负责把关。
|
|
1234
|
+
if str(prov.get("protocol") or "").lower() == "anthropic":
|
|
1235
|
+
return ("qwen 只认 OpenAI 兼容端点,拒绝把 anthropic 面 %s 写进 OPENAI_BASE_URL"
|
|
1236
|
+
% (prov.get("base_url") or "?"))
|
|
1230
1237
|
updates = {"OPENAI_API_KEY": prov.get("api_key") or "",
|
|
1231
1238
|
"OPENAI_BASE_URL": prov.get("base_url") or ""}
|
|
1232
1239
|
if model:
|
|
@@ -24,6 +24,7 @@ import os
|
|
|
24
24
|
import ipaddress
|
|
25
25
|
import json
|
|
26
26
|
import re
|
|
27
|
+
import secrets
|
|
27
28
|
import shutil
|
|
28
29
|
import socket
|
|
29
30
|
import subprocess
|
|
@@ -343,11 +344,13 @@ def _entries_from_cache(cache=None):
|
|
|
343
344
|
return entries
|
|
344
345
|
|
|
345
346
|
|
|
346
|
-
def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None):
|
|
347
|
+
def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None, installed=None):
|
|
347
348
|
"""外部目录视图(服务端过滤 + 分页)。缓存全在本地,对全量条目过滤再切页
|
|
348
349
|
零成本——搜索/来源筛选不再受「已加载页」限制,滚动翻页逛完全部目录。
|
|
350
|
+
installed=真值时只看已安装(「已安装 N」入口的过滤器);installed_total
|
|
351
|
+
始终是**未过滤前**的全量已装数(徽章计数与过滤条件无关)。
|
|
349
352
|
返回 {sources, entries(本页), total(过滤后全量), offset, has_more,
|
|
350
|
-
categories}。"""
|
|
353
|
+
categories, installed_total}。"""
|
|
351
354
|
cache = _load_cache()
|
|
352
355
|
entries = _entries_from_cache(cache)
|
|
353
356
|
sources = []
|
|
@@ -360,6 +363,9 @@ def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None):
|
|
|
360
363
|
for e in entries:
|
|
361
364
|
if e["category"] and e["category"] not in cats:
|
|
362
365
|
cats.append(e["category"])
|
|
366
|
+
installed_total = sum(1 for e in entries if e["installed"])
|
|
367
|
+
if installed:
|
|
368
|
+
entries = [e for e in entries if e["installed"]]
|
|
363
369
|
if source:
|
|
364
370
|
entries = [e for e in entries if e["source_id"] == source]
|
|
365
371
|
qq = str(q or "").strip().lower()
|
|
@@ -372,7 +378,7 @@ def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None):
|
|
|
372
378
|
page = entries[offset:offset + limit]
|
|
373
379
|
return {"sources": sources, "entries": page, "total": len(entries),
|
|
374
380
|
"offset": offset, "has_more": offset + limit < len(entries),
|
|
375
|
-
"categories": cats}
|
|
381
|
+
"categories": cats, "installed_total": installed_total}
|
|
376
382
|
|
|
377
383
|
|
|
378
384
|
def _fetch_clawhub_catalog():
|
|
@@ -659,7 +665,9 @@ def _safe_extract(zf, dest):
|
|
|
659
665
|
|
|
660
666
|
|
|
661
667
|
def _download_zip(entry, tmp):
|
|
662
|
-
"""下载 zip(校验 sha256)→ 安全解包 →
|
|
668
|
+
"""下载 zip(校验 sha256)→ 安全解包 → 返回 (插件根目录, 完整性说明)。
|
|
669
|
+
完整性:清单带 sha256 且校验通过 = verified;清单没带 = unverified
|
|
670
|
+
(靠 https 传输 + 解包后的纯技能白名单兜底,如实告知不装懂)。"""
|
|
663
671
|
data = _fetch(entry["install"]["url"], cap=_CAP_DOWNLOAD)
|
|
664
672
|
want = (entry["install"].get("sha256") or "").lower()
|
|
665
673
|
if want:
|
|
@@ -667,7 +675,7 @@ def _download_zip(entry, tmp):
|
|
|
667
675
|
if got != want:
|
|
668
676
|
raise ValueError("sha256 校验不符(期望 %s,实际 %s)" % (want[:12], got[:12]))
|
|
669
677
|
dest = _safe_extract(zipfile.ZipFile(io.BytesIO(data)), tmp / "unzip")
|
|
670
|
-
return _plugin_root(dest)
|
|
678
|
+
return _plugin_root(dest), ("verified" if want else "unverified")
|
|
671
679
|
|
|
672
680
|
|
|
673
681
|
def _gh_split(url):
|
|
@@ -870,7 +878,7 @@ def _download_clawhub(entry, tmp):
|
|
|
870
878
|
_CLAWHUB_BASE, q(inst["slug"], safe=""), q(inst["reference"] or inst["slug"], safe=""))
|
|
871
879
|
data = _fetch(url, cap=_CAP_DOWNLOAD)
|
|
872
880
|
dest = _safe_extract(zipfile.ZipFile(io.BytesIO(data)), tmp / "clawhub")
|
|
873
|
-
return _plugin_root(dest)
|
|
881
|
+
return _plugin_root(dest), "unverified"
|
|
874
882
|
|
|
875
883
|
|
|
876
884
|
def _plugin_root(base):
|
|
@@ -961,9 +969,20 @@ def build_files(entry, root):
|
|
|
961
969
|
return out, None, stripped
|
|
962
970
|
|
|
963
971
|
|
|
964
|
-
def
|
|
965
|
-
"""
|
|
966
|
-
|
|
972
|
+
def _fetch_root(entry, tmp):
|
|
973
|
+
"""按来源下载并解包,返回 (插件根目录, 完整性说明)。
|
|
974
|
+
verified=zip 带 sha256 且校验通过;unverified=直链无哈希(https+白名单兜底);
|
|
975
|
+
git-ref/git-head=git 类来源按 ref/HEAD 取树。"""
|
|
976
|
+
kind = entry["install"]["kind"]
|
|
977
|
+
if kind == "zip":
|
|
978
|
+
return _download_zip(entry, tmp)
|
|
979
|
+
if kind == "clawhub":
|
|
980
|
+
return _download_clawhub(entry, tmp)
|
|
981
|
+
return _download_git_any(entry, tmp), \
|
|
982
|
+
("git-ref" if entry["install"].get("ref") else "git-head")
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _resolve_entry(entry_id):
|
|
967
986
|
entries = _entries_from_cache()
|
|
968
987
|
entry = next((e for e in entries if e["id"] == entry_id), None)
|
|
969
988
|
if not entry:
|
|
@@ -972,35 +991,114 @@ def install_remote(entry_id):
|
|
|
972
991
|
return None, entry["block_reason"] or "该插件不适配 CodeBee"
|
|
973
992
|
if entry["install"]["kind"] == "unsupported":
|
|
974
993
|
return None, "来源类型不支持(仅支持 zip 直链、git 子目录与 ClawHub)"
|
|
994
|
+
return entry, None
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
# 预览→确认安装两阶段之间的已检内容缓存:token 一次性、TTL 10 分钟、最多 8 份
|
|
998
|
+
# (files 是纯文本 dict、总量有 _CAP_TOTAL_TEXT 上限,缓存占用有界)。
|
|
999
|
+
_PREVIEW_TTL = 600
|
|
1000
|
+
_PREVIEW_MAX = 8
|
|
1001
|
+
_PREVIEW_CACHE = {}
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _preview_purge():
|
|
1005
|
+
now = time.time()
|
|
1006
|
+
for k in [k for k, v in _PREVIEW_CACHE.items() if now - v["ts"] > _PREVIEW_TTL]:
|
|
1007
|
+
_PREVIEW_CACHE.pop(k, None)
|
|
1008
|
+
while len(_PREVIEW_CACHE) > _PREVIEW_MAX:
|
|
1009
|
+
_PREVIEW_CACHE.pop(min(_PREVIEW_CACHE, key=lambda k: _PREVIEW_CACHE[k]["ts"]))
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _skill_names_from_files(files, entry):
|
|
1013
|
+
"""从转换后的 files 键推技能名:market-<id>.md 是主技能(用条目名),
|
|
1014
|
+
market-<id>__<名>.md 是多技能包里的其余技能(用目录名)。"""
|
|
1015
|
+
names = []
|
|
1016
|
+
for k in files:
|
|
1017
|
+
if "/" in k or not k.endswith(".md") or not k.startswith("market-"):
|
|
1018
|
+
continue
|
|
1019
|
+
base = k[len("market-"):-3]
|
|
1020
|
+
names.append("__" in base and base.split("__", 1)[1] or entry["title"])
|
|
1021
|
+
return sorted(set(names))
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def preview_remote(entry_id):
|
|
1025
|
+
"""安装前预览(先看后装):下载 → 剥离式检查 → 转换,但**不落盘安装**。
|
|
1026
|
+
返回 (预览 dict, 错误)。dict 带 token,确认安装时把 token 传回
|
|
1027
|
+
install_remote 即安装这份已检内容,不重复下载。"""
|
|
1028
|
+
entry, err = _resolve_entry(entry_id)
|
|
1029
|
+
if err:
|
|
1030
|
+
return None, err
|
|
975
1031
|
tmp = Path(tempfile.mkdtemp(prefix="codebee-mkt-"))
|
|
976
1032
|
try:
|
|
977
|
-
|
|
978
|
-
if kind == "zip":
|
|
979
|
-
root = _download_zip(entry, tmp)
|
|
980
|
-
elif kind == "clawhub":
|
|
981
|
-
root = _download_clawhub(entry, tmp)
|
|
982
|
-
else:
|
|
983
|
-
root = _download_git_any(entry, tmp)
|
|
1033
|
+
root, integrity = _fetch_root(entry, tmp)
|
|
984
1034
|
files, blocked, stripped = build_files(entry, root)
|
|
985
1035
|
if blocked:
|
|
986
1036
|
return None, blocked
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1037
|
+
_preview_purge()
|
|
1038
|
+
token = secrets.token_hex(12)
|
|
1039
|
+
_PREVIEW_CACHE[token] = {"entry_id": entry_id, "files": files,
|
|
1040
|
+
"stripped": stripped, "ts": time.time()}
|
|
1041
|
+
file_list = sorted(files.items(), key=lambda kv: kv[0])
|
|
1042
|
+
return {
|
|
1043
|
+
"token": token, "id": entry_id, "title": entry["title"],
|
|
1044
|
+
"source": entry["source_name"], "version": entry["version"],
|
|
1045
|
+
"homepage": entry["homepage"], "integrity": integrity,
|
|
1046
|
+
"skills": _skill_names_from_files(files, entry),
|
|
1047
|
+
"files_total": len(files),
|
|
1048
|
+
"total_chars": sum(len(v) for v in files.values()),
|
|
1049
|
+
"file_list": [{"path": k, "chars": len(v)} for k, v in file_list[:60]],
|
|
1050
|
+
"stripped": sorted(stripped)[:40], "stripped_total": len(stripped),
|
|
1051
|
+
"expires_in": _PREVIEW_TTL,
|
|
1052
|
+
}, None
|
|
996
1053
|
except ValueError as e:
|
|
997
1054
|
return None, str(e)
|
|
998
|
-
except Exception as e:
|
|
1055
|
+
except Exception as e:
|
|
999
1056
|
return None, "下载或解析插件失败: %s" % e
|
|
1000
1057
|
finally:
|
|
1001
1058
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
1002
1059
|
|
|
1003
1060
|
|
|
1061
|
+
def install_remote(entry_id, token=""):
|
|
1062
|
+
"""安装外部目录插件:下载 → 纯技能检查 → 转换 → 复用 market 安装通道。
|
|
1063
|
+
token 非空且命中预览缓存时直接安装那份已检内容(不重复下载);token
|
|
1064
|
+
一次性(装成功即焚,防重放)。返回 (结果 dict, 错误)。"""
|
|
1065
|
+
entry, err = _resolve_entry(entry_id)
|
|
1066
|
+
if err:
|
|
1067
|
+
return None, err
|
|
1068
|
+
cached = None
|
|
1069
|
+
if token:
|
|
1070
|
+
_preview_purge()
|
|
1071
|
+
c = _PREVIEW_CACHE.get(str(token))
|
|
1072
|
+
if c and c["entry_id"] == entry_id:
|
|
1073
|
+
cached = c
|
|
1074
|
+
if cached:
|
|
1075
|
+
files, stripped = cached["files"], cached["stripped"]
|
|
1076
|
+
else:
|
|
1077
|
+
tmp = Path(tempfile.mkdtemp(prefix="codebee-mkt-"))
|
|
1078
|
+
try:
|
|
1079
|
+
root, _integrity = _fetch_root(entry, tmp)
|
|
1080
|
+
files, blocked, stripped = build_files(entry, root)
|
|
1081
|
+
if blocked:
|
|
1082
|
+
return None, blocked
|
|
1083
|
+
except ValueError as e:
|
|
1084
|
+
return None, str(e)
|
|
1085
|
+
except Exception as e: # 网络/解包等意外错误也要兜成用户可读的一句话
|
|
1086
|
+
return None, "下载或解析插件失败: %s" % e
|
|
1087
|
+
finally:
|
|
1088
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
1089
|
+
res, err = market.install_files(entry_id, entry["title"], files, extra={
|
|
1090
|
+
"remote": {"source": entry["source_id"], "name": entry["name"],
|
|
1091
|
+
"version": entry["version"], "homepage": entry["homepage"]},
|
|
1092
|
+
})
|
|
1093
|
+
if err:
|
|
1094
|
+
return None, err
|
|
1095
|
+
if cached:
|
|
1096
|
+
_PREVIEW_CACHE.pop(str(token), None)
|
|
1097
|
+
res["skills"] = sum(1 for k in files if k.endswith(".md") and "/" not in k)
|
|
1098
|
+
res["stripped"] = sorted(stripped)
|
|
1099
|
+
return res, None
|
|
1100
|
+
|
|
1101
|
+
|
|
1004
1102
|
def remove_remote(entry_id):
|
|
1005
1103
|
"""卸载外部插件:记账与标记齐全时走 market 既有卸载;否则报错。"""
|
|
1006
1104
|
return market.remove(entry_id)
|
package/app/core/modelhub.py
CHANGED
|
@@ -29,6 +29,7 @@ anthropic/openai 可注入到 CLI;google 仅登记(当前无对应 CLI 可
|
|
|
29
29
|
from __future__ import annotations
|
|
30
30
|
|
|
31
31
|
import copy
|
|
32
|
+
import hashlib
|
|
32
33
|
import ipaddress
|
|
33
34
|
import json
|
|
34
35
|
import os
|
|
@@ -759,6 +760,34 @@ def providers():
|
|
|
759
760
|
return _load().get("providers", [])
|
|
760
761
|
|
|
761
762
|
|
|
763
|
+
def connection_identity(provider_id, key_id="") -> dict:
|
|
764
|
+
"""Resolve provider/credential identity without exposing credential values.
|
|
765
|
+
|
|
766
|
+
A provider definition (protocol + normalized host), provider instance (the
|
|
767
|
+
configured provider id), account/key identity and credential id are kept as
|
|
768
|
+
separate fields for run snapshots. The existing provider store remains the
|
|
769
|
+
source of truth; this helper only supplies stable, redacted references.
|
|
770
|
+
"""
|
|
771
|
+
provider_id = str(provider_id or "").strip()
|
|
772
|
+
key_id = str(key_id or "").strip()
|
|
773
|
+
prov = next((p for p in providers() if str(p.get("id") or "") == provider_id), {})
|
|
774
|
+
from . import upstream
|
|
775
|
+
host = upstream.normalize_upstream(prov.get("base_url") or "")
|
|
776
|
+
protocol = str(prov.get("protocol") or "")[:24]
|
|
777
|
+
definition_key = "%s|%s" % (protocol, host)
|
|
778
|
+
definition_id = "conn-def-" + hashlib.sha256(definition_key.encode("utf-8")).hexdigest()[:16]
|
|
779
|
+
account_id = key_id or "provider-default"
|
|
780
|
+
return {
|
|
781
|
+
"connection_id": provider_id,
|
|
782
|
+
"definition_id": definition_id,
|
|
783
|
+
"instance_id": provider_id,
|
|
784
|
+
"account_id": account_id,
|
|
785
|
+
"credential_id": account_id,
|
|
786
|
+
"protocol": protocol,
|
|
787
|
+
"upstream": host,
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
|
|
762
791
|
def bindings():
|
|
763
792
|
with _LOCK:
|
|
764
793
|
return _load().get("bindings", {})
|