给网站做 Codex 插件:从生成模板到生成组件

我最开始想做的事情很简单:给 Codex 一张概念图,再给它一篇文章,让它帮我生成一个适合展示的 MDX 页面。

为此,我先做了一个生成布局模板的 MCP 工具。后来又逐步补上了组件查询、能力查询、预览和审核。回头看,这个插件之所以长成现在这样,是因为每解决一个问题,就会遇到下一个需要想清楚的问题。

这篇文章按这条思路展开:我想完成什么,为什么原来的办法不够,以及后来为什么选择这样设计。文中的代码保留关键逻辑,省略与当前问题无关的部分;相关实现来自这次组件工坊改造,提交为 ab37f128

我最初要做什么?整体思路是什么?

我想让 Codex 围绕一篇具体文章,完成展示页面的规划和生成。已有的展示能力可以直接复用,确实缺少的组件可以生成,但最终放到网站里的内容需要经过检查。

把这件事拆开,我整理出了下面这张思路图。它表达的是设计问题之间的关系,不是每次工具调用的严格执行顺序。

text
让 Codex 根据概念图和文章生成展示页面
├─ 模板应该负责什么?
│  └─ 组织内容;复杂样式和交互交给组件
├─ 需要的组件已经存在吗?
│  └─ 先查摘要,再看详情,必要时读取源码
├─ 平台允许生成什么?
│  └─ MCP 查询实时能力,Skill 规定查询与决策方法
├─ 生成的代码真的能显示吗?
│  └─ 编译后进入独立预览,再检查运行、视觉和交互
├─ 我看到的候选,怎样才能可靠保存?
│  └─ 运行状态、用户批准、源码与版本基线共同校验
└─ 中途失败了怎么办?
   └─ 修复编译、重建预览、显式刷新地址,或说明能力限制

下面从模板开始讲。

已经能生成模板了,为什么还需要组件?

模板生成工具有一个明确边界:它只能组织安全 Markdown 和已经注册的组件,不能把任意 JSX、JavaScript 或 CSS 塞进正文。

工具里的生成约束是这样写的:

ts
// apps/api/src/mcp/mcp-server.ts,提示词节选
'Use safe Markdown and registered component-island fences only. ' +
'Never output arbitrary HTML, executable JSX, imports, exports, ' +
'JavaScript, CSS, class names, scripts, or external resources.'

这意味着,模板能够组合已经开放的展示能力,却不能凭空实现一种新的交互。如果一个页面需要复杂展示,把所有细节都压到内容层也会让模型花越来越多的上下文处理低层表达。

所以我把两层职责分开:MDX 负责文章结构,组件负责复杂样式和交互。模板需要一个展示能力时,引用组件即可,不必每次重新表达它的内部实现。

但允许 Codex 生成组件以后,又出现了一个很实际的问题。

每次都生成一个组件,会不会重复?

会有这个风险。网站里可能已经有视觉和功能相近的组件,如果模型没有先查询,就可能又做出一个新的版本。

我需要让它先知道站内有什么。但组件列表会持续变化,把清单写进 Skill 意味着每次新增组件都要同步改文档,这件事很容易漏掉。

因此,Skill 只规定“先查询,再决定复用还是新建”;当前有哪些组件,由 MCP 返回。

ts
// apps/api/src/mcp/mcp-server.ts,工具定义节选
server.registerTool(
    'list_generated_components',
    {
        description: '列出当前用户可用的生成组件摘要,不返回源码或 bundle',
        inputSchema: {},
        outputSchema: z.array(GeneratedComponentMcpListItemSchema).max(1_000),
    },
    // 查询实现省略
);

这里我还做了一个取舍:列表只返回摘要。查重时就把所有组件源码交给模型,会占用上下文,而这一步通常还没有必要了解内部实现。

只有摘要,模型怎么判断组件能不能用?

摘要适合筛选候选,真正决定是否复用,还需要用途、参数和版本等信息。如果需要修改,才进一步读取源码。

因此,详情工具区分了两个层级:

ts
// get_generated_component 的输入 Schema 节选
inputSchema: z.object({
    componentId: z.string().min(1),
    detailLevel: z.enum(['metadata', 'source']).default('metadata'),
    versionId: z.string().min(1).optional(),
}).strict()

默认返回元数据,需要修改上下文时再明确请求 source。编译后的 bundle 不返回给模型。

这样,模型拿到的信息会随任务逐步增加:先找到可能合适的组件,再判断能否使用,最后才进入代码修改。

不过,即使没有合适的已有组件,也不代表模型可以随意生成。它还需要知道网站到底支持什么。

为什么不能直接把平台能力写在 Skill 里?

