跳到主要内容

OpenUI Lang — 运行时求值

本章讲什么: [02 章]的解析器产出的 ElementNode 树里,props 仍可能含有「保留的 AST 节点」——$dayssales.rows.valuea ? b : c。这一章讲运行时怎么把它们算成真实值,以及让界面真正「活起来」的三件套:取数(queryManager)、响应式状态(store)、按钮编排(Action)。最后讲增量编辑(edit-mode)。

核心仍在 lang-core(框架无关);React 适配在 react-lang/src/Renderer.tsx 把它们接进 React。


1. 全景:谁喂谁

ParseResult(第 02 章)
├── root: ElementNode 树(props 里残留 AST)
├── stateDeclarations: { $days: "7", ... }
├── queryStatements: [ {tool, argsAST, depsAST, ...} ]
└── mutationStatements: [ ... ]


┌───────────────┐ 读 $变量 ┌──────────┐
│ evaluator │◄─────────────│ store │ 响应式状态
│ evaluate(ast)│ resolveRef └──────────┘
│ │◄─────────────┐
└──────┬────────┘ Query 结果 │
│ 求值后的 props 树 │
▼ ┌─────┴────────┐
React 渲染 │ queryManager │ 取数/缓存/刷新
└──────────────┘
│ callTool

toolProvider(你的后端/MCP)

怎么读: evaluator 是中枢,算 props 时需要值就找两个数据源——$变量找 store,Query/Mutation 结果找 queryManager。算完的纯值树交给 React 画。


2. evaluator:把保留的 AST 算成值

它要解决的小问题: kpi = TextContent("合计:" + total) 里的 "合计:" + total 在渲染时得变成真正的字符串。

evaluate(runtime/evaluator.ts:42)是一个直白的 AST 求值器,按 k 分发。几个有「DSL 个性」的求值规则:

表达式求值规则位置
$days优先 extraScope,否则查 storeevaluator.ts:61-62
arr.field(数组上)拔字段:对每个元素取 .field 成新数组evaluator.ts:208-217
a + b任一边是字符串就字符串拼接(null 当 ""),否则数字加evaluator.ts:157-162
a / 0返回 0(不抛 Infinity/NaN,DSL 取舍)evaluator.ts:168-169
&& / ||短路求值evaluator.ts:144-151

那个「数组上 .field 自动拔字段」很重要:它让 sales.rows.value 直接得到一列数值喂给图表,模型不用写循环。这是语言对「给 LLM 用」的人体工学优化。

@Each:唯一的「惰性」内置

大多数内置函数(Sum/Filter/Sort…)是「先算参数,再调 .fn」(evaluator.ts:82-86)。但 @Each(array, varName, template) 不行——它得对每个元素重算一遍 template,所以参数不能提前求值,归为 LAZY_BUILTINS(builtins.ts:185)。

evaluateLazyBuiltin(evaluator.ts:447)对每个数组项:把 template AST 里的循环变量 Ref(varName) 预先替换成该项的字面值(substituteRef),再求值。为什么要预替换而不只是临时塞进作用域?因为像 Action([@Set($id, t.id)]) 这种延迟到点击时才执行的表达式,如果只靠临时作用域,等点击时循环早结束、t 早没了——预替换把 t.id 在生成时就固化成具体值(evaluator.ts:440-446 注释)。


3. queryManager:取数引擎

它要解决的小问题: Query() 要做到「默认值立刻显示、真数据到了替换、依赖变了重取、可定时刷新、出错有结构化错误」,还不能因为竞态显示过期数据。

createQueryManager(runtime/queryManager.ts:137)是个不依赖框架的小型数据层。核心是缓存键:

cacheKey = toolName + "::" + stableStringify(args) + "::" + stableStringify(deps)

buildCacheKey(queryManager.ts:130)用稳定序列化(键排序、特殊值归一)拼键。args 或依赖($变量值)一变,cacheKey 就变 → 视为新查询 → 自动重取(evaluateQueries,queryManager.ts:319)。这就是「下拉框一改、Query 自动刷新」的底层机制——依赖在解析期由 collectQueryDeps 预抽([01 章]),运行时求值后并进缓存键。

几个让人「用着顺手」的细节:

  • 乐观显示旧值。 重取途中先显示上一个 cacheKey 的数据(prevCacheKey 兜底),不闪空(queryManager.ts:163-175)。
  • 竞态防护。 发起 fetch 时记下 fetchKey,回来时若 query 的当前 cacheKey 已变(说明依赖又变了),丢弃这次结果(queryManager.ts:236-240)。
  • 定时刷新。 第四个参数是秒数,装个 setInterval 周期重取(queryManager.ts:374-390)。
  • 结构化错误。 工具找不到/MCP 错/一般错分别映射成带 codehintOpenUIError(queryManager.ts:256-289),喂回模型纠错。

Mutation 走另一条路:不自动触发,只在按钮点击经 fireMutation 执行(queryManager.ts:471),带 idle/loading/success/error 状态机,且拒绝并发重复提交(queryManager.ts:481);成功后可顺带 invalidate 一批 Query 实现「写完即刷新」。


4. store:响应式 $变量

createStore(runtime/store.ts:14)是个极简的可订阅状态容器(get/set/subscribe/getSnapshot)。两个考究处:

  • set 带相等性短路。 Object.is 比对,外加对「表单那种浅层对象」做逐键浅比较,值没变就不通知,避免无谓重渲染(store.ts:34-63)。
  • initialize 只补新键,绝不覆盖用户已改的值。 流式途中声明可能短暂消失,若这时用默认值覆盖就会丢掉用户输入——所以默认值只填「还不存在的键」(store.ts:76-91)。

