Twenty 应用实战:Document Generator 示例 App 的数据模型、模板渲染与 PDF 生成全解析
本文以 Twenty 官方示例应用 Document Generator(packages/twenty-apps/examples/document-generator)为主体,完整拆解它如何把 CRM 数据一键变成成品文档:从 documentTemplate/document 双对象数据模型,到 {{占位符}} 渲染管线,再到纯本地的 Markdown → PDF 生成与 AI 工具、工作流动作、HTTP 路由三种触发方式。读完你可以掌握 Twenty App SDK 的核心构件(对象、逻辑函数、前端组件、Agent/Skill、universalIdentifier 体系),并得到一个可复制到自托管 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 声明 universalIdentifier、displayName、logo、galleryImages 等元信息,并指定分类为 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,只有两个纯函数,行为如下:
flattenRecord:把嵌套记录递归压平为点路径键,例如{ name: { firstName: 'Ada' } }→{ 'name.firstName': 'Ada' }。只有 string/number/boolean 叶子被保留,number/boolean 会字符串化,null/undefined 变成空字符串,数组则被跳过。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 投影:
jobTitle、city、name.{firstName,lastName}、emails.primaryEmail、phones.primaryPhoneNumber、linkedinLink.primaryLinkUrl、company.name; - Company 投影:
name、employees、domainName.primaryLinkUrl、address.{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.ts 的 generateDocumentHandler 中,输入仅两个参数(见 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,查询 content 与 file.url,用纸张样式渲染 Markdown,并提供两个动作:RestApiClient().resolveUrl('/s/documents/view', ...) 打开独立网页版、直接下载 PDF——对应 README 的 "Shareable links" 能力。
七、AI 侧:Agent 与 Skill 的分工
- Agent(document-assistant.agent.ts):
defineAgent声明 "Document Assistant",responseFormat: { type: 'text' },system prompt 只负责角色设定——"你是 CRM 的文档助手,用 generate-document 工具产出文档,并始终确认你创建了什么"。 - Skill(document-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-sdk 与 twenty-client-sdk(2.31.0)。环境要求 node ^24.5.0、yarn >= 4.0.2。脚本层面提供 typecheck(tsgo --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 应用提供了可直接参考的工程范式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
