Zorv AI 对话框 Python 执行 · 技术架构文档
系列说明:本文是「Zorv AI 对话框技术架构」系列第 3/3 篇,三篇都基于
Quor-a/ZorvAI开源仓库真实源码撰写。
1.**AIP 排版引擎** — 长文档 / PPT / 思维导图原生渲染
2.**可视化小卡片** — quro-card 围栏与 Canvas 自绘
3.👉 **对话框 Python 执行** — 四级降级链 + 原生 CPython 3.14 嵌入(本文)
关键词:
Android、Python、CPython、JNI、AI Agent
[TOC]
run_code/python_run:让 AI 在手机里真的把 Python 跑起来 —— 四级降级链:原生 CPython 3.14 嵌入引擎 → proot Linux 沙箱 python3 → 系统 python3 → Brython(WebView 纯 JS)源码依据:
github.com/Quor-a/ZorvAI@main(2026-09-07) 包名com.ai.assistance.quro· 核心路径core/python/、jni/pybridge.c、core/tools/· Kotlin + C + Python + JS
一、通道定位:两条工具,四种引擎
ZorvAI 给 AI 准备了两个跑 Python 的工具,入口不同、定位不同、底层引擎也不同:
| 工具 | 名字 | 定位 | 返回物 | 默认引擎 |
|---|---|---|---|---|
| 手机 AI IDE | run_code | AI 写代码直接跑,产出物渲染在对话框里 | 可渲染 HTML 工件 / 纯文本 | 原生 CPython 3.14(嵌入) |
| 沙箱执行器 | python_run | 在 proot Ubuntu 24.04 容器里跑 | exit_code + stdout + stderr 纯文本 | proot 容器 python3 -c |
run_code 的工具描述(写给模型看的)原文:
在手机端执行一段代码并返回结果,是 AI 自带的「手机 AI IDE(带可视化)」核心工具 —— 你(AI)可以直接写代码并运行,产出物会渲染在对话框里,无需用户手动编辑文件。
而 Python 分支的描述说清了降级承诺:
· python(默认):原生 CPython 3.14 嵌入引擎(full 风味),无需 Termux —— 完整标准库离线运行:数据处理/清洗、算法计算、json/csv/re/itertools/collections、hashlib/sqlite3/ssl 等含 C 扩展的模块全部可用,print 输出与 traceback 直接返回(15s 超时保护)。若无原生引擎则依次降级:应用内 Linux 沙箱 python3 → Brython(纯 JS 子集)。
1.1 run_code 支持的语言矩阵
run_code 不只是 Python,它是一个多语言执行器。lang 参数决定走哪条分支:
| lang | 行为 | 底层 |
|---|---|---|
python / py / py3(默认) | 真的执行 | 四级降级链 |
node / js / ts | 真的执行 | QuickJS 完整脚本沙箱(.ts 自动转译) |
shell / sh / bash | 真的执行 | 应用沙盒内 sh -c |
html | 原样返回 HTML 源码 | 对话框 WebView 实时渲染成可交互网页 |
json | 格式化 + 可视化 JSON 树 | 端侧 JSONTokener 校验 |
css | CSS 预览页 | HTML 包装 |
xml / svg | SVG 直出矢量 / XML 树形视图 | HTML 包装 |
c cpp java kotlin go rust … | 不编译,返回语法高亮代码页 | HTML 包装 |
关键设计:所有分支都返回可渲染的 HTML 页面(或纯文本),对话框统一用 WebView 预览 —— 这是「产出物直接长在对话框里」的实现基础。
二、四级降级链总览
flowchart TD
A[run_code lang=python] --> B{PyEngine.probeAvailable?<br/>assets/python 是否打包}
B -->|是| C[① 原生 CPython 3.14<br/>PyEngine.run]
C --> D{引擎级错误且<br/>stdout+stderr 均空?}
D -->|否| E[返回结果]
D -->|是| F[降级]
B -->|否| F
F --> G[② Linux 沙箱 python3<br/>filesDir/linux-sandbox/usr/bin/python3]
G --> H{存在?}
H -->|是| E
H -->|否| I[③ 系统 python3<br/>直接 python3 -c]
I --> J{含 not found?}
J -->|否| E
J -->|是| K[④ Brython<br/>WebView 纯 JS 解释器]
K --> E
2.1 降级判定的核心代码
/**
* Python 执行:优先级 原生 CPython 3.14(嵌入,full 风味)> 本应用 Linux 沙箱 python3
* > 系统 python3 > Brython(WebView 纯 JS 解释器兜底)。
*/
private fun runPython(code: String, ctx: Context): String {
// 1. 原生 CPython 3.14(libpython3.14.so 嵌入,标准库完整含 C 扩展,首启解压 assets)
if (PyEngine.probeAvailable(ctx)) {
val r = PyEngine.run(ctx, code)
// 引擎级错误(不可用/初始化失败,stdout/stderr 均空)→ 走降级链;
// 用户代码出错(stderr 有 traceback)→ 正常返回结果
if (!(r.error != null && r.stderr.isEmpty() && r.stdout.isEmpty())) {
return r.format()
}
}
// 2. 本应用自带 Linux 沙箱 Python 与系统 Python(不再依赖第三方 Termux 包路径)
val base = ctx.filesDir.absolutePath
val candidate = listOf(
"$base/linux-sandbox/usr/bin/python3",
"$base/linux-sandbox/usr/bin/python",
"python3",
"python"
).firstOrNull { java.io.File(it).exists() }
if (candidate != null) {
return execShell(ctx, "$candidate -c ${quoteShell(code)}")
}
val sys = execShell(ctx, "python3 -c ${quoteShell(code)}")
if (!sys.contains("not found") && !sys.startsWith("执行失败")) {
return sys
}
// 3. 无原生/沙箱/系统 Python → Brython 兜底
return runPythonBrython(code)
}
2.2 一处很讲究的判定:区分「引擎挂了」和「用户代码错了」
if (!(r.error != null && r.stderr.isEmpty() && r.stdout.isEmpty())) {
return r.format()
}
这个三重条件是关键:只有引擎级错误且完全无输出时才降级。如果用户写的 Python 抛出 ZeroDivisionError,stderr 里会有 traceback —— 这是正常结果,必须如实返回给 AI 让它自己修,绝不能降级重跑(否则 AI 永远看不到真实报错)。
2.3 四级引擎能力对照
| 级别 | 引擎 | 启动耗时 | 标准库 | C 扩展 | 三方库 | 超时 | 网络 |
|---|---|---|---|---|---|---|---|
| ① | 原生 CPython 3.14(嵌入) | 常驻,秒回 | 完整 | ✅ 全部可用 | ❌ | 15s(可打断) | ✅ |
| ② | proot Ubuntu 24.04 沙箱 | 0.8–2s(冷启) | 容器自带 | ✅ | ✅ 可 apt/pip | 20s(最大 60s) | ✅ |
| ③ | 系统 python3 | 快 | 视系统 | 视系统 | 视系统 | 20s | 视系统 |
| ④ | Brython(WebView JS) | 快 | 纯 JS 子集 | ❌ | ❌ | 浏览器策略 | ✅ XHR |
核心取舍:① 快但装不了三方库;② 慢但能 apt-get install;④ 最弱但零依赖、任何构建都能跑。
三、第一级:原生 CPython 3.14 嵌入引擎
3.1 它是什么
PyEngine 是 ZorvAI 自研的 CPython 嵌入层,遵循 python.org 官方 Android 嵌入指南(PEP 738 Tier 3 路线)。
/**
* 原生 CPython 3.14 引擎(嵌入模式,PEP 738 Tier 3 路线)。
*
* 集成方式遵循 python.org 官方 Android 嵌入指南:
* - libpython3.14.so + libssl/crypto/sqlite3_python.so → full 风味 jniLibs(arm64-v8a);
* - 标准库 python3.14 整棵目录(含 lib-dynload C 扩展)→ full 风味 assets,首启解压到 filesDir/python;
* - libquropybridge.so(app/src/main/jni/pybridge.c)经 JNI 提供初始化 / 执行 / 中断;
* - PYTHONHOME = filesDir/python。
*
* 执行模型:单解释器常驻进程;看门狗线程超时调 PyErr_SetInterrupt,
* 用户代码在字节码边界收到 KeyboardInterrupt,安全打断。
*/
object PyEngine {
const val PY_VERSION = "3.14.7"
private const val ASSET_ROOT = "python"
private const val HOME_DIR = "python"
private const val DEFAULT_TIMEOUT_MS = 15000L
3.2 打包体积
| 组成 | 位置 | 体积 |
|---|---|---|
libpython3.14.so | jniLibs/arm64-v8a/ | 5.56 MB |
libcrypto_python.so | 同上 | 4.33 MB |
libssl_python.so | 同上 | 0.91 MB |
libsqlite3_python.so | 同上 | 0.85 MB |
| Python 标准库整棵 | assets/python/ | 19.5 MB(710 文件,含 67 个 C 扩展 .so) |
源码注释描述首启解压体量为「~2600 文件 / 几秒钟」,实际仓库树统计为 710 条目 / 19.5 MB —— 差异可能来自构建期裁剪(剔除 tests 等)。无论如何这是一次性成本,靠版本标记文件避免重复解压。
3.3 状态机与幂等初始化
private sealed class State {
object Uninit : State()
object Loading : State()
object Ready : State()
data class Failed(val reason: String) : State()
}
fun ensure(context: Context): String? {
when (val s = state) {
is State.Ready -> return null
is State.Failed -> return s.reason // 失败后不再重试,直接返回原因
}
synchronized(this) {
when (val s = state) {
is State.Ready -> return null
is State.Failed -> return s.reason
State.Loading -> return "Python 引擎初始化中"
State.Uninit -> {}
}
state = State.Loading
val err = doEnsure(context.applicationContext)
if (err != null) { state = State.Failed(err); return err }
state = State.Ready
return null
}
}
双检锁 + 状态记住失败 —— 失败了就永久记住 reason,避免每次调用都重跑一遍几秒钟的解压。
3.4 初始化四步
private fun doEnsure(ctx: Context): String? {
// 1. 原生桥
try { System.loadLibrary("quropybridge") }
catch (e: UnsatisfiedLinkError) { return "原生 Python 桥不可用:${e.message}" }
// 2. 标准库解压(版本标记文件在则跳过,升级换版本号即可全量重解压)
val home = File(ctx.filesDir, HOME_DIR)
val marker = File(home, ".stdlib-$PY_VERSION")
if (!marker.exists()) {
val top = runCatching { ctx.assets.list(ASSET_ROOT) }.getOrNull()
if (top.isNullOrEmpty()) return "此构建未打包 Python 标准库(full 风味专属)"
runCatching { extractAssetDir(ctx, ASSET_ROOT, ctx.filesDir) }
.getOrElse { return "标准库解压失败:${it.message}" }
marker.writeText(PY_VERSION)
}
// 3. TMPDIR(Python 找临时目录用,Android 仅 API 33+ 自动设置)
runCatching { android.system.Os.setenv("TMPDIR", ctx.cacheDir.absolutePath, false) }
// 4. 启动解释器
return nativeInit(home.absolutePath)
}
版本标记文件 .stdlib-3.14.7 是个小而关键的技巧:升级 Python 版本时只需改 PY_VERSION 常量,标记文件名变了 → 自动全量重解压,不用写迁移逻辑。
3.5 看门狗中断:不杀进程,只设标志位
fun run(context: Context, code: String, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result {
ensure(context)?.let { return Result("", "", it) }
val watchdog = Thread {
try {
Thread.sleep(timeoutMs)
nativeInterrupt() // → PyErr_SetInterrupt()
} catch (_: InterruptedException) { }
}.apply { isDaemon = true; start() }
return try {
val out = arrayOfNulls<String>(2)
nativeRun(code, out)
val stdout = out[0] ?: ""
val stderr = out[1] ?: ""
val timedOut = stderr.contains("KeyboardInterrupt") // ← 用 stderr 内容判定超时
Result(stdout, stderr,
error = if (timedOut) "执行超时(>${timeoutMs / 1000}s,已中断)" else null)
} catch (e: Throwable) {
Result("", "", "Python 执行异常:${e.message}")
} finally {
watchdog.interrupt() // 正常返回则取消看门狗
}
}
超时识别方式很聪明:不靠线程通信,而是检查 stderr 里有没有 KeyboardInterrupt —— 因为 PyErr_SetInterrupt 的效果就是在用户代码里抛这个异常,traceback 必然进 stderr。
为什么不直接杀:解释器是常驻单例,杀了整个 Python 就废了。PyErr_SetInterrupt 只在下一个字节码边界生效,安全且不破坏解释器状态。
3.6 结果格式化
data class Result(
val stdout: String,
val stderr: String,
/** 引擎级错误(不可用 / 超时 / 异常);用户代码的 traceback 在 stderr。 */
val error: String?,
) {
fun format(): String = buildString {
if (error != null) append("⚠️ ").append(error).append('\n')
if (stdout.isNotBlank()) append(stdout.trimEnd()).append('\n')
if (stderr.isNotBlank()) append("⚠️ ").append(stderr.trim()).append('\n')
}.trim().ifEmpty { "(无输出)" }
}
注意 error 与 stderr 的语义区分 —— 这个区分直接支撑了 2.2 的降级判定。
3.7 轻量探测
/** 引擎是否可用(不触发完整初始化时用于探测:仅看桥与资产是否打包)。 */
fun probeAvailable(context: Context): Boolean {
val hasAssets = runCatching {
!context.assets.list(ASSET_ROOT).isNullOrEmpty()
}.getOrDefault(false)
return hasAssets
}
只看 assets 有没有打包,不触发几秒钟的解压。这是降级链第一跳的判断依据。
四、JNI 桥:pybridge.c 的 dlopen 魔法
4.1 为什么用 dlopen 而不是直接链接
/*
* pybridge.c — CPython 3.14 嵌入桥(libquropybridge.so)
* =====================================================
* 运行时 dlopen libpython3.14.so(APK lib 目录,full 风味 jniLibs 打包提供),
* dlsym 解析 Python C API,由 Kotlin 侧 com.ai.assistance.quro.core.python.PyEngine
* 经 JNI 调用。fdroid 风味不含预编译库 → dlopen 失败 → Kotlin 侧自动降级(Brython)。
*/
这是整个设计里最巧的一招:libquropybridge.so 不链接 libpython3.14.so,而是在运行时 dlopen + dlsym。
好处:同一个 libquropybridge.so 在没有 libpython3.14.so 的 fdroid 风味里也能加载成功,只是 dlopen 返回 NULL → 转成一个友好的错误字符串 → Kotlin 侧走降级链。一套代码,两个风味,不用条件编译。
4.2 dlsym 符号表
static void (*p_Py_Initialize)(void);
static int (*p_Py_IsInitialized)(void);
static int (*p_PyRun_SimpleString)(const char *);
static PyObject *(*p_PyImport_ImportModule)(const char *);
static PyObject *(*p_PyObject_GetAttrString)(PyObject *, const char *);
static PyObject *(*p_PyObject_CallMethod)(PyObject *, const char *, const char *, ...);
static const char *(*p_PyUnicode_AsUTF8)(PyObject *);
static void (*p_Py_DecRef)(PyObject *);
static int (*p_PyGILState_Ensure)(void); /* PyGILState_STATE 是 int 枚举 */
static void (*p_PyGILState_Release)(int);
static void (*p_PyErr_SetInterrupt)(void);
static int resolve_all(void) {
#define RESOLVE(sym, name) \
do { \
*(void **)(&sym) = dlsym(pylib, name); \
if (!(sym)) { LOGE("dlsym 缺少 %s", name); return -1; } \
} while (0)
RESOLVE(p_Py_Initialize, "Py_Initialize");
/* ... */
#undef RESOLVE
return 0;
}
只解析 11 个符号 —— 最小够用集。
4.3 nativeInit:装 SIGINT 处理器
pylib = dlopen("libpython3.14.so", RTLD_NOW | RTLD_GLOBAL);
if (!pylib) { snprintf(errbuf, ...); return (*env)->NewStringUTF(env, errbuf); }
if (resolve_all() != 0) return (*env)->NewStringUTF(env, "libpython3.14.so 符号解析不完整(版本不匹配?)");
setenv("PYTHONHOME", home, 1);
setenv("PYTHONPATH", home, 1);
p_Py_Initialize();
/* 装 SIGINT 默认处理器,保证 PyErr_SetInterrupt 表现为 KeyboardInterrupt */
p_PyRun_SimpleString(
"import signal\n"
"try:\n"
" signal.signal(signal.SIGINT, signal.default_int_handler)\n"
"except (ValueError, OSError):\n"
" pass\n");
这行 signal.signal(signal.SIGINT, signal.default_int_handler) 是超时中断能工作的前提 —— 没有它,PyErr_SetInterrupt 不会转成 KeyboardInterrupt,看门狗就形同虚设。
4.4 nativeRun:三段式输出捕获
int gil = p_PyGILState_Ensure();
/* 1. 输出重定向到 StringIO */
int rc = p_PyRun_SimpleString(
"import sys, io\n"
"__quro_out = io.StringIO()\n"
"__quro_err = io.StringIO()\n"
"sys.stdout = __quro_out\n"
"sys.stderr = __quro_err\n");
/* 2. 用户代码:PyRun_SimpleString 出错时自身会把 traceback 打进 sys.stderr(__quro_err) */
if (rc == 0) rc = p_PyRun_SimpleString(code);
/* 3. 取回两个流的内容(同时恢复 sys 流,避免脏状态留给下一次) */
jstring jout = NULL, jerr = NULL;
PyObject *sysmod = p_PyImport_ImportModule("sys");
if (sysmod) {
jout = attr_stream_to_jstring(env, sysmod, "stdout");
jerr = attr_stream_to_jstring(env, sysmod, "stderr");
p_Py_DecRef(sysmod);
}
p_PyRun_SimpleString(
"import sys\n"
"sys.stdout = sys.__stdout__\n"
"sys.stderr = sys.__stderr__\n");
三个细节:
- 不用
PyRun_SimpleString的返回值判断成败 —— 它出错时会自己把 traceback 打到sys.stderr,也就是已经重定向的__quro_err。等于免费拿到了完整 traceback。 - 每次执行结束都恢复
sys.__stdout__/sys.__stderr__—— 因为解释器是常驻的,不恢复就会把脏 StringIO 状态留给下一次调用。 PyGILState_Ensure/Release包裹 —— 为将来多线程调用留好口子。
4.5 nativeInterrupt
JNIEXPORT void JNICALL
Java_com_ai_assistance_quro_core_python_PyEngine_nativeInterrupt(JNIEnv *env, jobject thiz) {
(void) env; (void) thiz;
if (initialized && p_Py_IsInitialized()) p_PyErr_SetInterrupt();
}
三行,但这是「超时不杀进程」的实现核心。
4.6 完整执行时序
sequenceDiagram
participant AI as AI(模型)
participant RT as RunCodeTool
participant PE as PyEngine
participant WD as 看门狗线程
participant PB as pybridge.c
participant PY as CPython 3.14
AI->>RT: run_code {code, lang:"python"}
RT->>PE: probeAvailable(ctx)
PE-->>RT: true(assets/python 已打包)
RT->>PE: run(ctx, code, 15000)
PE->>PE: ensure() → 首启解压 stdlib / 复用
PE->>PB: nativeInit(PYTHONHOME)
PB->>PY: dlopen + Py_Initialize
PB->>PY: signal.signal(SIGINT, default_int_handler)
PB-->>PE: null(就绪)
PE->>WD: 启动 15s 看门狗(daemon)
PE->>PB: nativeRun(code, out[2])
PB->>PY: stdout/stderr → StringIO
PB->>PY: PyRun_SimpleString(code)
alt 用户代码正常
PY-->>PB: 0
else 用户代码抛错
PY-->>PB: -1 + traceback 进 __quro_err
else 超时
WD->>PB: nativeInterrupt()
PB->>PY: PyErr_SetInterrupt()
PY-->>PB: KeyboardInterrupt 进 __quro_err
end
PB->>PY: getvalue() × 2 + 恢复 sys 流
PB-->>PE: out[0]=stdout, out[1]=stderr
PE->>PE: stderr 含 KeyboardInterrupt? → error="执行超时"
PE-->>RT: Result.format()
RT-->>AI: 文本结果(或降级到下一级)
五、第二级:proot Linux 沙箱 python3
5.1 另一条独立入口:python_run
注意 python_run 是一个独立注册的工具,不经过 run_code:
class PythonRunTool : QuroTool {
override val name = "python_run"
override val description = "在 proot Ubuntu 24.04 容器内执行 Python 代码并返回结果。" +
"参数 {\"code\":\"Python 源码(必填)\",\"timeout_ms\":20000(最大 60000)}。" +
"适用:AI 自己写 Python 做数据处理/正则/格式化/小型算法/抓取后的二次清洗等。" +
"若容器没装 Python:首次调用会自动 apt-get install -y python3(需联网,写入层已挂载)。"
它和 run_code 的关键差异:
| 维度 | run_code (python) | python_run |
|---|---|---|
| 首选引擎 | 原生嵌入 CPython | proot 容器 python3 |
| 能装三方库 | ❌ | ✅ apt-get install / pip |
| 返回格式 | 人类可读文本(⚠️ 前缀) | exit_code= + --- stdout --- + --- stderr --- |
| 超时 | 15s | 20s(可配 1s–60s) |
| 冷启动 | 常驻秒回 | 0.8–2s |
5.2 自动安装 python3
private fun ensurePython(context: Context) {
val py = File(QuroLinuxEnv.rootfsPath(context), "usr/bin/python3")
if (py.exists()) return
// 缺失:触发 apt 安装(写入层已挂载)。静默执行,失败在 execPython 阶段会显式提示。
try {
val p = Runtime.getRuntime().exec(arrayOf(
QuroLinuxEnv.prootPath(context),
"--link2symlink", "--kill-on-exit",
"--rootfs=${QuroLinuxEnv.rootfsPath(context)}",
"--bind=${QuroLinuxEnv.sharedStorageHostDir(context)?.absolutePath ?: "/mnt"}:/mnt",
"/usr/bin/env", "sh", "-c",
"apt-get update -qq >/dev/null 2>&1 && apt-get install -y python3 python3-pip >/dev/null 2>&1"
))
p.waitFor(60_000, java.util.concurrent.TimeUnit.MILLISECONDS)
} catch (_: Exception) { /* 吞掉,execPython 会以更友好的方式报错 */ }
}
先探测再安装,且安装失败静默吞掉 —— 因为真正的报错在 execPython 阶段会以更友好的方式给出。这是不错的错误分层。
5.3 proot 参数与 -I 隔离模式
val args = arrayOf(
proot,
"--link2symlink", // 软链接兼容层
"--kill-on-exit", // 主进程退出即清干净子进程
"--rootfs=$rootfs",
"--bind=${sharedStorageHostDir}:/mnt",
"/usr/bin/env",
"python3", "-I", "-u", "-c", code
)
python3 -I 是隔离模式(isolated mode):忽略 PYTHONPATH、忽略用户 site-packages、不把脚本目录加进 sys.path。跑 AI 生成的一次性代码时,这能避免环境污染和意外的模块注入。
-u 无缓冲输出 —— 配合 PYTHONUNBUFFERED=1 双保险,保证超时被杀时也能拿到已产生的部分输出。
5.4 双线程非阻塞读取 + 截断
val outThread = Thread { proc.inputStream.bufferedReader().forEachLine { if (outSb.length < 12_000) outSb.appendLine(it) } }
val errThread = Thread { proc.errorStream.bufferedReader().forEachLine { if (errSb.length < 4_000) errSb.appendLine(it) } }
outThread.isDaemon = true; errThread.isDaemon = true
outThread.start(); errThread.start()
val ok = proc.waitFor(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
if (!ok) { proc.destroyForcibly(); outThread.join(500); errThread.join(500); return "python_run 超时…" }
outThread.join(2000); errThread.join(2000)
stdout 12KB / stderr 4KB 双截断,注释写明理由:
大段代码 / 大量输出会被截断(stdout 12KB、stderr 4KB),避免 AI 上下文被撑爆。
这是很实际的工程判断 —— 在移动端跑 AI,上下文是比正确性更稀缺的资源。
5.5 环境变量清洗
val pb = ProcessBuilder(*args)
pb.environment().clear() // ← 先清空
envArr.forEach { kv ->
val eq = kv.indexOf('=')
if (eq > 0) pb.environment()[kv.substring(0, eq)] = kv.substring(eq + 1)
}
pb.redirectErrorStream(false) // stdout / stderr 分开读
environment().clear() 再逐个塞入白名单变量 —— 不给容器继承宿主的脏环境。
六、第四级:Brython 兜底
6.1 触发条件
前面三级全部不可用时(无原生引擎、无沙箱、无系统 python3),runPythonBrython(code) 会生成一段 HTML,里面用 Brython(纯 JS 的 Python 解释器)执行代码 —— 对话框用 WebView 渲染,用户直接看到输出。
6.2 转义:五连替换
private fun runPythonBrython(code: String): String {
val escaped = code
.replace("\\", "\\\\")
.replace("</script", "<\\/script") // 防提前闭合 script 标签
.replace("`", "\\`") // 防破坏 JS 模板字符串
.replace("\$", "\\$") // 防模板字符串插值
return """<!DOCTYPE html>..."""
}
四道转义里,两道是安全相关的(会破坏 HTML/JS 结构,属于注入面),两道只保证语法正确:
| 序 | 转义 | 性质 | 防的是什么 |
|---|---|---|---|
| ① | 反斜杠自身 | 语法 | 后续转义序列被二次解析 |
| ② | script 闭合标签 | 安全 | 提前闭合 script 块,把后面的 JS 当普通文本 |
| ③ | 反引号 | 语法 | 破坏包裹代码的 JS 模板字符串 |
| ④ | $ 符号 | 安全 | 触发模板字符串插值 ${...} 提前求值 |
6.3 独立的 Brython 控制台资产
仓库里还有一套完整的 Brython 交互环境:
| 文件 | 行数 | 作用 |
|---|---|---|
assets/www/python_console.html | 80 | Brython 控制台页面(<body onload="brython()">) |
assets/www/python_env.py | 224 | 「完整环境模块」:预加载 40+ 标准库 + 增强 print/input/JSON/字符串/数学/日期/列表/字典工具 |
assets/www/python_env_lite.py | 152 | 精简版(去掉 socket/ssl/http.client 等网络相关) |
assets/www/network.py | 126 | requests 风格 API,基于浏览器 XMLHttpRequest |
assets/www/python.min.js | — | Brython 运行时(离线打包) |
network.py 值得单说 —— 它在浏览器里复刻了 requests 的手感:
from browser import window
class Response:
"""HTTP 响应对象"""
def __init__(self, xhr_response):
self.status_code = xhr_response['status']
self.status_text = xhr_response['statusText']
self.text = xhr_response['response']
self.headers = self._parse_headers(xhr_response['headers'])
def json(self):
"""解析 JSON 响应"""
return json.loads(self.text)
def __repr__(self):
return f"<Response [{self.status_code}]>"
还有 Session 类支持持久化设置。python_env.py 里 network 未加载时的降级:
try:
from network import get, post, put, delete, patch
except ImportError:
def get(url, **kwargs): raise Exception("network 模块未加载")
def post(url, **kwargs): raise Exception("network 模块未加载")
...
Brython 里 import 失败是 ImportError —— 用异常兜底而非静默失败。
6.4 python_env.py 的增强 print
_original_print = print
def enhanced_print(*args, **kwargs):
"""增强的 print 函数,支持更多格式化选项"""
sep = kwargs.get('sep', ' ')
end = kwargs.get('end', '\n')
file = kwargs.get('file', sys.stdout)
# 处理特殊对象
formatted_args = []
...
# 替换全局 print
连 input() 都做了模拟(浏览器里没法阻塞等待输入):
def enhanced_input(prompt=""):
# 增强的 input 函数(在浏览器中不支持,提供模拟)
6.5 能力边界(诚实标注)
工具描述里对 Brython 的措辞是「纯 JS 子集」而非「Python」—— 这是准确的。Brython 不支持 C 扩展,numpy/pandas 这类全废。所以它是兜底而非替代。
七、两条工具入口对照
7.1 run_code 的 lang 分发
override fun run(context: Context, arguments: String): String {
val code = JSONObject(arguments).optString("code", "").trim()
val lang = JSONObject(arguments).optString("lang", "python").trim().lowercase()
if (code.isEmpty()) return "缺少 code 参数"
return when (lang) {
"html", "htm", "markup" -> code // ← 注意:直接返回,不做任何处理
"node", "javascript", "js", "ts", "typescript" ->
SandboxRuntime(context, workspaceRoot(context)).runCode(code, lang).format()
"shell", "sh", "bash" -> execShell(context, code)
"python", "py", "py3" -> runPython(code, context)
"json" -> runJson(code)
"css" -> runCss(code)
"xml", "svg" -> runXmlSvg(code)
"c", "cpp", "c++", "cc", "h", "hpp", "java", "kotlin", "kt" -> runCompiledLang(lang, code)
"dart", "flutter", "go", ... -> runOtherLang(lang, code)
else -> "不支持的语言:$lang(…)"
}
}
lang=html 分支是最短的:-> code,直接把 HTML 源码原样返回。注释解释:
网页工件:返回 HTML 源码,对话框用 WebView 内联实时预览
这是「AI 生成的网页直接长在对话框里」的实现 —— 工具本身不渲染,只是把源码透传给对话框的 WebView。
7.2 可视化 JSON / CSS / XML
三个分支都是「端侧不执行,返回可视化 HTML」:
/** JSON 数据:端侧不执行,校验合法性后返回格式化 JSON(供对话框预览渲染)。 */
private fun runJson(code: String): String = try {
val v = org.json.JSONTokener(code).nextValue()
val formatted = when (v) {
is org.json.JSONObject -> v.toString(2)
is org.json.JSONArray -> v.toString(2)
else -> code
}
// 返回 HTML 可视化 JSON 树
buildString {
append("<!DOCTYPE html><html><head><meta charset=\"UTF-8\">")
append("<style>")
append(".json-key{color:#881391;font-weight:bold;}")
append(".json-string{color:#0B7500;}")
...
先 JSONTokener 校验合法性 —— 非法 JSON 走 catch 分支,不会渲染出空白页。
7.3 编译型语言的诚实处理
c/cpp/java/kotlin/go/rust/... 分支不编译,只返回语法高亮代码页。工具描述里明说:
· c / cpp / c++ / java / kotlin:编译型语言,返回语法高亮代码页面(HTML 渲染),完整编译请用 workspace/ACI 构建台。
不装作支持,明确指向真正能编译的通道 —— 这比给个假结果好得多。
八、对照系:JS/TS 的 QuickJS 沙箱
Python 走的是「嵌入 + 容器」混搭,JS/TS 走的是纯自研沙箱。放在一起看设计哲学很清晰。
8.1 组装方式
/**
* SandboxPackage 运行时(Kotlin 侧整合层)。
*
* 组装:assets/scripting/prelude.js(console + CommonJS + Tools.* + Lodash-lite + dataUtils)
* + 用户脚本(.ts 先经 [TsTranspiler] 转译)
* + 结果回传包装(module.exports / hostSetData("__result"))
* → QuickJS 脚本沙箱(64MB 内存上限 + 超时中断 + allowEval 供 CommonJS require 用)。
*
* 宿主 API(fs/net/system/calc)经 [HostApiDispatcher] 网关分发,全部限制在 [sandboxRoot] 内。
*/
class SandboxRuntime(
private val appContext: Context,
private val sandboxRoot: File,
) {
private val dispatcher = HostApiDispatcher(appContext.applicationContext, sandboxRoot)
三段拼接:prelude + 用户代码 + 结果回传包装,拼成一个字符串丢给 QuickJS。
8.2 三个入口
/** 跑内联代码。lang:js(默认)/ ts / typescript(自动转译)。 */
fun runCode(code: String, lang: String = "js", timeoutMs: Int = 15000): Result
/** 跑工作区内相对路径的脚本文件(.js 原样 / .ts 自动转译)。 */
fun runFile(relPath: String, timeoutMs: Int = 15000): Result
/** 加载 CommonJS 模块(工作区相对路径,.ts 自动转译)并调用其导出函数。 */
fun callModuleFunction(modulePath: String, fnName: String, argsJson: String, timeoutMs: Int): Result
8.3 路径越界防护
fun runFile(relPath: String, timeoutMs: Int = 15000): Result {
val f = resolveInRoot(relPath) ?: return Result(emptyList(), null, "路径越界或非法:$relPath")
if (!f.isFile) return Result(emptyList(), null, "文件不存在:$relPath")
...
}
第一件事就是 resolveInRoot —— 解析失败直接拒绝,不做任何尝试。
8.4 结果格式化与 Python 同构
fun format(): String = buildString {
if (error != null) append("⚠️ 执行错误:").append(error).append('\n')
logs.forEach { append(it).append('\n') }
val r = resultJson?.takeIf { it != "undefined" && it != "null" }
if (r != null) append("➡️ 返回值:").append(prettyJson(r)).append('\n')
if (isEmpty()) append("(无输出)")
}.trim()
和 PyEngine.Result.format() 是同一个模板:⚠️ 错误 → 输出 → 返回值 → (无输出) 兜底。给 AI 看的格式统一,模型不用学两套。
8.5 两套执行体系对照
| 维度 | Python | JS / TS |
|---|---|---|
| 引擎 | CPython 3.14(嵌入) / proot / Brython | QuickJS(内置编译) |
| 执行单元 | PyRun_SimpleString | 拼接后的完整脚本字符串 |
| 标准库 | 完整(含 C 扩展) | prelude.js 提供的 Lodash-lite + dataUtils |
| 宿主 API | 无(纯 Python) | Tools.Files/Net/System/calc + require() |
| 路径限制 | 无(原生引擎不限制) | 全部限制在 sandboxRoot |
| 内存上限 | 无显式限制 | 64MB |
| 超时 | 15s(PyErr_SetInterrupt) | 15s(QuickJS 中断) |
| 类型 | 无 | .ts 经 TsTranspiler 转译 |
一个值得注意的差异:原生 Python 引擎没有路径限制(它跑在 app 自己的进程里,用的是 app 自己的权限),而 JS 沙箱严格限制在 sandboxRoot。这是两种不同的信任模型 —— Python 引擎信任的是「AI 生成的一次性代码 + 15s 超时」,JS 沙箱信任的是「可复用的脚本包 + 路径隔离」。
九、风味与合规:full vs fdroid
9.1 风味划分
flavorDimensions += "distribution"
productFlavors {
create("full") {
dimension = "distribution"
// 默认风味:包含 src/full/jniLibs 下的预编译原生库(ncnn / sherpa / proot / talloc)
}
create("fdroid") {
dimension = "distribution"
}
}
build.gradle 里的注释说清了动机:
F-Droid 合规:fdroid 风味剔除预编译原生库(离线 ASR + 应用内 Linux 沙箱),仅保留源码编出的
libquroplugin.so;full 风味保持完整原生特性(Google Play / 自有分发)。
9.2 对 Python 的影响
| 组件 | full | fdroid |
|---|---|---|
libpython3.14.so 等 4 个 .so | ✅ 打包 | ❌ 剔除 |
assets/python/ 标准库 19.5MB | ✅ 打包 | ❌ 剔除 |
libquropybridge.so | ✅ 能加载 | ✅ 能加载(dlopen 失败但不崩) |
| 原生 Python 引擎 | ✅ 可用 | ❌ 自动降级 |
| 有效执行路径 | ① → ② → ③ → ④ | ② → ③ → ④ |
这正是 dlopen 设计的价值所在:fdroid 风味里 probeAvailable() 返回 false(assets 空),直接跳到第二级,System.loadLibrary("quropybridge") 都不会被调用。
十、Python 项目模板
project_create 工具能从内置模板创建项目,Python 是其中一种。
templates/python/
├── main.py # 入口
├── requirements.txt # 依赖说明
└── README.md
main.py 只有 15 行,但注释点明了运行方式:
#!/usr/bin/env python3
"""项目入口:`run_code {lang:"python"}` 直接运行(App 内置原生 CPython 3.14)。"""
def main() -> None:
print("Hello from Python!")
# 标准库全量可用:json / re / math / datetime / sqlite3 / ...
import json
data = {"project": "my-python-app", "stdlib": "full"}
print(json.dumps(data, ensure_ascii=False))
if __name__ == "__main__":
main()
requirements.txt 对三方库的说明非常直白:
# 依赖留空即可(原生 CPython 标准库已全量内置)。
# 纯 Python 依赖在端侧沙箱不可 pip 安装,需要三方库时:
# 1) 优先用标准库等价能力改写;2) 或让 AI 把逻辑改成纯 JS(Tools.* 宿主 API)。
README 里还给了可视化建议:
- 数据处理 / 算法计算 / 爬虫解析用 python 直接跑;
- 结果要可视化时,让 AI 输出
围栏);html</code> 工件或 AIP 排版(<code>aip- 文件读写走 AI 的 write_file / 工作区,或 JS 沙盒 Tools.Files。
这把三份文档串起来了:Python 算数据 → 出排版文档 / html</code> 出网页工件 / <code>aip```quro-card 出小卡片。执行层与展示层解耦。
十一、工程坑位清单
11.1 首启解压的阻塞问题
19.5MB / 710 文件的标准库解压是同步阻塞的。ensure() 虽然加了双检锁,但首次调用仍会卡住调用线程几秒钟。源码注释说「几秒钟」,实际低端机上可能更久。
⚠️ 缓解手段只有版本标记文件(避免重复解压),首启本身没有异步化或进度提示。
11.2 超时识别的脆弱性
val timedOut = stderr.contains("KeyboardInterrupt")
用字符串匹配判定超时。如果用户代码自己 except KeyboardInterrupt 吞掉了,或者打印了包含这个单词的文本,就会误判/漏判。
⚠️ 更稳的做法是让 native 层返回一个明确的中断标志位。
11.3 单解释器无隔离
注释写明「单解释器常驻进程」。这意味着:
- AI 上一次跑的代码定义的全局变量/函数会残留到下一次
- 用户代码
sys.exit()或触发致命错误会污染解释器状态 - 没有多会话隔离
nativeRun 里恢复 sys.stdout/stderr 只解决了流污染,变量命名空间是共享的。
11.4 原生引擎无路径/资源限制
原生 CPython 跑在 app 进程里,没有 sandboxRoot 那样的路径隔离(对比 JS 沙箱的 resolveInRoot)。AI 生成的 Python 代码理论上能读写 app 有权限的任何文件。
⚠️ 当前的防线只有 15s 超时。这是「能力优先」的取舍,但值得知道。
11.5 Brython 转义非完备
.replace("</script", "<\\/script")
只处理了 script 闭合标签,没处理 <!--、script 开始标签等其他 HTML/JS 上下文逃逸序列。若 AI 生成的 Python 代码里含这些字符串,仍可能破坏生成的 HTML。
11.6 工具描述与实际实现不一致
run_code 的描述末尾写着:
纯计算/爬虫/分析用 python(Brython 引擎,直接运行)
但同一段描述开头说的是「原生 CPython 3.14 嵌入引擎(full 风味)」。这是 Brython 时代遗留的文案没更新 —— 实际优先走的就是原生引擎。
⚠️ 这种自相矛盾的提示词会误导模型。同类问题在 AIP 文档里也出现过(提示词说 16 种块型,引擎实际 17 种)。
11.7 已定义但未接线的能力
| 能力 | 状态 |
|---|---|
python_env.py 完整版(含 socket/ssl/http.client) | 资产存在,未在主执行链路引用(Brython 兜底走的是内联生成 HTML) |
python_console.html Brython 控制台 | 独立资产,未见对话框入口 |
network.py requests 风格 API | 同上,需手动 import |
十二、代码地图
12.1 文件清单
| 文件 | 行数 | 职责 |
|---|---|---|
core/python/PyEngine.kt | 172 | 原生 CPython 嵌入引擎(状态机/解压/看门狗) |
jni/pybridge.c | 191 | JNI 桥(dlopen + dlsym + 输出捕获 + 中断) |
core/tools/PythonRunTool.kt | 117 | python_run 工具(proot 容器) |
core/tools/QuroToolsSystem.kt | 1,300+ | run_code 工具 + 四级降级链 + 多语言分发 |
core/scripting/SandboxRuntime.kt | 192 | JS/TS QuickJS 沙箱整合层 |
core/scripting/HostApiDispatcher.kt | 600+ | Tools.* 宿主 API 网关 |
core/scripting/TsTranspiler.kt | 900+ | TypeScript → JS 转译 |
core/tools/QuroSandboxTool.kt | 247 | sandbox 应用内隔离沙箱(shell/文件/grep) |
core/tools/QuroScriptingTools.kt | 265 | code_runner / toolpkg_* / project_create |
assets/www/python_env.py | 224 | Brython 完整环境增强模块 |
assets/www/python_env_lite.py | 152 | Brython 精简环境 |
assets/www/network.py | 126 | Brython requests 风格网络 API |
assets/www/python_console.html | 80 | Brython 控制台页面 |
assets/templates/python/ | 3 文件 | Python 项目模板 |
12.2 原生库清单
app/src/full/jniLibs/arm64-v8a/
├── libpython3.14.so 5.56 MB ← CPython 解释器本体
├── libcrypto_python.so 4.33 MB ← ssl 模块依赖
├── libssl_python.so 0.91 MB
└── libsqlite3_python.so 0.85 MB
app/src/full/assets/python/ 19.5 MB / 710 文件
└── lib/python3.14/
├── (标准库 .py)
└── lib-dynload/ 67 个 C 扩展 .so
12.3 调用链速查
AI: run_code{lang:"python"}
→ RunCodeTool.run() QuroToolsSystem.kt:429
→ runPython(code, ctx) QuroToolsSystem.kt:651
① PyEngine.probeAvailable() PyEngine.kt:147
PyEngine.run() PyEngine.kt:97
→ ensure() PyEngine.kt:52
→ doEnsure() PyEngine.kt:78 (loadLibrary → 解压 → setenv → nativeInit)
→ nativeRun() pybridge.c:119 (StringIO → 执行 → getvalue → 恢复)
→ nativeInterrupt() pybridge.c:184 (PyErr_SetInterrupt)
② execShell("...linux-sandbox/usr/bin/python3 -c ...")
③ execShell("python3 -c ...")
④ runPythonBrython(code) QuroToolsSystem.kt:677
AI: python_run{code}
→ PythonRunTool.run() PythonRunTool.kt:29
→ ensurePython() PythonRunTool.kt:36 (探测 → apt-get install)
→ execPython() PythonRunTool.kt:57 (proot + python3 -I -u -c)
→ runWithTimeout() PythonRunTool.kt:83 (双线程读 + 12KB/4KB 截断)
附录:AI 侧使用要点
优先用哪个工具?
- 要快速算点东西 / 清洗数据 / 生成可视化网页 →
run_code(走原生引擎,秒回) - 要装三方库 / 需要完整 Linux 环境 / 跑较长任务 →
python_run(走 proot 容器,慢但能装包)
给模型的隐含约定(从源码里读出来的):
- Python 代码要用 print 输出结果 —— 只有 stdout/stderr 会被捕获,表达式的最后一个值不会自动返回(不像 REPL)
- 全局命名空间会残留到下一次调用,写代码时别依赖这个特性
- 输出超过 12KB 会被截断(
python_run),长结果要自己分批 print - 15s / 20s 超时后会收到
KeyboardInterrupt,这是正常现象不是 bug - 需要三方库时优先用标准库等价改写(
requirements.txt原话)
文档基于 Quor-a/ZorvAI @ main 真实源码撰写,所有代码片段均取自仓库(含 PyEngine.kt、pybridge.c、PythonRunTool.kt、QuroToolsSystem.kt、SandboxRuntime.kt、Brython 资产与项目模板),未作功能性改写。标注 ⚠️ 的为实现短板或"已定义但未接线"的观察点。
📚 系列回顾
- 第 1 篇 · AIP 排版引擎 — 长文档 / PPT / 思维导图原生渲染
- 第 2 篇 · 可视化小卡片 — quro-card 围栏与 Canvas 自绘
- 第 3 篇 · 对话框 Python 执行 — 四级降级链 + 原生 CPython 3.14 嵌入 (本文)
版权声明:本文为原创技术解析,基于开源项目 Quor-a/ZorvAI(Apache-2.0)源码撰写,代码片段均取自仓库未作功能性改写。转载请注明出处。
如果这篇解析对你有帮助,欢迎 点赞 ⭐ 收藏 📌 关注,后续会继续更新 Zorv AI 的终端、ACI 跨应用调用、离线大模型等模块。