Zorv AI GenUI 技术架构深度解析:从双面设计到安全边界

Zorv AI GenUI · 技术架构文档

两套 GenUI,一个目标:让 AI 的回复是可触摸的界面,而不是一段文字 ------ :genui 独立库(对话框内嵌卡片)+ app/genui/app(与文本对话框平级的全屏生成式界面)

源码依据:github.com/Quor-a/ZorvAI @ main(2026-09-12) genui/ 库模块约 1,600 行 · app/.../genui/app/4,600 行 Kotlin + Compose + WebView + JS


目录

  • 一、先厘清:仓库里有两套 GenUI
  • 二、A 面:全屏 GenUI 模式
  • 三、B 面:genui 库模块的生成式卡片
  • 四、两阶段 Agent 循环:决策轮 + 渲染轮
  • 五、工具委派:从 30 个孤岛到 123 个全量
  • 六、渲染通道:HTML 是宿主,原生是叠加层
  • 七、GenUiCanvas 与 MoBridge 设备桥
  • 八、灵魂系统与模型配置桥接
  • 九、zorv/ui 协议:增量解析与静态扫描
  • 十、WebView 池、崩溃恢复与状态快照
  • 十一、错误自愈循环
  • 十二、回写对话框:为什么必须包围栏
  • 十三、过程可观测:事件流与绘制探针
  • 十四、安全边界
  • 十五、工程坑位清单
  • 十六、代码地图

一、先厘清:仓库里有两套 GenUI

这是读代码时最容易混淆的地方,必须放在最前面说清楚。

settings.gradle.kts:genui 是一个独立库模块app/build.gradle.kts 里有 implementation(project(":genui"))。但 app 自己的 com/ai/assistance/quro/genui/app/ 下又有一整套完整的 GenUI 实现。

它们不是重复,而是两件不同的事:

维度 A 面 app/.../genui/app/ B 面 genui/ 库模块
包名 com.ai.assistance.quro.genui.app com.zorv.genui
定位 全屏生成式界面:整个屏幕就是 AI 的回复 对话框内嵌卡片:混在聊天气泡流里
与对话框关系 平级(切换会话类型,不是嵌在 ChatScreen 里) 内嵌(聊天列表的一个 Compose 项)
产物形态 一份完整 HTML 文档 zorv/ui 围栏里的 JSX / HTML 组件
持久化 filesDir/gen/pages.jsonl(界面栈) Room(版本树 + 状态快照)
规模 ~4,600 行 ~1,600 行
minSdk 26(跟随 app) 24(库自身)

1.1 A 面的自我定位

QuroGenUiApp.kt 的注释把这条界线画得很清楚:

这是与文本对话框【平级】的全新全屏界面生成器:点切换按钮进入,整个屏幕即 AI 的回复。 ...... 不再依附于文本对话框(之前把它塞进 ChatScreen 的 generateGenUi 是错误做法)。

这句话记录了一次架构纠偏 ------ 早期把 GenUI 塞进 ChatScreen 是错的方向。

1.2 B 面的自我定位

GenUiPrompt.kt 里:

GenUI 不是聊天机器人。它的每一次回答都是一件可以被触摸、被点击、被使用的界面作品 ------ 整个界面就是 AI 的回复,没有文本气泡流。

1.3 两者怎么咬合

不是完全独立,有三处真实连接:

① 运行时清单复用。 app 的 GenUiCanvas.kt 直接 import 了库的类:

复制代码
import com.zorv.genui.runtime.GenUiRuntimes

② 渲染器是移植关系。 GenUiCanvas 的类注释写着「忠实移植自 GenUI A2UIRenderer」。

③ 但存在重复的运行时注册表。 app 自己有一个 render/RuntimeRegistry.kt,库里有一个 runtime/GenUiRuntimes.kt两份清单内容几乎一样

⚠️ 这是个真实的一致性风险。两份表各自维护,改了 assets/runtimes/ 里的文件却只更新其中一处,就会出现「提示词告诉模型某个库可用、但那个文件其实不存在」的静默故障 ------ 而这恰恰是 RuntimeRegistry 注释里明确说要避免的问题。


二、A 面:全屏 GenUI 模式

2.1 入口链路

复制代码
QuroMainScreen(会话类型开关)
  genUiType == "genui"  →  QuroGenUiApp(全屏)
  genUiType == "normal" →  ChatScreen(文本对话框)
复制代码
if (genUiType == "genui") {
    com.ai.assistance.quro.genui.app.QuroGenUiApp(
        dark = darkMode,
        onPushToChat = { html, title -> chatVm.pushGenUiHtmlToChat(html, title) },
        onExitToChat = { chatVm.setGenUiType("normal") },
        onTextReply = { chatVm.pushGenUiTextToChat(it) }
    )
} else {
    ChatScreen(...)
}

类型是每个会话独立持久化 的(存在会话的 genUiType 字段里),不是全局开关。

2.2 QuroGenUiApp:根屏 + 设置子页

复制代码
@Composable
fun QuroGenUiApp(
    dark: Boolean = false,
    onPushToChat: (html: String, title: String) -> Unit,
    onExitToChat: () -> Unit,
    onTextReply: (text: String) -> Unit,
)

内含四个子系统,单字直达:

NavTarget 子系统 界面文件
Soul 灵魂(人格孵化) SoulScreen.kt (14KB)
Memory 记忆库 MemoryScreen.kt (6KB)
Perms 工具授权 PermissionScreen.kt (16KB)
ModelConfig 模型服务 ModelConfigScreen.kt (39KB)
Settings 设置 SettingsScreen.kt

返回键分两层(注释里专门讲了这个设计):

复制代码
// 系统返回键拦截:GenUI 是根屏,默认返回会 finish Activity 直接退回手机桌面。
// 这里改为「设置子页开着 → 先关子页;否则退回 ZorvAI 文本对话框」。
// 注意:GenScaffold 内部还有一层 BackHandler(仅当界面栈 size>1 时启用,用于翻回上一屏),
// 它注册在更内层、优先级更高,所以多页时返回键先弹栈,到根屏才落到本处理。

2.3 GenScaffold:主屏壳(1,299 行)

这是 A 面最大的文件。结构是:

复制代码
GenScaffold
├── 状态行(Phase 阶段机 + 秒表 + 工具计数 + 通道标注)
├── A2UI 画布(WebView,remember 持有绝不重建)
├── 指令条(输入框)
├── 界面栈入口(抖音式上下滑翻看历史界面)
├── 思考时间线(ThinkingSheet)
├── 原生组件弹层(NativeWidgetSheet,495 行)
└── 原生渲染叠加层(XML / Compose / Canvas / Code)

阶段机(状态行的语义骨架,每个阶段有标签、颜色、是否计时):

复制代码
private enum class Phase(val label: String, val color: Color, val timed: Boolean) {
    Idle("待命", GenTheme.Dim, false),
    Thinking("思考", GenTheme.AmberDim, true),
    Deciding("决策", GenTheme.Amber, true),
    Tooling("调用工具", Color(0xFF5FA8A0), true),
    Awaiting("等待授权", GenTheme.Red, false),
    Rendering("绘制界面", Color(0xFF8A7BC8), true),
    Done("完成", GenTheme.Green, false),
    Failed("失败", GenTheme.Red, false),
}

注释里说明了为什么要有这个枚举:

状态行不该只是一串会变的字:得让人一眼看出"现在处于哪个阶段"。

一个关键的 Compose 陷阱(注释原文):

复制代码
// WebView / 渲染器只创建一次:用 remember 持有,绝不放进任何会变化的状态里,
// 否则 AndroidView 的 factory 会重跑、把正在流式写入的文档整个销毁重建。

供应商缓存同理:

复制代码
// 供应商配置缓存:store.loadProviders() 是磁盘读 + JSON 解析,
// 绝不能放在组合函数里每次重组都跑(会直接拖垮滚动/动画帧率)。
// 缓存键 = 外部传入的 configVersion:设置页里改完模型、关掉设置页时由
// MainActivity 把它 +1,这里就会重新读盘。
// 没有这个键就会出现真实 bug:用户新增/改好模型后回到主屏,cachedProvider
// 还是旧值 → 点发送要么报"未配置",要么用了旧地址。

