首页
/ Twenty 应用实战:Document Generator 示例 App 的数据模型、模板渲染与 PDF 生成全解析

Twenty 应用实战:Document Generator 示例 App 的数据模型、模板渲染与 PDF 生成全解析

2026-09-05 19:34:49作者:蔡丛锟

本文以 Twenty 官方示例应用 Document Generator(packages/twenty-apps/examples/document-generator)为主体,完整拆解它如何把 CRM 数据一键变成成品文档:从 documentTemplate/document 双对象数据模型,到 {{占位符}} 渲染管线,再到纯本地的 Markdown → PDF 生成与 AI 工具、工作流动作、HTTP 路由三种触发方式。读完你可以掌握 Twenty App SDK 的核心构件(对象、逻辑函数、前端组件、Agent/Skill、universalIdentifier 体系),并得到一个可复制到自托管 Twenty 的完整参考实现。

生成的文档在 Twenty 中以独立纸张样式展示

一、应用定位:一次 Twenty SDK 的完整巡礼

README 中对该应用的核心描述只有两句话,但足以定调:它是"官方 Document Generator 教程中构建的参考应用","是对 Twenty SDK 大部分能力的一次巡礼",并且生成过程不依赖任何外部 API,因此没有按文档计费的成本

整个应用的代码全部位于 document-generator 目录,目录结构按职责清晰分层:

  • application-config.ts:应用声明;
  • objects/:两个业务对象(模板、文档);
  • logic-functions/:共享 handler、三种触发器与纯函数工具(渲染、取数、PDF);
  • front-components/:生成表单与文档查看器;
  • command-menu-items/navigation-menu-items/views/page-layouts/:CRM 界面扩展;
  • agents/skills/:AI 侧的 Assistant 与技能;
  • universal-identifiers.ts:全应用的稳定 UUID 注册表。

application-config.ts 中,应用通过 defineApplication 声明 universalIdentifierdisplayNamelogogalleryImages 等元信息,并指定分类为 Productivity。这里的 universalIdentifier 是整个应用同步(sync)到工作区时的稳定身份——所有构件(对象、字段、逻辑函数、视图、页面布局)都带 UUID,集中在 constants/universal-identifiers.ts 维护,其注释明确说明:"把它们放在一个文件里,便于跨引用(关系、视图、布局)追踪,并保证它们跨同步和版本保持稳定"。

二、数据模型:documentTemplate 与 document 两个对象

documentTemplate:带占位符的可复用模板

document-template.object.ts 定义了三个字段:

字段 类型 说明
name TEXT 模板名,如 "Sales proposal"
body RICH_TEXT 用富文本编辑器编写的模板正文,可写 {{name.firstName}}{{jobTitle}} 等占位符
target SELECT 模板适用的记录类型:PERSON(默认值)或 COMPANY

target 字段的两个选项 Person/Company 的 option value 分别是常量 TEMPLATE_TARGET_PERSON'PERSON')和 TEMPLATE_TARGET_COMPANY'COMPANY'),定义在 universal-identifiers.ts。文件注释特别强调:"Select option values 必须是大写下划线式(UPPER_CASE),集中定义可以确保对象元数据与写入它们的 handler 永远不漂移"——这是 Twenty 元数据驱动架构下的一个实际约束。

body 采用 RICH_TEXT 类型意味着模板直接用 Twenty 原生的富文本编辑器(与 Notes、Tasks 同一个编辑器)编写,这正对应 README 中 "Native rich-text editor" 的能力。源码注释说明了其底层存储形态:RICH_TEXT 存储 { blocknote, markdown },生成管线统一取 Markdown 投影喂给后续的占位符替换与 PDF/HTML 渲染。

document:生成的文档实体

document.object.ts 定义了:

字段 类型 说明
name TEXT 文档名,格式为 {模板名} — {记录显示名}
content TEXT 所有占位符已填充的渲染后正文
status SELECT 生命周期:DRAFT(默认)→ GENERATED
file FILES 生成的可下载 PDF,universalSettings: { maxNumberOfValues: 1 } 限制单个文件

两个对象之间通过关系字段互相关联(模板侧 template-documents-relation.field.ts、文档侧 document-template-relation.field.ts),从而支撑"在 Person 记录上看到它生成的文档、在模板上看到它产出的文档"这类界面展示。

三、模板渲染管线:flattenRecord 与 renderTemplate

渲染核心在 render-template.ts,只有两个纯函数,行为如下:

  1. flattenRecord:把嵌套记录递归压平为点路径键,例如 { name: { firstName: 'Ada' } }{ 'name.firstName': 'Ada' }。只有 string/number/boolean 叶子被保留,number/boolean 会字符串化,null/undefined 变成空字符串,数组则被跳过。
  2. renderTemplate:用正则 /\{\{\s*([\w.]+)\s*\}\}/g 匹配 {{token}}(容忍占位符内部空白),逐个替换为压平后的值;未知 token 渲染为空字符串并收集进 missingTokens,返回 { content, missingTokens },让调用方能向用户提示"哪些占位符没有解析到值"。

