Zorv AI 对话框 Python 执行 · 技术架构文档

2 阅读19分钟

Zorv AI 对话框 Python 执行 · 技术架构文档

系列说明:本文是「Zorv AI 对话框技术架构」系列第 3/3 篇,三篇都基于 Quor-a/ZorvAI 开源仓库真实源码撰写。

1.**AIP 排版引擎** — 长文档 / PPT / 思维导图原生渲染
2.**可视化小卡片** — quro-card 围栏与 Canvas 自绘
3.👉 **对话框 Python 执行** — 四级降级链 + 原生 CPython 3.14 嵌入(本文)

关键词AndroidPythonCPythonJNIAI 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.ccore/tools/ · Kotlin + C + Python + JS


一、通道定位:两条工具,四种引擎

ZorvAI 给 AI 准备了两个跑 Python 的工具,入口不同、定位不同、底层引擎也不同:

工具名字定位返回物默认引擎
手机 AI IDErun_codeAI 写代码直接跑,产出物渲染在对话框里可渲染 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 校验
cssCSS 预览页HTML 包装
xml / svgSVG 直出矢量 / 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/pip20s(最大 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.sojniLibs/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 { "(无输出)" }
}

注意 errorstderr 的语义区分 —— 这个区分直接支撑了 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");

三个细节

  1. 不用 PyRun_SimpleString 的返回值判断成败 —— 它出错时会自己把 traceback 打到 sys.stderr,也就是已经重定向的 __quro_err。等于免费拿到了完整 traceback。
  2. 每次执行结束都恢复 sys.__stdout__ / sys.__stderr__ —— 因为解释器是常驻的,不恢复就会把脏 StringIO 状态留给下一次调用。
  3. 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:&#34;python&#34;}
    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=&#34;执行超时&#34;
    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
首选引擎原生嵌入 CPythonproot 容器 python3
能装三方库apt-get install / pip
返回格式人类可读文本(⚠️ 前缀)exit_code= + --- stdout --- + --- stderr ---
超时15s20s(可配 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.html80Brython 控制台页面(<body onload="brython()">
assets/www/python_env.py224「完整环境模块」:预加载 40+ 标准库 + 增强 print/input/JSON/字符串/数学/日期/列表/字典工具
assets/www/python_env_lite.py152精简版(去掉 socket/ssl/http.client 等网络相关)
assets/www/network.py126requests 风格 API,基于浏览器 XMLHttpRequest
assets/www/python.min.jsBrython 运行时(离线打包)

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 两套执行体系对照

维度PythonJS / TS
引擎CPython 3.14(嵌入) / proot / BrythonQuickJS(内置编译)
执行单元PyRun_SimpleString拼接后的完整脚本字符串
标准库完整(含 C 扩展)prelude.js 提供的 Lodash-lite + dataUtils
宿主 API无(纯 Python)Tools.Files/Net/System/calc + require()
路径限制无(原生引擎不限制)全部限制在 sandboxRoot
内存上限无显式限制64MB
超时15s(PyErr_SetInterrupt)15s(QuickJS 中断)
类型.tsTsTranspiler 转译

一个值得注意的差异:原生 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 的影响

组件fullfdroid
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.kt172原生 CPython 嵌入引擎(状态机/解压/看门狗)
jni/pybridge.c191JNI 桥(dlopen + dlsym + 输出捕获 + 中断)
core/tools/PythonRunTool.kt117python_run 工具(proot 容器)
core/tools/QuroToolsSystem.kt1,300+run_code 工具 + 四级降级链 + 多语言分发
core/scripting/SandboxRuntime.kt192JS/TS QuickJS 沙箱整合层
core/scripting/HostApiDispatcher.kt600+Tools.* 宿主 API 网关
core/scripting/TsTranspiler.kt900+TypeScript → JS 转译
core/tools/QuroSandboxTool.kt247sandbox 应用内隔离沙箱(shell/文件/grep)
core/tools/QuroScriptingTools.kt265code_runner / toolpkg_* / project_create
assets/www/python_env.py224Brython 完整环境增强模块
assets/www/python_env_lite.py152Brython 精简环境
assets/www/network.py126Brython requests 风格网络 API
assets/www/python_console.html80Brython 控制台页面
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:429runPython(code, ctx)                 QuroToolsSystem.kt:651
     ① PyEngine.probeAvailable()         PyEngine.kt:147
        PyEngine.run()                   PyEngine.kt:97ensure()                     PyEngine.kt:52doEnsure()                   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:29ensurePython()                       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 容器,慢但能装包)

给模型的隐含约定(从源码里读出来的):

  1. Python 代码要用 print 输出结果 —— 只有 stdout/stderr 会被捕获,表达式的最后一个值不会自动返回(不像 REPL)
  2. 全局命名空间会残留到下一次调用,写代码时别依赖这个特性
  3. 输出超过 12KB 会被截断(python_run),长结果要自己分批 print
  4. 15s / 20s 超时后会收到 KeyboardInterrupt,这是正常现象不是 bug
  5. 需要三方库时优先用标准库等价改写requirements.txt 原话)

文档基于 Quor-a/ZorvAI @ main 真实源码撰写,所有代码片段均取自仓库(含 PyEngine.ktpybridge.cPythonRunTool.ktQuroToolsSystem.ktSandboxRuntime.kt、Brython 资产与项目模板),未作功能性改写。标注 ⚠️ 的为实现短板或"已定义但未接线"的观察点。


📚 系列回顾

  • 第 1 篇 · AIP 排版引擎 — 长文档 / PPT / 思维导图原生渲染
  • 第 2 篇 · 可视化小卡片 — quro-card 围栏与 Canvas 自绘
  • 第 3 篇 · 对话框 Python 执行 — 四级降级链 + 原生 CPython 3.14 嵌入 (本文)

版权声明:本文为原创技术解析,基于开源项目 Quor-a/ZorvAI(Apache-2.0)源码撰写,代码片段均取自仓库未作功能性改写。转载请注明出处。

如果这篇解析对你有帮助,欢迎 点赞 ⭐ 收藏 📌 关注,后续会继续更新 Zorv AI 的终端、ACI 跨应用调用、离线大模型等模块。