项目里的能力说明原本散落在几个地方:编译器维护白名单,Skill 描述使用方法,开发文档再介绍支持范围。

加入 UI、图表、MDX 和 3D 支持后,只要其中一份漏改,模型就可能生成文档允许、编译器却拒绝的代码。

把 Skill 写得更详细,仍然需要维护多份相同事实。我选择把这些事实集中到运行时注册表,让编译器和 MCP 从同一个地方读取。

ts
// packages/component-runtime/src/modules.ts,注册表节选
export const GENERATED_COMPONENT_CAPABILITY_REGISTRY = deepFreeze({
    contractVersion: '1.3.0',
    compilerVersion: '2.1.0',
    security: {
        networkAccess: false,
        dynamicCode: false,
        nodeApis: false,
        remoteMediaInSandbox: false,
    },
    // 模块导出、Schema 规则和大小限制等省略
});

编译器直接读取它:

ts
export const GENERATED_COMPONENT_COMPILER_VERSION =
    GENERATED_COMPONENT_CAPABILITY_REGISTRY.compilerVersion;

MCP 的 get_generated_component_capabilities 则把同一份能力契约提供给 Codex。执行组件任务时,模型先查当前允许的模块、参数规则和限制,再生成代码。

到这里,Skill 和 MCP 的分工就比较清楚了:Skill 规定查询与决策的方法,MCP 提供执行时的事实。

Skill 仍保留离线快照,用来解释兼容范围;它不能充当已经成功查询实时能力的证据。插件构建时还会检查快照与注册表的一致性,避免发布出去的说明再次落后。

有了这些信息,模型终于可以生成符合平台要求的代码。但接下来还有一道关。

编译通过了,为什么预览仍然可能白屏?

公开文章和临时预览的运行环境不同。

公开文章使用 browserBundle,页面上已经有共享 runtime。临时审核页运行在禁止网络的 sandbox iframe 中,不能假定外部环境已经准备好了 React Root、变量 Provider 或 Portal Host。

如果直接把公开文章使用的产物放进 iframe,编译成功也不代表组件能够正常挂载。

因此,编译结果分成两个产物:

ts
export interface GeneratedComponentCompileSuccess {
    browserBundle: string; // 公开文章使用
    sandboxBundle: string; // 临时审核 iframe 使用
    sourceHash: string;    // 标识对应源码
    // 产物哈希与完整性字段省略
}

独立的 sandbox 入口负责建立预览所需的运行环境。这样做增加了一套需要维护的运行路径,但也让临时审核页不再依赖公开页面的宿主条件。

另一个问题是:什么时候才能说它运行起来了?

我把 READY 信号放到了 React 的提交之后:

tsx
function ReadyReporter() {
    useLayoutEffect(() => report('growthtrace:ready'), []);
    return null;
}

脚本加载成功只能说明加载这一步完成;READY 则提供进一步的运行信号。它仍然不能证明页面好看、所有交互正确,或业务任务已经完成。那些内容还需要浏览器检查和用户审核。

运行错误也需要被记录。预览会处理全局错误、未处理的 Promise 拒绝,以及 React Error Boundary 捕获的异常,并上报错误状态。

原文记录的自动化测试覆盖了静态组件、Hook、UI 和 MDX 的隔离 DOM 运行信号;图表和 3D 的覆盖主要是编译与 facade。这些测试不能代替生产环境中的完整浏览器验证。

已经有运行状态了,审核为什么还会出错?

这次改造里,一个比较具体的问题是:READY 请求先发出,ERROR 请求后发出,但 ERROR 的响应先返回。

服务端已经记录了错误,前端却可能在收到迟到的 READY 响应后,把状态覆盖回成功,重新启用批准按钮。

text
发出 READY 请求 ────────────────┐
                               │ 暂未返回
发出 ERROR 请求                │
       ↓                       │
收到 ERROR,界面进入错误态      │
                       收到旧 READY 响应
                       旧逻辑重新启用按钮

这里不能只按响应到达的顺序更新状态。对于同一个预览候选,已经确认发生的错误,不应该被旧的成功响应抹掉。

服务端在原子状态更新中加入检查:

lua
-- 当前候选已经出错时,不接受迟到的 READY。
if ARGV[3] == 'READY'
   and redis.call('HGET', KEYS[1], 'ownerRuntimeStatus') == 'RUNTIME_ERROR'
then return 'UNCHANGED' end

前端也需要保留同一候选的错误态,因为旧响应可能已经在网络中。只修服务端,仍然可能让用户看到错误的按钮状态。

复核这个问题时,测试故意挂起 READY 请求,让 ERROR 先完成,最后再返回 READY。检查的结果是:前端仍保持错误态。

这个规则只约束当前预览候选。后续重新尝试需要进入新的预览会话,不能让旧会话的迟到响应为新候选提供依据。