这个设计在 render-template.test.ts 中有完整的行为契约验证,包括:

  • 嵌套对象压平为点路径(name.firstName);
  • null/undefined 叶子转为空串、number/boolean 字符串化(42 → '42');
  • {{ name.firstName }} 容忍内部空白;
  • 未知占位符渲染为空且被报告('Hi {{firstName}} from {{city}}' 只缺 city);
  • 同一缺失 token 重复出现只报告一次('{{x}} {{x}}'missingTokens: ['x'])。

记录取数:loadRecordValues

load-record-values.ts 根据模板的 target 决定加载 Person 还是 Company,并通过 CoreApiClient 发起 GraphQL 查询:

  • Person 投影:jobTitlecityname.{firstName,lastName}emails.primaryEmailphones.primaryPhoneNumberlinkedinLink.primaryLinkUrlcompany.name
  • Company 投影:nameemployeesdomainName.primaryLinkUrladdress.{addressCity,addressCountry}

值得注意的实现细节:这里刻意使用带 filter 的列表查询而非单数查询——注释写明"单数查询在记录不存在时会抛异常,而列表查询返回空,调用方才能给出 404 而不是 500"。这与 handler 中模板查询的注释是同一种错误处理哲学。函数最后返回 { found, displayName, values }displayName 用于拼接文档名(Person 取全名,缺省为 'Person')。

四、PDF 生成:marked + pdf-lib 的纯本地排版

generate-document-pdf.ts 把 Markdown 正文渲染成 A4 多页 PDF,完全不调用外部服务——这正是 README 所说 "free to run" 的来源。关键实现:

  • 页面参数595.28 x 841.89(A4 pt),页边距 64,正文 11pt、行高 16;
  • 字体:嵌入 StandardFonts 的 Helvetica 常规/粗体/斜体/粗斜体与 Courier 等宽共 5 套,按 run 的 bold/italic/code 组合切换;
  • Markdown 解析marked.lexer(content, { breaks: true, gfm: true })breaks: true 保证模板中单个换行在 PDF 中也是换行,与 HTML 渲染器行为对齐;
  • 排版能力drawBlocks 支持 heading(h1 18pt / h2 15pt / h3 13pt)、paragraph、有序/无序列表、blockquote(左侧 3pt 强调色竖条,且正确处理跨页场景——注释详细解释了如何避免跨页复用 startY 导致的负高度 bug)、code 块、hr
  • 自动换行与长词切分drawRuns 先按空白分词,pushWord 会用 font.widthOfTextAtSize 测量,把超过行宽的超长 URL/标识符按字符切成能放进行的块,避免右溢出;
  • 编码兜底:内置字体是 WinAnsi 编码,遇到无法编码的字符会抛错,因此 toWinAnsi 把破折号、弯引号、省略号映射为 ASCII,并丢弃 Latin-1 之外的字符(注释说明:CJK、阿拉伯文、西里尔文需要嵌入 Unicode 字体才能支持,但 HTML 侧仍渲染完整文本)。

最终 generateDocumentPdf 返回 Uint8Array,由 handler 上传为文档的 file 字段值。

五、一个 handler,三种触发器

整个应用的业务逻辑收敛在 generate-document-handler.tsgenerateDocumentHandler 中,输入仅两个参数(见 generate-document-input.schema.ts):

{
  "type": "object",
  "properties": {
    "templateId": { "type": "string", "label": "Document template" },
    "recordId":   { "type": "string", "label": "Record" }
  },
  "required": ["templateId", "recordId"],
  "additionalProperties": false
}

handler 的执行顺序为:校验入参(缺参 → 400)→ filter 查询模板(未找到 → 404)→ 校验 target 是否受支持(isSupportedTarget,否则 400)→ loadRecordValues 取记录(未找到 → 404)→ renderTemplate 填充占位符 → createDocument mutation 落库(文档名为 {模板名} — {记录显示名},status 直接置 GENERATED)→ 尽力而为地附加 PDF。PDF 附件步骤被包在 try/catch 里:注释说明"文档已存在,PDF/上传失败不应丢弃它,只向调用方提示警告",且服务端记录真实原因、对外保持通用消息——因为这个 handler 同时暴露给 HTTP、AI 工具与工作流动作三种调用方。

同一 handler 被三种触发器复用:

1. AI 工具 + 工作流动作generate-document.ts

defineLogicFunction({
  universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
  name: 'generate-document',
  timeoutSeconds: 30,
  toolTriggerSettings: { inputSchema: generateDocumentInputSchema },
  workflowActionTriggerSettings: {
    label: 'Generate Document',
    icon: 'IconFileText',
    inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
    outputSchema: [ /* success / message / documentId / content / missingTokens */ ],
  },
  handler: generateDocumentHandler,
});

文件头注释一句话点明设计意图:"同一个函数,两种暴露方式——作为 AI tool 供 agent 调用,作为 workflow action 供可视化工作流构建器使用"。

2. HTTP 路由generate-document-route.ts

