Zorv AI 对话框 AIP 排版引擎 · 技术架构文档
AIP = AI Presentation Protocol —— 让大模型的长回答不再是"一坨 Markdown",而是在聊天气泡里直接长成文档 / PPT / 思维导图的原生排版协议。
源码依据:
github.com/Quor-a/ZorvAI@main(2026-09-07) 包名com.ai.assistance.quro· Kotlin + Jetpack Compose · Apache-2.0
目录
- 一、定位与设计原则
- 二、全局分层架构
- 三、三档通道与路由决策
- 四、协议层:AIP 信封与块型
- 五、容错解析:四级降级机制
- 六、形态互转与导出序列化
- 七、渲染层:AIP Canvas
- 八、对话框接入链路
- 九、工具调用通道:aip_compose
- 十、模型侧契约(系统提示词)
- 十一、端到端时序
- 十二、工程坑位与修复清单
- 十三、代码地图与规模
一、定位与设计原则
1.1 它解决什么问题
对话框里 AI 输出"一份行业调研报告",传统做法只有两条路:纯 Markdown(排版弱),或生成文件让用户自己打开(跳出对话)。AIP 走第三条路:
模型输出结构化 JSON 信封 → 客户端原生渲染 → 排版结果直接长在气泡流里,还能一键转 PPT/导图、导出 docx/pptx。
1.2 四条设计原则(Aip.kt 文件头注释,源自 PRD 4.1)
| 原则 | 实现手段 |
|---|---|
| 流式友好 | lastSafeCut() 截断边界扫描,任意位置截断都能部分解析 |
| 模型友好 | optString/optInt + 默认值 + 安全转换,字段缺失不报错(L1) |
| 渲染友好 | 一个 Block = 一个可独立渲染的 UI 单元,块间无隐式依赖 |
| 演进友好 | 未知块型 → Block.Fallback 富文本兜底,绝不丢弃 |
1.3 三条硬底线
- 永不空白气泡 —— 解析彻底失败也走 Markdown 兜底
- JSON 源码不上界面 —— 只有
Fallback会显示原始内容,且是渲染成富文本块 - 零三方依赖 —— 解析用
org.json,图表用 ComposeCanvas手绘,导出用自研 OOXML
二、全局分层架构
┌──────────────────────────────────────────────────────────────────┐
│ L5 模型契约层 QuroChatViewModel 系统提示词 │
│ 通道路由表 / AIP 信封结构 / 16 块型说明 / 写法铁律 │
├──────────────────────────────────────────────────────────────────┤
│ L4 路由层 CanvasRouter.kt │
│ A 增强 Markdown │ B 结构化 AIP │ C WebView 模板 │
├──────────────────────────────────────────────────────────────────┤
│ L3 接入层 ChatScreen.kt │
│ parseBlocks() 围栏识别 → MsgBlock.Aip │
│ 气泡过滤 + 消息底部全宽内联 │
│ 工具结果路径:aip_compose 返回值嗅探 │
├──────────────────────────────────────────────────────────────────┤
│ L2 协议层 core/canvas/Aip.kt │
│ Envelope / Block(18) / parse() / 四级降级 │
│ sanitizeJson · extractEnvelopeJson · lastSafeCut │
├──────────────────────────────────────────────────────────────────┤
│ L2' 转换层 core/canvas/AipConvert.kt │
│ doc ⇄ deck ⇄ mindmap · toMarkdown · toPptxText │
├──────────────────────────────────────────────────────────────────┤
│ L1 渲染层 ui/canvas/AipCanvas.kt │
│ AipCanvas · AipCanvasBlock(when 注册表) │
│ DeckPager · SlideCard · MindmapView · AipChart │
├──────────────────────────────────────────────────────────────────┤
│ L0 基础设施 MarkdownText · HtmlPreviewWebView · AiwpsCreateTool│
└──────────────────────────────────────────────────────────────────┘
关键解耦:协议层(core/canvas)不 import 任何 Compose;渲染层(ui/canvas)不碰 JSON。两边只通过 Aip.Block sealed interface 对话。
三、三档通道与路由决策
3.1 通道定义
| 通道 | 名称 | 覆盖场景 | 实现 |
|---|---|---|---|
| A | 增强 Markdown | 默认档,约 70% 问答 | Compose 原生 MarkdownText |
| B | 结构化 AIP + 原生渲染 | 长文档 / 导图 / PPT / 图表主力 | Aip.parse + AipCanvas |
| C | WebView 模板 | 逃生舱,B 表达不了时启用 | 复用既有 html 工件路径 |
3.2 决策链(CanvasRouter.route)
fun route(
query: String, // 用户原始输入
envelopeKind: String? = null, // 流式前 120 token 嗅探到的 kind
contentLength: Int = 0,
h2Count: Int = 0,
hasTableOrChart: Boolean = false,
): RouteDecision
代码实际执行顺序(注意与注释里宣称的 PRD 顺序有出入,见下方 ⚠️):
图示 · CanvasRouter 路由决策链
- → B{硬指令关键词命中?}
- **PPT/幻灯/演示文稿/汇报材料** → C[锁定 deck → B 通道]
- **思维导图/脑图/发散一下** → D[锁定 mindmap → B 通道]
- **写成文档/调研报告/建设方案** → E[锁定 doc → B 通道]
- **否** → F{信封头 kind 有效?}
- **doc/deck/mindmap** → G[信封头信号 → B 通道]
- **否** → H{复杂度启发式}
- **长度>1500 或 二级标题≥3 或 含表格图表** → I[升 B 通道]
- **否** → J[默认 A 通道]
关键词规则表:
private val DECK_WORDS = listOf("ppt", "幻灯", "汇报材料", "演示文稿", "slide", "做成演示", "deck")
private val MINDMAP_WORDS = listOf("思维导图", "导图", "脑图", "mindmap", "发散一下", "头脑风暴", "梳理一下结构")
private val DOC_WORDS = listOf("写成文档", "写一份", "调研报告", "建设方案", "行业方案", "长文档", "word", "docx")
⚠️ 实现与文档的差异:类注释声称顺序是「硬指令 → 意图分类 → 信封头 → 复杂度 → 兜底」,但
route()里没有独立的意图分类器实现,实际是「硬指令 → 信封头 → 复杂度 → 兜底」。注释也自陈"V1 用规则"、分类器置信度低的场景自然落不到该分支。审计时以代码为准。
3.3 生成中途软重路由
模型声称要输出 AIP,结果一直在吐 Markdown —— 不能打断重绘,只能软改道:
fun shouldSoftReroute(envelopeKind: String?, streamedSoFar: String, tokenCount: Int): Boolean {
if (envelopeKind == null && tokenCount > 120) return true
if (envelopeKind != null && tokenCount > 240 && !streamedSoFar.contains("\"blocks\"")) return true
return false
}
语义:保留已渲染内容、切换解析档位,不打断、不重绘。
四、协议层:AIP 信封与块型
4.1 信封结构
{
"v": 1,
"kind": "doc",
"meta": { "title": "标题", "subtitle": "副标题", "author": "作者" },
"theme": { "name": "aurora", "accent": "#2E6BE6" },
"blocks": [ { "id": "b1", "type": "...", "data": { ... } } ],
"assets": {}
}
对应 Kotlin 数据类:
data class Envelope(
val v: Int,
val kind: String, // doc | deck | mindmap | markdown
val title: String,
val subtitle: String,
val author: String,
val accent: String, // 主题色 #RRGGBB
val themeName: String,
val blocks: List<Block>,
val assets: JSONObject?,
)
扁平 / 嵌套双兼容 —— 这是关键容错。模型常按 aip_compose 的工具参数格式直接写扁平 {kind,title,subtitle,author,accent,blocks},也可能写嵌套 meta/theme。解析时二者任一存在即采用:
val topTitle = obj.optString("title", "").trim()
val metaTitle = meta?.optString("title")?.trim().orEmpty()
...
title = metaTitle.ifBlank { topTitle } // meta 优先,回退顶层
accent = themeAccent.ifBlank { topAccent }
themeName = themeName.ifBlank { "aurora" } // 兜底主题
4.2 块型全表
Aip.Block 是 sealed interface,18 个实现类(17 个可声明类型 + 1 个兜底):
| # | type | 数据类 | 关键字段 |
|---|---|---|---|
| 1 | heading | Heading | level(1~6), text |
| 2 | paragraph | Paragraph | text(走 MarkdownText 富文本) |
| 3 | list | ListBlock | ordered, items[] |
| 4 | table | Table | headers[], rows[][] |
| 5 | code | Code | lang, code |
| 6 | quote | Quote | text, cite |
| 7 | callout | Callout | tone(info/warn/success/danger), title, text |
| 8 | divider | Divider | — |
| 9 | image | Image | ref, caption, ratio |
| 10 | chart | Chart | chartType(bar/line/pie/radar), labels[], series[].{name,values} |
| 11 | columns | Columns | ratio[], children[][](最多一层嵌套) |
| 12 | steps | Steps | items[], direction |
| 13 | timeline | Timeline | items[].{time,title,text} |
| 14 | mindmap | Mindmap | layout, root.{id,text,tone,children} 递归树 |
| 15 | slide | Slide | layout, title, subtitle, bullets[], columns[], stats[], chart, table, quote, notes |
| 16 | section | Section | level(1~4), title |
| 17 | html | Html | html(兼容 data.html 与块级 html 两种写法) |
| — | 任意 | Fallback | type, text(未知/损坏块兜底) |
⚠️ 契约与实现不一致:系统提示词写的是「16 种块类型」,列出的正好是上表 1~16,漏了
html。引擎实际支持 17 种。这是个真实的文档债 —— 结果是模型几乎不会主动用html块(尽管引擎完全支持且渲染器已就绪)。
4.3 单块解析(parseBlock 节选)
private fun parseBlock(bo: JSONObject): Block {
val id = bo.optString("id", "b_${bo.hashCode().toUInt()}")
val type = bo.optString("type", "paragraph").lowercase()
val d = bo.optJSONObject("data") ?: JSONObject()
return when (type) {
"heading" -> Block.Heading(id, d.optInt("level", 2).coerceIn(1, 6), d.optString("text"))
"table" -> { /* headers + rows 双重 map */ }
"chart" -> parseChart(id, d)
"columns" -> { /* 递归 parseBlock,天然限深一层 */ }
"mindmap" -> Block.Mindmap(id, d.optString("layout", "right"), parseNode(...))
"slide" -> parseSlide(id, d)
// HTML 块:AI 可能把 HTML 放在 data.html 或块级 html 字段
"html" -> {
val html = d.optString("html", "").ifBlank { bo.optString("html", "") }
Block.Html(id, html)
}
else -> Block.Fallback(id, type, bo.toString()) // 未知类型:兜底富文本,不丢弃
}
}
chart 还带单系列兜底:模型没写 series 而把 data 直接写成数值数组时,自动包一个无名系列:
if (series.isEmpty()) {
val flat = d.optJSONArray("data")
if (flat != null && flat.length() > 0 && flat.opt(0) is Number) {
series.add(Block.Chart.Series("", flat.map { (it as? Number)?.toDouble() ?: 0.0 }))
}
}
五、容错解析:四级降级机制
这是 AIP 最硬的部分 —— 面对的是流式、随时截断、格式随性的模型输出。
5.1 降级矩阵
| 级别 | 名称 | 触发条件 | 表现 | Degradation 枚举 |
|---|---|---|---|---|
| L1 | 字段级修复 | 类型不符 / 字段缺失 | safe 取值 + 默认值 | Ok / FieldRepair |
| L2 | 块级降级 | 单块解析抛异常 | 该块 → Block.Fallback,其余块不受影响 | — |
| L3 | 通道降级 | 整体解析失败 | 回退增强 Markdown + 降级横幅 | ChannelDown |
| L4 | 纯文本兜底 | 内容为空 / 彻底不可解析 | 按 raw 纯文本渲染 | TextDown |
实现细节:L2 不体现在
ParseResult.degradation上 —— 它体现在blocks列表里出现Fallback实例。也就是说一次解析可以「整体 Ok,但局部有 Fallback」。排查时不能只看degradation字段。
enum class Degradation { Ok, FieldRepair, BlockDown, ChannelDown, TextDown }
(BlockDown 枚举值存在但 parse() 从不返回它 —— 保留给未来块级审计用。)
5.2 解析主流程
fun parse(source: String): ParseResult {
val text = source.trim().trimStart('\uFEFF', '\u00A0') // BOM 不是空白,trim() 不去除
if (text.isEmpty()) return ParseResult(null, Degradation.TextDown, source)
// 1. 完整解析
parseEnvelope(text)?.let { return ParseResult(it, Degradation.Ok, source) }
// 1.5 宽松修复:转义字符串内的裸控制字符后重试
val sanitized = sanitizeJson(text)
if (sanitized != text) {
parseEnvelope(sanitized)?.let { return ParseResult(it, Degradation.FieldRepair, source) }
}
// 1.6 提取信封:AI 先写「以下是 XX」再贴 JSON 时,定位第一个完整 {…}
extractEnvelopeJson(sanitized)?.let { envJson ->
if (envJson != sanitized) {
parseEnvelope(envJson)?.let { return ParseResult(it, Degradation.FieldRepair, source) }
}
}
// 2. 截断修复:找最后一个安全截断点,补闭合后缀重解析
lastSafeCut(sanitized)?.let { cut ->
parseEnvelope(sanitized.substring(0, cut.pos) + cut.suffix)?.let {
return ParseResult(it, Degradation.FieldRepair, source)
}
}
// 3. 通道降级(L3/L4 由调用方处理渲染形态)
return ParseResult(null, Degradation.ChannelDown, source)
}
5.3 三把容错钥匙
钥匙① sanitizeJson —— 修裸换行
模型在 ```aip 围栏里直接写 JSON,code 块内容、多行 paragraph 文本里的换行经常不转义,JSONTokener 直接抛 Unterminated string → 整体 L3 降级。这个状态机只转义字符串字面量内部的裸控制字符,不动结构、不动已有转义:
internal fun sanitizeJson(json: String): String {
val sb = StringBuilder(json.length + 32)
var inStr = false
var esc = false
var i = 0
while (i < json.length) {
val c = json[i]
if (inStr) {
if (esc) { sb.append(c); esc = false }
else when (c) {
'\\' -> { sb.append(c); esc = true } // 进入转义,下一字符原样保留
'"' -> { sb.append(c); inStr = false }
'\n' -> sb.append("\\n")
'\r' -> sb.append("\\r")
'\t' -> sb.append("\\t")
else -> if (c.code < 0x20) sb.append("\\u%04x".format(c.code)) else sb.append(c)
}
} else {
if (c == '"') { sb.append(c); inStr = true } else sb.append(c)
}
i++
}
return sb.toString()
}
钥匙② extractEnvelopeJson —— 从说明文字里捞 JSON
模型爱写「好的,这是您要的文档:」再贴信封。此函数定位首个 { 起、括号深度归零(字符串内括号不计数)的完整段。
钥匙③ lastSafeCut —— 流式截断修复(核心)
深度模型:0=信封外,1=信封对象内,2=blocks 数组/根级值内,3=块元素内,4+=元素 data 内。
三类安全截断点:
| 场景 | 条件 | 补后缀 |
|---|---|---|
| 一个完整块元素结束 | c == '}' && depth == 2 | ]} |
| blocks 数组本身闭合 | c == ']' && depth == 1 | } |
| 根级键值对结束 | c == '}' && depth == 1(非 blocks 内) | } |
internal data class SafeCut(val pos: Int, val suffix: String)
// 关键片段:嗅探信封级 "blocks" 键,确认值以 [ 开头
if (depth == 1 && text.regionMatches(i, "\"blocks\"", 0, 8)) {
var j = i + 8
while (j < text.length && text[j].isWhitespace()) j++
if (j < text.length && text[j] == ':') {
j++
while (j < text.length && text[j].isWhitespace()) j++
if (j < text.length && text[j] == '[') insideBlocks = true
}
}
效果:模型写到第 7 个块时断流,前 6 个完整块照常渲染出来,不白屏、不报错。
5.4 快速嗅探
fun looksLikeAip(source: String): Boolean {
val t = source.trimStart()
if (!t.startsWith("{")) return false
val head = t.take(400)
return (head.contains("\"kind\"") || head.contains("\"v\"")) &&
(head.contains("\"blocks\"") || head.contains("\"kind\""))
}
只看前 400 字符,O(1) 开销,供对话框在每条消息、每帧重组时零成本判定走不走 B 通道。
六、形态互转与导出序列化
AipConvert 让一份内容在 doc / deck / mindmap 三种形态间自由切换 —— 用户点一下 Chip 就能把报告变 PPT。
6.1 转换矩阵
| 转换 | 策略 |
|---|---|
| doc → deck | 标题做封面 → Section/Heading 开新页 → 段落/列表聚合成要点 → 图表/表格各成一页 |
| doc → mindmap | 按标题层级建树(1~3 级),正文截断为叶子(26 字 + 省略号) |
| deck → doc | 每页转 Section + 副标题 + 要点列表 + 图表/表格/引用 |
| mindmap → doc | 根做一级标题,分支做层级标题,叶子做列表 |
fun convert(env: Envelope, targetKind: String): Envelope = when (targetKind) {
env.kind -> env
"deck" -> toDeck(env)
"mindmap" -> toMindmap(env)
else -> toDoc(env)
}
成本:O(blocks),且 kind 相同时直接原样返回零成本。渲染层用 remember(env0, kind) 缓存,切换 Chip 不重复计算。
6.2 导出序列化
fun toMarkdown(env: Envelope): String // docx / md:Markdown 语法(**加粗**、| 表 |、代码围栏)
fun toPptxText(env: Envelope): String // pptx:`---` 分页,每页首行标题、其余要点
fun exportFileStem(env: Envelope): String // 净化标题为文件名(去非法字符、空格转 _、截 40 字)
对接 AiwpsCreateTool(自研 OOXML,零三方依赖)落地为真实 .docx / .pptx / .md / .pdf。
toDeck 里一个精妙处理 —— Chart/Table 强制独占一页:
is Block.Chart -> { flush(); slides.add(Block.Slide(nid(), "chart", b.title.ifBlank { "图表" }, "", ...)) }
is Block.Table -> { flush(); slides.add(Block.Slide(nid(), "table", "表格", "", ...)) }
flush() 先把累积中的要点页收尾,图表/表格另起新页,避免"半页要点 + 半个图"的破碎排版。
七、渲染层:AIP Canvas
7.1 顶层可组合函数
@Composable
fun AipCanvas(source: String, onLinkClick: (String) -> Unit = {}) {
val result = remember(source) { Aip.parse(source) } // 解析缓存
var kindOverride by remember { mutableStateOf<String?>(null) }
var presenting by remember { mutableStateOf(false) }
var fullscreen by remember { mutableStateOf(false) }
when {
result.envelope != null -> {
val kind = kindOverride ?: env0.kind
val env = remember(env0, kind) { AipConvert.convert(env0, kind) } // 转换缓存
/* 头部 + 工具栏 + 块列表 */
}
else -> {
/* L3/L4:降级横幅 + MarkdownText(result.raw) */
}
}
}
7.2 块渲染:注册表模式的 when
@Composable
fun AipCanvasBlock(b: Aip.Block, onLinkClick: (String) -> Unit = {}) {
when (b) {
is Aip.Block.Heading -> when (b.level.coerceAtMost(4)) { 1 -> headlineSmall; 2 -> titleLarge; ... }
is Aip.Block.Section -> /* 4dp 竖条 + 标题 + 分割线 */
is Aip.Block.Paragraph-> MarkdownText(text = b.text, onLinkClick = onLinkClick)
is Aip.Block.Table -> AipTable(b)
is Aip.Block.Code -> if (isHtml) AipHtml(b.code) else /* 深色等宽源码框 */
is Aip.Block.Chart -> AipChart(b)
is Aip.Block.Columns -> AipColumns(b, onLinkClick)
is Aip.Block.Mindmap -> MindmapView(b)
is Aip.Block.Slide -> SlideCard(b)
is Aip.Block.Html -> AipHtml(b.html)
is Aip.Block.Fallback -> Surface { Text(b.text, fontSize = 11.sp, fontFamily = Monospace) }
// ...18 分支全覆盖
}
}
新增块型的成本 = 加一个 data class + 一个 when 分支 + 一个 parseBlock 分支,三方解耦,无接线代码。
7.3 图表:Compose Canvas 手绘,零三方库
private val CHART_COLORS = listOf(
Color(0xFF2E6BE6), Color(0xFFE65100), Color(0xFF00897B),
Color(0xFF8E24AA), Color(0xFF558B2F), Color(0xFFD81B60),
)
Canvas(Modifier.fillMaxWidth().height(180.dp).clip(RoundedCornerShape(10.dp))) {
when (b.chartType) {
"pie", "donut" -> drawPie(b)
"line", "area" -> drawLine(b)
"radar" -> drawRadar(b)
else -> drawBar(b)
}
}
柱 / 线 / 饼 / 雷达四种全部 DrawScope 手绘:柱状带垂直渐变 + 分组偏移(barW = (groupW * 0.6f) / series.size),折线用 Path + StrokeCap.Round,饼图 useCenter 区分 pie/donut,雷达画三层同心网格 + 半透明填充。多系列时自动渲染图例。
7.4 Deck:16:9 横滑 + 九种版式
@Composable
private fun DeckPager(env: Aip.Envelope) {
val slides = env.blocks.filterIsInstance<Aip.Block.Slide>()
if (slides.isEmpty()) { env.blocks.forEach { AipCanvasBlock(it) }; return }
val pagerState = rememberPagerState { slides.size }
Column(Modifier.fillMaxWidth()) {
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxWidth()) { page ->
SlideCard(slides[page])
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.Center) {
Text("${pagerState.currentPage + 1} / ${slides.size}", fontSize = 11.sp, ...)
}
}
}
SlideCard 落地的版式:cover / section / stats / quote / twoCol / chart / table / summary / titleBody(imageLeft/imageFull/timeline 等未知版式回落 titleBody)。
演示模式:全屏黑底 Dialog,右 2/3 点按下一页、左 1/3 上一页,✕ 退出,底部 n / total 计数。
7.5 思维导图:缩进树 + 肘形引导线
@Composable
private fun MindmapNodeView(node: Aip.Block.Mindmap.Node, isRoot: Boolean) {
Column {
Surface(color = if (isRoot) cs.primary else cs.secondaryContainer.copy(alpha = 0.6f), ...) {
Text(node.text.ifBlank { " " }, fontSize = if (isRoot) 14.sp else 12.sp, ...)
}
if (node.children.isNotEmpty()) {
Column(Modifier.padding(start = 12.dp)) {
node.children.forEach { child ->
Row {
Box(Modifier.width(14.dp).height(24.dp).verticalGuide(...)) // 肘形引导线
MindmapNodeView(child, isRoot = false) // 递归
}
}
}
}
}
}
private fun Modifier.verticalGuide(color: Color): Modifier = this.drawBehind {
drawLine(color, Offset(size.width / 2, 0f), Offset(size.width / 2, size.height), strokeWidth = 2f)
}
不用 WebView、不引图形库,纯 Compose 递归 + drawBehind 画引导线,天然支持任意深度。
7.6 交互能力
| 能力 | 入口 | 实现 |
|---|---|---|
| 形态切换 | 三个 FilterChip(文档/幻灯/导图) | kindOverride → AipConvert.convert |
| 全屏阅读 | 「全屏」Chip | AipFullscreenSheet(Dialog 满屏 + 纵向滚动) |
| 演示模式 | 放映图标(deck 专有) | DeckPresentOverlay |
| 复制 | 复制图标 | AipConvert.toMarkdown → 系统剪贴板(IO 线程) |
| 导出 | 下拉菜单 | docx / pptx / md → AiwpsCreateTool(IO 线程 + Toast 回执) |
八、对话框接入链路
8.1 消息块模型
/** AIP 排版引擎(Canvas,B 通道):```aip 围栏或裸信封 JSON → 原生富排版(长文档/导图/PPT)。 */
data class Aip(val source: String) : MsgBlock()
8.2 两条接入路径
图示 · 对话框两条 AIP 接入路径
- → A2{裸信封 JSON? / looksLikeAip}
- **是** → A3[MsgBlock.Aip]
- **否** → A4{RE_FENCE 围栏匹配}
- **aip / aip+json / canvas** → A3
- **其他** → A5[其他块型]
- → B2[substringBefore 分割导出信息]
- → B3{工具结果 looksLikeAip?}
- **是** → B4[AipCanvas 直接渲染]
- **否** → B5[FormattedResultContent]
- → C[气泡内过滤剔除]
- → D[消息底部全宽内联渲染]
路径一:正文围栏识别
// 裸信封 JSON({"v":1,"kind":...,"blocks":[...]})不经围栏直接输出 → 整段走 B 通道。
// 流式期间截断的残缺信封也放行 —— AipCanvas 内部做截断修复 + 分块渲染 + 四级降级。
if (com.ai.assistance.quro.core.canvas.Aip.looksLikeAip(text)) return listOf(MsgBlock.Aip(text))
...
lang.equals("aip", true) || lang.equals("aip+json", true) || lang.equals("canvas", true) ->
blocks.add(MsgBlock.Aip(code))
ANR 修复背景:正则全部预编译为文件级常量。原实现在解析函数内 Regex(...),每次 Compose 重组都重编译,走 ICU native PatternNative.compileImpl;几百条消息 × 多正则 × 每帧重组 → 主线程卡死。
private val RE_FENCE = Regex("(?m)^```([\\w+#-]*)\\n?([\\s\\S]*?)```")
行首锚定 (?m)^ 是必需的 —— 否则模型在正文里用反引号引用围栏语法(如「我用 ```aip 围栏」)时,非贪婪匹配会从内联代码的 ``` 开始吞,真正的围栏被吃掉,本该渲染的块全部降级为纯文本。
路径二:工具结果渲染
// 后台 AIP 排版:任何发出 AIP 信封的工具结果(aip_compose 或工具箱-文档类工具
// chat_doc / workspace_doc / enhanced_doc_create 发出的 kind=doc 信封)都在对话框内
// 用 Canvas 引擎渲染成完整 AIP 文档("工具调用形式,最后渲染在对话框")。
val aipJson = t.result!!.substringBefore("\n\n[导出]")
if (com.ai.assistance.quro.core.canvas.Aip.looksLikeAip(aipJson)) {
AipCanvas(source = aipJson)
val exportNote = t.result!!.substringAfter("\n\n[导出]", "")
if (exportNote.isNotBlank()) { Spacer(Modifier.height(6.dp)); Text(exportNote, ...) }
} else {
FormattedResultContent(t.result!!, scaled)
}
工具图标/配色分类:
name == "aip_compose" || name == "aiwps_create" || name == "enhanced_doc_create" || name.contains("doc") ->
ToolCategory("file_text", Color(0xFF0EA5E9), "文档排版")
8.3 关键布局决策:全宽内联
AIP 块不在 280dp 气泡内渲染,而是移到底部全宽内联 —— 与动态 UI / 生成式 UI / 富组件同源机制:
// 气泡内:统一解析一次,过滤掉 AIP
blocks.filter { it !is MsgBlock.DynamicUi && it !is MsgBlock.SelfCard }.forEach { blk -> ... }
// 消息底部:
if (!msg.mine && blocks.any { it is MsgBlock.Aip }) {
Spacer(Modifier.height(8.dp))
Box(Modifier.fillMaxWidth().clipToBounds()) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
blocks.filterIsInstance<MsgBlock.Aip>().forEach { blk ->
com.ai.assistance.quro.ui.canvas.AipCanvas(source = blk.source, onLinkClick = onOpenLink)
}
}
}
}
blocks 解析结果在消息级 remember,气泡过滤和内联渲染共用一次解析结果,避免重复解析。
九、工具调用通道:aip_compose
9.1 定位
工具调用形式,最后渲染在对话框。 让模型以 Function Calling 产出 AIP 信封,而非在正文里写围栏。工具做 L1 字段修复与规范化后回传规范化信封。
class AipComposeTool : QuroTool {
override val name = "aip_compose"
// 注册:QuroBuiltInTools.kt:429 r.register(AipComposeTool())
}
9.2 参数契约
| 参数 | 类型 | 说明 |
|---|---|---|
kind | string | 必填 · doc / deck / mindmap |
title / subtitle / author | string | 元信息 |
accent | string | 主题色 #RRGGBB |
blocks | array | 块数组,可省略 |
sections | array | [{title, level, body|content|blocks}] 简写形态 |
content | string | 整段正文,别名 markdown / text |
export | string | docx / pptx / md / pdf,不填则仅对话框渲染 |
9.3 核心容错:synthesizeBlocks
模型经常省略大 blocks 数组(长文档尤其),只传标题 + 正文。旧实现会报「缺少非空 blocks 数组」→ 白屏。现在三级兜底:
val blocks = jo.optJSONArray("blocks")
val effectiveBlocks: JSONArray = when {
blocks != null && blocks.length() > 0 -> blocks
else -> synthesizeBlocks(jo)
}
图示 · synthesizeBlocks 三级兜底
- → B{有 sections 数组?}
- **是** → C[每节 → section 块 + 正文切段落块]
- **否** → D{有 content/markdown/text?}
- **是** → E[按行切块:## 开头当 heading,其余当 paragraph]
- **否** → F[单个 paragraph 占位「文档内容待补充」]
第三级占位看似敷衍,实则有讲究 —— 标题已由信封头部渲染,所以不会白屏。
9.4 执行流程
override fun run(context: Context, arguments: String): String {
val kind = jo.optString("kind", "doc").ifBlank { "doc" }.lowercase()
if (kind !in setOf("doc", "deck", "mindmap")) return "aip_compose 的 kind 必须是 ..."
// 组装规范化信封(meta/theme 给默认值)
val env = JSONObject().apply { /* v, kind, meta, theme, blocks */ }
// L1 字段修复 + 规范化校验(复用 Aip.parse)
val parsed = Aip.parse(env.toString())
val envelope = parsed.envelope
?: return "AIP 信封解析失败(降级级别:${parsed.degradation}),请检查 blocks 结构是否合法。..."
// 可选导出 → AiwpsCreateTool
val exportMsg = if (export.isNotBlank()) {
val content = if (export == "pptx") AipConvert.toPptxText(envelope) else AipConvert.toMarkdown(envelope)
val r = runCatching { AiwpsCreateTool().run(context, ...) }.getOrElse { "导出失败:${it.message}" }
"\n\n[导出] $r"
} else ""
return env.toString() + exportMsg // 回传规范化信封;渲染端用 substringBefore 拆分
}
9.5 文档类工具共用同一出口
Aip.docEnvelope() 让工具箱文档类工具(chat_doc / workspace_doc / enhanced_doc_create)产出与 AIP Canvas 兼容的完整结构化文档,替代旧的极简渲染卡:
fun docEnvelope(title: String, content: String, format: String, language: String = "", note: String = ""): String {
// note → callout(tone=info) 生成信息卡
when {
fmt == "html" || fmt == "htm" -> /* html 块(WebView 渲染) */
fmt in setOf("code","json","xml","yaml","css","js","java","kt","py","c","cpp","go","rust","swift","ts","bash","sh","sql","csv","svg")
-> /* code 块 */
else -> /* paragraph(MarkdownText 富文本) */
}
}
设计细节:不额外塞
heading块 —— 标题由AipCanvas头部统一渲染,避免重复。
十、模型侧契约(系统提示词)
引擎再强,模型不按格式输出也是白搭。QuroChatViewModel 里注入的提示词是契约的源头。
10.1 通道路由表(提示词片段)
| 排版引擎(AIP) | 回复正文写 ```aip 围栏(或裸 AIP JSON 信封) | 原生排版引擎(16 种块型:文档流/横滑PPT/导图/图表) | 「排版一下」「做成PPT」「做份报告/长文档」 | 长文档、演示文稿、结构化报告的整篇排版 |
配套路由口诀划清通道边界:
- 画图 → mermaid;做网页 →
html;多文件工程 → workbench;整篇排版长文档/PPT/报告 →aip 围栏(AIP 信封)。
10.2 通道边界铁律
进文档(AIP / aip_compose) | 只在对话框渲染 |
|---|---|
| 整篇长文档、报告、方案、PPT、思维导图、表格/图表结构化内容 | 单张流程图/架构图 → mermaid<br/>网页成品 → html可交互小程序 → miniapp<br/>动态 UI → quro-ui |
10.3 流式规则(写给模型看)
信封头(v/kind/meta/theme)必须最先输出完整;blocks 按顺序一块一块长出来(渲染端支持任意截断的部分解析,写一半也不会白屏);不要在信封外加多余文字解释。
10.4 使用原则(防滥用)
普通短问答继续用 Markdown,严禁滥用 AIP;只有长文档/PPT/报告/整篇排版才走这条路。
轻量容器语法兜住中间地带 —— 不需要完整信封时,普通 Markdown 里直接写:
:::card 卡片标题 → 浮起卡片
:::columns + --- → 双栏/多栏对比
:::chart bar 季度营收 → 原生图表(bar/line/pie/radar)
:::steps → 步骤条
内容不复杂时优先用容器语法,超过 5 个块 / 要 PPT 翻页时才上完整 AIP 信封。
十一、端到端时序
图示 · AIP 端到端时序
- U → VM:「做一份行业调研报告」
- VM → RT:route(query, envelopeKind, ...)
- → >VM: Channel.B / hintKind=doc
- VM → LLM:系统提示词(含 AIP 契约 + hintKind)
- → >VM: 流式输出 aip_compose 工具调用 /
aip 围栏
Note over VM,CS: 流式期间每帧增量
CS->>CS: parseBlocks(cleanText) 记得住
CS->>CS: RE_FENCE / looksLikeAip → MsgBlock.Aip
CS->>P: Aip.parse(source) [remember 缓存]
P->>P: 完整解析 → sanitizeJson → extractEnvelopeJson → lastSafeCut
P-->>CS: ParseResult(envelope, degradation)
alt 解析成功
CS->>CV: 气泡内过滤 AIP,底部全宽内联渲染
CV->>CV: AipConvert.convert(env, kind) [remember 缓存]
CV->>CV: AipCanvasBlock when 分发 18 分支
CV-->>U: 原生排版卡片(文档流 / 横滑 PPT / 导图)
else L3/L4 降级
CS-->>U: 降级横幅 + MarkdownText(raw)
end
opt 用户点导出
CV->>EX: AipConvert.toMarkdown / toPptxText → AiwpsCreateTool
EX-->>U: .docx / .pptx / .md / .pdf
end
---
## 十二、工程坑位与修复清单
这一节是**真实踩过的坑**,全部来自源码注释。做同类引擎时能省不少事。
### 12.1 布局类
| 症状 | 根因 | 修复 |
|---|---|---|
| 文字被压成逐字竖排、一个盖一个 | 外层 `SurfaceHost(360f)` 等比缩放,把 `BoxWithConstraints` 内子 Column 压窄,`Box(weight(1f))` 只剩 ~10dp | 移除外层缩放,改无框纯容器,由各 Block 自行 `fillMaxWidth()` |
| 表格列错位、窄屏互相覆盖 | `horizontalScroll` 给子级无限水平宽约束,`Row` 里 `Text(weight(1f))` 权重失效,各列按内容各自测宽 | 去掉横向滚动,列宽 `weight(1f)` 均分 + 软换行;行数补齐/截断到表头列数 |
| 幻灯片内容溢出、与下一页重叠 | `aspectRatio` 与 `verticalScroll` 叠在同一节点,滚动器把测量高度撑成内容真实高度 | 外层 `Box` 定 16:9 高 + 内层 `Column` 滚动,**两者不叠同节点** |
| 长文档"不满屏" | 渲染被限制在对话框层 | 新增 `AipFullscreenSheet`,Dialog 满屏 + 纵向滚动 |
| HTML 块留白或裁切 | WebView 固定高度 | `onPageFinished` 回传 `scrollHeight` → 高度自适应 `160~1440dp` |
### 12.2 解析类
| 症状 | 根因 | 修复 |
|---|---|---|
| JSON 原文 + "排版引擎已降级为 Markdown" 横幅 | 模型字符串内写裸换行,`JSONTokener` 抛 `Unterminated string` | `sanitizeJson` 状态机只转义字符串内裸控制字符 |
| 真正的围栏被吞,全变纯文本 | `RE_FENCE` 无行首锚定,非贪婪从内联代码的 ``` 开始匹配 | 加 `(?m)^` 行首锚定 |
| 首字符解析报错 | BOM `\uFEFF` 不是空白,`trim()` 不去 | `trimStart('\uFEFF', '\u00A0')` |
| 流式中断则白屏 | 无截断边界扫描 | `lastSafeCut` 深度模型 + 补闭合后缀 |
| 说明文字混入信封 | 模型先写"以下是 XX"再贴 JSON | `extractEnvelopeJson` 定位首个完整 `{…}` 平衡段 |
| 「缺少非空 blocks 数组」 | 模型省略大 blocks 数组 | `synthesizeBlocks` 三级兜底 |
### 12.3 性能类
| 症状 | 根因 | 修复 |
|---|---|---|
| 主线程 ANR | 正则在解析函数内现场编译,每次重组重编译,走 ICU native | 正则预编译为**文件级常量**,只编译一次 |
| 重复解析开销 | 气泡过滤和内联渲染各解析一次 | `blocks` 声明上提到消息级作用域,`remember` 共用 |
| 形态切换卡顿 | 转换 O(blocks) 每帧重算 | `remember(env0, kind)` 缓存;kind 相同零成本返回 |
---
## 十三、代码地图与规模
### 13.1 核心文件
| 文件 | 行数 | 职责 |
|---|---|---|
| `core/canvas/Aip.kt` | 576 | 协议层:Envelope / Block(18) / parse / 四级降级 / 三把容错钥匙 |
| `ui/canvas/AipCanvas.kt` | 898 | 渲染层:AipCanvas / 18 分支分发 / Deck / Mindmap / Chart / 全屏 / 演示 |
| `core/canvas/AipConvert.kt` | 275 | 形态互转 + 导出序列化(toMarkdown / toPptxText) |
| `core/tools/AipComposeTool.kt` | 198 | 工具调用通道 + synthesizeBlocks 兜底 |
| `core/canvas/CanvasRouter.kt` | 84 | 三档通道路由 + 软重路由判定 |
| `ui/ChatScreen.kt` | 8,984 | 对话框接入(AIP 相关约 15 处钩子) |
| `ui/QuroChatViewModel.kt` | 2,616 | 系统提示词契约(AIP 段约 145 行) |
### 13.2 测试覆盖
app/src/test/java/com/ai/assistance/quro/core/canvas/ ├── AipParserTest.kt 解析正确性 ├── AipConvertTest.kt 形态互转 / 导出序列化 ├── AipFenceRenderReproTest.kt 围栏渲染回归(对应 12.2 的围栏被吞 bug) └── core/tools/AipComposeToolTest.kt 工具参数容错
### 13.3 三个值得抄走的架构决策
1. **降级优先于校验** —— 不追求"模型必须输出合法 JSON",而是假定它会错,把错误分四级吃掉。任何一级都不允许白屏。
2. **协议层零 UI 依赖** —— `core/canvas` 只依赖 `org.json`,可独立单测、可跨端复用(未来桌面端/Web 端直接搬)。
3. **全宽内联而非气泡内嵌** —— 富内容一律脱离 280dp 气泡约束,与动态 UI / 富组件统一机制。这是从"文字一个盖一个"的惨痛教训里长出来的规则。
---
## 附录:最小可用示例
模型侧输出(工具调用形式,推荐):
```json
{
"kind": "doc",
"title": "2026 端侧 AI 行业调研",
"subtitle": "设备端智能体的机会与约束",
"blocks": [
{ "id": "b1", "type": "section", "data": { "level": 1, "title": "市场概览" } },
{ "id": "b2", "type": "paragraph","data": { "text": "端侧推理成本在过去 18 个月下降 **62%**。" } },
{ "id": "b3", "type": "chart", "data": {
"type": "bar",
"title": "季度出货量",
"labels": ["Q1", "Q2", "Q3", "Q4"],
"series": [{ "name": "出货(百万)", "data": [12, 19, 27, 41] }]
} },
{ "id": "b4", "type": "callout", "data": { "tone": "warn", "title": "风险", "text": "高权限能力须严守最小授权原则。" } }
],
"export": "docx"
}
围栏兜底形式(等价):
```aip
{"v":1,"kind":"doc","meta":{"title":"2026 端侧 AI 行业调研"},"blocks":[...]}
```
文档基于 Quor-a/ZorvAI @ main 真实源码撰写,所有代码片段均取自仓库,未作功能性改写。