codebee 0.1.102 → 0.1.103
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 +5 -0
- package/LICENSE +21 -21
- package/README.md +4 -8
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/acp_client.py +457 -457
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backend_profiles.py +188 -188
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -102
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/command_auth.py +302 -302
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +1 -1
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +91 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -33
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/knowledge_pipeline.py +86 -86
- package/app/core/manager.py +2385 -2385
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -181
- package/app/core/openapi.py +217 -217
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +2 -2
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +206 -206
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +659 -659
- package/app/core/publish/chaprows.py +138 -138
- package/app/core/publish/flows-fanqie-calibrated.json +442 -442
- package/app/core/publish/manager.py +1185 -1185
- package/app/core/publish/qimao.py +193 -193
- package/app/core/publish/volumes.py +108 -108
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +201 -201
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/sandbox_runtime.py +95 -95
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +215 -215
- package/app/core/settings_schema.py +282 -282
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -107
- package/app/core/tracing.py +207 -207
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -89
- package/app/core/wxdigest.py +865 -865
- package/app/core/zentao.py +2692 -2692
- package/app/mcp_server.py +244 -244
- package/app/output/_createbook_body.json +1 -0
- package/app/output/_probe_fq_click.py +50 -0
- package/app/output/_probe_fq_form.py +42 -0
- package/app/output/_probe_fq_modal.py +55 -0
- package/app/output/_probe_fq_points.py +71 -0
- package/app/output/_probe_fq_submit.py +34 -0
- package/app/output/_probe_fq_watch.py +62 -0
- package/app/output/workbench-clean-preview.jpg +0 -0
- package/app/output/workbench-reference-render.png +0 -0
- package/app/output/workbench-shell.png +0 -0
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +2 -3
- package/app/ui/artdiff.js +74 -74
- package/app/ui/i18n.js +0 -1
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/bin/tutti.js +147 -147
- package/package.json +1 -1
- package/tools/wx_reader.py +261 -261
package/app/core/preview.py
CHANGED
|
@@ -1,293 +1,293 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""运行预览:把任务工作目录里的网页成品挂成「可实时查看」的页面。
|
|
3
|
-
|
|
4
|
-
借鉴对话式编程产品(右栏「运行 / index.html / style.css / script.js」页签):
|
|
5
|
-
代码任务跑完,用户要的不是翻文件,而是**直接看到东西跑起来**。这里提供两件事:
|
|
6
|
-
|
|
7
|
-
1. list_app(run_id):从本任务的成品文件里挑出可预览的应用——入口 HTML +
|
|
8
|
-
同目录的代码文件(按 html→css→js 排序),供前端渲染页签条。
|
|
9
|
-
2. serve(run_id, rel):只读挂载工作目录里的静态资源,供 iframe / 新窗口直接
|
|
10
|
-
打开(<img>/<link>/<script> 这些子资源请求带不了自定义请求头)。
|
|
11
|
-
|
|
12
|
-
**访问令牌走路径段**(/preview/<run>/<令牌>/<文件>):子资源是浏览器按文档
|
|
13
|
-
URL 相对解析的,查询串不会被继承——令牌只能放路径里才覆盖得到 style.css /
|
|
14
|
-
script.js。鉴权本身直接复用 remote.request_authed(与 /api/* 同一把尺子,
|
|
15
|
-
含反向代理下"带转发头的 loopback 算远程"的判断),所以本地免令牌、公网必须
|
|
16
|
-
带令牌,两条路都不需要新写一套判断。
|
|
17
|
-
|
|
18
|
-
安全边界(这是把一个任意目录交给浏览器渲染,必须写清楚):
|
|
19
|
-
- 只认静态扩展名白名单,单文件体积上限;路径 resolve 后必须落在工作目录内。
|
|
20
|
-
- 工作目录下的服务端可执行物(.py/.php/.rb/.sh…)不在白名单,不会被预览。
|
|
21
|
-
- HTML 响应注入 <base>(相对资源在子路径下也解析得对)+ 一段极小的补丁:
|
|
22
|
-
页面里的 fetch/XHR 指向 /api/ 时自动补上访问令牌,让「对话生成的示例应用」
|
|
23
|
-
能真的连通后端。补丁只加一个请求头;写接口该 423/401 的照旧。
|
|
24
|
-
- 前端把预览 iframe 挂在同一来源下、不加 sandbox:预览要**像真的在跑**——生成的
|
|
25
|
-
示例应用大量用 localStorage,sandbox 去掉 same-origin 会让它当场抛 SecurityError,
|
|
26
|
-
而 allow-same-origin 的 sandbox 等于没加(页面可自行摘掉)。这与本地 dev server
|
|
27
|
-
的信任模型一致:跑的是你自己的代码、在你自己的机器上。代价是预览页能读到
|
|
28
|
-
本会话的 localStorage 令牌,故「预览」页签只在用户主动点开时才加载(不预跑)。
|
|
29
|
-
"""
|
|
30
|
-
from __future__ import annotations
|
|
31
|
-
|
|
32
|
-
import json
|
|
33
|
-
import re
|
|
34
|
-
from pathlib import Path
|
|
35
|
-
|
|
36
|
-
from . import store
|
|
37
|
-
|
|
38
|
-
# 静态资源白名单:只挂能安全交给浏览器渲染的扩展名。刻意不含 .py/.php/.rb/.sh
|
|
39
|
-
# 等工作目录里可能出现的服务端可执行物——预览是只读静态托管,不是网站服务器。
|
|
40
|
-
PREVIEW_EXT = {
|
|
41
|
-
".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map",
|
|
42
|
-
".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif",
|
|
43
|
-
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
44
|
-
".mp3", ".wav", ".ogg", ".mp4", ".webm",
|
|
45
|
-
".txt", ".md", ".csv", ".xml", ".wasm", ".yml", ".yaml", ".toml",
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
# 页签条里列出的扩展名(比可挂载范围窄):图片/字体是资源不是「文件」,
|
|
49
|
-
# 给它们开页签只是噪音。
|
|
50
|
-
TAB_EXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".svg",
|
|
51
|
-
".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml", ".map"}
|
|
52
|
-
|
|
53
|
-
_HTML_EXT = {".html", ".htm"}
|
|
54
|
-
|
|
55
|
-
# 页签排序权重:入口 → 样式 → 脚本 → 数据 → 其它
|
|
56
|
-
_TAB_ORDER = {".html": 0, ".htm": 0, ".css": 1, ".js": 2, ".mjs": 2, ".cjs": 2,
|
|
57
|
-
".json": 3, ".map": 4, ".svg": 5, ".md": 6, ".txt": 6, ".csv": 6,
|
|
58
|
-
".xml": 6, ".yml": 6, ".yaml": 6, ".toml": 6}
|
|
59
|
-
|
|
60
|
-
BASE = "/preview" # 路由前缀,与 main.py 的 do_GET 分发约定
|
|
61
|
-
MAX_FILE = 4 << 20 # 单文件上限:超过不挂(大文件走「成果」页签下载)
|
|
62
|
-
MAX_TABS = 12 # 页签上限:防病态目录撑爆页签条
|
|
63
|
-
MAX_LIST = 400 # 参与挑选的成品文件上限
|
|
64
|
-
|
|
65
|
-
_MIME = {
|
|
66
|
-
".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
|
|
67
|
-
".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
68
|
-
".mjs": "text/javascript; charset=utf-8", ".cjs": "text/javascript; charset=utf-8",
|
|
69
|
-
".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8",
|
|
70
|
-
".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
|
|
71
|
-
".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
|
|
72
|
-
".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
|
|
73
|
-
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
|
|
74
|
-
".otf": "font/otf", ".eot": "application/vnd.ms-fontobject",
|
|
75
|
-
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
|
76
|
-
".mp4": "video/mp4", ".webm": "video/webm",
|
|
77
|
-
".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
|
|
78
|
-
".csv": "text/plain; charset=utf-8", ".xml": "text/xml; charset=utf-8",
|
|
79
|
-
".wasm": "application/wasm",
|
|
80
|
-
".yml": "text/plain; charset=utf-8", ".yaml": "text/plain; charset=utf-8",
|
|
81
|
-
".toml": "text/plain; charset=utf-8",
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
_TXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map", ".svg",
|
|
85
|
-
".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml"}
|
|
86
|
-
|
|
87
|
-
_HEAD_RE = re.compile(r"<head\b[^>]*>", re.I)
|
|
88
|
-
_HTML_RE = re.compile(r"<html\b[^>]*>", re.I)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _ext(name):
|
|
92
|
-
return Path(str(name or "")).suffix.lower()
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def safe_rel(rel):
|
|
96
|
-
"""归一化工作目录内相对路径。返回 None 表示越界/非法。"""
|
|
97
|
-
rel = str(rel or "").replace("\\", "/").lstrip("/")
|
|
98
|
-
if not rel:
|
|
99
|
-
return None
|
|
100
|
-
parts = [p for p in rel.split("/") if p not in ("", ".")]
|
|
101
|
-
if not parts or any(p == ".." for p in parts):
|
|
102
|
-
return None
|
|
103
|
-
# 隐藏目录(.git/.zcode/.claude…)是工具过程文件,不是成品,一律不给挂
|
|
104
|
-
if any(p.startswith(".") for p in parts):
|
|
105
|
-
return None
|
|
106
|
-
return "/".join(parts)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def resolve(run_id, rel):
|
|
110
|
-
"""把 rel 解析到运行工作目录内的真实文件。返回 (Path|None, 错误文本)。
|
|
111
|
-
|
|
112
|
-
与 store.read_run_file 同一道守卫:resolve 之后必须仍在工作目录内,
|
|
113
|
-
顺带挡掉软链接指向工作目录之外的形态。
|
|
114
|
-
"""
|
|
115
|
-
wd = store.run_workdir(run_id)
|
|
116
|
-
if not wd:
|
|
117
|
-
return None, "该运行没有关联的工作目录"
|
|
118
|
-
safe = safe_rel(rel)
|
|
119
|
-
if not safe:
|
|
120
|
-
return None, "非法路径"
|
|
121
|
-
if _ext(safe) not in PREVIEW_EXT:
|
|
122
|
-
return None, "该类型不支持预览"
|
|
123
|
-
root = Path(wd).resolve()
|
|
124
|
-
try:
|
|
125
|
-
p = (root / safe).resolve()
|
|
126
|
-
except OSError:
|
|
127
|
-
return None, "非法路径"
|
|
128
|
-
if root != p and root not in p.parents:
|
|
129
|
-
return None, "非法路径"
|
|
130
|
-
if not p.is_file():
|
|
131
|
-
return None, "文件不存在"
|
|
132
|
-
return p, None
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _base_href(run_id, tok, rel):
|
|
136
|
-
"""入口 HTML 的 <base>:挂在 /preview/<run>/<令牌>/<入口目录>/ 上。
|
|
137
|
-
|
|
138
|
-
令牌放在路径里是为了让 style.css / ./js/a.js / ../x.css 这些相对子资源
|
|
139
|
-
自动继承它(查询串不会被子资源继承);这个绝对路径也让相对资源在
|
|
140
|
-
子目录入口(web/index.html)下解析得对。
|
|
141
|
-
"""
|
|
142
|
-
d = rel.rsplit("/", 1)[0] if "/" in rel else ""
|
|
143
|
-
return "%s/%s/%s/%s" % (BASE, run_id, tok, (d + "/") if d else "")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _patch_token(tok):
|
|
147
|
-
"""注入页面脚本:预览页里的 fetch/XHR 打 /api/ 时自动补访问令牌。
|
|
148
|
-
|
|
149
|
-
生成的示例应用常直接 fetch("/api/..."),而子资源与脚本发起的请求带不了
|
|
150
|
-
自定义请求头——不补的话预览里一切接口调用都 401,用户会以为是自己代码写
|
|
151
|
-
错了。补丁只加一个请求头;写接口该 423/401 的照旧,不放行任何服务端能力。
|
|
152
|
-
"""
|
|
153
|
-
tk = json.dumps(str(tok or ""))
|
|
154
|
-
return (
|
|
155
|
-
"<script>(function(){var TK=%s;if(!TK)return;try{"
|
|
156
|
-
"var isApi=function(u){u=String(u||'');return u.indexOf('/api/')===0"
|
|
157
|
-
"||u.indexOf('api/')===0;};"
|
|
158
|
-
"var _f=window.fetch;if(_f){window.fetch=function(i,o){o=o||{};"
|
|
159
|
-
"var u=(typeof i==='string')?i:((i&&i.url)||'');"
|
|
160
|
-
"if(isApi(u)){try{o.headers=new Headers(o.headers||{});"
|
|
161
|
-
"o.headers.set('X-CodeBee-Token',TK);}catch(e){}}"
|
|
162
|
-
"return _f.call(this,i,o);};}"
|
|
163
|
-
"var _o=XMLHttpRequest.prototype.open;"
|
|
164
|
-
"XMLHttpRequest.prototype.open=function(m,u){"
|
|
165
|
-
"this.__cbApi=isApi(u);return _o.apply(this,arguments);};"
|
|
166
|
-
"var _s=XMLHttpRequest.prototype.send;"
|
|
167
|
-
"XMLHttpRequest.prototype.send=function(b){"
|
|
168
|
-
"if(this.__cbApi){try{this.setRequestHeader('X-CodeBee-Token',TK);}"
|
|
169
|
-
"catch(e){}}return _s.apply(this,arguments);};"
|
|
170
|
-
"}catch(e){}})();</script>" % tk
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def _inject(html, run_id, tok, rel):
|
|
175
|
-
"""把 <base> + 令牌补丁插到入口 HTML 的最前面(head 内最优先)。"""
|
|
176
|
-
inject = ('<base href="%s">' % _base_href(run_id, tok, rel)) + _patch_token(tok)
|
|
177
|
-
m = _HEAD_RE.search(html)
|
|
178
|
-
if m:
|
|
179
|
-
return html[:m.end()] + inject + html[m.end():]
|
|
180
|
-
m = _HTML_RE.search(html)
|
|
181
|
-
if m:
|
|
182
|
-
return html[:m.end()] + inject + html[m.end():]
|
|
183
|
-
return inject + html
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def _decode(data):
|
|
187
|
-
for enc in ("utf-8", "gbk"):
|
|
188
|
-
try:
|
|
189
|
-
return data.decode(enc)
|
|
190
|
-
except UnicodeDecodeError:
|
|
191
|
-
continue
|
|
192
|
-
return data.decode("utf-8", "replace")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def serve(run_id, rel, tok=""):
|
|
196
|
-
"""读取一个预览资源。返回 (bytes|None, content-type, 错误文本)。"""
|
|
197
|
-
p, err = resolve(run_id, rel)
|
|
198
|
-
if err:
|
|
199
|
-
return None, "", err
|
|
200
|
-
try:
|
|
201
|
-
if p.stat().st_size > MAX_FILE:
|
|
202
|
-
return None, "", "文件过大,不支持预览"
|
|
203
|
-
data = p.read_bytes()
|
|
204
|
-
except OSError as e:
|
|
205
|
-
return None, "", "读取失败: %s" % e
|
|
206
|
-
ext = p.suffix.lower()
|
|
207
|
-
ctype = _MIME.get(ext, "application/octet-stream")
|
|
208
|
-
if ext in _HTML_EXT:
|
|
209
|
-
# 入口页:注入 <base> 与令牌补丁,并按 UTF-8 重编码(非 UTF-8 落 GBK
|
|
210
|
-
# 回退——子代理在中文 Windows 上常把文件写成 GBK)
|
|
211
|
-
data = _inject(_decode(data), run_id, tok, rel).encode("utf-8")
|
|
212
|
-
elif ext in _TXT:
|
|
213
|
-
data = _decode(data).encode("utf-8")
|
|
214
|
-
return data, ctype, None
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def _pick_entry(avail):
|
|
218
|
-
"""挑入口 HTML:优先工作目录根的 index.html,其次最浅、最新。
|
|
219
|
-
|
|
220
|
-
先只看根目录(单页应用的标准形态),根目录没有 HTML 时才退到最浅的子目录
|
|
221
|
-
入口——`web/index.html` 这类布局也该能预览。
|
|
222
|
-
"""
|
|
223
|
-
htmls = [f for f in avail if _ext(f["name"]) in _HTML_EXT]
|
|
224
|
-
if not htmls:
|
|
225
|
-
return ""
|
|
226
|
-
roots = [f for f in htmls if "/" not in f["name"]]
|
|
227
|
-
cands = roots or htmls
|
|
228
|
-
best = None
|
|
229
|
-
for f in cands:
|
|
230
|
-
name = f["name"]
|
|
231
|
-
base = name.rsplit("/", 1)[-1].lower()
|
|
232
|
-
key = (0 if base in ("index.html", "index.htm") else 1,
|
|
233
|
-
name.count("/"), -int(f.get("mtime") or 0), name)
|
|
234
|
-
if best is None or key < best[0]:
|
|
235
|
-
best = (key, name)
|
|
236
|
-
return best[1] if best else ""
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def _tab_key(f):
|
|
240
|
-
name = f["name"]
|
|
241
|
-
return (_TAB_ORDER.get(_ext(name), 9), name.count("/"),
|
|
242
|
-
-int(f.get("mtime") or 0), name)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def preview_path(run_id, tok):
|
|
246
|
-
"""预览根路径(带路径令牌):前端据此拼 iframe src 与新窗口地址。
|
|
247
|
-
|
|
248
|
-
令牌为空时落「-」占位段:远程开着时它会自然 401,本机(remote 未配令牌
|
|
249
|
-
或 loopback 豁免)照常放行——路径里永远有一段,前端不用做两种拼接。
|
|
250
|
-
"""
|
|
251
|
-
return "%s/%s/%s/" % (BASE, run_id, tok or "-")
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def list_app(run_id, tok=""):
|
|
255
|
-
"""本任务可预览的网页应用。返回前端渲染页签条所需的全部信息。
|
|
256
|
-
|
|
257
|
-
{"ok": False, "reason": "no_run"|"no_workdir"|"no_steps"|"no_html"} 表示没有
|
|
258
|
-
可预览的东西(前端据此把「预览」页签整体隐藏,不打扰非前端任务)。
|
|
259
|
-
"""
|
|
260
|
-
if not store.get_run(run_id):
|
|
261
|
-
return {"ok": False, "reason": "no_run"}
|
|
262
|
-
wd, files = store.run_artifacts(run_id, limit=MAX_LIST)
|
|
263
|
-
if not wd or not Path(wd).is_dir():
|
|
264
|
-
return {"ok": False, "reason": "no_workdir"}
|
|
265
|
-
# 与「成果」同一口径:一步没跑出来过的任务没有成品可言(历次都在检出等
|
|
266
|
-
# 前置环节失败时,工作目录里的变动是并行活动的噪音)
|
|
267
|
-
run = store.get_run(run_id) or {}
|
|
268
|
-
if not store.task_step_count(run.get("task_id") or ""):
|
|
269
|
-
return {"ok": False, "reason": "no_steps"}
|
|
270
|
-
avail = [f for f in (files or [])
|
|
271
|
-
if _ext(f["name"]) in PREVIEW_EXT
|
|
272
|
-
and int(f.get("size") or 0) <= MAX_FILE]
|
|
273
|
-
entry = _pick_entry(avail)
|
|
274
|
-
if not entry:
|
|
275
|
-
return {"ok": False, "reason": "no_html", "workdir": wd}
|
|
276
|
-
edir = entry.rsplit("/", 1)[0] if "/" in entry else ""
|
|
277
|
-
tabs = [f for f in avail
|
|
278
|
-
if _ext(f["name"]) in TAB_EXT
|
|
279
|
-
and (f["name"].rsplit("/", 1)[0] if "/" in f["name"] else "") == edir]
|
|
280
|
-
tabs.sort(key=_tab_key)
|
|
281
|
-
names, seen = [], set()
|
|
282
|
-
for f in tabs:
|
|
283
|
-
if f["name"] in seen:
|
|
284
|
-
continue
|
|
285
|
-
seen.add(f["name"])
|
|
286
|
-
names.append(f["name"])
|
|
287
|
-
if len(names) >= MAX_TABS:
|
|
288
|
-
break
|
|
289
|
-
# 入口一定在,且一定排第一(页签条第一格就是"这个应用",运行视图另占一格)
|
|
290
|
-
names = [entry] + [n for n in names if n != entry]
|
|
291
|
-
return {"ok": True, "entry": entry, "workdir": wd,
|
|
292
|
-
"base": preview_path(run_id, tok),
|
|
293
|
-
"files": [{"name": n, "ext": _ext(n).lstrip(".")} for n in names]}
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""运行预览:把任务工作目录里的网页成品挂成「可实时查看」的页面。
|
|
3
|
+
|
|
4
|
+
借鉴对话式编程产品(右栏「运行 / index.html / style.css / script.js」页签):
|
|
5
|
+
代码任务跑完,用户要的不是翻文件,而是**直接看到东西跑起来**。这里提供两件事:
|
|
6
|
+
|
|
7
|
+
1. list_app(run_id):从本任务的成品文件里挑出可预览的应用——入口 HTML +
|
|
8
|
+
同目录的代码文件(按 html→css→js 排序),供前端渲染页签条。
|
|
9
|
+
2. serve(run_id, rel):只读挂载工作目录里的静态资源,供 iframe / 新窗口直接
|
|
10
|
+
打开(<img>/<link>/<script> 这些子资源请求带不了自定义请求头)。
|
|
11
|
+
|
|
12
|
+
**访问令牌走路径段**(/preview/<run>/<令牌>/<文件>):子资源是浏览器按文档
|
|
13
|
+
URL 相对解析的,查询串不会被继承——令牌只能放路径里才覆盖得到 style.css /
|
|
14
|
+
script.js。鉴权本身直接复用 remote.request_authed(与 /api/* 同一把尺子,
|
|
15
|
+
含反向代理下"带转发头的 loopback 算远程"的判断),所以本地免令牌、公网必须
|
|
16
|
+
带令牌,两条路都不需要新写一套判断。
|
|
17
|
+
|
|
18
|
+
安全边界(这是把一个任意目录交给浏览器渲染,必须写清楚):
|
|
19
|
+
- 只认静态扩展名白名单,单文件体积上限;路径 resolve 后必须落在工作目录内。
|
|
20
|
+
- 工作目录下的服务端可执行物(.py/.php/.rb/.sh…)不在白名单,不会被预览。
|
|
21
|
+
- HTML 响应注入 <base>(相对资源在子路径下也解析得对)+ 一段极小的补丁:
|
|
22
|
+
页面里的 fetch/XHR 指向 /api/ 时自动补上访问令牌,让「对话生成的示例应用」
|
|
23
|
+
能真的连通后端。补丁只加一个请求头;写接口该 423/401 的照旧。
|
|
24
|
+
- 前端把预览 iframe 挂在同一来源下、不加 sandbox:预览要**像真的在跑**——生成的
|
|
25
|
+
示例应用大量用 localStorage,sandbox 去掉 same-origin 会让它当场抛 SecurityError,
|
|
26
|
+
而 allow-same-origin 的 sandbox 等于没加(页面可自行摘掉)。这与本地 dev server
|
|
27
|
+
的信任模型一致:跑的是你自己的代码、在你自己的机器上。代价是预览页能读到
|
|
28
|
+
本会话的 localStorage 令牌,故「预览」页签只在用户主动点开时才加载(不预跑)。
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from . import store
|
|
37
|
+
|
|
38
|
+
# 静态资源白名单:只挂能安全交给浏览器渲染的扩展名。刻意不含 .py/.php/.rb/.sh
|
|
39
|
+
# 等工作目录里可能出现的服务端可执行物——预览是只读静态托管,不是网站服务器。
|
|
40
|
+
PREVIEW_EXT = {
|
|
41
|
+
".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map",
|
|
42
|
+
".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif",
|
|
43
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
44
|
+
".mp3", ".wav", ".ogg", ".mp4", ".webm",
|
|
45
|
+
".txt", ".md", ".csv", ".xml", ".wasm", ".yml", ".yaml", ".toml",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# 页签条里列出的扩展名(比可挂载范围窄):图片/字体是资源不是「文件」,
|
|
49
|
+
# 给它们开页签只是噪音。
|
|
50
|
+
TAB_EXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".svg",
|
|
51
|
+
".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml", ".map"}
|
|
52
|
+
|
|
53
|
+
_HTML_EXT = {".html", ".htm"}
|
|
54
|
+
|
|
55
|
+
# 页签排序权重:入口 → 样式 → 脚本 → 数据 → 其它
|
|
56
|
+
_TAB_ORDER = {".html": 0, ".htm": 0, ".css": 1, ".js": 2, ".mjs": 2, ".cjs": 2,
|
|
57
|
+
".json": 3, ".map": 4, ".svg": 5, ".md": 6, ".txt": 6, ".csv": 6,
|
|
58
|
+
".xml": 6, ".yml": 6, ".yaml": 6, ".toml": 6}
|
|
59
|
+
|
|
60
|
+
BASE = "/preview" # 路由前缀,与 main.py 的 do_GET 分发约定
|
|
61
|
+
MAX_FILE = 4 << 20 # 单文件上限:超过不挂(大文件走「成果」页签下载)
|
|
62
|
+
MAX_TABS = 12 # 页签上限:防病态目录撑爆页签条
|
|
63
|
+
MAX_LIST = 400 # 参与挑选的成品文件上限
|
|
64
|
+
|
|
65
|
+
_MIME = {
|
|
66
|
+
".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
|
|
67
|
+
".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
68
|
+
".mjs": "text/javascript; charset=utf-8", ".cjs": "text/javascript; charset=utf-8",
|
|
69
|
+
".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8",
|
|
70
|
+
".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
|
|
71
|
+
".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
|
|
72
|
+
".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
|
|
73
|
+
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
|
|
74
|
+
".otf": "font/otf", ".eot": "application/vnd.ms-fontobject",
|
|
75
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
|
76
|
+
".mp4": "video/mp4", ".webm": "video/webm",
|
|
77
|
+
".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
|
|
78
|
+
".csv": "text/plain; charset=utf-8", ".xml": "text/xml; charset=utf-8",
|
|
79
|
+
".wasm": "application/wasm",
|
|
80
|
+
".yml": "text/plain; charset=utf-8", ".yaml": "text/plain; charset=utf-8",
|
|
81
|
+
".toml": "text/plain; charset=utf-8",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_TXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map", ".svg",
|
|
85
|
+
".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml"}
|
|
86
|
+
|
|
87
|
+
_HEAD_RE = re.compile(r"<head\b[^>]*>", re.I)
|
|
88
|
+
_HTML_RE = re.compile(r"<html\b[^>]*>", re.I)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ext(name):
|
|
92
|
+
return Path(str(name or "")).suffix.lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def safe_rel(rel):
|
|
96
|
+
"""归一化工作目录内相对路径。返回 None 表示越界/非法。"""
|
|
97
|
+
rel = str(rel or "").replace("\\", "/").lstrip("/")
|
|
98
|
+
if not rel:
|
|
99
|
+
return None
|
|
100
|
+
parts = [p for p in rel.split("/") if p not in ("", ".")]
|
|
101
|
+
if not parts or any(p == ".." for p in parts):
|
|
102
|
+
return None
|
|
103
|
+
# 隐藏目录(.git/.zcode/.claude…)是工具过程文件,不是成品,一律不给挂
|
|
104
|
+
if any(p.startswith(".") for p in parts):
|
|
105
|
+
return None
|
|
106
|
+
return "/".join(parts)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve(run_id, rel):
|
|
110
|
+
"""把 rel 解析到运行工作目录内的真实文件。返回 (Path|None, 错误文本)。
|
|
111
|
+
|
|
112
|
+
与 store.read_run_file 同一道守卫:resolve 之后必须仍在工作目录内,
|
|
113
|
+
顺带挡掉软链接指向工作目录之外的形态。
|
|
114
|
+
"""
|
|
115
|
+
wd = store.run_workdir(run_id)
|
|
116
|
+
if not wd:
|
|
117
|
+
return None, "该运行没有关联的工作目录"
|
|
118
|
+
safe = safe_rel(rel)
|
|
119
|
+
if not safe:
|
|
120
|
+
return None, "非法路径"
|
|
121
|
+
if _ext(safe) not in PREVIEW_EXT:
|
|
122
|
+
return None, "该类型不支持预览"
|
|
123
|
+
root = Path(wd).resolve()
|
|
124
|
+
try:
|
|
125
|
+
p = (root / safe).resolve()
|
|
126
|
+
except OSError:
|
|
127
|
+
return None, "非法路径"
|
|
128
|
+
if root != p and root not in p.parents:
|
|
129
|
+
return None, "非法路径"
|
|
130
|
+
if not p.is_file():
|
|
131
|
+
return None, "文件不存在"
|
|
132
|
+
return p, None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _base_href(run_id, tok, rel):
|
|
136
|
+
"""入口 HTML 的 <base>:挂在 /preview/<run>/<令牌>/<入口目录>/ 上。
|
|
137
|
+
|
|
138
|
+
令牌放在路径里是为了让 style.css / ./js/a.js / ../x.css 这些相对子资源
|
|
139
|
+
自动继承它(查询串不会被子资源继承);这个绝对路径也让相对资源在
|
|
140
|
+
子目录入口(web/index.html)下解析得对。
|
|
141
|
+
"""
|
|
142
|
+
d = rel.rsplit("/", 1)[0] if "/" in rel else ""
|
|
143
|
+
return "%s/%s/%s/%s" % (BASE, run_id, tok, (d + "/") if d else "")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _patch_token(tok):
|
|
147
|
+
"""注入页面脚本:预览页里的 fetch/XHR 打 /api/ 时自动补访问令牌。
|
|
148
|
+
|
|
149
|
+
生成的示例应用常直接 fetch("/api/..."),而子资源与脚本发起的请求带不了
|
|
150
|
+
自定义请求头——不补的话预览里一切接口调用都 401,用户会以为是自己代码写
|
|
151
|
+
错了。补丁只加一个请求头;写接口该 423/401 的照旧,不放行任何服务端能力。
|
|
152
|
+
"""
|
|
153
|
+
tk = json.dumps(str(tok or ""))
|
|
154
|
+
return (
|
|
155
|
+
"<script>(function(){var TK=%s;if(!TK)return;try{"
|
|
156
|
+
"var isApi=function(u){u=String(u||'');return u.indexOf('/api/')===0"
|
|
157
|
+
"||u.indexOf('api/')===0;};"
|
|
158
|
+
"var _f=window.fetch;if(_f){window.fetch=function(i,o){o=o||{};"
|
|
159
|
+
"var u=(typeof i==='string')?i:((i&&i.url)||'');"
|
|
160
|
+
"if(isApi(u)){try{o.headers=new Headers(o.headers||{});"
|
|
161
|
+
"o.headers.set('X-CodeBee-Token',TK);}catch(e){}}"
|
|
162
|
+
"return _f.call(this,i,o);};}"
|
|
163
|
+
"var _o=XMLHttpRequest.prototype.open;"
|
|
164
|
+
"XMLHttpRequest.prototype.open=function(m,u){"
|
|
165
|
+
"this.__cbApi=isApi(u);return _o.apply(this,arguments);};"
|
|
166
|
+
"var _s=XMLHttpRequest.prototype.send;"
|
|
167
|
+
"XMLHttpRequest.prototype.send=function(b){"
|
|
168
|
+
"if(this.__cbApi){try{this.setRequestHeader('X-CodeBee-Token',TK);}"
|
|
169
|
+
"catch(e){}}return _s.apply(this,arguments);};"
|
|
170
|
+
"}catch(e){}})();</script>" % tk
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _inject(html, run_id, tok, rel):
|
|
175
|
+
"""把 <base> + 令牌补丁插到入口 HTML 的最前面(head 内最优先)。"""
|
|
176
|
+
inject = ('<base href="%s">' % _base_href(run_id, tok, rel)) + _patch_token(tok)
|
|
177
|
+
m = _HEAD_RE.search(html)
|
|
178
|
+
if m:
|
|
179
|
+
return html[:m.end()] + inject + html[m.end():]
|
|
180
|
+
m = _HTML_RE.search(html)
|
|
181
|
+
if m:
|
|
182
|
+
return html[:m.end()] + inject + html[m.end():]
|
|
183
|
+
return inject + html
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _decode(data):
|
|
187
|
+
for enc in ("utf-8", "gbk"):
|
|
188
|
+
try:
|
|
189
|
+
return data.decode(enc)
|
|
190
|
+
except UnicodeDecodeError:
|
|
191
|
+
continue
|
|
192
|
+
return data.decode("utf-8", "replace")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def serve(run_id, rel, tok=""):
|
|
196
|
+
"""读取一个预览资源。返回 (bytes|None, content-type, 错误文本)。"""
|
|
197
|
+
p, err = resolve(run_id, rel)
|
|
198
|
+
if err:
|
|
199
|
+
return None, "", err
|
|
200
|
+
try:
|
|
201
|
+
if p.stat().st_size > MAX_FILE:
|
|
202
|
+
return None, "", "文件过大,不支持预览"
|
|
203
|
+
data = p.read_bytes()
|
|
204
|
+
except OSError as e:
|
|
205
|
+
return None, "", "读取失败: %s" % e
|
|
206
|
+
ext = p.suffix.lower()
|
|
207
|
+
ctype = _MIME.get(ext, "application/octet-stream")
|
|
208
|
+
if ext in _HTML_EXT:
|
|
209
|
+
# 入口页:注入 <base> 与令牌补丁,并按 UTF-8 重编码(非 UTF-8 落 GBK
|
|
210
|
+
# 回退——子代理在中文 Windows 上常把文件写成 GBK)
|
|
211
|
+
data = _inject(_decode(data), run_id, tok, rel).encode("utf-8")
|
|
212
|
+
elif ext in _TXT:
|
|
213
|
+
data = _decode(data).encode("utf-8")
|
|
214
|
+
return data, ctype, None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _pick_entry(avail):
|
|
218
|
+
"""挑入口 HTML:优先工作目录根的 index.html,其次最浅、最新。
|
|
219
|
+
|
|
220
|
+
先只看根目录(单页应用的标准形态),根目录没有 HTML 时才退到最浅的子目录
|
|
221
|
+
入口——`web/index.html` 这类布局也该能预览。
|
|
222
|
+
"""
|
|
223
|
+
htmls = [f for f in avail if _ext(f["name"]) in _HTML_EXT]
|
|
224
|
+
if not htmls:
|
|
225
|
+
return ""
|
|
226
|
+
roots = [f for f in htmls if "/" not in f["name"]]
|
|
227
|
+
cands = roots or htmls
|
|
228
|
+
best = None
|
|
229
|
+
for f in cands:
|
|
230
|
+
name = f["name"]
|
|
231
|
+
base = name.rsplit("/", 1)[-1].lower()
|
|
232
|
+
key = (0 if base in ("index.html", "index.htm") else 1,
|
|
233
|
+
name.count("/"), -int(f.get("mtime") or 0), name)
|
|
234
|
+
if best is None or key < best[0]:
|
|
235
|
+
best = (key, name)
|
|
236
|
+
return best[1] if best else ""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _tab_key(f):
|
|
240
|
+
name = f["name"]
|
|
241
|
+
return (_TAB_ORDER.get(_ext(name), 9), name.count("/"),
|
|
242
|
+
-int(f.get("mtime") or 0), name)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def preview_path(run_id, tok):
|
|
246
|
+
"""预览根路径(带路径令牌):前端据此拼 iframe src 与新窗口地址。
|
|
247
|
+
|
|
248
|
+
令牌为空时落「-」占位段:远程开着时它会自然 401,本机(remote 未配令牌
|
|
249
|
+
或 loopback 豁免)照常放行——路径里永远有一段,前端不用做两种拼接。
|
|
250
|
+
"""
|
|
251
|
+
return "%s/%s/%s/" % (BASE, run_id, tok or "-")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def list_app(run_id, tok=""):
|
|
255
|
+
"""本任务可预览的网页应用。返回前端渲染页签条所需的全部信息。
|
|
256
|
+
|
|
257
|
+
{"ok": False, "reason": "no_run"|"no_workdir"|"no_steps"|"no_html"} 表示没有
|
|
258
|
+
可预览的东西(前端据此把「预览」页签整体隐藏,不打扰非前端任务)。
|
|
259
|
+
"""
|
|
260
|
+
if not store.get_run(run_id):
|
|
261
|
+
return {"ok": False, "reason": "no_run"}
|
|
262
|
+
wd, files = store.run_artifacts(run_id, limit=MAX_LIST)
|
|
263
|
+
if not wd or not Path(wd).is_dir():
|
|
264
|
+
return {"ok": False, "reason": "no_workdir"}
|
|
265
|
+
# 与「成果」同一口径:一步没跑出来过的任务没有成品可言(历次都在检出等
|
|
266
|
+
# 前置环节失败时,工作目录里的变动是并行活动的噪音)
|
|
267
|
+
run = store.get_run(run_id) or {}
|
|
268
|
+
if not store.task_step_count(run.get("task_id") or ""):
|
|
269
|
+
return {"ok": False, "reason": "no_steps"}
|
|
270
|
+
avail = [f for f in (files or [])
|
|
271
|
+
if _ext(f["name"]) in PREVIEW_EXT
|
|
272
|
+
and int(f.get("size") or 0) <= MAX_FILE]
|
|
273
|
+
entry = _pick_entry(avail)
|
|
274
|
+
if not entry:
|
|
275
|
+
return {"ok": False, "reason": "no_html", "workdir": wd}
|
|
276
|
+
edir = entry.rsplit("/", 1)[0] if "/" in entry else ""
|
|
277
|
+
tabs = [f for f in avail
|
|
278
|
+
if _ext(f["name"]) in TAB_EXT
|
|
279
|
+
and (f["name"].rsplit("/", 1)[0] if "/" in f["name"] else "") == edir]
|
|
280
|
+
tabs.sort(key=_tab_key)
|
|
281
|
+
names, seen = [], set()
|
|
282
|
+
for f in tabs:
|
|
283
|
+
if f["name"] in seen:
|
|
284
|
+
continue
|
|
285
|
+
seen.add(f["name"])
|
|
286
|
+
names.append(f["name"])
|
|
287
|
+
if len(names) >= MAX_TABS:
|
|
288
|
+
break
|
|
289
|
+
# 入口一定在,且一定排第一(页签条第一格就是"这个应用",运行视图另占一格)
|
|
290
|
+
names = [entry] + [n for n in names if n != entry]
|
|
291
|
+
return {"ok": True, "entry": entry, "workdir": wd,
|
|
292
|
+
"base": preview_path(run_id, tok),
|
|
293
|
+
"files": [{"name": n, "ext": _ext(n).lstrip(".")} for n in names]}
|