🌟 项目简介
ZorvAI 动态 UI(quro-ui) 是一套基于 Jetpack Compose 原生构建的可交互界面渲染框架。它让 AI 不再局限于「文字 + 代码块」的输出形态,而是能够主动地生成卡片、表单、列表、播放器、浏览器、富媒体等完整的交互式界面——就像一位熟练的前端工程师,根据用户意图即时绘制出最合适的 UI。
🔗 开源地址:https://github.com/Quor-a/ZorvAI
✨ 核心理念
| 原生即正义 | 直接用 Compose 渲染,不依赖 WebView/HTML(除了白名单的 HTML 节点) |
| DSL = 结构 | 用 JSON 描述界面,模型输出友好、解析稳定、可版本控制 |
| 稳定可重现 | 每个节点生成稳定 ID,重渲染后状态不丢、回调不串 |
| 密度自适应 | 以 360 dp 设计宽度为基线,按当前真实宽度动态缩放(手机/折叠屏/平板) |
| 暗色优先 | 16 阶灰度 + 语义色板,深色场景默认开启,亮色按需切换 |
| 必备输出 | v1.0.82 起,AI 把动态 UI 作为默认呈现方式,不再需要用户要求 |
🏛 架构总览
┌────────────────────────────────────────────────────────────────────────┐
│ QuroAssistant 主对话流水线 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌────────────┐ ┌─────────┐ ┌─────────────────┐ │
│ │ 用户提问 │ → │ System │ → │ LLM │ → │ 文本/quro-ui │ │
│ │ + 上下文 │ │ Prompt │ │ 决策 │ │ JSON 混合输出 │ │
│ └─────────┘ │ 「动态 UI │ │ 工具调用│ └─────────────────┘ │
│ │ 必备输出」 │ └─────────┘ │ │
│ └────────────┘ ▼ │
│ ┌─────────────────┐ │
│ │ A2uiEnvelope │ │
│ │ (a2ui 协议信封) │ │
│ └─────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌────────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ QuroUiDslParser│ → │ QuroUiCatalog│ → │ QuroUiNode │ │
│ │ 净化/解析/纠错 │ │ 调色板与图标 │ │ AST │ │
│ │ │ │ 字面量校验 │ │ │ │
│ └────────────────┘ └──────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ SurfaceHost │ │
│ │ 挂载 Compose 容器 │ │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ QuroUiRenderer │ │
│ │ 17+ 原生组件渲染 │ │
│ └──────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
📦 模块拆解 · 10 个文件各司其职
core/ui/dynamicui/
├── QuroUiNode.kt # AST 节点类型定义
├── QuroUiDslParser.kt # quro-ui 字符串净化 + JSON 解析
├── QuroUiCatalog.kt # 调色板 / 图标库 / 校验器
├── QuroUiColor.kt # 16 阶灰度 + 语义色映射
├── QuroUiIcons.kt # Lucide 图标库(camelCase → snake_case)
├── QuroUiPointer.kt # 路径解析 + 数据更新指针([a-z0-9_./-]+)
├── QuroUiRenderer.kt # ⭐ 核心:JSON AST → Compose 组件
├── SurfaceHost.kt # 无限尺寸 / maxWidth 崩溃修复 + 渲染挂载
├── A2uiEnvelope.kt # a2ui 信封协议(deepMerge / updateDataModel)
├── A2uiInterpreter.kt # 信封嗅探(lowercase 开头自动识别)
└── QuroDynamicUiTool.kt # ⭐ ui_dsl_spec / ui_validate 工具
🔧 各文件职责一览
| QuroUiNode.kt | ~350 | QuroUiRootNode/QuroUiContainerNode/QuroUiLeafNode 三层 AST;QuroListNode 支持 {{item.field}};QuroHtmlNode 透明 WebView |
| QuroUiDslParser.kt | ~280 | sanitizeJson() 抹平误置闭合符;fixOutsideStrings() 修复字符串外的脏括号;normalizeQuotes() 跟踪 inSgl/inDbl 状态 |
| QuroUiCatalog.kt | ~220 | 颜色通过 QuroUiColor.parse() 校验;图标白名单 + 未知图标回退默认;LeafNode 合法性守卫 |
| QuroUiColor.kt | ~120 | gray.0-gray.15 灰度;primary/secondary/success/warning/danger 语义色;自动判别 light/dark |
| QuroUiIcons.kt | ~200 | Lucide 图标集;camelCase → snake_case → 小写归一;缺失自动回退 circle_help |
| QuroUiPointer.kt | ~150 | 路径字段名正则 [\\w\\-./];解决 JSON 路径解析时的中划线/下划线混用 |
| QuroUiRenderer.kt | ~1400 | 核心:RenderColumn/RenderRow/RenderBox/RenderCard/RenderList/RenderTabs/RenderSlider/RenderText/RenderImage/RenderIcon/RenderBadge/RenderProgress/RenderButton/RenderTextInput/RenderSelect/RenderMarkdown/RenderHtml/RenderVideo/RenderAudio/RenderBrowser/RenderCode/RenderDivider/RenderSpacer 等 |
| SurfaceHost.kt | ~180 | 修复 Infinity 触发的 Compose 崩溃;包一层 BoxWithConstraints 提供真实可用宽度 |
| A2uiEnvelope.kt | ~200 | { "version": …, "a2ui": … } 信封;deepMerge() 支持增量数据模型合并 |
| A2uiInterpreter.kt | ~120 | 嗅探 lowercase 键名头({"kind":"a2ui", …})自动剥信封;纯 quro-ui JSON 不受影响 |
| QuroDynamicUiTool.kt | ~300 | ui_dsl_spec 拉取动态 UI 规格(提示词);ui_validate 模型自检输出可解析性 |
🧬 DSL 解析管线
quro-ui 的输入是模型输出在 fenced code block 里的 JSON。我们永远不假设模型一定写出干净 JSON,所以解析管线有四层防护:
🛡 1️⃣ sanitizeJson(raw: String)
目标:抹平「误置闭合符」(最常见的 AI 病)。
fun sanitizeJson(raw: String): String {
// 1) 找到第一个 '[' 或 '{' 作为起点
// 2) 跟踪 (字符串内/外) + (反斜杠转义) 状态机
// 3) 在字符串外允许的成对字符 [ ] { } :
// – 若遇到孤立的 ']' 或 '}',先看上层栈;不平衡则补一个同向(保守)补齐
// 4) 丢弃顶层其余杂质(多余反引号、注释尾巴)
}
🔧 典型拯救:
// 模型输出:
[
{ "type": "text", "text": "你好" }
{ "type": "button", "label": "确定" } // ← 漏了 ,
]
// sanitizeJson 后:
[
{ "type": "text", "text": "你好" },
{ "type": "button", "label": "确定" }
]
🛡 2️⃣ fixOutsideStrings(s: String)
目标:修字符串外的脏括号({ type: "foo} — 引号未关)。
fun fixOutsideStrings(s: String): String {
val out = StringBuilder()
var inSgl = false; var inDbl = false
for (c in s) {
when {
c == '\\\\' && (inSgl || inDbl) -> { out.append(c); /* 跳过下个 */ }
c == '"' && !inSgl -> inDbl = !inDbl
c == '\\'' && !inDbl -> inSgl = !inSgl
...
}
out.append(c)
}
}
🛡 3️⃣ normalizeQuotes(s: String)
目标:统一单/双引号 → JSON 标准双引号。在字符串外为 inSgl = false 时安全替换。
🛡 4️⃣ QuroUiCatalog + QuroUiColor.parse() 校验
目标:颜色字面量必须是已知 token,否则归一为 gray.7(中灰);图标名必须存在于白名单。
🎨 渲染管线
JSON AST (QuroUiNode)
│
▼
┌─────────────────────────────┐
│ QuroUiRenderer.render(root) │
└─────────────────────────────┘
│
├─ 容器节点 → RenderColumn/RenderRow/RenderBox/RenderCard
│ │
│ └─ forEach child → 递归调用 renderChild()
│
└─ 叶子节点 → RenderText/RenderImage/RenderButton/RenderHtml/…
│
└─ stableId(prefix, json) → 用于 Compose Key
📐 密度自适应(360 dp 设计宽度)
@Composable
fun rememberDensityScale(): Float {
val config = LocalConfiguration.current
val designWidthDp = 360f
val actualWidthDp = config.screenWidthDp.toFloat()
return (actualWidthDp / designWidthDp).coerceIn(0.85f, 2.0f)
}
文本、间距、内边距、圆角、卡片宽度都按 densityScale 缩放;图标按矢量自
🛠 实战:构建一个待办清单
理论讲完,来点能直接跑的东西。下面用 quro-ui 构建一个完整的「待办清单」:顶部一个输入框,中间是 list 渲染的待办项(每项带 checkbox 勾选),底部一个「清空已完成」按钮。
下面是你的待办清单,试试勾选或新增:
```quro-ui
{
"type": "column",
"gap": 12,
"padding": 14,
"children": [
{
"type": "text",
"text": "📝 今日待办",
"weight": "bold",
"size": 18
},
{
"type": "row",
"gap": 8,
"children": [
{
"type": "text_input",
"placeholder": "输入新任务,回车添加",
"value": "{{input}}",
"onSubmit": {
"type": "callback",
"name": "todo_add",
"payload": { "text": "{{input}}" }
}
},
{
"type": "button",
"label": "添加",
"variant": "primary",
"action": {
"type": "callback",
"name": "todo_add",
"payload": { "text": "{{input}}" }
}
}
]
},
{
"type": "list",
"gap": 8,
"data": [
{ "id": "t1", "title": "写周报", "done": false },
{ "id": "t2", "title": "回复邮件", "done": true },
{ "id": "t3", "title": "预约会议室", "done": false }
],
"template": {
"type": "row",
"gap": 10,
"align": "spaceBetween",
"children": [
{
"type": "checkbox",
"label": "{{item.title}}",
"checked": "{{item.done}}",
"onChange": {
"type": "toggle",
"stateKey": "todo.{{item.id}}.done"
}
},
{
"type": "button",
"label": "删除",
"variant": "secondary",
"action": {
"type": "callback",
"name": "todo_remove",
"payload": { "id": "{{item.id}}" }
}
}
]
}
},
{
"type": "button",
"label": "🗑 清空已完成",
"variant": "danger",
"action": {
"type": "callback",
"name": "todo_clear_done",
"payload": {}
}
}
]
}
```
🧩 节点渲染效果拆解
| column | 垂直容器,gap: 12 让标题、输入行、列表、清空按钮之间保持 12 dp 间距 |
| text | 顶部加粗标题「📝 今日待办」,size: 18 突出层级 |
| row | 水平排列「输入框 + 添加按钮」,gap: 8 让两者紧贴不粘连 |
| text_input | 占位提示「输入新任务,回车添加」,value 绑定 {{input}} 保持受控 |
| button | 「添加」用 primary 主色;「删除」用 secondary 次色;「清空」用 danger 红色 |
| list | 遍历 data 数组,每行按 template 渲染,{{item.title}} 取当前行标题 |
| checkbox | 左侧勾选框 + 右侧标签,checked 绑定 {{item.done}} 回显完成状态 |
⚡ 交互动作如何绑定
- callback(新增 / 删除 / 清空):按钮或输入框的 action / onSubmit 里声明 { "type": "callback", "name": "todo_add", "payload": {…} }。点击后前端把 name + payload 回传给宿主,由业务层更新数据模型并重渲染。
- toggle(勾选完成):checkbox 的 onChange 用 { "type": "toggle", "stateKey": "todo.{{item.id}}.done" }。它不经过业务回调,直接翻转 stateKey 指向的布尔状态,实现「本地即时勾选」——配合 {{item.done}} 回显,勾选后整行状态立刻同步。
- 占位符联动:{{item.id}} / {{item.title}} / {{item.done}} 在 list 内逐行求值,让每个 checkbox 和删除按钮都拿到自己那一行的数据,互不串扰。
💡 要点:callback 适合「需要宿主处理」的动作(增删、持久化),toggle 适合「纯本地状态翻转」(勾选、开关)。两者组合,就能在纯 JSON 里搭出可交互的完整界面。
适应不缩放。
🧩 节点类型完整清单 · 17+ 组件
| column | 容器 | gap / padding / align / scroll |
| row | 容器 | gap / padding / align / wrap |
| box | 容器 | padding / align |
| card | 容器 | padding / radius / elevation / background |
| tabs | 容器 | tabs[] + activeIndex 状态 |
| list | 容器 | data + template 占位符 {{item}}/{{item.field}}/{{index}} |
| text | 叶子 | text / size / weight / color / align / maxLines |
| image | 叶子 | src / fit / radius / placeholder |
| icon | 叶子 | name (Lucide) / size / color |
| badge | 叶子 | text / variant (success/warning/danger/…) |
| progress | 叶子 | value / max / variant |
| divider | 叶子 | color / thickness |
| spacer | 叶子 | height / width |
| button | 叶子 | label / action / variant |
| text_input | 叶子 | placeholder / value / onSubmit |
| checkbox | 叶子 | label / checked / onChange |
| switch | 叶子 | label / checked / onChange |
| select | 叶子 | options[] / value / onChange |
| slider | 叶子 | min / max / value / onChange |
| markdown | 叶子 | content 实时渲染 Markdown |
| html | 叶子 | content 透明 WebView 容器(v1.0.82 深度修复) |
| video | 叶子 | src / controls / autoplay |
| audio | 叶子 | src / controls |
| browser | 叶子 | url / height / cookies / ua(内嵌 WebView 容器,v1.0.82 已稳定) |
| code | 叶子 | code / lang / theme |
📝 实战示例:AI 输出
下面是配置服务器的一键操作清单:
```quro-ui
{
"type": "list",
"padding": 12,
"gap": 8,
"data": [
{ "emoji": "🛠", "title": "安装 Nginx", "desc": "通过 apt/yum 安装最新稳定版" },
{ "emoji": "🔒", "title": "配置 HTTPS", "desc": "使用 Let's Encrypt 自动签发" },
{ "emoji": "📦", "title": "部署静态站点", "desc": "/var/www/html 权限设置" }
],
"template": {
"type": "row",
"gap": 12,
"children": [
{ "type": "text", "text": "{{item.emoji}}", "size": 20 },
{
"type": "column",
"children": [
{ "type": "text", "text": "{{item.title}}", "weight": "bold" },
{ "type": "text", "text": "{{item.desc}}", "size": 12, "color": "gray.10" }
]
}
]
}
}
```
渲染效果:每行 = 表情 + 加粗标题 + 灰色描述,自适应宽度。
⚡ 动作类型 · 8+ 种交互
| callback | { name, payload } | — |
| tool_call | { name, args } | — |
| skill | { name, args } | — |
| open_url | { url } | 支持深链 zorvai://… |
| copy | { text } | — |
| open_app | { packageName } | — |
| toggle | { stateKey } | — |
| open_screen 🆕 | { screen, args } | 直达应用内屏(设置/插件/会话) |
| render_html 🆕 | { html } | 服务端/Skill 主动渲染 HTML 节点 |
| render_vispro 🆕 | { spec } | 触发可视化处理管线(图表/流程图) |
| visual_popup 🆕 | { payload } | 系统级浮层提示 |
| visual_ask 🆕 | { question, options[] } | 阻塞式可视化提问,等待用户选择 |
🔁 占位符与数据流
| {{index}} | list 节点里返回当前序号 | 第 {{index}} 项 |
| {{item}} | list 节点里整行数据(字符串字段时) | — |
| {{item.field}} | list 节点里按字段取数据 | {{item.title}} / {{ite> **同源更新**:List 内部如嵌套 tabs/card,子节点也能取到外层的 {{item.xxx}}`,渲染时整树连坐求值。 |
🔧 工具支持 · 模型自检
🧰 ui_dsl_spec
// 模型调用:
{ "tool": "ui_dsl_spec", "args": { "section": "all" } }
// 返回:quro-ui 节点清单 + 示例 JSON + 注意事项
🧰 ui_validate
// 模型自检:把刚才输出的 quro-ui JSON 喂回工具,立即返回可解析性评分
{ "tool": "ui_validate", "args": { "dsl": "<JSON>" } }
// 返回: { "ok": true, "warnings": […], "fix_suggestions": […] }
✅ 典型用法:模型自检一轮后再发出,比直接发送错误 JSON 被前端报错更稳健。
🎨 主题与样式
🌑 调色板(QuroUiColor)
| gray.0–gray.15 | gray.0 = #FFFFFF gray.15 = #0A0A0A | 16 阶中性灰 |
| primary | 主品牌色(v1.0.82:靛蓝 #5046E4) | 主操作 |
| secondary | 次操作色 | 次按钮 |
| success warning danger info | 绿/橙/红/蓝 | 状态徽章 |
| surface onSurface | 卡片背景/前景 | 自动暗色反转 |
✏️ 图标(QuroUiIcons)
- 内置 Lucide 图标集(约 1000 个常用图标)
- camelCase → snake_case → 小写归一
- 未知图标回退 circle_help,绝不渲染空白方块
🧠 v1.0.82 必备输出设计 · 系统提示词
这一节是让「动态 UI」真正成为默认行为的关键。
### 动态 UI(quro-ui 原生组件 · 必备输出)
**何时用:** 始终默认使用。任何需要呈现「操作清单 / 选项 / 表单 /
播放器 / 浏览器 / 富媒体」的回答,都优先用 quro-ui 渲染,而不是
纯文本。即使只生成一张卡片也要用它。
**输出规范:**
1. 单条 quro-ui JSON 必须被 ```quro-ui … ```围栏包裹;
2. 复杂的可拆为多条 ```quro-ui 块;
3. 关键结论、解释、对话照常用正文;UI 只是更强的呈现通道。
**自检:** 发送前调用 ui_validate 工具。
🧭 在工具分类中的位置
🧠 ToolCapabilityDirectory.DYNAMIC_UI
├─ IntentMatcher: "原生交互界面 / 动态UI"
│ └─ 命中工具: [ui_dsl_spec, ui_validate]
├─ IntentMatcher: "卡片 / 列表 / 表单 / 播放器 / 浏览器界面"
│ └─ 命中工具: [ui_dsl_spec]
└─ 优先级: 5(高于普通 text/image)
QuroToolRouter.categorize() 已加入 DYNAMIC_UI 映射,早于 ui_* 规则,避免被通用 UI 工具误判。
🐞 8 轮 Bug 修复亮点
| Round 2 | QuroUiRenderer | 占位符 {{item.emoji}} 显示原文不替换 | ListNode 取值模板改用 key 路径 item.emoji,不再依赖整段 item 字符串化 |
| Round 2 | QuroUiDslParser | 字符串内含未转义引号导致 parse 崩溃 | normalizeQuotes 引入 inSgl 跟踪,未关闭时强制补双引号 |
| Round 3 | SurfaceHost | 父容器传 Infinity 触发 Compose 测量崩溃 | 外层裹 BoxWithConstraints,把可用宽度收紧到 maxWidth – padding |
| Round 4 | QuroUiCatalog | 颜色字面量大小写不一致(Primary/PRIMARY) | QuroUiColor.parse() 单点入口,统一归一 |
| Round 5 | QuroUiRenderer | text_input 在 card 内只能点一次聚焦 | 拆 Modifier.focusRequester,remember(root) 防止重渲染拿错引用 |
| Round 6 | QuroUiRenderer | 暗色下文字看不清(用了浅色 token) | 渲染时根据当前 isSystemInDarkTheme() 二次反转 |
| Round 7 | QuroUiNode | QuroHtmlNode 透明背景露原生控件色 | WebView setBackgroundColor(Color.TRANSPARENT) + 容器同步 graphicsLayer = 0f |
| Round 8 | QuroUiRenderer | QuroUI 区块与普通消息块视觉混淆(无边框、间距过近) | 给 quro-ui 段落加 12 dp 顶部间距 + 卡片化外框,淡化正文连续感 |
🎯 设计哲学
❓ 为什么选 Compose 原生而不是 WebView?
| 性能 | 与系统同帧率,零额外进程 | 独立进程,重绘制、内存抖动 |
| 暗色一致性 | 跟随主题,零额外样式 | 需要在 HTML 里镜像一套 token |
| 滚动/手势 | LazyColumn、NestedScroll 原生开箱即用 | 手势与宿主 Activity 冲突、需手写桥接 |
| 体积 | 代码约 40 KB(解析+渲染) | 离线 HTML 模板 + 50 KB+ 运行时桥接 |
| 调试 | Layout Inspector / Preview 直接看 | 远程 Chrome DevTools |
| AI 输出适配 | JSON 描述简单、字段扁平 | HTML/CSS 结构脆弱、标签嵌套深 |
➡ 结论:可枚举的非媒体场景一律 Compose 原生;只有真正需要浏览器内核的(如打开任意 URL)才走 WebView 容器节点 browser。
❓ 为什么 JSON DSL 而不是 JSON Schema 或 Protobuf?
- JSON Schema 太啰嗦,模型不爱输出;结构校验可以靠 catalog 完成
- Protobuf / TypeScript 模型往往拼错大小写或忘了枚举值;JSON 字面量最稳
- YAML 缩进依赖坑惨过模型
- JSON 是当下 LLM 输出文本的最稳定格式(token 训练量最大)
🚀 未来扩展方向
- 🧩 可视化处理(render_vispro):流程图、时序图、思维导图渲染器
- 🎞 Timeline / Carousel 节点:横向滑动 + 自动播放
- 🪟 visual_ask 增强:多选、可填空、附件上传
- 🧠 state.io 持久化:节点状态写入数据模型,跨消息保持
- 🌐 a2ui envelope 互通:与外部 a2ui 协议完全双向兼容
- 📱 桌面 / 折叠屏断点:除 360 dp 外,新增 ≥ 600 dp / ≥ 840 dp 的多断点布局
📚 参考示例 · 完整卡片输出
下面为你列出 3 款适合远程开发的笔记本,按性价比排序:
```quro-ui
{
"type": "card",
"padding": 14,
"gap": 10,
"background": "surface",
"radius": 14,
"children": [
{
"type": "row",
"align": "spaceBetween",
"children": [
{ "type": "text", "text": "🏆 性价比首选", "weight": "bold", "size": 16 },
{ "type": "badge", "text": "TOP1", "variant": "success" }
]
},
{
"type": "text",
"text": "MacBook Air M2 · 16 GB / 512 GB",
"size": 14
},
{
"type": "row",
"gap": 8,
"children": [
{
"type": "button",
"label": "查看配置",
"variant": "primary",
"action": { "type": "open_url", "url": "https://example.com/mac-air" }
},
{
"type": "button",
"label": "加入对比",
"variant": "secondary",
"action": { "type": "tool_call", "name": "add_to_compare", "args": { "id": "mac-air-m2" } }
}
]
}
]
}
```
如果你需要开发 Android 原生,建议再考虑内存升级到 24 GB 的型号。
🌟 动态 UI,让 AI 的回答「看得见、用得上」🌟
ZorvAI · v1.0.82 · 2026-09-05
如嵌套 `tabs`/`card`,子节点也能