三、B 面:genui 库模块的生成式卡片

3.1 模块配置

复制代码
android {
    namespace = "com.zorv.genui"
    compileSdk = 36
    defaultConfig {
        minSdk = 24
        consumerProguardFiles("consumer-rules.pro")
        multiDexEnabled = true
    }
    // 预置 lib / shell 资源按默认忽略规则打包(不压缩、保持原样供 WebViewAssetLoader 读取)
    androidResources {
        ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~"
    }
}

依赖极简:androidx.webkit(WebView 隔离)、coroutines、Room(版本树 + 状态快照)、Compose。

3.2 端到端链路

复制代码
LLM 流 ──ingest──▶ ProtocolParser ──▶ Store ──▶ Host(WebView 池) ──▶ shell.html
                                                       │
                                       Runtime/Intent/Emit 事件回流
                                                       │
                                                 SelfHeal 注入系统轮次

GenUiController 是编排器,对外暴露四个只读状态给对话框:

状态 用途
streamText 当前轮非围栏正文(普通气泡展示)
cards 本次对话产出的卡片引用列表
cardStatus 每卡片渲染状态(Loading/Ready/Error/Degraded)
events 宿主事件流(emit/intent/错误)
复制代码
sealed interface CardStatus {
    object Loading : CardStatus
    object Ready : CardStatus
    data class Error(val message: String, val attempt: Int) : CardStatus
    data class Degraded(val reason: String) : CardStatus
}

3.3 shell.html:卡片运行沙箱(13KB)

一个预置的 HTML 宿主,负责:转译、执行、错误上报、高度自适应。

转译(JSX → JS,用 sucrase):

复制代码
function transpile(src, lang) {
    if (lang === "html") return { code: src, isModule: false };
    var out = window.sucrase.transform(src, {
        transforms: ["jsx"],
        jsxRuntime: "automatic",
        production: true
    });
    return { code: out.code, isModule: true };
}

执行(Blob URL + module script):

复制代码
var url = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
var s = document.createElement("script");
s.type = "module";
s.src = url;

三层错误捕获

复制代码
window.addEventListener("error", function (e) { ... report(e.error, "runtime"); });
window.addEventListener("unhandledrejection", function (e) { report(e.reason, "async"); });
var nativeConsoleError = console.error;
console.error = function () { ... post({ type: "console-error", ... }); };

外加 React ErrorBoundary(shell 里重定义了 Boundary.prototype.render)。

高度自适应(100ms 去抖):

复制代码
function reportHeight() {
    if (heightTimer) return;
    heightTimer = setTimeout(function () {
        heightTimer = null;
        var h = Math.ceil(document.documentElement.scrollHeight);
        post({ type: "resize", height: h });
    }, 100);
}

3.4 shell 的 CSP(安全核心)

复制代码
<meta http-equiv="Content-Security-Policy"
      content="default-src 'none';
               script-src 'self' blob:;
               style-src 'self' 'unsafe-inline';
               img-src 'self' data: blob:;
               font-src 'self';
               connect-src 'none';
               form-action 'none';
               base-uri 'none'">

注释点明了核心:

connect-src 'none' 是核心:即使模型绕过静态扫描写出 fetch,也发不出去。

3.5 设计系统变量与「防破坏」CSS

shell 预置了一套 CSS 变量(深色模式切 html.dark),还有一条很有意思的兜底:

复制代码
/* 生成物常见破坏项,强制兜底 */
body * { position: static !important; }
body [data-zv-allow-fixed] { position: relative !important; }

模型写出的 position: fixed 会破坏宿主布局,直接强制拉回 static,需要浮动的加 data-zv-allow-fixed 白名单。

还有一处细节 ------ 不指定字体

复制代码
/* 不单独指定字体:继承 WebView 默认系统字体,与宿主应用字体保持一致,
   避免动态 UI 组件出现与应用其余界面不一致的「单独字体」。 */

四、两阶段 Agent 循环:决策轮 + 渲染轮

AgentLoop.kt(528 行)是 A 面的大脑。

4.1 为什么分两轮

复制代码
/**
 * 两阶段设计(体验与可靠性平衡):
 *  1) 决策轮(非流式,带 tools,仅 OpenAI 兼容协议):模型决定查资料/读记忆/用设备能力,
 *     工具结果回填上下文,模型自行决定查几轮(不再被固定数字腰斩,靠死循环检测兜底);
 *     若模型直接交出 HTML 则跳过渲染轮;
 *  2) 渲染轮(流式,不带 tools):全部上下文(含工具结果)交给模型逐字写出 HTML → 画布。
 */

决策轮禁止写 HTML(否则模型会在决策轮就吐整页 HTML,非流式、耗时且被丢弃,导致画布空白):

复制代码
val decisionSystem = system + "\n\n# 当前阶段:工具决策(重要)\n" +
    "现在是【决策阶段】,禁止输出 HTML 文档。只做两件事之一:\n" +
    "a) 需要实时信息/记忆/设备能力 → 调用相应工具(可连续多个);\n" +
    "b) 无任何工具需求 → 只回复四个字符:NO_TOOLS。\n" +
    "不要写界面、不要写代码、不要解释。"

4.2 工具轮数:软提醒 + 两道安全闸

这是一个很有意思的设计立场:

复制代码
// 关键立场:工具调用【不再被固定轮数腰斩】。模型想查多少轮就查多少轮,
// 自己会靠 NO_TOOLS / 直接成稿收尾。maxToolRounds 现在只是【软提醒阈值】——
// 超过后轻推一次、绝不强制中断;0 = 完全不限制。
// 唯一的硬停止只有两道"意外兜底"安全闸,二者都只拦失控、不拦正常调研:
//   ① 死循环检测:累计重复调用"完全相同的工具+参数"(拿不到新信息、在空转);
//   ② 很宽松的总时长兜底:防模型无限调不同工具把一次生成挂死。

安全闸 ①:死循环检测

复制代码
// 把本轮所有工具调用的"名称|参数"拼成一个签名。
// 若与之前某轮完全相同 → 没拿到新信息、在空转;累计到阈值就温柔收尾。
val sig = buildString {
    for (i in 0 until calls.length()) {
        val fn = calls.getJSONObject(i).optJSONObject("function") ?: continue
        append(fn.optString("name")).append('|').append(fn.optString("arguments").trim()).append(';')
    }
}
if (seenSigs.contains(sig)) {
    redundant++
    if (redundant >= REDUNDANT_LIMIT) { /* 温柔收尾 */ }
}

安全闸 ②:总时长兜底HARD_TIME_MS,注释说「很宽松,正常任务远到不了」)。

4.3 一个隐蔽的坑:决策轮的"说明文字"

复制代码
// 决策轮里模型可能说一些"数据已确认,开始落稿"之类的话——
// 如果它不是 HTML,不能作为 assistant 消息继续;否则模型进入渲染轮后
// 会顺着胡说,最终只写出一行字就停。把它转成一条 user 备注即可。

4.4 渲染轮:tool 消息折成 user 备注

复制代码
// 渲染轮:把 tool 消息折成 user 备注(避免部分端点要求 tool/tool_calls 严格配对)
for (i in 0 until messages.length()) {
    val m = messages.getJSONObject(i)
    if (role == "tool") {
        renderMsgs.put(JSONObject().put("role", "user")
            .put("content", "[工具结果] " + m.optString("content").take(8000)))
    }
    ...
}

4.5 续写模式:只给尾部片段

复制代码
// —— 续写模式:画布上已有上次留下的部分 HTML ——
// 只给模型【尾部片段】而不是全文,避免把几万字符塞进上下文(既贵又慢);
// 尾部足够让它判断"写到哪儿了、接下来该写什么"。
val tailLimit = provider.contextChars.coerceIn(500, 8000)
val tail = seed?.takeLast(tailLimit).orEmpty()

