// 生成式 UI:Agent 声明意图,引擎生成界面。
//
// 理念:Agent 的角色不是逐件工具调用摆放每个节点(纯工具调用 = 慢、贵、
// 易错,且把布局/规范决策压给模型),而是声明"要什么"——页面结构、组件
// 选择、间距规范、视觉一致性由引擎负责:
// 意图解析(本文件,确定性关键词分类,引擎无 LLM)
// → 模板实例化(templates.mbt:模板理解页面语义)
// → 规范自愈(auto_fix:norms/谓词一键还债)
// → 差距报告(intelligence.mbt:缺失元素 + 质量评分)
// generate 是这条管线的一调用入口:一句话进,完整画板 + 报告信封出。
// Agent 拿着报告(missing/score)决定是否精修——从"画笔"退到"导演"。
///|
/// 意图解析结果(确定性:同输入恒同决策;歧义时给候选留一次精修线索)
pub(all) struct GenIntent {
template_id : String
artboard_name : String // 缺省 = template_id
width : Double // 0 = 模板默认
height : Double // 0 = 模板默认
theme : String // "" = 不动主题;"dark" / 主题名
page_hint : String // 识别出的页面语义(人读)
alternatives : Array[String] // 同分候选 template id
}
///|
fn contains_any(haystack : String, needles : Array[String]) -> Bool {
for kw in needles.iter() {
if haystack.contains(kw) {
return true
}
}
false
}
///|
fn is_ascii_alnum(c : Char) -> Bool {
let code = c.to_int()
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122)
}
///|
/// 短拉丁词(pc/web)按整词匹配:裸子串会把 "npc" 判成 pc、"webview"
/// 判成 web(实证:"npc 仪表盘" 被强改为 pc_app+1280,整句页面语义被
/// 设备映射覆盖)。命中位置的前后邻字符不是字母/数字才算整词;中文
/// 关键词不受此影响,仍走 contains。
fn contains_latin_word(haystack : String, word : String) -> Bool {
let n = haystack.length()
let w = word.length()
let mut i = 0
while i + w <= n {
if haystack.substring(start=i, end=i + w) == word {
let before_ok = i == 0 ||
{
match haystack.get_char(i - 1) {
Some(c) => !is_ascii_alnum(c)
None => true
}
}
let after_ok = i + w == n ||
{
match haystack.get_char(i + w) {
Some(c) => !is_ascii_alnum(c)
None => true
}
}
if before_ok && after_ok {
return true
}
}
i = i + 1
}
false
}
///|
/// 意图 → 生成决策。中英关键词、设备暗示(web/平板/PC)与主题暗示
/// (dark/暗色 → dark)一次解析;页面语义按"具体优先"计分,平分时全部
/// 记入 alternatives(信封可见,Agent 可换 template 精修)。
pub fn parse_generate_intent(text : String) -> GenIntent {
let lower = text.to_lower()
// 设备暗示(pc/web 是 2-3 字母短词,按整词匹配防 "npc"/"webview" 误判)
let is_web = contains_latin_word(lower, "web") ||
contains_any(lower, [
"网页", "落地页", "landing", "浏览器", "官网",
])
let is_pc = contains_latin_word(lower, "pc") ||
contains_any(lower, ["桌面应用", "desktop", "工作站"])
let is_tablet = contains_any(lower, ["tablet", "平板", "ipad"])
// 主题暗示
let theme = if contains_any(lower, [
"dark", "暗色", "深色", "夜间",
]) {
"dark"
} else if contains_any(lower, ["light", "亮色", "浅色"]) {
"light"
} else {
""
}
// 页面语义计分(关键词 → 候选模板)。顺序即稳定性:先具体后泛化。
//
// **词表纪律**(2026-10-26 修正):一个模板只能声明它**真的是什么**。
// 此前 list_detail 把裸「列表」「详情」都收进词表,而它实际渲染的是聊天列表
// (app_bar "Chats" + Alice/Bob 会话 + "Select a chat to view details" 占位卡)——
// 结果「首页 列表」「装备详情」全被解析成同一个聊天界面,用户拿到的原型与需求无关。
// 现在 list_detail 收窄到「聊天/会话/消息」这一族;裸「列表」「详情」归 detail/list。
// 同理 empty_state 只收真正的空状态语义,不再用「无数据」这类会被误中的宽词兜住。
let candidates : Array[(String, String, Array[String])] = [
(
"signup",
"注册页",
["signup", "sign up", "注册", "register", "创建账号"],
),
("onboarding", "引导页", ["onboarding", "引导", "欢迎页", "首次"]),
("empty_state", "空状态页", ["empty", "空状态", "占位", "暂无内容"]),
("settings", "设置页", ["settings", "设置", "偏好", "preferences"]),
(
"dashboard",
"仪表盘",
["dashboard", "仪表盘", "大盘", "概览", "overview", "统计"],
),
(
"profile",
"个人主页",
["profile", "个人主页", "个人中心", "资料", "我的"],
),
// 聊天族必须排在 detail/list 之前:「聊天列表」同时命中「聊天」和「列表」,
// 各计 1 分打成平手——评分是严格大于才更新 best,平局由**候选顺序**裁决。
// 更具体的语义必须排在更泛的语义前面,否则「聊天列表」会被判成通用列表页。
(
"list_detail",
"聊天列表",
["聊天", "会话", "消息", "chat", "inbox", "收件箱"],
),
(
"detail",
"详情页",
["详情", "商品详情", "内容详情", "detail", "详细", "订单详情"],
),
(
"list",
"列表页",
["列表", "list", "信息流", "feed", "搜索结果", "分类浏览"],
),
(
"checkout",
"确认订单",
[
"确认订单", "订单确认", "结算", "checkout", "购物车", "下单", "提交订单",
"cart", "place order", "payment", "支付",
],
),
(
"login",
"登录页",
["login", "登录", "登陆", "sign in", "signin", "验证码", "手机号"],
),
("adaptive_landing", "响应式落地页", ["响应式", "adaptive"]),
("web_landing", "落地页", ["landing", "落地页", "官网", "marketing"]),
]
let score_of = fn(keywords : Array[String]) -> Int {
let mut score = 0
for kw in keywords.iter() {
if lower.contains(kw) {
score = score + 1
}
}
score
}
// 两遍扫描:先定 best,再收集等分者。一遍扫描的「顺手 push 等分」会
// 在 best 被后续候选抬高后残留针对旧 best 构建的列表(含实际低分候选),
// 误导 Agent 换模板精修。
let mut best_id = ""
let mut best_hint = ""
let mut best_score = 0
for cand in candidates.iter() {
let (id, hint, keywords) = cand
let score = score_of(keywords)
if score > best_score {
best_score = score
best_id = id
best_hint = hint
}
}
let alternatives : Array[String] = []
if best_score > 0 {
for cand in candidates.iter() {
let (id, _, keywords) = cand
if id != best_id && score_of(keywords) == best_score {
alternatives.push(id)
}
}
}
// 无命中:空状态兜底(最安全的生成物;信封标注低置信)
if best_id == "" {
best_id = "empty_state"
best_hint = "未识别(空状态兜底)"
}
// 设备映射:web 暗示把 auth/dashboard 换成 web 形态;PC → pc_app
let final_id = if is_pc {
"pc_app"
} else if is_web && best_id == "login" {
"web_login"
} else if is_web && best_id == "dashboard" {
"web_dashboard"
} else if is_web &&
(best_id == "web_landing" || best_id == "adaptive_landing") {
best_id
} else {
best_id
}
// 尺寸暗示:平板/PC 作为意图宽度传给模板。注意这只是意图——仅少数
// 宽响应模板(adaptive_landing/login_v2)消费宽度,其余模板保留内建
// 默认尺寸;画板实际尺寸以信封 width/height 为准(Agent 据此发现
// 「平板 仪表盘」产出 390 宽画板这类与意图不符的结果)。
let mut w = 0.0
let mut h = 0.0
if is_tablet {
w = 768.0
h = 1024.0
} else if is_pc {
w = 1280.0
h = 800.0
}
{
template_id: final_id,
artboard_name: "",
width: w,
height: h,
theme,
page_hint: best_hint,
alternatives,
}
}
///|
/// 生成式入口:意图一句话 → 完整画板 + 报告信封。
/// 管线:模板实例化 → auto_fix(规范/谓词自愈,生成物即合规)→
/// infer_missing(还差什么)→ design_quality_score(质量分)。
/// name 缺省 = template_id;theme 非 "" 时对整个项目应用主题(生成前,
/// 重着色项目内所有画板)。画板名经 sanitize_id 归一后与既有画板同名
/// 时会静默覆盖旧 Document(连同其上的 tier-2 精修)——信封以
/// replaced:true 提示;width/height 暴露画板实际尺寸(模板可能不消费
/// 意图宽度,见 apply_template)。
pub fn Project::generate(
self : Project,
intent_text~ : String,
name? : String = "",
spec? : String = "",
) -> Result[String, String] {
// issue #24:spec 在场 = 第一权威;缺失 = 显式要求,不静默用引擎内置
// 规范生成。宿主拿到 design_spec_required 后按 design_spec_draft 落盘重试。
if ds_is_blank(spec) {
return Err("design_spec_required")
}
// 组合意图("登录+注册"):多段走 multi 通道(多画板 + 相邻导航流);
// 单段路径保持字节级不变(兼容既有信封消费者)
let segs = split_intent_segments(intent_text)
if segs.length() > 1 {
if segs.length() > GENERATE_MULTI_MAX {
return Err("generate_multi_too_many:\{GENERATE_MULTI_MAX}")
}
return self.generate_multi(segs, name=name, spec=spec)
}
let intent = parse_generate_intent(intent_text)
let ab_name = if name == "" { intent.template_id } else { name }
// 覆盖探测:create_artboard 经 sanitize_id 归一后 upsert,重跑同意图
// 必得同 id——先探测归一名,覆盖发生时信封可见(replaced:true)。
let replaced = self.artboards.contains(sanitize_id(ab_name))
// 主题先生效(模板用令牌取色——先换主题再实例化,视觉一致)
let mut theme_applied = ""
if intent.theme != "" {
match self.apply_theme(intent.theme) {
Ok(_) => theme_applied = intent.theme
Err(_) => theme_applied = ""
}
}
let ab = match
self.apply_template(
template_id=intent.template_id,
name=ab_name,
width=intent.width,
height=intent.height,
) {
Err(error) => return Err(error)
Ok(id) => id
}
// issue #24:spec 应用到新板(调色板重着色 + 字号阶/圆角档规范化)
match self.apply_design_spec(artboard=ab, spec=spec) {
Err(error) => return Err(error)
Ok(_) => ()
}
// issue #25-B4(规则化):mock 数据缺口只报 id,真实样例由 Agent 填充
let mock_gap_ids = self.mock_data_gaps(artboard=ab)
let mg_j = json_string_array(mock_gap_ids)
// issue #24 要求 2:白名单/禁用项偏差随信封上报
let spec_violations = self.design_spec_component_violations(artboard=ab)
let sv_j = json_string_array(spec_violations)
// 信封暴露实际宽高:模板可能不消费意图宽度(多数模板保留内建
// 390×844),Agent 无从发现与意图不符,除非信封可见。
let (actual_w, actual_h) = match self.artboards.get(ab) {
Some(meta) => (meta.width, meta.height)
None => (0.0, 0.0)
}
// 生成即合规:规范/谓词自愈(生成物不允许带着可见债交付)
let autofix_json = match self.auto_fix(artboard=ab) {
Ok(json) => json
Err(_) => "{\"fixed\":0}"
}
let missing_json = match self.infer_missing(artboard=ab) {
Ok(json) => json
Err(_) => "{\"missing\":[]}"
}
let score_json = match self.design_quality_score(artboard=ab) {
Ok(json) => json
Err(_) => "{}"
}
let summary = "生成" + intent.page_hint + "(" + json_int_field(score_json, "total", 0).to_string() + "分,自愈" + json_int_field(autofix_json, "fixes", 0).to_string() + "处,缺" + json_int_field(missing_json, "missing_count", 0).to_string() + "项)"
let alts : Array[String] = []
for a in intent.alternatives.iter() {
alts.push("\"" + a + "\"")
}
let alts_j = alts.join(",")
Ok(
"{\"ok\":true,\"op\":\"generate\",\"artboard\":\"\{ab}\",\"template\":\"\{intent.template_id}\",\"width\":\{actual_w},\"height\":\{actual_h},\"replaced\":\{replaced},\"page_hint\":\"\{json_escape(intent.page_hint)}\",\"theme_applied\":\"\{theme_applied}\",\"alternatives\":[\{alts_j}],\"autofix\":\{autofix_json},\"missing\":\{missing_json},\"score\":\{score_json},\"mock_missing\":[\{mg_j}],\"spec_violations\":[\{sv_j}],\"summary\":\"\{json_escape(summary)}\",\"next\":\"autofill \{ab} 一键补缺 → critique 审阅 → ops 精修\"}",
)
}
///|
/// 组合意图上限:一句话最多展开 4 块画板(防滥用;真实原型一屏意图
/// 极少超过 4 页)。
pub const GENERATE_MULTI_MAX : Int = 4
///|
/// 意图分段:按 "+" / "+" 切分(保守分隔符——空格是单意图内的正常
/// 组成部分,"登录页 dark" 不受影响),去空白、丢空段。
pub fn split_intent_segments(text : String) -> Array[String] {
let out : Array[String] = []
let cur = StringBuilder::new()
for c in text.iter() {
if c == '+' || c == '+' {
let seg = mbt_seg_trim(cur.to_string())
if seg != "" {
out.push(seg)
}
cur.reset()
} else {
cur.write_char(c)
}
}
let seg = mbt_seg_trim(cur.to_string())
if seg != "" {
out.push(seg)
}
out
}
///|
fn mbt_seg_trim(s : String) -> String {
let ws : Array[Char] = [' ', '\t', '\n', '\r']
let chars : Array[Char] = []
for c in s.iter() {
chars.push(c)
}
let mut start = 0
let mut end = chars.length()
while start < end {
let c = chars[start]
if ws.contains(c) {
start = start + 1
} else {
break
}
}
while end > start {
let c = chars[end - 1]
if ws.contains(c) {
end = end - 1
} else {
break
}
}
let sb = StringBuilder::new()
for i in start.. String {
if target_hint.length() >= 3 &&
target_hint.substring(start=target_hint.length() - 3) == "页" {
target_hint.substring(start=0, end=target_hint.length() - 3)
} else {
target_hint
}
}
///|
/// 目标模板的惯用英文出口词(小写)。全部内置模板文案为英文(templates.mbt
/// 83 处 set_text 零 CJK),纯中文关键词对 text/name 做 contains 永远命不中
/// 现成出口——旗舰示例「登录+注册」会在 login 既有的 signup_btn(Create
/// Account)之下再凭空合成第二个注册 CTA。按目标模板给英文同义词,命中
/// 既有出口(节点 text 或 id 名)即就地连线不再合成。web_* 设备形态与其
/// 移动形态同词;无惯用出口的模板(empty_state/落地页)返回空表,退回中文
/// 关键词匹配。不收 "stats" 一类会撞 dashboard 自身 stat_N 节点名的泛词。
fn egress_template_keywords(template_id : String) -> Array[String] {
if template_id == "signup" {
["signup", "sign up", "register"]
} else if template_id == "login" || template_id == "web_login" {
["login", "sign in", "signin"]
} else if template_id == "settings" {
["settings", "preferences"]
} else if template_id == "dashboard" || template_id == "web_dashboard" {
["dashboard", "overview"]
} else if template_id == "profile" {
["profile", "account"]
} else if template_id == "list_detail" {
["list", "detail", "feed"]
} else if template_id == "list" {
["list", "discover", "search", "browse"]
} else if template_id == "detail" {
["detail", "product", "continue"]
} else if template_id == "checkout" {
["checkout", "cart", "order", "submit"]
} else if template_id == "onboarding" {
["onboarding", "get started"]
} else {
[]
}
}
///|
/// 在 from 画板上找出口节点:text/name 含目标语义关键词(中文 hint 词 +
/// 目标模板惯用英文词)者就地连线;找不到则合成 "前往X" 按钮——放置经
/// 画布高度钳制(autofill 同款 `y + node_h > ab.height`),底缘放不下先
/// 自上而下找带区空位,仍无空位则放弃合成返回 ""(调用方跳过该相邻流)
/// ——流必须指向真实存在的节点(mbt_validate_flows 契约),而越界按钮是
/// auto_fix 清偿不动的债(fix_overflow 对负剩余空间保持原尺寸)。
fn Project::find_or_make_egress(
self : Project,
from_ab~ : String,
target_hint~ : String,
target_id~ : String,
target_template~ : String,
) -> String {
let kw = egress_keyword(target_hint)
let kws : Array[String] = []
if kw != "" {
kws.push(kw.to_lower())
}
for en in egress_template_keywords(target_template).iter() {
kws.push(en)
}
let ab = match self.artboards.get(from_ab) {
Some(meta) => meta
None => return "goto_" + target_id
}
let layout = solve(ab.doc)
for id in ab.doc.nodes.keys() {
if id == "root" {
continue
}
match ab.doc.nodes.get(id) {
Some(n) =>
match layout.rect_of(id) {
Some(_) => {
let hay = (n.text + " " + n.name).to_lower()
if kws.length() > 0 && contains_any(hay, kws) {
return id
}
}
None => ()
}
None => ()
}
}
// 合成导航按钮:底缘之下放置;高内容模板(settings/dashboard 底缘已近
// 满页)放不下时找带区空位,再放不下则放弃——宁可少一条流也不落出画布
let node_w = 140.0
let node_h = 44.0
let mut bottom = 0.0
for id in ab.doc.nodes.keys() {
if id == "root" {
continue
}
match layout.rect_of(id) {
Some(r) => if r.y + r.height > bottom {
bottom = r.y + r.height
}
None => ()
}
}
let mut y = bottom + 16.0
if y + node_h > ab.height {
// 带区空位扫描:候选矩形与既有内容仅贴边不算相交(Rect::intersects)
y = -1.0
let mut cy = 8.0
while y < 0.0 && cy + node_h <= ab.height {
let cand = Rect::new(24.0, cy, node_w, node_h)
let mut taken = false
for id in ab.doc.nodes.keys() {
if id == "root" {
continue
}
match layout.rect_of(id) {
Some(r) => if cand.intersects(r) {
taken = true
}
None => ()
}
}
if taken {
cy = cy + 8.0
} else {
y = cy
}
}
if y < 0.0 {
return ""
}
}
let node_id = "goto_" + target_id
let _ = self.place_component(
artboard=from_ab,
component_id="button",
instance_id=node_id,
x=24.0,
y~,
w=node_w,
h=node_h,
)
let _ = self.edit_node(
artboard=from_ab,
node_id=node_id,
kvs=[("text", "前往" + kw)],
)
node_id
}
///|
/// 从 JSON 串取 `"key":` 之后的非负整数;键缺失或后随非数字时返回 fallback。
/// 仅供 multi 信封摘要解析(autofix fixes/remaining、missing_count、score)。
fn json_int_field(json : String, key : String, fallback : Int) -> Int {
let pat = "\"" + key + "\":"
match json.find(pat) {
None => fallback
Some(pos) => {
let rest = json.substring(start=pos + pat.length())
let mut j = 0
let mut v = 0
while j < rest.length() {
match rest.get_char(j) {
Some(c) =>
if c >= '0' && c <= '9' {
v = v * 10 + (c.to_int() - 48)
j = j + 1
} else {
break
}
None => break
}
}
if j == 0 {
fallback
} else {
v
}
}
}
}
///|
/// 导航流按 (from,to,trigger) 去重,保留首条。add_flow 无条件 push
/// (project.mbt 越界不可改),模板条件流又随画板 upsert 重建再次追加
/// ——generate_multi 幂等重跑的线性膨胀(实测 2→5→8)在此一次性收口。
fn Project::dedup_flows(self : Project) -> Unit {
let seen : Map[String, Bool] = Map::new()
let out : Array[FlowEdge] = []
for f in self.flows.iter() {
let key = "\{f.from}>\{f.to}@\{f.trigger}"
if !seen.contains(key) {
seen.set(key, true)
out.push(f)
}
}
if out.length() != self.flows.length() {
self.flows = out
}
}
///|
/// 组合意图生成:逐段分类 → 逐板实例化(纯主题段只换主题不生成板,
/// 同模板重名加序号)→ 相邻板自动连线(出口启发)→ 流去重 → 逐板自愈/
/// 评分。信封 mode=multi,逐板带 replaced(覆盖探测,与单通道同构)、
/// autofix(ok/fixes[/remaining] 摘要)、missing_count/score。
pub fn Project::generate_multi(
self : Project,
segments : Array[String],
name? : String = "",
spec? : String = "",
) -> Result[String, String] {
// issue #24:直调者也必须带 spec(不静默兜底)
if ds_is_blank(spec) {
return Err("design_spec_required")
}
let multi_mock_gaps : Array[String] = []
let multi_violations : Array[String] = []
// 1) 逐段分类;无页面语义的段视为主题修饰(dark 等)不生成板。
// 页面段原文必须记账:退化单通道时拿页面段重拼——写死 segments[0]
// 会把主题词在前的组合("dark+登录")退化成 "dark dark",页面段
// 被丢弃、静默落到空状态兜底板。
// 丢弃必须**可见**(2026-10-27 兜底审计):组合里的未知段可能承载
// 真实页面语义("登录+帮助中心"),静默丢段 = 页面无声消失。skipped
// 记账进信封,Agent 可见后可向用户复述或换说法重试。
let boards : Array[GenIntent] = []
let page_segs : Array[String] = []
let skipped : Array[String] = []
let mut theme = ""
for seg in segments.iter() {
let gi = parse_generate_intent(seg)
if gi.page_hint.contains("未识别") {
if gi.theme != "" && theme == "" {
// 主题词被消费(theme_applied 可见)——不算丢段
theme = gi.theme
} else {
// 未被任何方式消费的段才进 skipped
skipped.push(seg)
}
continue
}
if gi.theme != "" && theme == "" {
theme = gi.theme
}
boards.push(gi)
page_segs.push(seg)
}
if boards.length() == 0 {
return Err("generate_multi_no_page_intent")
}
if boards.length() == 1 {
// 退化为单段:走单通道保持信封形状兼容。页面段原文 + 被丢弃的
// 主题修饰段关键词拼回意图,单通道重新解析即生效(顺序无关:
// "dark+登录" 与 "登录+dark" 同得 login + dark 主题)。
// 单通道信封不含 skipped——有丢段时在结果头部注入,保持可见性。
let only = page_segs[0]
let with_theme = if theme != "" { "\{only} \{theme}" } else { only }
let r = self.generate(intent_text=with_theme, name=name, spec=spec)
if skipped.length() == 0 {
return r
}
match r {
Err(e) => return Err(e)
Ok(json) => {
let parts : Array[String] = []
for s in skipped.iter() {
parts.push("\"" + json_escape(s) + "\"")
}
// 前缀手术:单通道信封恒以 {"ok":true 开头(10 个 ASCII 码元),
// 跳过重建为 {"skipped":[...],"ok":true…(substring 惯用法同 variants.mbt)
return Ok(
"{\"skipped\":[" + parts.join(",") + "],\"ok\":true" + json.substring(start=10),
)
}
}
}
// 2) 主题一次全局生效(先换主题再实例化,模板取色一致)
let mut theme_applied = ""
if theme != "" {
match self.apply_theme(theme) {
Ok(_) => theme_applied = theme
Err(_) => theme_applied = ""
}
}
// 3) 逐板实例化:同模板重名追加序号;name 参数归第一板。
// 查重按 sanitize_id 归一口径:create_artboard 以 sanitize_id(name)
// 为画板 id 做 Map upsert(大写转小写、非法字符转 _),未归一查重
// 把 "Signup"/"signup" 当两个名,归一后同 id 的第二板静默覆盖第一板
// (信封仍报 count:2,相邻导航流成自环)。
let ids : Array[String] = []
let hints : Array[String] = []
let replaced_flags : Array[Bool] = []
let used_ids : Map[String, Bool] = Map::new()
let mut first = true
for gi in boards.iter() {
let mut ab_name = if first && name != "" { name } else { gi.template_id }
let mut ab_id = sanitize_id(ab_name)
let mut n = 2
while used_ids.contains(ab_id) {
ab_name = "\{gi.template_id}_\{n}"
ab_id = sanitize_id(ab_name)
n = n + 1
}
used_ids.set(ab_id, true)
// 覆盖探测与单通道同口径:重跑同组合意图经 upsert 静默覆盖旧
// Document(连同其上的 tier-2 精修),信封逐板 replaced 可见。
let replaced = self.artboards.contains(ab_id)
let ab = match
self.apply_template(
template_id=gi.template_id,
name=ab_name,
width=gi.width,
height=gi.height,
) {
Err(error) => return Err(error)
Ok(id) => id
}
// issue #24:spec 应用到本板(与单板通道同一判据)
match self.apply_design_spec(artboard=ab, spec=spec) {
Err(error) => return Err(error)
Ok(_) => ()
}
// issue #25-B4(规则化):逐板缺口 id(内容由 Agent 填充)
for gid in self.mock_data_gaps(artboard=ab).iter() {
multi_mock_gaps.push(gid)
}
for v in self.design_spec_component_violations(artboard=ab).iter() {
multi_violations.push(v)
}
ids.push(ab)
hints.push(gi.page_hint)
replaced_flags.push(replaced)
first = false
}
// 4) 相邻板导航流:出口启发(就地找语义节点,找不到合成前往按钮;
// 画布放不下放弃合成时跳过该流——流契约要求 trigger 指向真实节点)
let mmg_j = json_string_array(multi_mock_gaps)
let mv_items : Array[String] = []
for v in multi_violations.iter() {
mv_items.push("\"" + json_escape(v) + "\"")
}
let mv_j = mv_items.join(",")
let flows_json : Array[String] = []
let mut i = 0
while i + 1 < ids.length() {
let egress = self.find_or_make_egress(
from_ab=ids[i],
target_hint=hints[i + 1],
target_id=ids[i + 1],
target_template=boards[i + 1].template_id,
)
if egress != "" {
self.add_flow(from=ids[i], to=ids[i + 1], trigger="tap:\{egress}")
flows_json.push(
"{\"from\":\"\{json_escape(ids[i])}\",\"to\":\"\{json_escape(ids[i + 1])}\",\"trigger\":\"tap:\{json_escape(egress)}\"}",
)
}
i = i + 1
}
// 5) 流去重:模板条件流(templates.mbt 在目标板已存在时追加内建流,
// 随 upsert 重建再次追加)与出口流每次重跑各追加一遍,而 add_flow
// 无条件 push(project.mbt 越界不可改)——幂等重跑线性膨胀,在
// 构造信封前按 (from,to,trigger) 保留首条一次性收口。
self.dedup_flows()
// 6) 逐板自愈 + 摘要(先逐板 auto_fix 清偿合成按钮引入的重叠)
let arts : Array[String] = []
let mut k = 0
while k < ids.length() {
// autofix 结果透传(不再 `let _ =` 丢弃):逐板 ok/remaining 摘要
// 进入信封——清偿不动时调用方可见,生成器不谎报合规。
let autofix_json = match self.auto_fix(artboard=ids[k]) {
Ok(j) => j
Err(_) => "{\"ok\":false,\"remaining\":0,\"fixes\":0}"
}
let fixes_n = json_int_field(autofix_json, "fixes", 0)
let autofix_summary = if autofix_json.contains("\"ok\":true") {
"{\"ok\":true,\"fixes\":\{fixes_n}}"
} else {
"{\"ok\":false,\"remaining\":\{json_int_field(autofix_json, "remaining", 0)},\"fixes\":\{fixes_n}}"
}
let missing_json = match self.infer_missing(artboard=ids[k]) {
Ok(j) => j
Err(_) => "{\"missing_count\":0}"
}
let missing_count = json_int_field(missing_json, "missing_count", 0)
let score_json = match self.design_quality_score(artboard=ids[k]) {
Ok(j) => j
Err(_) => "{}"
}
let sv = json_int_field(score_json, "total", -1)
let score_total = if sv < 0 { "?" } else { sv.to_string() }
arts.push(
"{\"id\":\"\{json_escape(ids[k])}\",\"template\":\"\{json_escape(boards[k].template_id)}\",\"page_hint\":\"\{json_escape(hints[k])}\",\"replaced\":\{replaced_flags[k]},\"autofix\":\{autofix_summary},\"missing_count\":\{missing_count},\"score\":\{score_total}}",
)
k = k + 1
}
let arts_j = arts.join(",")
let ms = "组合生成 " + ids.length().to_string() + " 板:" + ids.join(" → ")
let flows_j = flows_json.join(",")
// 丢段可见性:未知段在信封显式列出(Agent 可向用户复述或换说法重试),
// 全部段都有语义时字段为空数组(形状恒定,消费者免判空缺)
let skipped_j : Array[String] = []
for s in skipped.iter() {
skipped_j.push("\"" + json_escape(s) + "\"")
}
Ok(
"{\"ok\":true,\"op\":\"generate\",\"mode\":\"multi\",\"count\":\{ids.length()},\"summary\":\"\{json_escape(ms)}\",\"theme_applied\":\"\{theme_applied}\",\"skipped\":[\{skipped_j.join(",")}],\"artboards\":[\{arts_j}],\"flows\":[\{flows_j}],\"mock_missing\":[\{mmg_j}],\"spec_violations\":[\{mv_j}],\"next\":\"逐画板 autofill/critique → ops 精修\"}",
)
}