httpRouteTriggerSettings: {
  path: '/documents/generate',
  httpMethod: 'POST',
  isAuthRequired: true,
}

路由版把 handler 返回的建议状态码映射到真实 HTTP 状态(result.success ? 200 : result.status ?? 400),调用方能拿到正确的 400/404/500 而不是"200 带错误"。前端生成表单就是 RestApiClient().post('/s/documents/generate', ...) 打到这里的。

3. 只读的文档查看路由view-document.ts

GET /documents/view?id=<documentId>isAuthRequired: false,把文档渲染为独立、可打印的 HTML 页面,超时 15 秒。id 缺失返回 400 页面、记录不存在返回干净的 404 页面(同样是 filter 查询而非单数查询的考量)。

六、前端体验:命令菜单、生成表单与文档查看器

命令菜单入口generate-document.command-menu-item.ts 通过 defineCommandMenuItem 声明:availabilityType: 'RECORD_SELECTION' 且限定对象为 Person,即选中一条 Person 记录时,命令菜单里出现 "Generate document",点击后在侧边面板打开生成表单——这正是 README 所说 "from the command menu on a record"。

生成表单generate-document-form.front-component.tsx 的流程是:useSelectedRecordIds() 取选中记录 → 用 CoreApiClient 查询 target: 'PERSON' 的模板列表(first: 100)填充下拉框并默认选中第一项 → 提交时 POST 到 /s/documents/generate → 成功后弹出 snackbar(若有 missingTokens 会提示"有 N 个占位符没有值")并关闭面板。组件还演示了一个 SDK 细节:主题 token 以内联 CSS 变量值(var(--t-spacing-2) 等)形式写死,因为"SDK 在 manifest 抽取阶段会 mock UI 包,模块级导入主题常量会得到 undefined"。

文档查看器document-viewer.front-component.tsx 从执行上下文取当前文档记录 id,查询 contentfile.url,用纸张样式渲染 Markdown,并提供两个动作:RestApiClient().resolveUrl('/s/documents/view', ...) 打开独立网页版、直接下载 PDF——对应 README 的 "Shareable links" 能力。

七、AI 侧:Agent 与 Skill 的分工

  • Agentdocument-assistant.agent.ts):defineAgent 声明 "Document Assistant",responseFormat: { type: 'text' },system prompt 只负责角色设定——"你是 CRM 的文档助手,用 generate-document 工具产出文档,并始终确认你创建了什么"。
  • Skilldocument-drafting.skill.ts):defineSkill 携带"操作手册"式内容,教模型如何调用 generate-document 工具:用户报模板/人名而非 id 时先查记录再传 id;多个匹配时列出候选让用户选择,绝不猜测;生成前核对模板 target 与记录类型是否一致;生成后报告文档名并告知可在 Documents 视图中打开。

从源码结构看,这是一种"角色(Agent)+ 程序性知识(Skill)+ 工具(Logic Function)"三层分离的 AI 扩展模式,与 README 中 "or AI chat" 的生成入口相呼应。

八、安全设计与工程化细节

模板内容是不可信输入render-document.ts 中的 Marked 实例做了加固:html() 渲染器直接吞掉原始 HTML;image() 整体丢弃(避免输出指向任意外部 URL 的 <img>);link() 只放行 http(s)/mailto 链接,且对 href 与 text 都做 HTML 转义。产物既可安全注入独立页面,也可安全用于前端查看器。

依赖与工程配置package.json 显示运行时依赖只有两个:marked ^18.0.5(Markdown)与 pdf-lib ^1.17.1(PDF);SDK 依赖为 twenty-sdktwenty-client-sdk(2.31.0)。环境要求 node ^24.5.0yarn >= 4.0.2。脚本层面提供 typechecktsgo --noEmit)、test/test:unit(vitest 分层)与 lint(oxlint)。

测试分层。渲染管线有纯单元测试(上文 render-template.test.ts);PDF 生成有 generate-document-pdf.test.ts;HTML 渲染有 render-document.test.ts。集成测试 document-generator.integration-test.ts 则验证"应用能装进工作区":全局 setup 同步安装应用,测试用 MetadataApiClient 查询 findManyApplications 并按 APPLICATION_UNIVERSAL_IDENTIFIER 断言已安装,teardown 时卸载。

九、小结

Document Generator 作为 Twenty 官方教程的配套参考实现,用一个"模板 → 渲染 → PDF/HTML → 落库"的紧凑闭环,串起了 Twenty App 的全部关键扩展点:defineApplication 与应用级 universalIdentifier 体系、defineObject 双对象数据模型(含 RICH_TEXT/FILES/SELECT 字段)、共享 handler 驱动的 AI 工具/工作流动作/HTTP 路由三触发器、defineFrontComponent 与命令菜单、以及 Agent/Skill 的 AI 扩展模式;同时其纯本地生成(marked + pdf-lib、无外部 API)、filter 查询防 500、不可信模板内容的 Markdown 加固等实现细节,也为编写生产级 Twenty 应用提供了可直接参考的工程范式。

登录后查看全文
热门项目推荐
相关项目推荐