Zorv AI 对话框 AIP 排版引擎:让大模型回答在聊天气泡里直接长成文档 / PPT / 思维导图

3 阅读12分钟

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 三条硬底线

  1. 永不空白气泡 —— 解析彻底失败也走 Markdown 兜底
  2. JSON 源码不上界面 —— 只有 Fallback 会显示原始内容,且是渲染成富文本块
  3. 零三方依赖 —— 解析用 org.json,图表用 Compose Canvas 手绘,导出用自研 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
CWebView 模板逃生舱,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数据类关键字段
1headingHeadinglevel(1~6), text
2paragraphParagraphtext(走 MarkdownText 富文本)
3listListBlockordered, items[]
4tableTableheaders[], rows[][]
5codeCodelang, code
6quoteQuotetext, cite
7calloutCallouttone(info/warn/success/danger), title, text
8dividerDivider
9imageImageref, caption, ratio
10chartChartchartType(bar/line/pie/radar), labels[], series[].{name,values}
11columnsColumnsratio[], children[][](最多一层嵌套)
12stepsStepsitems[], direction
13timelineTimelineitems[].{time,title,text}
14mindmapMindmaplayout, root.{id,text,tone,children} 递归树
15slideSlidelayout, title, subtitle, bullets[], columns[], stats[], chart, table, quote, notes
16sectionSectionlevel(1~4), title
17htmlHtmlhtml兼容 data.html 与块级 html 两种写法
任意Fallbacktype, 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 / titleBodyimageLeft/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(文档/幻灯/导图)kindOverrideAipConvert.convert
全屏阅读「全屏」ChipAipFullscreenSheet(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 参数契约

参数类型说明
kindstring必填 · doc / deck / mindmap
title / subtitle / authorstring元信息
accentstring主题色 #RRGGBB
blocksarray块数组,可省略
sectionsarray[{title, level, body|content|blocks}] 简写形态
contentstring整段正文,别名 markdown / text
exportstringdocx / 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 真实源码撰写,所有代码片段均取自仓库,未作功能性改写。