配合明确的续写指令:

你要做的是【续写】:上面是已写入画布的部分文档(只展示尾部)。 直接从它中断的地方接着往下写,绝对不要重复任何已有内容, 不要重新输出 <!DOCTYPE html> / <html> / <head> / 已有的 <style><script>

4.6 LeadingFenceFilter:无状态剥围栏

复制代码
// 部分模型即使在提示词里被禁止,仍会顺手把 HTML 包在 ```html ... ``` 里。
// 这个过滤器只在【开头】剥一次围栏:一旦确认不是围栏就原样放行后续所有内容,
// 绝不把正常 HTML 当成围栏缓冲而丢弃(v0.11.9 黑屏回归的根因正是旧版有状态
// stripper 把正文误判成围栏首部、持续返回空、最终吞掉整段输出)。
val fenceFilter = LeadingFenceFilter()

这是一个真实的线上事故记录 :有状态的 stripper 把正文误判成围栏首部 → 持续返回空 → 吞掉整段输出 → 黑屏。修法是改成只在开头判断一次的无状态设计。

4.7 空输出的明确报错

复制代码
if (html.isBlank()) {
    // 模型跑完若干轮思考却没吐出任何 HTML:绝不静默黑屏,明确报错让用户重试
    onError("模型未输出任何界面内容(接口返回为空,或提示词过严导致模型困惑)。请重试,或换一种说法 / 换个模型。")
}

4.8 快速模型预检

复制代码
// 快速模型预检(专项模型分派的真实用途之一):这条指令要不要先联网?
// 失败/未配置时静默跳过,绝不阻塞主流程。
if (!isPrefetchSkipped(userPrompt)) {
    runCatching { fast.preflight(userPrompt) }.getOrNull()?.let { hint -> ... }
}

跳过条件(续写类短指令不需要联网):

复制代码
private fun isPrefetchSkipped(prompt: String): Boolean {
    if (prompt.length < 4) return true
    return prompt.contains("继续") || prompt.contains("接着写") || prompt.contains("补完")
}

五、工具委派:从 30 个孤岛到 123 个全量

这是 A 面最重要的一次架构升级,ZorvToolAdapter.kt 的注释完整记录了动机。

5.1 之前的问题

此前 GenUI 全模式用自己的 BuiltinTools(仅 ~30 个 GenUI 私有的小工具集,且各自重实现了一遍 web/记忆/文件等能力,与 ZorvAI 主对话的工具是两套互不相通的孤岛)。

BuiltinTools.kt 现在还在仓库里(38KB),但已经不在主链路上了。

5.2 现在的对接

复制代码
class ZorvToolAdapter(context: Context) {
    /** ZorvAI 完整工具注册表(含全部内置工具 + 导入工具 + 技能工具)。 */
    private val registry: QuroToolRegistry = buildQuroRegistry(appContext).apply { attach(appContext) }
    /** ZorvAI 双引擎工具执行器(droid-mcp 派发 + 权限前置申请)。 */
    private val engine = QuroToolEngine(registry).apply { setContext(appContext) }
    /** 记忆库(已委托 QuroMemoryRepository)。 */
    val memory = AgentMemory(appContext)

四个对接点

能力 对接到
工具声明 declarations() registry.coreSpecs()(主对话默认下发的完整工具集)
工具执行 execute() QuroToolEngine 派发
权限 QuroToolEngine 内部的 QuroPermissionHolder
记忆 AgentMemoryQuroMemoryRepository(group="genui")

5.3 权限:解决双重门禁

这是集成时必然遇到的问题 ------ 两套权限流程会重复弹窗。ZorvGate 的解法:

复制代码
/**
 * 对接 ZorvAI 后的设计:真正的权限把关已经下沉到 QuroToolEngine 内部
 * (QuroPermissionHolder,与 ZorvAI 主对话完全一致),因此这里对 GenUI 决策轮
 * 一律返回「放行」(modeOf = ALWAYS_ALLOW,authorize = null),避免 GenUI 与 ZorvAI
 * 两套权限流程叠加导致重复弹窗 / 双重拦批。
 */
class ZorvGate {
    fun modeOf(tool: String): ToolGate.AuthMode = ToolGate.AuthMode.ALWAYS_ALLOW
    suspend fun authorize(...) : String? = null
}

为什么必须是具名类(一个很刁钻的 Kotlin 细节):

注意:必须是具名类ZorvGate),不能用匿名 object ------ AgentLoop 跨文件访问 gate.modeOf / gate.authorize 时,匿名对象含 suspend 成员的签名无法解析(Unresolved reference)。

5.4 结果规整

复制代码
/**
 * 结果规整策略:
 *  - 若 ZorvAI 返回的是合法 JSON 对象(如 web_search 的 {"results":[...]}),原样回传;
 *  - 若为纯文本,带失败特征(失败/错误/异常/超时/需要权限/未知工具)则包成 {"error":"..."},
 *    否则包成 {"result":"..."},使 AgentLoop 的摘要/事件逻辑正常工作。
 */

失败特征用子串匹配(中英双覆盖):

复制代码
private fun looksFailed(text: String): Boolean {
    val t = text.lowercase()
    return t.contains("失败") || t.contains("错误") || t.contains("异常") ||
            t.contains("超时") || t.contains("需要权限") || t.contains("未授权") ||
            t.contains("未知工具") || t.contains("error") || t.contains("exception")
}

5.5 对外同形,零改动切换

复制代码
/**
 * 对外暴露与 BuiltinTools 完全同形的成员(declarations / execute / gateFor / gate / memory),
 * 因此 AgentLoop 只需把 `BuiltinTools` 换成 `ZorvToolAdapter`,其余决策轮 / 渲染轮逻辑零改动。
 */

这是适配器模式的干净用法。


六、渲染通道:HTML 是宿主,原生是叠加层

RenderChannel.kt 定义了一套通道调度:

6.1 四种通道

复制代码
/** AI 声明的技术通道 */
enum class Kind { WEB, XML, COMPOSE, CANVAS }

AI 在文档里写形态标记:

复制代码
<!--stack:html-->      网页通道(默认,WebView 整体渲染)
<!--stack:xml-->       AI 在 <script type="text/xml-layout"> 里给了 XML 布局
<!--stack:compose-->   AI 在 <script type="text/x-compose"> 里给了组件树描述

占位容器 id 约定:

复制代码
const val XML_CONTAINER_ID = "gen-xml"
const val COMPOSE_CONTAINER_ID = "gen-compose"
const val CANVAS_CONTAINER_ID = "gen-canvas"

6.2 为什么 HTML 始终是宿主

复制代码
/**
 * 关键设计:**HTML 始终是宿主**。
 * 无论 AI 选哪条通道,最终产出的都是一份 HTML 文档 —— WebView 负责承载与排版。
 * 当 AI 声明 xml / compose 通道时,它在文档里嵌入一个占位容器(约定 id),
 * 端上把该容器替换为**真实原生渲染结果**(原生控件 / Compose 组件)。
 *
 * 为什么这样设计:
 * 1. **零回归** —— 现有 HTML 流式渲染管线完全不动,出问题也不影响默认路径;
 * 2. **混合能力** —— 一个界面里可以既有 HTML 画的图表、又有原生控件做的表单;
 * 3. **可降级** —— 原生渲染失败时页面其余部分照常显示,只是那块给出错误提示。
 */

这个设计很聪明 :不走「要么全 HTML 要么全原生」的二选一,而是让 WebView 做承载层,原生做局部增强。XML 和 Compose 描述都是数据/源码字符串,不需要编译

6.3 原生渲染态

复制代码
sealed interface NativeRender {
    data class Xml(val xml: String) : NativeRender
    data class Compose(val json: String) : NativeRender
    /** 代码呈现层 —— Kotlin / Java / C++ / Python 这类端上跑不了的语言,
     *  作为**界面题材**由端上统一渲染成代码视图(行号 + 高亮 + 复制)。 */
    data class Code(val blocks: List<CodeBlock>) : NativeRender
    data class Canvas(val json: String) : NativeRender
}

