> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twenty.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 2. 正在生成文档

> 一个逻辑函数作为一个 AI 工具和工作流动作暴露。

现在核心：一个[逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions)
加载模板和记录，填充占位符，并保存一个新的
文档。

我们将把业务逻辑写成一个**处理器**，然后透露它通过
几个触发器。 本章将其中的两个线路连接起来——一个 **AI 工具** 和
**Workflow 操作** 。

## 渲染帮助器

将纯逻辑保留在它自己的文件中，这样便于拆除测试。 这会将一条记录
展平成 `{{dot.path}}` 占位符并进行替换。

```ts filename="src/logic-functions/utils/render-template.ts" theme={null}
const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;

export const renderTemplate = (body: string, values: Record<string, string>) => {
  const missingTokens = new Set<string>();
  const content = body.replace(PLACEHOLDER_PATTERN, (_m, token: string) => {
    const value = values[token];
    if (value === undefined || value === '') { missingTokens.add(token); return ''; }
    return value;
  });
  return { content, missingTokens: [...missingTokens] };
};
```

<Tip>
  由于此文件没有副作用，你可以使用快速单元测试对其进行覆盖
  （`yarn test:unit`）。 见 [Testing](/l/zh/developers/extend/apps/operations/testing)。
</Tip>

## 处理程序

处理程序使用生成的 [`CoreApiClient`](/l/zh/developers/extend/apps/logic/logic-functions)
读写CRM 数据。 它加载模板，加载目标记录，填充
物体，并创建一个“文档”。

```ts filename="src/logic-functions/handlers/generate-document-handler.ts" theme={null}
import { CoreApiClient } from 'twenty-client-sdk/core';
import { loadRecordValues } from 'src/logic-functions/utils/load-record-values';
import { renderTemplate } from 'src/logic-functions/utils/render-template';

export const generateDocumentHandler = async (
  input: { templateId: string; recordId: string },
) => {
  const client = new CoreApiClient();

  // Use a filtered list query, not the singular lookup: the singular query
  // throws when nothing matches, which would become a 500 instead of a 404.
  const { documentTemplates } = await client.query({
    documentTemplates: {
      __args: { filter: { id: { eq: input.templateId } }, first: 1 },
      edges: { node: { id: true, name: true, body: true, target: true } },
    },
  });
  const documentTemplate = documentTemplates?.edges?.[0]?.node;
  if (!documentTemplate?.id) return { success: false, status: 404, message: 'Template not found.' };

  const record = await loadRecordValues(client, documentTemplate.target, input.recordId);
  if (!record.found) return { success: false, status: 404, message: 'Record not found.' };

  const { content, missingTokens } = renderTemplate(documentTemplate.body ?? '', record.values);

  const { createDocument } = await client.mutation({
    createDocument: {
      __args: { data: {
        name: `${documentTemplate.name} — ${record.displayName}`,
        content, status: 'GENERATED', templateId: documentTemplate.id,
      } },
      id: true, name: true,
    },
  });

  return { success: true, documentId: createDocument.id, content, missingTokens };
};
```