$变量 怎么和组件双向绑定?当某个 prop 的 schema 被标了 reactive() 且传进来的是 $变量,evaluator 不直接给值,而是发一个 ReactiveAssign 标记(evaluator.ts:99-105),React 适配层据此把组件的 onChange 接回 store.set,形成读写闭环。


5. Action:把按钮点击编排成有序步骤

它要解决的小问题: 一个「提交」按钮可能要:跑 Mutation → 刷新 Query → 重置表单。怎么声明这串动作?

Action([...steps]) 求值成一个 ActionPlan(有序 ActionStep 数组,evaluator.ts:306)。每种 step 由对应的 @ 调用产出(evaluator.ts:311-363):

写法step干什么
@Run(ref)run执行 Mutation 或重取 Query
@ToAssistant("msg")continue_conversation把消息发回助手(对话式按钮)
@OpenUrl("...")open_url导航
@Set($v, value)set设某个 $变量(value 延迟到点击求值)
@Reset($a, $b)reset$变量恢复到声明默认值

步骤按序执行,且 @Run(mutation) 失败则后续中止(提示词 actionSection 明说「halt on failure」)。@Set 的 value 被保留为 AST 延迟到点击时才算(evaluator.ts:342-350),所以能读到点击那一刻的最新状态。


6. edit-mode:增量编辑与垃圾回收

它要解决的小问题: 用户说「把标题改成 X」,模型不该重吐整个程序——只吐变的那几行,运行时合并。

mergeStatements(parser/merge.ts:141)做三件事:

  1. 按语句名合并:patch 里同名语句替换已有,新名追加(merge.ts:161-175)。
  2. name = null 即删除:patch 里把某语句赋成 null,表示删掉它(merge.ts:162-169)。
  3. 垃圾回收孤儿:合并后从 root 做 BFS 可达性遍历,删掉不可达的语句(gcUnreachable,merge.ts:92);$state 变量豁免(运行时绑定,不靠 Ref 连)。

所以「删一个图表」只需重发一行父语句(把它从 children 里去掉),原 chart 语句因不可达被自动 GC——提示词 editModeSection 正是这么教的(prompt.ts:307)。


7. React 适配:Renderer 怎么接

react-langRenderer(react-lang/src/Renderer.tsx:Renderer)是把上面这套接进 React 的胶水:吃 response(原始 OpenUI Lang 文本)+ library + toolProvider,内部跑流式解析 → 求值 → 渲染。两个体现「为流式而生」的细节:

  • 错误边界「显示最后一次成功的渲染」(Renderer.tsx:ElementErrorBoundary):流式途中某帧渲染崩了,不让界面变白,保留上一帧好状态,新合法子树到了自动恢复。
  • onError 回灌:把 OpenUIError[] 抛给上层,正好用于「让模型自己改」的纠错循环(Renderer.tsxonError prop)。

8. 巧妙之处

  • 缓存键即依赖追踪。 Query 的重取不靠手写订阅,而是「args/deps 变 → cacheKey 变 → 新查询」,简单且准(queryManager.ts:buildCacheKey)。
  • 乐观旧值 + 竞态丢弃。 重取不闪空、过期响应不污染——两条规则撑起顺滑的活数据体验(queryManager.ts:executeFetch)。
  • @Each 预替换循环变量。 让延迟执行的 Action 步骤也能捕获到正确的那一项(evaluator.ts:substituteRef)。
  • edit 靠可达性 GC。 删组件 = 父语句不再引用 + 自动回收,无需显式删除指令(merge.ts:gcUnreachable)。
  • store 不覆盖用户值。 流式声明抖动时也不丢用户输入(store.ts:initialize)。

9. 边界与局限

  • $变量只存简单值。 提示词明说 $变量 持有字符串/数字,不放数组/对象(prompt.ts:interactiveFiltersSection);复杂状态走 Query/表单。
  • 求值是「尽力而为」。 求值出错的 prop 用原值兜底并记 runtime-error,不抛(runtime/evaluate-tree.ts:43-57)。
  • division/取模兜底为 0、== 用宽松相等——是 DSL 的刻意取舍,和 JS 语义不完全一致(evaluator.ts:168-175)。

10. 代码地图

主题文件符号
AST 求值packages/lang-core/src/runtime/evaluator.tsevaluate
@Each / 循环变量替换packages/lang-core/src/runtime/evaluator.tsevaluateLazyBuiltinsubstituteRef
Action 编排packages/lang-core/src/runtime/evaluator.tsevaluateActionCallActionPlan
取数引擎packages/lang-core/src/runtime/queryManager.tscreateQueryManagerevaluateQueriesfireMutation
缓存键packages/lang-core/src/runtime/queryManager.tsbuildCacheKeystableStringify
响应式状态packages/lang-core/src/runtime/store.tscreateStore
反应式绑定标记packages/lang-core/src/runtime/evaluator.tsReactiveAssignisReactiveAssign
prop 树求值入口packages/lang-core/src/runtime/evaluate-tree.tsevaluateElementProps
增量编辑合并packages/lang-core/src/parser/merge.tsmergeStatementsgcUnreachable
React 渲染packages/react-lang/src/Renderer.tsxRendererElementErrorBoundary