等待审核时,只查状态为什么也会出问题?

还有一个问题藏在看起来很普通的读取方法里。

旧版 getForMcp 查询预览时,会顺便签发新的 token:

ts
// 旧实现,节选
async getForMcp(userId, previewId) {
    const record = await getRequired(previewId);
    const { token } = await issueToken(record);
    return toDto(record, urls(previewId, token));
}

在当时的 token 轮换机制下,新地址签发后旧地址会失效。于是,Codex 等待用户审核时的一次状态轮询,就可能让刚发出去的预览链接作废。

我把两个动作拆开:

  • get_generated_component_preview 只读取状态,不轮换 token。
  • refresh_generated_component_preview_url 在地址过期或丢失时显式刷新,旧地址随之失效。

拆分之后,Skill 也能明确告诉模型何时调用哪个工具。正常等待审核只查询状态,确实需要新地址时才刷新;刷新本身的影响也需要让调用方知道。

这件事让我更容易判断一个工具应该承担多少职责:如果一个附带动作会改变用户正在使用的东西,就应该把这个影响写进明确的操作里。

用户确认以后,为什么还不能简单地保存源码?

用户看到并批准的候选,需要与最后保存的内容对应起来。

文中的 Owner 指拥有组件、在审核页作出决定的用户。按本次改造记录,审核页上报当前源码的 READY 后,用户才能批准;finalize 保存前还会再次检查批准状态、运行状态和源码哈希。

这里需要区分几个动作:

text
生成候选
→ 打开预览并检查
→ 用户审核页对当前源码上报 READY
→ 用户批准
→ finalize 再次校验
→ 保存不可变组件版本

源码哈希能标识源码,但不应被理解成单凭它就证明所有预览条件相同。Props、Variables 等输入如何绑定候选,同样属于实现需要明确的边界。

更新已有组件时,还要防止另一种情况:我开始修改时读到的是旧版本,但等待审核期间,组件已经被更新了。

因此,更新候选需要声明 baseVersionIdexpectedSourceHash,服务端与当前基线比较:

ts
if (
    baseline.currentVersionId !== parsed.baseVersionId ||
    baseline.sourceHash !== parsed.expectedSourceHash
) {
    throw new GeneratedComponentPreviewError(
        'COMPONENT_PREVIEW_BASE_CONFLICT',
    );
}

创建更新预览时检查一次,finalize 时再检查一次。发生冲突就重新读取基线,不能把先前批准当作对后续修改的自动批准。

保存成功后返回 componentId + versionId。文章引用的是这个不可变版本,组件后续新增版本不会仅凭同名就改变这次引用。

如果模型做不到,我希望它怎么处理?

这条流程也需要为失败留出明确出口。

站内组件和平台能力属于可查询的信息,模型应先调用工具。编译失败有 diagnostics,就根据诊断修复。某种视觉或交互确实超出平台能力,再说明限制并提供受支持的替代方案。

这几类情况不能混在一起。一次 import 错误不应该立刻变成向用户提问;平台不支持的能力,也不能靠继续生成代码假装解决。

如果 Agent 没有浏览器,可以把审核地址交给用户,但必须说明没有完成自己的视觉检查。用户批准仍然由用户作出,模型不能替代。

这些分支写进 Skill 后,MCP 继续负责输入校验、状态约束和正式保存。生成任务因此有了可以继续、可以停止,也可以解释原因的路径。

这次开发最后改变了我什么想法?

回到最开始的需求,我只是希望 Codex 能根据文章和概念图帮我生成展示页面。为了让这件事可用,我需要逐步回答:已有能力在哪里查,生成结果在哪里看,用户确认如何记录,以及最终保存的是哪个版本。

Skill 适合描述这些步骤之间怎样选择。涉及真实状态和写入结果的地方,还需要工具和运行环境提供检查。

现在这条路径已经比较清楚,但我不会把自动化运行信号当成完整的使用体验验证。页面在实际浏览器里的表现、不同组件的交互,以及用户看到的内容,都还需要按对应场景检查。

发布文章时还遇到了一件相关的小事:正文更新成功,公开页面却仍显示旧快照。后来确认,正文更新和重新发布是两个步骤。它不属于前面的组件改造,但提醒了我,在自己的网站里,接口返回成功之后,还要继续确认用户最终看到的结果。


实现记录:本文结合《从模板 MCP 到组件生成闭环:Skill + MCP 的设计起点》与组件工坊改造笔记整理。代码为关键逻辑节选,相关改造提交为 ab37f128;文中插件与能力契约分别对应记录中的 0.3.01.3.0,不代表平台此后的最新版本。

Comments

0
No comments yet. Start the conversation.