`loadRecordValues` 会针对 Person 与 Company 运行不同的查询，并将结果展平 — 参见
[`load-record-values.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts)。

## 显示为一个工具和工作流操作

单个的 `defineeLogicFunction` 可以带几个触发器。 在这里，`toolTriggerSettings`
让它可以被AI 代理人调用，`workflowActionTriggerSettings` 将它变成可视工作流生成器中的
步骤。 两者都用JSON方案描述他们的输入情况。

```ts filename="src/logic-functions/generate-document.ts" theme={null}
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-sdk/logic-function';
import { GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
import { generateDocumentInputSchema } from 'src/logic-functions/schemas/generate-document-input.schema';

export default defineLogicFunction({
  universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
  name: 'generate-document',
  description: 'Generate a document from a template and a CRM record.',
  timeoutSeconds: 30,
  toolTriggerSettings: {
    inputSchema: generateDocumentInputSchema,
  },
  workflowActionTriggerSettings: {
    label: 'Generate Document',
    icon: 'IconFileText',
    inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
    outputSchema: [{ type: 'object', properties: {
      success: { type: 'boolean' }, documentId: { type: 'string' },
    } }],
  },
  handler: generateDocumentHandler,
});
```

输入schema是一个普通的 JSON schema 描述了 `templateId` 和 `recordId` -
查看 [`generate-document-input.schema.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts)。

## 授予访问权限

逻辑函数作为应用程序的角色运行。 它需要阅读模板和记录
并创建文档，以便允许在 "src/roles/default-role.ts" 中：

```ts theme={null}
export default defineApplicationRole({
  universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
  label: 'Document Generator default role',
  canReadAllObjectRecords: true,
  canUpdateAllObjectRecords: true,
  canAccessAllTools: true,
  canBeAssignedToAgents: true,
  permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
});
```

`UPLOAD_FILE` 让函数在下一节上传生成的 PDF。
请参阅 [Roles](/l/zh/developers/extend/apps/config/roles) 以获取更精细的权限。

## 附加一个真实的 PDF 文件

渲染文本字段是有用的，但用户需要一个真正的文档。 让我们生成一个
**PDF** 并将其作为可下载的文件存储在记录上。

首先，给`文档`对象一个`FILES`字段来持有PDF。 应用将
上传到他们**拥有** 的文件字段，所以此字段是上传的路线：

```ts filename="src/objects/document.object.ts" theme={null}
{
  universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
  type: FieldType.FILES,
  name: 'file',
  label: 'File',
  icon: 'IconFileTypePdf',
  universalSettings: { maxNumberOfValues: 1 },
}
```

现在渲染那个PDF。 一个应用是一个真正的节点项目，所以您可以添加任何您需要的npm
包，并且像其他任何地方一样导入它。 我们使用 **[pdf-lib](https://pdf-lib.js.org/)**
绘制PDF 和 **[marked](https://marked.js.org/)** 解析Markdown
正文--CLI 将它们安装到函数中供您使用的运行时间：

```bash filename="Terminal" theme={null}
yarn add pdf-lib marked
```

完整的助手是
[`generate-document-pdf.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts)。
它将Markdown解析为代币，标记为“标记”。 \`运行，然后使用
pdf-lib：真实标题，**bold**/*italic* 运行，子弹和编号列表
blockquotes and rules — — 一个经过筛选、多页的 A4 渲染模板
本身，而不是一个文本墙。

<Frame caption="生成的 PDF：真实的排版和Markdown 格式化，呈现模板正文。">
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/twenty/images/docs/developers/extends/apps/documents/document-generator/07b-generated-pdf.png" alt="一个打造的可营销的 PDF" />
</Frame>

<Note>
  pdf-lib的内置字体使用WinAnsi编码，所以西欧语音会将
  渲染到盒子之外； 助手地图的智能引用和破折号并掉落它
  无法编码。 渲染非拉丁脚本(中文、 阿拉伯语、 西里尔语) 将意味着
  嵌入一个 Unicode 字体。
</Note>

然后上传它并在记录中存储参考。 `uploadFile` 路由字节
到你的应用拥有的文件字段。返回的 `id` 是你保存的：

```ts filename="src/logic-functions/handlers/generate-document-handler.ts" theme={null}
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf';

const documentName = `${documentTemplate.name} — ${record.displayName}`;
const bytes = await generateDocumentPdf(documentName, content);
const fileName = 'proposal.pdf';

const uploaded = await new MetadataApiClient().uploadFile(
  Buffer.from(bytes),
  fileName,
  'application/pdf',
  DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);

await client.mutation({
  updateDocument: {
    __args: {
      id: documentId,
      data: { file: [{ fileId: uploaded.id, label: fileName }] },
    },
    id: true,
  },
});
```

生成的文档现在带有可下载的 PDF：

<Frame caption="已生成的 PDF 存储在文档文件字段中。">
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/twenty/images/docs/developers/extends/apps/documents/document-generator/08-document-with-pdf.png" alt="生成一个 PDF 文件的文档记录" />
</Frame>

<Note>
  `uploadFile` 仅针对**app-owned** 文件字段。(所以上传文件总需要一个拥有字段的
  应用，加上`UPLOAD_FILE` 角色标志)。 这就是为什么PDF
  会降落在记录自己的“file”字段上——相同的样式
  [call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
  用于录制的原因。
</Note>

**在这一步之后：** 每个生成的文档都有一个真实的、可下载的 PDF。 但
没有任何东西能够\*调用UI 的生成器 — — 因为我们需要一个 HTTP 路由。

<Card title="下一步：HTTP路由 →" icon="全局模式" href="/l/zh/developers/extend/apps/tutorials/document-generator/http-route">
  通过 HTTP 提供函数并将文档渲染为 web 页面。
</Card>