Code 那条的态度很有意思:

只在 AI 没有自己用 HTML 画代码视图、直接甩了裸代码块时启用。 这是兜底,不是规范:AI 想自己画就自己画,端上不抢。

6.4 流式安全的宽松识别

复制代码
/**
 * 流式安全:生成过程中文档是不完整的,此时只能做"宽松识别"——
 * 找不到结尾标记也照常返回已抽到的内容,让原生部分尽早显示。
 */
fun analyze(html: String): Plan

七、GenUiCanvas 与 MoBridge 设备桥

7.1 GenUiCanvas:流式写入机制

复制代码
/**
 * 机制:
 * 1. WebView 开启 WebMessageListener(__moHost),把 MoBridge 暴露给 AI 页面;
 * 2. 生成开始:loadDataWithBaseURL("https://genui.local/", ...) → onPageFinished → document.open()
 * 3. 每个流式 chunk:document.write(chunk) —— 浏览器原生流式 HTML 解析,截断在标签中间也安全;
 * 4. 生成完成:document.close(),页面可交互。
 */

「截断在标签中间也安全」 是这套机制的关键优势 ------ 浏览器原生解析器自己会等标签闭合,不需要端侧做任何补齐。

一个边界处理:

复制代码
/** end() 在页面未就绪时被调用:记下"想关",等 onPageFinished 写完正文后再真正 close */
private var pendingEnd = false
private val pendingChunks = ArrayDeque<String>()

7.2 MoBridge:AI 界面 ↔ 设备真实功能

复制代码
/**
 * MoBridge —— AI 的界面 ↔ 设备真实功能 的唯一通道。
 *
 * 协议:页面 → {id, api:"ns.method", args:{...}} ;native → {id, ok, val}
 *
 * 安全边界(v0.1):
 * - net.proxy 仅 https,限 5 次/分钟,走原生 OkHttp(AI 页面自身处于 about:blank origin)
 * - notify 需系统通知权限
 * - 文件系统不暴露
 */

JS 侧 Promise 包装(注入给 AI 页面,先于 AI 任何脚本执行):

复制代码
window.MoBridge = {
    call: call,
    time:      { now, format },
    store:     { get, put, keys, del },
    notify:    { send },
    haptics:   { tap },
    clipboard: { write },
    net:       { proxy },
    device:    { info },
    ui:        { title, widget }
};

七个命名空间 + 原生组件

API 能力 限制
time.now / time.format 时间 ---
store.* 键值持久化 页面级
notify.send 系统通知 需 POST_NOTIFICATIONS
haptics.tap 触感反馈 轻 12ms / 重 35ms
clipboard.write 剪贴板 ---
net.proxy 网络代理 仅 https,5 次/分钟
device.info 设备信息 型号/系统/电量/网络
ui.widget 原生组件 8 种

限流实现:

复制代码
private fun checkRate() {
    val now = System.currentTimeMillis() / 60_000
    if (now != minuteWindow) { minuteWindow = now; netCallsThisMinute = 0 }
    require(++netCallsThisMinute <= 5) { "net.proxy 限流:每分钟 5 次" }
}

7.3 一个记录下来的 bug:JS 对象字面量同名键覆盖

复制代码
/**
 * 注意:JS 对象字面量里同名键「后者覆盖前者」,历史上 ui 键被写过两次,
 * 导致 ui.widget 被静默丢弃(8 种原生组件整条链路不可达)。此处只在末尾出现一次 ui,
 * 且 wrapper 必须能安全重复注入(中断续写/回放时会再次写入同一文档)。
 */

配套一个幂等守卫:

复制代码
// 幂等:重复注入时保留已有实例,避免续写/replay 场景下丢失未决 Promise
if (window.__moReady) return;
window.__moReady = true;

7.4 8 种原生组件

提示词里的 schema(GenUiPrompt.kt):

kind 用途 关键字段
stat 单值大数字 + 涨跌 + 迷你趋势线 value 字符串、trend 数字数组
bar 横向柱状 data[].label / data[].value
line 折线图 data 纯数字数组(≥2 点)
progress 进度条 value 用 0--1 小数
list 可勾选清单 data[].text / data[].done
form 表单 fields[].{key,label,type}
slider 滑杆 min / max / value
timeline 时间线 data[].{time,title,desc}

选择原则(提示词原文):

需要精确录入(数字/表单/滑杆/勾选清单)时用原生组件 ------ 系统键盘与触感更好; 纯展示优先用页面内 HTML/SVG ------ 自绘图表与你的页面风格更统一。

为什么要有原生组件 :WebView 里的 <input type="number"> 体验远不如原生键盘。这是「HTML 宿主 + 原生增强」思路在交互层的体现。


八、灵魂系统与模型配置桥接

8.1 灵魂:AI 自己孵化人格

复制代码
/**
 * "AI 自动人格孵化":模型自己为自己撰写人格卡,端上保存并注入每一次生成。
 * 灵魂分两层生效:
 *  - 说话层(名字/语气/特质/原则/禁忌/样例)→ 注入决策轮+渲染轮系统提示
 *  - 视觉层(视觉签名 style)→ 注入渲染轮,影响 AI 写出的 UI 的美学方向
 */

九字段:

复制代码
data class Soul(
    val name: String,
    val tone: String,             // 说话语气
    val traits: List<String>,     // 性格特质
    val principles: List<String>, // 行事原则
    val taboos: List<String>,     // 禁忌:绝不做的事
    val mission: String,          // 使命:为什么存在
    val style: String,            // 视觉签名:生成 UI 的美学偏好
    val sample: String,           // 语气样例
    val greeting: String          // 开场白(空态画布)
)

视觉签名只在渲染轮注入是个精巧的设计 ------ 说话层影响两个阶段,视觉层只在真正画界面时才生效,避免污染决策轮的逻辑判断。

复制代码
// 渲染轮 system = 基座 + 灵魂(说话层+视觉签名层):视觉签名只在此轮注入,影响 UI 美学方向
val renderSystem = system + Soul.injectStyle(soul) + ...

8.2 模型配置 1:1 桥接

复制代码
/**
 * QuroAI 仅维护【单一】活动模型配置(SharedPreferences quro_model_config),
 * 而 GenUI 原生支持多供应商。为避免两套配置 UI 互相打架、各写各的,强制 GenUI 侧
 * 只认一个固定 id="quro_main" 的供应商,与 QuroAI 的活动配置 1:1 映射。
 */
object QuroGenUiBridge {
    const val MAIN_ID = "quro_main"

反向写回时的字段保护

复制代码
/**
 * 仅覆盖云端模型相关字段,保留本地离线引擎(local*)、上下文硬上限、技能开关等
 * QuroAI 独有字段,绝不互相覆盖。
 */

一个量纲转换的细节:

复制代码
// GenUI 的 contextChars 是"续写尾部字符数",与 QuroAI 的 token 上下文窗口非同一量纲;
// 这里做一个截顶映射,保证落在上游使用时的 clamp(500, 8000) 区间内,避免把 1M 当字符数。
contextChars = if (cfg.contextWindow > 0) cfg.contextWindow.coerceAtMost(8000) else 4000,

协议回落策略

复制代码
/** 非 OpenAI 兼容的全部回落 OpenAI 兼容 */
fun inferProtocol(provider: String): Protocol = when (provider.uppercase()) {
    "ANTHROPIC" -> Protocol.ANTHROPIC
    "GEMINI" -> Protocol.GEMINI
    else -> Protocol.OPENAI   // OPENAI / OTHER / MNN / LLAMA_CPP / 自定义 一律按 OpenAI 兼容接入
}

⚠️ 注意这个回落:本地离线引擎(MNN / llama.cpp)也被按 OpenAI 兼容协议接入。而决策轮需要 OpenAI 兼容协议才支持 function callingif (provider.protocol == Protocol.OPENAI)),所以 Anthropic / Gemini 在 GenUI 全屏模式下会跳过决策轮直接进渲染轮,不具备工具调用能力。


