🌟 项目简介
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 病)。
funsanitizeJson(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}— 引号未关)。
funfixOutsideStrings(s:String):String{valout=StringBuilder()varinSgl=false;varinDbl=falsefor(cins){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 设计宽度)
@ComposablefunrememberDensityScale():Float{valconfig=LocalConfiguration.currentvaldesignWidthDp=360fvalactualWidthDp=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+ 种交互
| 动作 | 参数 | v1.0.82 增强 |
|---|---|---|
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)
| Token 类 | 示例 | 说明 |
|---|---|---|
gray.0–gray.15 | gray.0 = #FFFFFFgray.15 = #0A0A0A | 16 阶中性灰 |
primary | 主品牌色(v1.0.82:靛蓝 #5046E4) | 主操作 |
secondary | 次操作色 | 次按钮 |
successwarningdangerinfo | 绿/橙/红/蓝 | 状态徽章 |
surfaceonSurface | 卡片背景/前景 | 自动暗色反转 |
✏️ 图标(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透明背景露原生控件色 | WebViewsetBackgroundColor(Color.TRANSPARENT)+ 容器同步graphicsLayer = 0f |
| Round 8 | QuroUiRenderer | QuroUI 区块与普通消息块视觉混淆(无边框、间距过近) | 给quro-ui段落加 12 dp 顶部间距 + 卡片化外框,淡化正文连续感 |
🎯 设计哲学
❓ 为什么选 Compose 原生而不是 WebView?
| 维度 | Compose 原生 | WebView + HTML |
|---|---|---|
| 性能 | 与系统同帧率,零额外进程 | 独立进程,重绘制、内存抖动 |
| 暗色一致性 | 跟随主题,零额外样式 | 需要在 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`,子节点也能