九、zorv/ui 协议:增量解析与静态扫描

9.1 传输格式

复制代码
```zorv/ui id=blk_7f3a rev=3 lang=jsx caps=emit deps=react,recharts
export default function Dashboard() { ... }
复制代码
### 9.2 三条设计要点
kotlin 复制代码
/** * 设计要点: * 1. 元信息放在围栏 info string 而非 JSON body ------ JSON 需等闭合才能解析, * 且代码内引号/换行需转义,流式体验差且转义 bug 高发。 * info string 在围栏首行即完整可得。 * 2. 代码区保持原始文本,零转义;解析失败时可降级为代码块展示。 * 3. CODE 状态内绝不执行 ------ 任何中间态都大概率语法不完整。 */ </pre> <p><strong>第 1 条是核心洞察</strong>:把元信息从 JSON body 挪到 info string,换取「首行即可得 + 零转义」。跟 AIP 把 kind 放在信封头、小卡片把 spec 放在围栏首行是同一个思路的反复出现。</p> <h3>9.3 状态机</h3> <pre> private enum class State { TEXT, FENCE_HEAD, CODE } class GenUiProtocolParser { fun push(delta: String): List&lt;StreamChunk&gt; fun finish(): StreamChunk? } </pre> <p>输出三种片段:</p> <pre> sealed interface StreamChunk { data class Text(val text: String) : StreamChunk data class ArtifactCommit(val artifact: Artifact) : StreamChunk /** 围栏未闭合而流结束 / 扫描不通过 ------ 降级为代码块 */ data class ArtifactFailed(val id: String?, val code: String, val reason: String, val violations: List&lt;String&gt; = emptyList()) : StreamChunk } </pre> <p>一个细致的状态处理:</p> <pre> } else if (fenceCharCount == 3) { // 围栏已闭合 if (c == '\n' || c == '\r') { out += commit() state = State.TEXT fenceCharCount = 0 } else { // ``` 后跟了别的字符 → 不是闭合,回吐到代码 codeBuf.append("```").append(c) fenceCharCount = 0 } } </pre> <p>还有:非 <code>zorv/ui</code> 的普通代码块(如 <code>```kotlin</code>)<strong>原样吐回文本,不拦截</strong>。</p> <h3>9.4 静态扫描:浅层防御</h3> <pre> /** * 浅层防御:字符串匹配,模型换个写法就能绕过。 * 真实价值是拦住绝大多数"无意违规",为 CSP 深层防御争取时间。 */ object StaticScan { private val RULES = listOf( "eval(" to Severity.FATAL, "new Function(" to Severity.FATAL, "Function(" to Severity.FATAL, "importScripts" to Severity.FATAL, "&lt;iframe" to Severity.FATAL, "document.cookie" to Severity.FATAL, "window.open" to Severity.FATAL, "top.location" to Severity.FATAL, "parent.location" to Severity.FATAL, "XMLHttpRequest" to Severity.FATAL, "WebSocket" to Severity.FATAL, // 网络与存储:CSP 已封死,这里只是提前给出更友好的降级理由 "fetch(" to Severity.WARN, "localStorage" to Severity.WARN, "sessionStorage" to Severity.WARN, "indexedDB" to Severity.WARN ) private const val MAX_CODE_CHARS = 48_000 } </pre> <p><strong>这个态度很专业</strong>:明确说自己是浅层防御、能绕过,真实边界是 CSP。不做假装安全的静态检查。</p> <h3>9.5 能力静默裁剪</h3> <pre> caps = (m.caps intersect GRANTABLE_CAPS), // 静默裁剪未授权能力 </pre> <pre> /** 产品策略决定:哪些 capability 可以被授予 */ val GRANTABLE_CAPS = setOf("emit", "storage", "net:api.zorv.ai") </pre> <p><strong>只有三个可授予能力</strong> ------ 白名单之外的静默丢弃,不报错也不执行。</p> <h3>9.6 版本树</h3> <pre> data class Artifact( val id: String, val rev: Int, val lang: Lang, val code: String, val caps: Set&lt;String&gt;, val deps: Set&lt;String&gt;, val createdAt: Long = System.currentTimeMillis(), /** 版本树回溯:指向被本版取代的上一 rev;首版为 null */ val parentRev: Int? = null ) </pre> <pre> parentRev = m.rev?.let { if (it &gt; 1) it - 1 else null } </pre> <p>Room 里同一 id 的多个 rev 全存 ------ 注释说这是「白送『撤销/回到第 N 版』」。</p> <hr/> <h2>十、WebView 池、崩溃恢复与状态快照</h2> <p><code>GenUiHost.kt</code>(495 行)是 B 面的核心。</p> <h3>10.1 三条铁律</h3> <pre> /** * 三条铁律(继承,不可违背): * 1. 绝不使用 addJavascriptInterface ------ 全走 WebMessageListener / postMessage 并校验 origin。 * 2. 绝不用 file:///android_asset/ 加载 shell ------ 必须经 WebViewAssetLoader 映射到 https://zorv.local/。 * 3. 任何失败都不允许中断会话流或崩溃 App。生成式 UI 是增强,不是主干。 */ </pre> <p><strong>第 1 条</strong>:<code>addJavascriptInterface</code> 是经典的 Android WebView 安全漏洞(早期版本可被反射攻击)。 <strong>第 2 条</strong>:<code>file://</code> 加载会让页面获得跨域能力。 <strong>第 3 条</strong>:这个定位判断很重要 ------ 生成式 UI 挂了不能连累主对话。</p> <h3>10.2 WebView 池:内存生死线</h3> <pre> /** * 多轮对话会堆出几十张卡片,每实例一个 WebView 必然 OOM。 * 池化是内存生死线。改用 `created` 计数统一约束实例上限(#628 修复)。 */ private class WebViewPool( private val context: Context, private val loader: WebViewAssetLoader, private val capacity: Int // 默认 3 ) </pre> <p>回收三种情形:</p> <pre> fun release(card: CardHandle) { val w = card.webView w.reset() if (idle.size &lt; capacity) idle += w else { runCatching { w.destroy() }; created-- } } fun discard(w: GenUiWebView) { // 崩溃过的实例不可复用 runCatching { w.destroy() } created-- } fun trimTo(n: Int) { while (idle.size &gt; n) { idle.removeFirst().destroy(); created-- } } </pre> <p>预热(消除首个卡片 200--500ms 冷启动):</p> <pre> fun warmUp() { if (Looper.myLooper() != Looper.getMainLooper()) return repeat(2) { if (created &lt; capacity) { idle += create(); created++ } } } </pre> <h3>10.3 视口回收</h3> <pre> @UiThread fun onViewportChanged(visibleIds: Set&lt;String&gt;) { active.keys.filter { it !in visibleIds }.forEach { release(it) } visibleIds.forEach { id -&gt; active[id]?.let { if (!it.isAlive) remount(id) } } } </pre> <p>内存紧张时:</p> <pre> fun onTrimMemory() { active.values.forEach { it.captureSnapshot() } releaseAll() pool.trimTo(1) } </pre> <h3>10.4 崩溃恢复</h3> <pre> override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { if (detail.didCrash()) { crashCount++ cardListener?.invoke(JSONObject().apply { put("type", "error"); put("phase", "crash") put("message", "渲染进程崩溃"); put("attempt", crashCount) }) } return true } </pre> <p>重建一次,再崩则彻底降级(<code>attempt</code> 传 99 表示不可恢复)。</p> <h3>10.5 状态快照:卡片被回收后重放</h3> <pre> /** * 组件通过 `emit('state', {...})` 上报的关键 UI 状态。 * 卡片被回收(滚出视口 / onTrimMemory)后,重进视口时用它重放,用户侧应无感。 */ data class ArtifactState(val artifactId: String, val rev: Int, val uiState: JSONObject?) </pre> <p>重建流程:</p> <pre> /** * 视口滚回时重建一张"死"卡(isAlive=false,已被系统回收 renderer)。 * 重新 bind 以重设 pendingRender/pendingHydrate,再 loadUrl,onPageFinished 会重放渲染。 */ private fun remount(id: String) { ... } </pre> <h3>10.6 渲染超时看门狗</h3> <pre> /** mount 后 5s 未上报 ready → 降级,绝不留白屏 */ private fun scheduleReadyWatchdog(artifactId: String) { mainHandler.postDelayed({ val card = active[artifactId] ?: return@postDelayed if (!card.isReady) { _events.tryEmit(GenUiEvent.Degrade(artifactId, "渲染超时")) release(artifactId) } }, 5_000L) } </pre> <h3>10.7 高度钳制</h3> <pre> fun applyHeight(cssPx: Int) { val px = (cssPx * context.resources.displayMetrics.density).roundToInt() val max = (context.resources.displayMetrics.heightPixels * 0.8f).roundToInt() val clamped = px.coerceIn(120, max) ... } </pre> <p>CSS px → 物理 px 转换后,钳制在 120px 到屏幕高度 80% 之间 ------ 防止 AI 写出超高卡片霸占整个聊天列表。</p> <hr/> <h2>十一、错误自愈循环</h2> <p><code>GenUiSelfHeal.kt</code>(66 行)是 B 面的核心机制。</p> <h3>11.1 它为什么存在</h3> <pre> /** * 错误自愈循环(#630)。 * * 纯生成式能否实用的命根子:模型一次写对的概率有限,必须把运行时错误喂回去自我修复 * (Self-Debug 模式)。循环硬上限 [maxAttempts],超阈则交由上层降级,绝不在原地打转烧 token。 */ </pre> <h3>11.2 反馈构造</h3> <pre> fun buildFeedback(artifact: Artifact, phase: String, message: String, stack: String?): String { val loc = stack?.lines()?.firstOrNull { it.contains("at line") || it.contains(".tsx") || it.contains(".jsx") } return buildString { appendLine("&lt;runtime-error&gt;") appendLine("你生成的卡片(id=${artifact.id})运行失败:") appendLine("阶段:$phase") appendLine("错误:$message") loc?.let { appendLine("位置:$it") } appendLine() appendLine("请完整重写该卡片,rev=${artifact.rev + 1},修复上述错误。") appendLine("不要输出 diff 或片段,输出完整代码(含 `export default`)。") appendLine("&lt;/runtime-error&gt;") }.trimIndent() } </pre> <p><strong>两个关键指令</strong>: - <code>rev+1</code> ------ 触发版本树新节点,旧版仍在 Room 里 - <strong>「不要输出 diff 或片段」</strong> ------ 模型天生倾向于只输出修改部分,必须显式禁止</p> <h3>11.3 上限保护</h3> <pre> if (attempt &gt;= maxAttempts) { delegate?.degrade(artifact.id, "自愈次数超限($maxAttempts)") return } </pre> <p>默认 3 次。</p> <h3>11.4 与 A 面的对比</h3> <blockquote> <p>⚠️ <strong>A 面没有自愈循环</strong>。全屏 GenUI 模式的 <code>AgentLoop</code> 里没有对应的 SelfHeal ------ 渲染错误只走 <code>onError</code> 提示用户重试。</p> <p>这是两套 GenUI 的能力差:B 面(卡片)有完整的「错误 → 反馈 → 重写」闭环,A 面(全屏)没有。考虑到全屏模式产物是一次性完整 HTML、重写成本高(几万字符),这个取舍可以理解,但确实是能力缺口。</p> </blockquote> <hr/> <h2>十二、回写对话框:为什么必须包围栏</h2> <h3>12.1 实现</h3> <pre> fun pushGenUiHtmlToChat(html: String, title: String = "") { if (html.isBlank()) return val content = "```miniapp\n$html\n```" store.add(QuroMessage(role = "assistant", content = content)) commitCurrent() } </pre> <h3>12.2 为什么不能直接存 HTML</h3> <pre> /** * GenUI 模式下 AI 返回纯文本(非 HTML 界面)时,把文本作为一条普通助手回复写回当前会话。 * 与 pushGenUiHtmlToChat 的区别:不包 ```miniapp 围栏,直接以纯文本气泡出现在 ZorvAI 对话框, * 避免「回复文本被甩在画布上」。 */ </pre> <p>反过来,HTML 必须包围栏的原因在 <code>parseBlocks</code> 的判断逻辑里 ------ 裸 HTML 文档会被 <code>isFullHtmlDocument</code> 判成 <code>Code("html")</code> <strong>代码块</strong>,不渲染成界面。</p> <p><strong>加围栏 = 走 <code>MiniAppWebView</code> 真实渲染通路。</strong></p> <h3>12.3 界面判定:宽松而非严格</h3> <p><code>GenScaffold</code> 里:</p> <pre> /** 判断模型产物是否为界面(含 HTML 标签),而非纯文本回复。 * 只要出现任何 HTML 标签(&lt;div&gt;/&lt;body&gt;/&lt;p&gt;/&lt;svg&gt;/&lt;!DOCTYPE&gt;...)即视为界面,渲染到画布; * 只有完全不含 HTML 标记的纯散文才回写对话框。不再强制 &lt;/html&gt; 收尾, * 否则省略文档外壳的合法界面会被误清空画布。 */ private fun isHtmlDoc(s: String): Boolean { return s.contains(Regex("&lt;[a-zA-Z/!][^&gt;]*&gt;")) } </pre> <p>注释里又是一次纠偏记录:<strong>不再强制 <code>&lt;/html&gt;</code> 收尾</strong>,因为省略文档外壳的合法界面会被误判。</p> <hr/> <h2>十三、过程可观测:事件流与绘制探针</h2> <h3>13.1 AgentEvent:把黑盒拆开</h3> <pre> /** * 设计意图: * 原版 AgentLoop 只有一个 `onStatus(String)` 回调,把所有过程压成一行状态文字 * ("调用工具:web_search...")。用户看不到:为什么调这个工具、参数是什么、 * 花了多久、返回了什么、失败为什么失败。整个过程是个黑盒。 * * 这里把过程拆成结构化事件,UI 层可以: * - 实时展示"思考中 → 决策 → 调用 → 结果"的时间线 * - 生成结束后回看完整过程(不需要重跑) * - 出错时精确定位是哪一步、哪个工具、什么原因 */ sealed class AgentEvent { abstract val ts: Long; ... } </pre> <p>事件类型:<code>Started</code> / <code>Thinking</code> / <code>Decided</code> / <code>ToolStarted</code> / <code>ToolAwaitingAuth</code> / <code>ToolFinished</code> / <code>ToolDenied</code> / <code>Rendering</code> / <code>Painting</code> / <code>RenderProgress</code> / <code>Finished</code> / <code>Failed</code>。</p> <p><strong><code>ToolStarted</code> 补上了一次可观测性缺口</strong>:</p> <pre> // 广播"开始执行":UI 靠它把时间线条目置为 running,并在完成时回填耗时。 // (此前只有 ToolFinished,条目永远停在"运行中",也看不到参数与结果) onEvent(AgentEvent.ToolStarted(callId, name, briefText, level, rounds)) </pre> <h3>13.2 PaintProbe:真实观测,不是假进度</h3> <pre> /** * 绘制过程探针 ------ 从**流式到达的 HTML 增量**里实时解析"正在画什么"。 * * 设计立场:这些结论全部来自对已到达文本的真实观测,不是预设的假进度条。 * 探针不认识"组件白名单",只在文档里找**结构里程碑**(标签、语义区块、图表容器、 * 脚本边界),因此 AI 怎么自由发挥都不影响判断。 * * 为什么不做精确解析:流式文档随时处于"半截标签"状态,任何严格解析都会失败。 * 这里只用**已经闭合的片段**做证据(如出现 `&lt;/style&gt;` 就知道样式写完), * 宁可晚一步报,也不错报。 */ </pre> <p>七个阶段与权重(按真实文档里各阶段通常占的篇幅给):</p> <pre> private val weightOf = mapOf( PaintStep.HEAD to 0.05f, PaintStep.STYLE to 0.25f, PaintStep.LAYOUT to 0.45f, PaintStep.CONTENT to 0.65f, PaintStep.CHART to 0.80f, PaintStep.SCRIPT to 0.92f, PaintStep.POLISH to 1.00f, ) </pre> <p>中文标签:读取文档头 → 铺设样式 → 搭建骨架 → 填充内容 → 绘制图表 → 接线交互 → 收尾校验。</p> <p><strong>「只用已经闭合的片段做证据」</strong> 是关键 ------ 流式文档随时半截,看到 <code>&lt;/style&gt;</code> 才能确定样式写完。</p> <p>再加一层体量兜底:</p> <pre> // 体量里程碑:作为兜底进度(结构无明显变化的长文档里仍有心跳) val n = sb.length if (n / 4096 &gt; lastMilestone) { lastMilestone = n / 4096; onEvent(AgentEvent.RenderProgress(n.toLong())) } </pre> <hr/> <h2>十四、安全边界</h2> <table> <thead> <tr> <th>层</th> <th>机制</th> <th>位置</th> </tr> </thead> <tbody> <tr> <td><strong>协议层</strong></td> <td>静态扫描(FATAL 拦截 / WARN 记录)</td> <td><code>StaticScan</code></td> </tr> <tr> <td><strong>协议层</strong></td> <td>能力白名单静默裁剪(仅 emit/storage/net:api.zorv.ai)</td> <td><code>GRANTABLE_CAPS</code></td> </tr> <tr> <td><strong>协议层</strong></td> <td>代码量上限 48,000 字符</td> <td><code>MAX_CODE_CHARS</code></td> </tr> <tr> <td><strong>WebView</strong></td> <td>CSP <code>connect-src 'none'</code></td> <td>shell.html</td> </tr> <tr> <td><strong>WebView</strong></td> <td>禁用 <code>addJavascriptInterface</code></td> <td>铁律 ①</td> </tr> <tr> <td><strong>WebView</strong></td> <td>域名白名单 <code>zorv.local</code> / <code>genui.local</code>,其余拦截返回空</td> <td><code>shouldInterceptRequest</code></td> </tr> <tr> <td><strong>WebView</strong></td> <td><code>allowFileAccess=false</code> / <code>allowContentAccess=false</code></td> <td>harden()</td> </tr> <tr> <td><strong>Bridge</strong></td> <td><code>net.proxy</code> 仅 https + 5 次/分钟</td> <td>MoBridgeHost</td> </tr> <tr> <td><strong>Bridge</strong></td> <td>notify 需系统权限,文件系统不暴露</td> <td>MoBridgeHost</td> </tr> <tr> <td><strong>权限</strong></td> <td>工具执行权限下沉到 <code>QuroPermissionHolder</code></td> <td>ZorvToolAdapter</td> </tr> <tr> <td><strong>稳定性</strong></td> <td>5s ready 看门狗 → 降级</td> <td>GenUiHost</td> </tr> <tr> <td><strong>稳定性</strong></td> <td>渲染进程崩溃 → 重建一次 → 再崩降级</td> <td>GenUiWebView</td> </tr> </tbody> </table> <p><code>GenUiWebView.harden()</code> 的完整清单:</p> <pre> settings.apply { javaScriptEnabled = true // 必需 allowFileAccess = false // 必须显式,不依赖默认值 allowContentAccess = false allowFileAccessFromFileURLs = false allowUniversalAccessFromFileURLs = false javaScriptCanOpenWindowsAutomatically = false domStorageEnabled = false // 存储走 postMessage 桥 databaseEnabled = false setGeolocationEnabled(false) cacheMode = WebSettings.LOAD_NO_CACHE mediaPlaybackRequiresUserGesture = true setLayerType(View.LAYER_TYPE_NONE, null) } </pre> <blockquote> <p>「必须显式,不依赖默认值」这句很对 ------ WebView 的默认值在不同 Android 版本上变过。</p> </blockquote> <hr/> <h2>十五、工程坑位清单</h2> <h3>15.1 有状态剥围栏导致黑屏(已修,v0.11.9 回归)</h3> <p>见 4.6。旧版有状态 stripper 把正文误判成围栏首部 → 持续返回空 → 吞掉整段输出。</p> <h3>15.2 WebView 放进了会变化的状态(已修)</h3> <p><code>GenScaffold</code> 注释:放进任何会变化的状态里,AndroidView 的 factory 会重跑、把正在流式写入的文档整个销毁重建。</p> <h3>15.3 供应商配置每次重组都读盘(已修)</h3> <p>拖垮滚动/动画帧率。用 <code>remember(configVersion)</code> 缓存。</p> <h3>15.4 JS 对象字面量同名键覆盖(已修)</h3> <p><code>ui</code> 键被写两次 → <code>ui.widget</code> 静默丢弃 → 8 种原生组件整条链路不可达。</p> <h3>15.5 裸 HTML 回写被判成代码块(已修)</h3> <p>必须包 <code>```miniapp</code> 围栏。</p> <h3>15.6 界面判定强制 <code>&lt;/html&gt;</code> 收尾(已修)</h3> <p>导致省略文档外壳的合法界面被误清空画布。改成「出现任何 HTML 标签即算界面」。</p> <h3>15.7 决策轮模型的说明文字被当成 assistant 消息(已修)</h3> <p>模型进入渲染轮后会顺着胡说,最终只写出一行字就停。改成转成 user 备注。</p> <h3>15.8 匿名 object 的 suspend 成员跨文件不可解析(已修)</h3> <p><code>ZorvGate</code> 必须是具名类,不能用匿名 object。</p> <h3>15.9 两套运行时注册表并存(未修)</h3> <p>app 的 <code>render/RuntimeRegistry.kt</code> 与 genui 库的 <code>runtime/GenUiRuntimes.kt</code> 内容几乎一样,各自维护。改了 <code>assets/runtimes/</code> 只更新一处 → 提示词指向不存在的文件 → 页面静默坏掉。</p> <blockquote> <p>⚠️ 这正是 <code>RuntimeRegistry</code> 自己注释里说要避免的问题:<strong>"改了资源忘了改提示词 → 模型写出引用不存在的 <code>&lt;script&gt;</code>,页面静默坏掉"</strong>。而两份表的存在让这个风险翻倍。</p> </blockquote> <h3>15.10 A 面没有错误自愈(能力缺口)</h3> <p>全屏 GenUI 模式无 SelfHeal 循环,渲染出错只提示用户重试。B 面(卡片)有完整闭环。</p> <h3>15.11 非 OpenAI 协议跳过决策轮</h3> <pre> if (provider.protocol == Protocol.OPENAI) { /* 决策轮 */ } </pre> <p>Anthropic / Gemini 直连时 GenUI 全屏模式<strong>不具备工具调用能力</strong>。</p> <h3>15.12 <code>BuiltinTools.kt</code> 仍在仓库但已不在主链路</h3> <p>38KB 的 GenUI 私有工具集(<code>web_search</code> 等),被 <code>ZorvToolAdapter</code> 取代。其中 <code>WebSearch.kt</code>(12KB)看起来是唯一还在被引用的能力。</p> <blockquote> <p>⚠️ 保留历史代码可以理解,但 38KB 的死代码容易误导后来者。</p> </blockquote> <h3>15.13 已定义但未接线的能力</h3> <table> <thead> <tr> <th>能力</th> <th>状态</th> </tr> </thead> <tbody> <tr> <td><code>GenUiCard</code> 的降级覆盖层</td> <td><code>CardStatus.Degraded</code> 已定义,<code>GenUiCard</code> 里读取了 status,但仓库中未见调用 <code>GenUiController</code> 的聊天列表接入点</td> </tr> <tr> <td><code>GenUiHost.warmUp()</code></td> <td>已实现,未见调用方</td> </tr> <tr> <td><code>emit('intent')</code> 组件主动接话</td> <td><code>onRepairRequest</code> / <code>onIntent</code> 两个回调都在 Controller 上,接线方需外部提供</td> </tr> </tbody> </table> <hr/> <h2>十六、代码地图</h2> <h3>16.1 A 面:<code>app/src/main/java/com/ai/assistance/quro/genui/app/</code></h3> <table> <thead> <tr> <th>文件</th> <th>行数</th> <th>职责</th> </tr> </thead> <tbody> <tr> <td><code>ui/shell/GenScaffold.kt</code></td> <td><strong>1,299</strong></td> <td>主屏壳:状态机 + 画布 + 指令条 + 叠加层</td> </tr> <tr> <td><code>agent/AgentLoop.kt</code></td> <td>528</td> <td>两阶段 Agent 循环</td> </tr> <tr> <td><code>ui/shell/NativeWidgetSheet.kt</code></td> <td>495</td> <td>8 种原生组件弹层</td> </tr> <tr> <td><code>agent/tools/BuiltinTools.kt</code></td> <td>~900</td> <td>GenUI 私有工具集(<strong>已被取代</strong>)</td> </tr> <tr> <td><code>bridge/MoBridgeHost.kt</code></td> <td>335</td> <td>设备桥 + 分发器</td> </tr> <tr> <td><code>ui/shell/ThinkingSheet.kt</code></td> <td>~380</td> <td>思考时间线</td> </tr> <tr> <td><code>llm/LLMClient.kt</code></td> <td>~560</td> <td>LLM 客户端(流式 / 非流式)</td> </tr> <tr> <td><code>llm/Prompts.kt</code></td> <td>~500</td> <td>系统提示词</td> </tr> <tr> <td><code>perms/PermRegistry.kt</code></td> <td>~400</td> <td>权限注册表</td> </tr> <tr> <td><code>agent/PaintProbe.kt</code></td> <td>150</td> <td>绘制过程探针</td> </tr> <tr> <td><code>agent/ToolGate.kt</code></td> <td>168</td> <td>L1--L5 权限策略(保留给设置屏语义)</td> </tr> <tr> <td><code>agent/Soul.kt</code></td> <td>100</td> <td>灵魂卡数据模型</td> </tr> <tr> <td><code>agent/AgentMemory.kt</code></td> <td>115</td> <td>记忆库(→ QuroMemoryRepository)</td> </tr> <tr> <td><code>agent/AgentEvent.kt</code></td> <td>177</td> <td>结构化事件流</td> </tr> <tr> <td><code>app/QuroGenUiApp.kt</code></td> <td>118</td> <td>根屏 + 设置路由</td> </tr> <tr> <td><code>store/GenStore.kt</code></td> <td>118</td> <td>界面栈 + 键值持久化</td> </tr> <tr> <td><code>store/QuroGenUiBridge.kt</code></td> <td>78</td> <td>模型配置 1:1 桥接</td> </tr> </tbody> </table> <p><strong>合计约 4,600 行。</strong></p> <p>另有 <code>app/src/main/java/com/ai/assistance/quro/ui/genui/</code>: - <code>GenUiCanvas.kt</code>(274 行)------ WebView 渲染引擎 - <code>GenUiMoBridge.kt</code>(~280 行)------ MoBridge 分发器</p> <p>以及 <code>app/.../ui/GenUiSurfaceScreen.kt</code>(22KB)------ 注释说是历史兼容保留,已并入主对话管线。</p> <h3>16.2 B 面:<code>genui/src/main/java/com/zorv/genui/</code></h3> <table> <thead> <tr> <th>文件</th> <th>行数</th> <th>职责</th> </tr> </thead> <tbody> <tr> <td><code>host/GenUiHost.kt</code></td> <td><strong>495</strong></td> <td>WebView 池 + 隔离 + 桥接 + 崩溃恢复</td> </tr> <tr> <td><code>protocol/GenUiProtocol.kt</code></td> <td>296</td> <td>增量解析 + 静态扫描</td> </tr> <tr> <td><code>controller/GenUiController.kt</code></td> <td>235</td> <td>编排器</td> </tr> <tr> <td><code>store/GenUiStore.kt</code></td> <td>189</td> <td>Room 持久层(版本树 + 快照)</td> </tr> <tr> <td><code>prompt/GenUiPrompt.kt</code></td> <td>191</td> <td>系统提示词(卡片版)</td> </tr> <tr> <td><code>ui/GenUiCard.kt</code></td> <td>171</td> <td>Compose 卡片(嵌聊天列表)</td> </tr> <tr> <td><code>runtime/GenUiRuntime.kt</code></td> <td>136</td> <td>离线运行时清单</td> </tr> <tr> <td><code>heal/GenUiSelfHeal.kt</code></td> <td>66</td> <td>错误自愈循环</td> </tr> <tr> <td><code>assets/genui/shell.html</code></td> <td>13KB</td> <td>卡片运行沙箱</td> </tr> </tbody> </table> <p><strong>合计约 1,600 行。</strong></p> <h3>16.3 调用链速查</h3> <pre> 【A 面 · 全屏 GenUI】 QuroMainScreen(genUiType=="genui") → QuroGenUiApp → GenScaffold → AgentLoop.run(provider, prompt, seedHtml) ├─ 决策轮:llm.chatOnce(messages, tools.declarations()) │ → ZorvToolAdapter.execute(name, args) │ → QuroToolEngine.execute() ← 权限在此把关 │ → 死循环检测 / 时长兜底 └─ 渲染轮:llm.chatStream(onChunk) → LeadingFenceFilter.feed(delta) 剥围栏(无状态) → PaintProbe.feed(delta, full) 真实进度 → onHtmlDelta → GenUiCanvas.write(chunk) → WebView document.write() → onDone(html, title) ├─ isHtmlDoc(html) == true → pushGenUiHtmlToChat(包 miniapp 围栏) └─ 否则 → pushGenUiTextToChat(纯文本气泡) 【B 面 · 卡片 GenUI】 LLM 流 → GenUiController.ingest(delta) → GenUiProtocolParser.push(delta) → StreamChunk.Text / ArtifactCommit / ArtifactFailed → GenUiStore(Room:版本树 + 状态快照) → GenUiHost.mount(artifact, container, snapshot) → WebViewPool.acquire() → loadUrl(https://zorv.local/genui/shell.html) → onPageFinished → deliverPending() → shell 转译 + 执行 → shell 错误上报 → GenUiEvent.RuntimeError → GenUiSelfHeal.onError() → injectSystemTurn(feedback) → 模型重写 rev+1 </pre> <hr/> <p><em>文档基于 <code>Quor-a/ZorvAI</code> @ <code>main</code> 真实源码撰写,所有代码片段均取自仓库未作功能性改写。标注 ⚠️ 的为实现短板、能力缺口或"已定义但未接线"的观察点。</em></p> <p></p>
相关推荐
晚安日记wanna1 小时前
分布式和微服务差在哪从一次订单超时雪崩说起
面试·架构
东离与糖宝1 小时前
SSE流式输出详解:大模型打字机效果底层原理
人工智能
李兆龙的博客1 小时前
从一到无穷大 #91:从 Habitat 看存储平台的整合与分工
数据库·人工智能·架构
阿文和她的Key1 小时前
OpenAI 关 Pro 入口事件复盘:企业 AI 架构的稳定性问题,不只是故障应急
人工智能·架构
sdzhyt1 小时前
从“台账分散”到“智能调度”,AI如何走进应急避难场所管理一线?
人工智能
. . . . .1 小时前
comfyUI原理
人工智能·算法·机器学习
安全指北针1 小时前
SaaS化轻量审计:技术架构与落地实践
架构
在所不辞兄1 小时前
【零基础学智能仿真-16】循环神经网络与LSTM/GRU——学习力学响应的历史记忆
人工智能·rnn·神经网络·gru·lstm·工程技术·工程仿真
愚公搬代码1 小时前
【愚公系列】《造浪者:AI创业实战地图》002-AI创业的六个本质差异
人工智能