i18n - docs translations (#22617)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22617?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
committed by
GitHub
parent
07a921f8ca
commit
18ca89bcdd
@@ -837,6 +837,18 @@
|
||||
"l/fr/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutoriel",
|
||||
"pages": [
|
||||
"l/fr/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/fr/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/fr/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/fr/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/fr/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/fr/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/fr/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Configuration",
|
||||
"pages": [
|
||||
@@ -1277,6 +1289,18 @@
|
||||
"l/ar/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "البرنامج التعليمي",
|
||||
"pages": [
|
||||
"l/ar/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/ar/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/ar/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/ar/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/ar/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/ar/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/ar/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "التهيئة",
|
||||
"pages": [
|
||||
@@ -1717,6 +1741,18 @@
|
||||
"l/cs/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Návod",
|
||||
"pages": [
|
||||
"l/cs/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/cs/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/cs/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/cs/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/cs/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/cs/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/cs/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Konfigurace",
|
||||
"pages": [
|
||||
@@ -2157,6 +2193,18 @@
|
||||
"l/de/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial",
|
||||
"pages": [
|
||||
"l/de/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/de/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/de/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/de/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/de/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/de/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/de/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Konfiguration",
|
||||
"pages": [
|
||||
@@ -2597,6 +2645,18 @@
|
||||
"l/es/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial",
|
||||
"pages": [
|
||||
"l/es/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/es/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/es/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/es/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/es/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/es/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/es/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Configuración",
|
||||
"pages": [
|
||||
@@ -3037,6 +3097,18 @@
|
||||
"l/it/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial",
|
||||
"pages": [
|
||||
"l/it/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/it/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/it/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/it/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/it/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/it/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/it/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Configurazione",
|
||||
"pages": [
|
||||
@@ -3477,6 +3549,18 @@
|
||||
"l/ja/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "チュートリアル",
|
||||
"pages": [
|
||||
"l/ja/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/ja/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/ja/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/ja/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/ja/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/ja/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/ja/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "設定",
|
||||
"pages": [
|
||||
@@ -3917,6 +4001,18 @@
|
||||
"l/ko/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "튜토리얼",
|
||||
"pages": [
|
||||
"l/ko/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/ko/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/ko/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/ko/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/ko/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/ko/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/ko/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "설정",
|
||||
"pages": [
|
||||
@@ -4357,6 +4453,18 @@
|
||||
"l/pt/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial",
|
||||
"pages": [
|
||||
"l/pt/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/pt/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/pt/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/pt/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/pt/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/pt/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/pt/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Configuração",
|
||||
"pages": [
|
||||
@@ -4797,6 +4905,18 @@
|
||||
"l/ro/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial",
|
||||
"pages": [
|
||||
"l/ro/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/ro/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/ro/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/ro/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/ro/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/ro/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/ro/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Configurare",
|
||||
"pages": [
|
||||
@@ -5671,6 +5791,18 @@
|
||||
"l/tr/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Öğretici",
|
||||
"pages": [
|
||||
"l/tr/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/tr/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/tr/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/tr/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/tr/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/tr/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/tr/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Yapılandırma",
|
||||
"pages": [
|
||||
@@ -6111,6 +6243,18 @@
|
||||
"l/zh/developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "教程",
|
||||
"pages": [
|
||||
"l/zh/developers/extend/apps/tutorials/document-generator/overview",
|
||||
"l/zh/developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"l/zh/developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"l/zh/developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"l/zh/developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"l/zh/developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"l/zh/developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "配置",
|
||||
"pages": [
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: السماح لوكيل بتوليد مستندات من محادثة، باستخدام أداتك.
|
||||
---
|
||||
|
||||
لأن `وثيقة توليد الطاقة` مكشوفة كـ **أداة**، يمكن لوكيل الذكاء الاصطناعي أن يطلق عليها.
|
||||
دعونا نضيف وكيلا ومهارة حتى يمكن للمستخدمين أن يقولوا فقط *"إنشاء اقتراح لـ
|
||||
Jeffery Griffin"*.
|
||||
|
||||
## المهارة
|
||||
|
||||
A [skill](/l/ar/developers/extend/apps/logic/skills-and-agents) قابل لإعادة الاستخدام- المعرفة التي تربطها بالوكلاء. علمنا النموذج كيفية استخدام
|
||||
الأداة.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## الوكيل
|
||||
|
||||
[agent](/l/ar/developers/extend/apps/logic/skills-and-agents) يزوج موجه مع نموذج
|
||||
. تعيين 'استمارة الاستجابة' بشكل صريح لتجنب تحذير البناء.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
ولا يمكن للوكيل الاتصال بالأداة إلا إذا كان دوره يسمح بذلك. لقد قمنا بالفعل بتعيين
|
||||
'cancessAllTools: true' و 'canBeAssignedToAgents: true' على دور التطبيق في
|
||||
[الفصل 2](/l/ar/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## جرب ذلك
|
||||
|
||||
افتح محادثة مع **مساعد مستندات** واطلب منها إعداد وثيقة لشخص
|
||||
في إدارة علاقات العملاء الخاصة بك. يجد السجل، يستدعي `generate-document`، ويُرجِع
|
||||
المستند الذي أنشأه — الذي يظهر الآن في عرض **Documents** لديك،
|
||||
تمامًا مثل مسارات قائمة الأوامر وسير العمل.
|
||||
|
||||
هذه هي ثمرة كشف المنطق كأداة: **دالة واحدة، والعديد من نقاط الدخول** —
|
||||
قائمة الأوامر، وHTTP، وخطوة سير العمل، والآن اللغة الطبيعية.
|
||||
|
||||
**بعد هذه الخطوة:** التطبيق كامل الميزة ومفيد حقا. حان الوقت
|
||||
لإطلاقه.
|
||||
|
||||
<Card title="التالي: النشر →" icon="rocket" href="/l/ar/developers/extend/apps/tutorials/document-generator/النشر">
|
||||
إضافة بيانات التعريف للسوق والنشر.
|
||||
</Card>
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
---
|
||||
title: 4. بناء واجهة المستخدم
|
||||
icon: table-columns
|
||||
description: المشاهدة، الملاحة الجانبية، الأوامر، والمكونات الأمامية.
|
||||
---
|
||||
|
||||
الآن يمكن الوصول إلى الكائنات فقط من خلال الإعدادات. لنمنح التطبيق حضورًا حقيقيًا في واجهة المستخدم: عروض قائمة، وعناصر في الشريط الجانبي، وأمر **Generate document** بنقرة واحدة، ومكوّن واجهة أمامية في صفحة السجل لِـ**preview** مستند، وعلامة تبويب **editor** نصيّة منسّقة (rich-text) أصلية للقوالب.
|
||||
|
||||
## وجهات النظر والملاحة
|
||||
|
||||
A [view](/l/ar/developers/extend/apps/layout/views) هي قائمة محفوظة لكائن معين.
|
||||
ويضع [عنصر قائمة التنقل](/l/ar/developers/extend/apps/layout/navigation-menu-items)
|
||||
هذا العرض في الشريط الجانبي.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
أضف نفس زوج للنماذج. ويظهر كلاهما الآن في الشريط الجانبي:
|
||||
|
||||
<Frame caption="المستندات والقوالب في الشريط الجانبي، مع إدراج الوثيقة التي تم إنشاؤها.">
|
||||
<img src="/images/docs/developers/extends/apps/document-Generator/04-documents-view.png" alt="المستندات المعروضة مع مستند تم إنشاؤه" />
|
||||
</Frame>
|
||||
|
||||
## مكون أمامي
|
||||
|
||||
[مكون أمامي](/l/ar/developers/extend/apps/layout/front-components) هو مكون React
|
||||
مربع رملي داخل عشرين. يقرأ السجل المحدد، يحمّل قوالب الشخص
|
||||
عبر `CoreApiClient`، و POSTs إلى المسار من الفصل
|
||||
الأخير.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
النمط مع متغيرات CSS المضمنة (`var(--t-color-blue)`)، ليس القيم المستوردة من
|
||||
`XXui`. تمزق SDK تلك الحزمة أثناء البناء، لذا فإن واردات مستوى الوحدة النمطية من ثوابت السمة
|
||||
ستكون 'غير محددة\`. انظر
|
||||
[الكامل المكون](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## أمر لفتحه
|
||||
|
||||
[أمر قائمة](/l/ar/developers/extend/apps/layout/command-menu-items) مع
|
||||
\`available ityType: 'RECORD_SELECTION'' يظهر عند اختيار شخص، و
|
||||
يفتح المكون في اللوحة الجانبية.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## جرب التدفق بأكمله
|
||||
|
||||
افتح **People**، وحدِّد شخصًا، ثم اضغط <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>.
|
||||
يظهر "إنشاء مستند"، وسم مع التطبيق الخاص بك:
|
||||
|
||||
<Frame caption="يظهر الأمر عندما يتم اختيار شخص.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="قائمة الأوامر مع توليد المستند" />
|
||||
</Frame>
|
||||
|
||||
قم بتشغيله – يفتح مكونك في اللوحة الجانبية. اختيار قالب، انقر
|
||||
**إنشاء**، وسجل جديد في **الوثائق**.
|
||||
|
||||
<Frame caption="المكون الأمامي، تحميل القوالب والتوليد على النقرة.">
|
||||
<img src="/images/docs/developers/extends/apps/document-Generator/06b-front-component.png" alt="إنشاء لوحة وثيقة جانبية" />
|
||||
</Frame>
|
||||
|
||||
يسجل كل مستند تم إنشاؤه التطبيق الخاص بك كمؤلف:
|
||||
|
||||
<Frame caption="تم إنشاؤها من قبل مولد المستندات، الحالة التي تم إنشاؤها.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="سجل مستند تم إنشاؤه" />
|
||||
</Frame>
|
||||
|
||||
## معاينة مستند في صفحة السجل الخاصة به
|
||||
|
||||
المكون الأمامي ليس فقط لقوائم الأوامر - يمكنك تحميل واحد كعلامة تبويب \*\*على صفحة تسجيل
|
||||
\*\*. دعونا نضيف علامة تبويب *Preview* إلى سجل المستند الذي يجعل جسم
|
||||
Markdown كصفحة مصقولة قابلة للطباعة.
|
||||
|
||||
ويقرأ المكون معرف السجل الحالي من سياق التنفيذ، ويحمل المستند
|
||||
ويضعه. تعمل المكوّنات الأمامية ضمن **sandbox** لا يسمح إلا بقائمة محددة من علامات HTML — يتم حظر حقن HTML الخام (`dangerouslySetInnerHTML`) وعنصر `\<style>` — لذلك نقوم بعرض Markdown كعناصر React مع أنماط مضمنة عبر مساعد [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx) صغير.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
قم بتحميلها مع [تخطيط الصفحة](/l/ar/developers/extend/apps/layout/page-layouts). يضيف تخطيط
|
||||
`RECORD_PAGE` علامات تبويب إلى طريقة عرض تسجيل الكائن؛ ويستضيف عنصر التحكم 'FRONT_COMPONENT`
|
||||
في علامة تبويب 'CANVAS` المكون:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
افتح أي مستند – علامة تبويب **المعاينة** تجعله جميلا، مع روابط إلى صفحة ويب
|
||||
القابلة للمشاركة و PDF:
|
||||
|
||||
<Frame caption="علامة التبويب المعاينة تجعل المستند بأنماط مضمنة، بالإضافة إلى روابط سريعة.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="عنصر واجهة عارض المستند في علامة تبويب صفحة التسجيل" />
|
||||
</Frame>
|
||||
|
||||
## تحرير قالب مع محرر النص الغني
|
||||
|
||||
القوالب لا تحتاج إلى عنصر مخصص على الإطلاق. لأن `body' هو حقل
|
||||
`RICH_TEXT' ، يقدم عشرون محرر نص ثري كامل له -
|
||||
نفس المحرر القياسي للمذكرة وعناصر المهمة. نحن فقط نسطحه على صفحة سجل القالب
|
||||
|
||||
إضافة علامة تبويب مع عنصر واجهة المستخدم 'FIELD' في وضع العرض 'EDITOR' ، مع الإشارة إلى حقل 'body'
|
||||
عبر 'field MetadataId':
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
يخزن حقل "RICH_TEXT" كتلة المحرر JSON وإسقاط Markdown
|
||||
على حد سواء. تقوم قناة التوليد بقراءة إسقاط Markdown هذا، بحيث تظل العناصر البديلة placeholders وملف PDF وصفحة الويب القابلة للمشاركة تعمل دون أي تغيير — اطّلع على الملف الكامل [`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
الآن المحررون يكتبون قوالب في محرر نص غني مناسب:
|
||||
|
||||
<Frame caption="علامة التبويب النموذج: محرر النص الغني الأصلي 20 مرتبط بحقل الجسم.">
|
||||
<img src="/images/docs/developers/extends/apps/document-Generator/10-template-editor.png" alt="سجل القالب مع علامة تبويب محرر النص الغني الأصلي" />
|
||||
</Frame>
|
||||
|
||||
**بعد هذه الخطوة:** معاينة الوثائق بشكل جميل والقوالب قابلة للتحرير
|
||||
في التطبيق. ثم دعنا وكيل الذكاء الاصطناعي يولدها من محادثة.
|
||||
|
||||
<Card title="التالي : وكيل AI →" icon="robot" href="/l/ar/developers/extend/apps/tutorials/document-Generator/ai-agent">
|
||||
إضافة وكيل ومهارة يتصلان بأداتك.
|
||||
</Card>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: ١. نموذج البيانات
|
||||
icon: database
|
||||
description: وثائق نموذجية ونماذج ذات كائنات وحقول وعلاقة.
|
||||
---
|
||||
|
||||
يحتاج تطبيقنا إلى عنصرين مخصصين: **قوالب وثيقة** (ما يجب كتابته) و
|
||||
**وثائق** (النتيجة المنشأة). دعونا نعرفهم.
|
||||
|
||||
سكاف كل ملف كيان مع CLI - يخلق مجلد UUID صالح و
|
||||
الصحيح لك:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
نعرض أدناه الملفات المكتملة.
|
||||
|
||||
<Note>
|
||||
كل `*_UNIVERSAL_IDENTIFIER` يعيش باستمرار في
|
||||
`src/constants/universal-Identiers.ts` ويتم استيراده حيثما استخدم. كتل الكود
|
||||
أسفل أغفل تلك الواردات للإيجاز، أبقيها في ملفاتك.
|
||||
</Note>
|
||||
|
||||
## عنصر القالب
|
||||
|
||||
يحتوي القالب على `name`، و `body` يتضمن `{{placeholders}}`، و `target` يحدد ما إذا كان مكتوبًا لشخص أو لشركة. 'body' هو حقل
|
||||
'RICH_TEXT' لذا فإن عشرون يمنحه محرر نص ثري كامل.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
يجب أن يكون خيار `SELECT` **القيم** `UPER_CASE` (`PERSON`، وليس `person`)، و
|
||||
`defaultValue` مغلقاً في اقتباسات إضافية: ` `PERSON`". إن `label\` هو ما يراه المستخدمون.
|
||||
</Warning>
|
||||
|
||||
## كائن المستند
|
||||
|
||||
الوثيقة التي تم إنشاؤها تخزن "المحتوى" و "الحالة". قم بتعريفه
|
||||
بنفس الطريقة، مع اختيار 'الحالة' من 'DRAFT` / 'GENERATED`. الملف الكامل:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## ربطهم بعلاقة
|
||||
|
||||
يجب أن يشير كل مستند مرة أخرى إلى النموذج الذي أتى منه. العلاقات هي
|
||||
**ثنائية الاتجاه** - أنت تحدد كلا الجانبين، كل منهما في ملف الحقل الخاص به.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
الجانب الآخر (`template-documents-relation.field.ts`) هو حقل
|
||||
`RelationType.ONE_TO_MANY` يسمى `documents` الذي يشير إلى الاتجاه المعاكس.
|
||||
انظر [Relations](/l/ar/developers/extend/apps/data/relations) للاطلاع على النمط الكامل.
|
||||
|
||||
## مشاهدته في العشرين
|
||||
|
||||
مع تشغيل 'yarn 20 inenty dev'، افتح **الإعدادات → البيانات النموذجية**. يظهر كلا العنصرين
|
||||
، وسم مع التطبيق الخاص بك.
|
||||
|
||||
<Frame caption="كلا العنصرين المخصصين، مملوكين لتطبيق مولد المستندات.">
|
||||
<img src="/images/docs/developers/extends/apps/document-Generator/01-data-model.png" alt="إعدادات نموذج البيانات التي تظهر قوالب المستندات والمستندات" />
|
||||
</Frame>
|
||||
|
||||
قم بإنشاء قالب واحد لاختباره - اسمه *اقتراح المبيعات*، وقم بتعيين **الهدف** إلى
|
||||
*Person*، وقم بلصق جسم مع بعض العناصر النائبة:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="سجل قالب. وتحافظ هذه الهيئة على الجهات الناقلة لها إلى حين إصدار وثيقة.">
|
||||
<img src="/images/docs/developers/extends/apps/document-Generator/03-template-record.png" alt="سجل قالب مقترح المبيعات مع الهيئة النائبة" />
|
||||
</Frame>
|
||||
|
||||
**بعد هذه الخطوة:** لديك كائنات 'documentTemplate' و 'document'، مرتبطة بـ
|
||||
علاقة، و قالب واحد لتوليد منها. وبعد ذلك، المنطق الذي يعبئه
|
||||
|
||||
<Card title="التالي: توليد الوثائق →" icon="bolt" href="/l/ar/developers/extend/apps/tutorials/document-Generator/التوليد-المستندات">
|
||||
اكتب الدالة المنطقية التي تملأ القالب.
|
||||
</Card>
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: ٢. إصدار الوثائق
|
||||
icon: bolt
|
||||
description: وظيفة منطقية واحدة معرّفة كأداة الذكاء الاصطناعي وعملية سير العمل.
|
||||
---
|
||||
|
||||
الآن النواة الأساسية: [دالة المنطقية](/l/ar/developers/extend/apps/logic/logic-functions)
|
||||
التي تحمّل قالب وسجل، تملأ العناصر النائبة، وتحفظ وثيقة
|
||||
جديدة.
|
||||
|
||||
سنقوم بكتابة منطق العمل مرة واحدة ك\*\*معالج \*\*، ثم نكشف عنه من خلال
|
||||
عدة مشغلات. هذا الفصل يربط اثنين منهم - أداة **AI** و
|
||||
**عمل سير العمل**.
|
||||
|
||||
## مساعد التقديم
|
||||
|
||||
حافظ على المنطق الخالص في ملفه الخاص بحيث أنه من السهل إجراء اختبار الوحدة. هذا يربط سجل
|
||||
في `{{dot.path}}` رموز ويبدلها.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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:unit\`). انظر [Testing](/l/ar/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## المعالج
|
||||
|
||||
يستخدم المعالج [`CoreApiClient`](/l/ar/developers/extend/apps/logic/logic-functions)
|
||||
لقراءة وكتابة بيانات CRM. يحمّل القالب، ويحمّل السجل المستهدف، ويملأ
|
||||
الجسم، وينشئ `وثيقة`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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' يدير استفسارا مختلفا لشخص ضد شركة ومسطحات
|
||||
النتيجة - انظر
|
||||
[\`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).
|
||||
|
||||
## الكشف عنه كأداة وإجراءات سير العمل
|
||||
|
||||
يمكن لـ `defineLogicFunction' أن يحمل عدة مشغلات. هنا، "إعدادات الأدوات"
|
||||
تجعلها قابلة للاستدعاء من قبل عملاء AI ، و `workflowtionTriggertings\` تحولها إلى خطوة
|
||||
في منشئ سير العمل البصري. ويصف كلاهما إسهاماتهما بمخطط "JSON".
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
مخطط المدخلات هو مخطط JSON بسيط يصف `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
|
||||
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/ar/developers/extend/apps/config/rolesللحصول على أذونات مأخوذة من الحبوب الأكثر.
|
||||
|
||||
## إرفاق ملف PDF حقيقي
|
||||
|
||||
حقل نص محرر مفيد، لكن المستخدمين يريدون وثيقة حقيقية. دعونا ننشئ
|
||||
**PDF** ونخزنه في السجل كملف قابل للتنزيل.
|
||||
|
||||
أولا، اعطي الكائن 'document' حقل 'FILES' للاحتفاظ بـ PDF. تقوم التطبيقات برفع
|
||||
في حقول الملفات الخاصة بها **الخاصة**، لذلك هذا الحقل هو مسارات الرفع:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
الآن اجعل قوات الدفاع الشعبي تلك. التطبيق هو مشروع عقدة حقيقي، بحيث يمكنك إضافة أي npm
|
||||
حزمة تحتاج إليها واستيرادها مثل أي مكان آخر. نحن نستخدم **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
لرسم PDF و **[marked](https://marked.js.org/)** لتحليل جسم Markdown- يقوم CLI بتثبيته في وقت تشغيل الوظيفة لك:
|
||||
|
||||
```bash filename="Terminal"
|
||||
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).
|
||||
إنه يحلل علامة الرموز المميزة مع علامة `علامة'. ممارسة '، ثم تضعها باستخدام
|
||||
pdf-lib: العناوين الحقيقية، **الجريئة**/*italic*، تدير الرصاصات والقوائم المرقمة،
|
||||
الكتل والقواعد-عبارة عن مقتطفات متعددة الصفحات A4 لتقديم القالب
|
||||
نفسه، وليس جدار نص.
|
||||
|
||||
<Frame caption="PDF المولدة: الطباعة الحقيقية وتنسيق Markdown، وتقديم نموذج الجسم.">
|
||||
<img src="/images/docs/developers/extends/apps/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"
|
||||
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="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="سجل مستند مع ملف PDF الذي تم إنشاؤه" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
'uploadFile' يستهدف فقط حقول الملفات **التي تملكها التطبيقات** (لذلك يتطلب التحميل دائمًا تطبيق
|
||||
يمتلك الحقل، بالإضافة إلى علم الدور 'UPLOAD_FILE\`). لهذا السبب يهبط PDF
|
||||
في الحقل 'file' الخاص بالسجل - نفس النمط الذي يستخدمه
|
||||
[call-record](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
للتسجيلات.
|
||||
</Note>
|
||||
|
||||
**بعد هذه الخطوة:** كل مستند تم إنشاؤه لديه PDF حقيقي، قابل للتنزيل. ولكن
|
||||
لا يوجد ما يمكنه *استدعاء* المولّد من واجهة المستخدم بعد — ولأجل ذلك نحتاج إلى مسار HTTP.
|
||||
|
||||
<Card title="التالي: طرق HTTP →" icon="الكرة الأرضية" href="/l/ar/developers/extend/apps/tutorials/document-Generator/http-routes">
|
||||
خدمة الوظيفة عبر HTTP وتقديم الوثائق كصفحات على الويب.
|
||||
</Card>
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: ٣. مسارات HTTP
|
||||
icon: globe
|
||||
description: قم بتفعيل الوظيفة على HTTP وتقديم الوثائق كصفحات ويب.
|
||||
---
|
||||
|
||||
نفس المعالج يمكنه أيضا الإجابة على طلبات HTTP. سوف نضيف مسارين:
|
||||
|
||||
* نقطة النهاية **POST** مكالمات واجهة المستخدم لإنشاء وثيقة، و
|
||||
* نقطة نهاية عامة **GET** تجعل الوثيقة صفحة ويب قابلة للطباعة.
|
||||
|
||||
وكلاهما يستخدم `httpRouteTriggerSettings`. طرق التطبيق تقدم تحت `/s` على خادم
|
||||
20 (على سبيل المثال 'http://localhost:2020/s/documents/generate\`).
|
||||
|
||||
## مسار POST - توليد حسب الطلب
|
||||
|
||||
هذا يعيد استخدام `GenerateDocumentHandler`، لذلك لا يوجد منطق لتكراره - مجرد محول رقيق
|
||||
الذي يقرأ الجسم المطلوب.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
يقوم المعالج المشترك بإرجاع 'حالة' مقترحة عند الفشل، بحيث يمكن للطريق
|
||||
الإجابة مع رمز `4xx`/`5xx`. `isAuthRerequirered: true` يعني أنه يجب على المتصل
|
||||
أن يقدم رمزا صالحا - المكون الأمامي في الفصل التالي يمر رمز الوصول إلى
|
||||
المستخدم تلقائيا.
|
||||
|
||||
## مسار GET - يقدم كصفحة على الشبكة
|
||||
|
||||
لإرجاع HTML بدلاً من JSON، قم بتدوين الجسم في 'استجابة` مع رأس
|
||||
'محتوى - نوع`. هذا المسار عام ('isAuthRerequirered: false\`) بحيث يمكن مشاركة مستند تم إنشاؤه
|
||||
كرابط .
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
'documentHtmlPage' يجعل جسم Markdown إلى HTML (مع [marked](https://marked.js.org/)،
|
||||
محسوسة) ويسقط في نظيف، صفحة قابلة للطباعة تعرض فقط محتوى القالب- نفس مظهر PDF والمعاينة داخل التطبيق.
|
||||
[انظر المساعد](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## جرب ذلك
|
||||
|
||||
مع قالب وشخص في مساحة العمل الخاصة بك، اتصل بالمسار (التقط الرمز المميز من
|
||||
**الإعدادات → APIs & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
افتح المستند الذي تم إرجاعه في المتصفح الخاص بك:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="ويجعل مسار الهيئة العامة للتكنولوجيا من الوثيقة صفحة قابلة للطباعة.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="صفحة وثيقة تم إصدارها" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
يمكنك أيضًا بث سجلات الدالة أثناء اختبارها باستخدام
|
||||
`yarn 20 dev:function:logs`، أو استدعاء ذلك مباشرة باستخدام
|
||||
`yarn 20 dev:function:exec`.
|
||||
</Tip>
|
||||
|
||||
**بعد هذه الخطوة:** يمكن للتطبيق إنشاء مستندات عبر HTTP وخدمتها كصفحات ويب
|
||||
الآن دعونا نجعله قابلا للاستخدام بدون "تعطيل".
|
||||
|
||||
<Card title="التالي : بناء واجهة المستخدم →" icon="table-columns" href="/l/ar/developers/extend/apps/tutorials/document-Generator/building-the-ui">
|
||||
المشاهدة، الملاحة، الأوامر، والعنصر الأمامي.
|
||||
</Card>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: "دليل تعليمي: مولِّد المستندات"
|
||||
icon: wand-magic-sparkles
|
||||
description: أنشئ تطبيق Twenty حقيقيًا يُولِّد مستندات مخصَّصة من بيانات نظام إدارة علاقات العملاء (CRM) الخاصة بك.
|
||||
---
|
||||
|
||||
في هذا الدليل التعليمي ستقوم بإنشاء **مولِّد المستندات** — تطبيق يحوِّل القوالب القابلة لإعادة الاستخدام إلى مستندات مخصَّصة باستخدام البيانات الموجودة مسبقًا في نظام إدارة علاقات العملاء (CRM) لديك.
|
||||
|
||||
اكتب قالبًا مرة واحدة باستخدام `{{placeholders}}`، ثم أنشئ مستندًا مكتمل الحقول لأي شخص أو شركة بنقرة واحدة — من قائمة الأوامر، أو من وكيل ذكاء اصطناعي، أو من سير عمل.
|
||||
|
||||
<Frame caption="قالب واحد، يتم إنشاؤه لشخص محدد، وفتحه كصفحة قابلة للطباعة.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="مستند عرض مبيعات تم إنشاؤه" />
|
||||
</Frame>
|
||||
|
||||
## ما ستتعلّمه
|
||||
|
||||
يُضيف كل فصل قدرة واحدة. بنهاية الدليل ستكون قد تعاملت مع معظم حزمة تطوير البرمجيات (SDK).
|
||||
|
||||
| الفصل | القدرة | مرجع |
|
||||
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
|
||||
| [١. نموذج البيانات](/l/ar/developers/extend/apps/tutorials/document-generator/data-model) | الكائنات، الحقول، وعلاقة واحدة | [البيانات](/l/ar/developers/extend/apps/data/overview) |
|
||||
| [٢. إنشاء المستندات](/l/ar/developers/extend/apps/tutorials/document-generator/generating-documents) | دالة منطقية (أداة ذكاء اصطناعي + خطوة سير عمل) تملأ قالب Markdown وتُرفِق ملف PDF مصقولًا | [الوظائف المنطقية](/l/ar/developers/extend/apps/logic/logic-functions) |
|
||||
| [٣. مسارات HTTP](/l/ar/developers/extend/apps/tutorials/document-generator/http-routes) | تقديم JSON وصفحة HTML قابلة للمشاركة من المسارات | [الوظائف المنطقية](/l/ar/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. بناء واجهة المستخدم](/l/ar/developers/extend/apps/tutorials/document-generator/building-the-ui) | العروض، التنقل، قائمة الأوامر، ومكوّنات الواجهة الأمامية التي تُعاين مستندًا وتحرّر قالبًا | [التخطيط](/l/ar/developers/extend/apps/layout/overview) |
|
||||
| [5. وكيل ذكاء اصطناعي](/l/ar/developers/extend/apps/tutorials/document-generator/ai-agent) | وكيل + مهارة | [المهارات والوكلاء](/l/ar/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. النشر](/l/ar/developers/extend/apps/tutorials/document-generator/publishing) | إطلاقه في السوق | [النشر](/l/ar/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## المتطلبات الأساسية
|
||||
|
||||
يجب أن تكون قد أنهيت قسم [البدء السريع](/l/ar/developers/extend/apps/getting-started/quick-start):
|
||||
خادم Twenty محلي يعمل على المنفذ `2020` وواجهة سطر أوامر (CLI) موثَّقة عليه.
|
||||
|
||||
إذا لم تكن قد فعلت ذلك، فقم بتهيئة واحد وتشغيله الآن:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
تفضّل قراءة الكود النهائي؟ يوجد التطبيق الكامل في
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
كل مقتطف أدناه منسوخ منه.
|
||||
</Note>
|
||||
|
||||
## كيف تتكامل مكوّنات التطبيق معًا
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="يُحوَّل قالب يحتوي على عناصر نائبة إلى مستند مصقول مع ملف PDF، ويتم تشغيل ذلك من قائمة الأوامر، أو وكيل ذكاء اصطناعي، أو سير عمل، أو رابط قابل للمشاركة" />
|
||||
</Frame>
|
||||
|
||||
تكتب **قالبًا** مرة واحدة في محرّر نص منسّق، مع `{{placeholders}}`. يؤدي اختيار قالب وسجل في نظام إدارة علاقات العملاء إلى ملء العناصر النائبة وتخزين مستند مصقول (مع ملف PDF). كل ما عدا ذلك — قائمة الأوامر، وكيل الذكاء الاصطناعي،
|
||||
خطوة سير العمل، الرابط القابل للمشاركة — ما هو إلا طريقة مختلفة لتشغيل هذا
|
||||
المولّد الواحد.
|
||||
|
||||
## أبقِ هذه الحلقة قيد التشغيل
|
||||
|
||||
اترك الأمر `yarn twenty dev` يعمل في أحد الطرفيات طوال الدرس التطبيقي. في كل مرة تُضيف أو تعدّل ملفًا ضمن `src/`، تُعاد مزامنته مع خادمك خلال بضع ثوانٍ، لكي تتمكّن من مشاهدة كل قدرة تظهر في واجهة المستخدم أثناء بنائها.
|
||||
|
||||
<Card title="ابدأ البناء →" icon="قاعدة البيانات" href="/l/ar/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
الفصل 1: نمذجة المستندات والقوالب.
|
||||
</Card>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: 6. النشر
|
||||
icon: rocket
|
||||
description: إضافة بيانات التعريف للسوق ونشر التطبيق الخاص بك.
|
||||
---
|
||||
|
||||
تطبيقك يعمل. والخطوة الأخيرة هي وصف ذلك للسوق والنشر.
|
||||
|
||||
## إضافة بيانات التعريف للسوق
|
||||
|
||||
[config](/l/ar/developers/extend/apps/config/application) يحمل الهوية
|
||||
التي تظهر في السوق: روابط المؤلف والفئة والشعار والدعم
|
||||
. ضع الشعار في `public/` وقم بالرجوع إليه مع `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/ar/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
يتم الإعلان عن الدور الافتراضي مع 'defineApplicationRole()' في ملفه الخاص - أنت
|
||||
لا تتجاوز 'defaultRoleUniversalIdentifier' هنا بعد الآن.
|
||||
</Tip>
|
||||
|
||||
أضف أيضا الكلمة المفتاحية `٢٢app` إلى `package.json` حتى يكون التطبيق قابلا للاكتشاف:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## إضافة لقطات شاشة معرض الصور
|
||||
|
||||
قائمة السوق تبيع نفسها مع لقطات شاشة. إسقاط عدد قليل من PNGs في
|
||||
`public/gallery/` والإشارة إليهم بـ \`screenshots' - يتم عرضهم كمعرض
|
||||
في صفحة الإدراج في القائمة.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
الرصاص مع الدفع: اجعل أول لقطة للشاشة النتيجة النهائية (وثيقة
|
||||
تم إنشاؤها)، ثم اظهر كيف تم تشغيلها وكتابتها. استخدم التقاط
|
||||
عالي الدقة - إنها أول شيء يراه المستخدم.
|
||||
</Tip>
|
||||
|
||||
إعطاء العلاج نفسه 'README.md' - إنها الصفحة الأولى على npm و GitHub.
|
||||
افتح مع اقتراح القيمة و لقطة شاشة، قائمة بميزات العنوان،
|
||||
ثم ابقي تفاصيل البناء تحت الطبع.
|
||||
|
||||
## تحقق قبل الشحن
|
||||
|
||||
تشغيل نفس البوابات CI :
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
يُظهر التشغيل التجريبي dry run بالضبط ما سيتغيّر على الخادم بدون تطبيقه —
|
||||
وهو فحص أخير جيّد للتأكّد من سلامة كل شيء. انظر
|
||||
[Testing](/l/ar/developers/extend/apps/operations/testing) و
|
||||
[المزامنة والاسترداد](/l/ar/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## النشر
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
'app:publish' يبني وينشر إلى npm بشكل افتراضي؛ '--private' يرفع قاعدة
|
||||
tarball إلى السجل الخاص لخادم 20 بدلا من ذلك. لتظهر تطبيق منشور
|
||||
في سوق مثيل ما، قم بتفعيل مزامنة الكتالوج:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
التفاصيل الكاملة وقائمة التحقق من الإصدار:
|
||||
[Publishing](/l/ar/developers/extend/apps/operations/publishing).
|
||||
|
||||
## قمت ببناء تطبيق 🎉
|
||||
|
||||
في ستة فصول استخدمت معظم سطح SDK:
|
||||
|
||||
* **الكائنات، الحقول و العلاقة** لنموذج البيانات
|
||||
* **دالة منطقية** مكشوفة كأداة \*\*AI \*\*، و **سير العمل**، و **مسارات HTTP**
|
||||
* **مشاهدات, تصفح, أمر و مكون أمامي** لواجهة المستخدم
|
||||
* **وكيل + مهارة** لتوليد اللغة الطبيعية
|
||||
* \*\*البيانات الوصفية للسوق \*\* وتدفق النشر
|
||||
|
||||
التطبيق النهائي هو في
|
||||
[`packages/XXapps/examples/document-Generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## من أين تذهب بعد
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="مرجع البيانات" icon="database" href="/l/ar/developers/extend/apps/data/overview">
|
||||
كل نوع من أنواع الحقول والعلاقة وخيارات الفهرس.
|
||||
</Card>
|
||||
<Card title="مرجع منطقي" icon="bolt" href="/l/ar/developers/extend/apps/logic/overview">
|
||||
مشغلات أحداث Cron وقاعدة البيانات، ومتجر القيمة المفتاح، واتصالات OAuth.
|
||||
</Card>
|
||||
<Card title="مرجع التخطيط" icon="table-columns" href="/l/ar/developers/extend/apps/layout/overview">
|
||||
مخططات الصفحة، ودويدات لوحة المعلومات، ومزيد من أسطح واجهة المستخدم.
|
||||
</Card>
|
||||
<Card title="العمليات" icon="rocket" href="/l/ar/developers/extend/apps/operations/overview">
|
||||
() CLI، والاختبار، وعمليات الإزالة، وCI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "البدء"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "البرنامج التعليمي"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "التهيئة"
|
||||
},
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: Nechte agenta generovat dokumenty z chatu, pomocí vašeho nástroje.
|
||||
---
|
||||
|
||||
Protože je `generate-document` vystaven jako **nástroj**, agent AI ho může zavolat.
|
||||
Přidejme agenta a dovednosti, aby uživatelé mohli říci *"vygenerovat návrh
|
||||
Jeffery Griffin"*.
|
||||
|
||||
## Dovednost
|
||||
|
||||
[skill](/l/cs/developers/extend/apps/logic/skills-and-agents) je opakovaně použitelné
|
||||
– znalosti, které připojujete k agentům. Náš model učí jak použít
|
||||
nástroj.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## Zástupce
|
||||
|
||||
[agent](/l/cs/developers/extend/apps/logic/skills-and-agents) spáruje výzvu s modelem
|
||||
. Nastavte `responseFormat` explicitně, abyste se vyhnuli varování sestavení.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
Zástupce může zavolat nástroj pouze v případě, že jej jeho role umožňuje. Již jsme nastavili
|
||||
`canAccessAllTools: true` a `canBeAssignedToAgents: true` na roli aplikace v
|
||||
[Chapter 2](/l/cs/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Vyzkoušejte
|
||||
|
||||
Otevřete chat s **asistentem dokumentu** a požádejte jej, aby připravil dokument pro osobu
|
||||
ve vašem CRM. Najde záznam, volání `generate-document`, a nahlásí
|
||||
dokument, který vytvořil – který se nyní zobrazí ve tvém **dokumentu** zobrazení,
|
||||
přesně se líbí příkazové menu a cesty workflow.
|
||||
|
||||
To je výsledek odhalování logiky jako nástroje: **jedna funkce, mnoho předních dveří** —
|
||||
příkaz menu, HTTP, krok workflow a nyní přirozený jazyk.
|
||||
|
||||
**Po tomto kroku:** je aplikace kompletní a opravdu užitečná. Čas do
|
||||
lodi.
|
||||
|
||||
<Card title="Další: publikování →" icon="rocket" href="/l/cs/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
Přidat metadata a publikovat tržiště.
|
||||
</Card>
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: 4. Budování uživatelského rozhraní
|
||||
icon: table-columns
|
||||
description: Zobrazení, postranní navigace, příkaz a přední komponenty.
|
||||
---
|
||||
|
||||
Právě teď jsou objekty dosažitelné pouze v nastavení. Dejme aplikaci
|
||||
skutečnou přítomnost v UI: seznam zobrazení, položek postranního panelu, jedním kliknutím
|
||||
**Generovat příkaz** dokumentu, přední část rekordové stránky do **náhledu** dokumentu
|
||||
a nativní text **editor** pro šablony.
|
||||
|
||||
## Zobrazení a navigace
|
||||
|
||||
[view](/l/cs/developers/extend/apps/layout/views) je uložený seznam daného objektu.
|
||||
[Položka navigačního menu](/l/cs/developers/extend/apps/layout/navigation-menu-items)
|
||||
umístí toto zobrazení do postranního panelu.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Přidejte stejný pár šablon. Oba se nyní zobrazí v postranním panelu:
|
||||
|
||||
<Frame caption="Dokumenty a šablony v postranním panelu s vygenerovaným dokumentem.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Zobrazení dokumentů s vygenerovaným dokumentem" />
|
||||
</Frame>
|
||||
|
||||
## Přední část
|
||||
|
||||
[přední komponenta](/l/cs/developers/extend/apps/layout/front-components) je komponenta React
|
||||
boxovaná uvnitř dvaceti. Náš přečte vybraný záznam, načte
|
||||
šablony osob přes `CoreApiClient`, a POSTE na trasu z poslední kapitoly
|
||||
.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Styl s inline CSS proměnnými (`var(--t-color-blue)`), ne hodnoty importované z
|
||||
`twenty-ui`. SDK mocniny, které balí během sestavení, takže importy
|
||||
téma na úrovni modulů by byly `nedefinované`. Podívejte se na
|
||||
[plnou komponentu](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## Příkaz k otevření
|
||||
|
||||
[položka příkazu](/l/cs/developers/extend/apps/layout/command-menu-items) s
|
||||
`availabilityType: 'RECORD_SELECTION'` se zobrazí při výběru osoby, a
|
||||
otevře komponentu v postranním panelu.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Vyzkoušejte celý tok
|
||||
|
||||
Otevřete **People**, zaškrtněte osobu a stiskněte <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>.
|
||||
"Generovat doklad" se zobrazí, označeno vaší aplikací:
|
||||
|
||||
<Frame caption="Příkaz se zobrazí při výběru osoby.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Příkazové menu s generováním dokumentu" />
|
||||
</Frame>
|
||||
|
||||
Spusťte - komponenta se otevře v postranním panelu. Vyberte si šablonu, klikněte na
|
||||
**Generovat**, a nový záznam země v **dokumentech**.
|
||||
|
||||
<Frame caption="Přední komponenta, načítání šablon a generování kliknutím.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Generovat boční panel dokumentu" />
|
||||
</Frame>
|
||||
|
||||
Každý vygenerovaný dokument zaznamenává vaši aplikaci jako autora:
|
||||
|
||||
<Frame caption="Vytvořeno generátorem dokumentů, stav vygenerován.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="Generovaný záznam dokladu" />
|
||||
</Frame>
|
||||
|
||||
## Náhled dokumentu na jeho stránce s záznamem
|
||||
|
||||
Přední komponenta není pouze pro menu příkazů — jeden můžete připojit jako \*\*kartu na stránce
|
||||
záznamu \*\*. Přidejme kartu \*Náhled \* k záznamu dokumentu, která vykreslí
|
||||
Markdown tělo jako leštěnou, vytisknutelnou stránku.
|
||||
|
||||
Komponenta čte aktuální id záznamu z kontextového kontextu, načte
|
||||
dokument a vykreslí jej. Přední komponenty běží v **sandboxu**, který povoluje pouze
|
||||
bílou listinu HTML tagů — syrovou injekci HTML (`dangerouslySetInnerHTML`) a
|
||||
`\<style>` jsou blokovány — takže Markdown vykreslujeme jako React prvky s inline
|
||||
pomocí malého [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
helper.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Připojit ji s [page layout](/l/cs/developers/extend/apps/layout/page-layouts). Rozložení
|
||||
`RECORD_PAGE` přidává záložky do zobrazení záznamu objektu; widget `FRONT_COMPONENT`
|
||||
v záložce `CANVAS` hostí komponentu:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Otevřete jakýkoliv dokument - záložka \*\*Náhled \*\* jej krásně vykreslí s odkazy na
|
||||
sdílitelnou webovou stránku a PDF:
|
||||
|
||||
<Frame caption="Karta Náhled vykresluje dokument s vloženými styly, plus rychlé odkazy.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Hlavní komponenta prohlížeče dokumentů na záložce záznamu" />
|
||||
</Frame>
|
||||
|
||||
## Upravit šablonu pomocí editoru s bohatým textem
|
||||
|
||||
Šablony vůbec nepotřebují vlastní komponentu. Protože je `body`
|
||||
`RICH_TEXT`, Dvacet již poskytuje plně bohatý textový editor –
|
||||
stejné jako standardní použití objektů poznámky a úkolu. Prostě jsme ji vykreslili na
|
||||
šablonové stránce záznamu.
|
||||
|
||||
Přidejte kartu s `FIELD` widgetem v režimu zobrazení `EDITOR` ukazujícím na pole `body`
|
||||
pomocí `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
Pole `RICH_TEXT` ukládá JSON blok editoru a Markdown
|
||||
projekci. Plynovod pro výrobu čte tyto Markdown projekce, takže
|
||||
zástupné symboly, PDF, a sdílitelná webová stránka všechny nefungují beze změny —
|
||||
viz celý
|
||||
[`template-record. age-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Nyní editoři píší šablony v řádném editoru s bohatým textem:
|
||||
|
||||
<Frame caption="Šablona: Dvacátý nativní textový editor vázaný na pole těla.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Záznam šablony s nativním textovým editorem" />
|
||||
</Frame>
|
||||
|
||||
**Po tomto kroku:** náhled dokumentů krásně a šablony jsou editovatelné
|
||||
v aplikaci. Dále nechte AI agenta generovat je z chatu.
|
||||
|
||||
<Card title="Další: agent AI →" icon="robot" href="/l/cs/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Přidejte agenta a dovednosti, které volají po nástroji.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Datový model
|
||||
icon: database
|
||||
description: Vzor dokumentů a šablon s objekty, polími a vztahem.
|
||||
---
|
||||
|
||||
Naše aplikace potřebuje dva vlastní objekty: **šablony dokumentů** (co psát) a
|
||||
**dokumenty** (generovaný výsledek). Pojďme je definovat.
|
||||
|
||||
Scaffold each entity file with the CLI — has a valid UUID and the right
|
||||
folder for you:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Níže zobrazujeme dokončené soubory.
|
||||
|
||||
<Note>
|
||||
Každý `*_UNIVERSAL_IDENTIFIER` trvale žije v
|
||||
`src/constants/universal-identifiers.ts` a je importován, pokud je použit. snippety
|
||||
níže vynechávají tyto importy pro brevitu - uchovejte je ve svých vlastních souborech.
|
||||
</Note>
|
||||
|
||||
## Objekt šablony
|
||||
|
||||
Šablona má `name`, `body` s `{{placeholders}}`, a `cíl`, který
|
||||
říká, zda je napsán pro osobu nebo společnost. `Těloy` je
|
||||
`RICH_TEXT` pole, takže dvacet jí dává plně bohatý textový editor.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` volba **hodnoty** musí být `UPPER_CASE` (`PERSON`, ne `person`) a
|
||||
`defaultValue` je zabalena do extra uvozovek: `` `'PERSON'` ``. `Štítek` je to, co vidí
|
||||
uživatelé.
|
||||
</Warning>
|
||||
|
||||
## Objekt dokumentu
|
||||
|
||||
Vygenerovaný dokument ukládá vykreslený `content` a `status`. Definujte
|
||||
stejným způsobem s `status` výběrem `DRAFT` / `GENERATED`. Celý soubor:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Propojení se vztahem
|
||||
|
||||
Každý dokument by se měl vrátit ke šabloně, odkud pochází. Vztahy jsou
|
||||
**obousměrné** — definujete obě strany, každý ve vlastním souboru polí.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Druhá strana (`template-documents-relation.field.ts`) je
|
||||
`RelationType.ONE_TO_MANY` pole `documents`, které ukazuje opačnou cestu.
|
||||
Celý vzor viz [Relations](/l/cs/developers/extend/apps/data/relations).
|
||||
|
||||
## Podívejte se na to ve dvaceti letech
|
||||
|
||||
S spuštěním `příze dvacet dev` otevřete **Nastavení → Datový model**. Oba objekty
|
||||
se zobrazí a označují vaší aplikací.
|
||||
|
||||
<Frame caption="Oba vlastní objekty v aplikaci Generátor dokumentů.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Nastavení datového modelu zobrazující dokumenty a šablony dokumentů" />
|
||||
</Frame>
|
||||
|
||||
Vytvořte jednu šablonu pro testování - pojmenujte ji *návrhem prodeje*, nastavte **Cíl** na
|
||||
*Osobní* a vložte tělo s několika zástupnými znaky:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="Záznam šablony. Subjekt uchovává své zástupné znaky, dokud není doklad vygenerován.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="Záznam šablony obchodního návrhu s zástupným tělem" />
|
||||
</Frame>
|
||||
|
||||
**Po tomto kroku:** máte `documentTemplate` a `document` objekty, propojené
|
||||
relací a jednu šablonu, ze které chcete vygenerovat. Dále platí, že logika, která ji naplňuje.
|
||||
|
||||
<Card title="Další: generování dokumentů →" icon="bolt" href="/l/cs/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
Napište logickou funkci, která vyplňuje šablonu.
|
||||
</Card>
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: 2. Generování dokumentů
|
||||
icon: bolt
|
||||
description: Jedna logická funkce, zobrazená jako nástroj AI a akce pracovního postupu.
|
||||
---
|
||||
|
||||
Nyní jádro: [logická funkce](/l/cs/developers/extend/apps/logic/logic-functions)
|
||||
, která načte šablonu a záznam, vyplní zástupné symboly a uloží nový
|
||||
dokument.
|
||||
|
||||
Obchodní logiku napíšeme jednou jako **handler**, pak ji vystavíme pomocí
|
||||
několika spouštěčů. Tato kapitola vrací dva z nich – **nástroj AI** a
|
||||
**akce pracovního postupu**.
|
||||
|
||||
## Pomocník pro vykreslování
|
||||
|
||||
Uchovávejte čistou logiku ve svém vlastním souboru, takže je snadné testovat jednotku. Toto zarovná záznam
|
||||
do `{{dot.path}}` tokenů a nahradí je.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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>
|
||||
Protože tento soubor nemá žádné vedlejší účinky, můžete jej zakrýt rychlými jednotkovými testy
|
||||
(`yarn test:unit`). Viz [Testing](/l/cs/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## Řidič
|
||||
|
||||
Pracovník používá vygenerovaná [`CoreApiClient`](/l/cs/developers/extend/apps/logic/logic-functions)
|
||||
pro čtení a zápis dat CRM. Načte šablonu, načte cílový záznam, vyplní
|
||||
tělo a vytvoří `dokument`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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` spouští jiný dotaz na Osobu vs. společnost a flattens
|
||||
výsledek — viz
|
||||
[`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).
|
||||
|
||||
## Vystavit ji jako nástroj a akce pracovního postupu
|
||||
|
||||
Jediný `defineLogicFunction` může obsahovat několik spouštěčů. Zde `toolTriggerSettings`
|
||||
dělá volatelné AI agenty a `workflowActionTriggerSettings` jej promění v
|
||||
krok na vizuálním workflow. Obě popisují svůj vstup se schématem JSON.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
Schéma vstupů je schéma prostého JSON popisující `templateId` a `recordId` —
|
||||
viz [`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).
|
||||
|
||||
## Povolit přístup
|
||||
|
||||
Logické funkce běží jako role aplikace. Potřebuje přečíst šablony a záznamy
|
||||
a vytvořit dokumenty, aby v `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
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` umožňuje funkci nahrát vygenerovaný PDF v další sekci.
|
||||
Pro jemnozrnná oprávnění viz [Roles](/l/cs/developers/extend/apps/config/roles).
|
||||
|
||||
## Připojit skutečný soubor PDF
|
||||
|
||||
Vykreslené textové pole je užitečné, ale uživatelé chtějí skutečný dokument. Vygenerujme
|
||||
**PDF** a uložme jej do záznamu jako soubor ke stažení.
|
||||
|
||||
Nejprve zadejte objekt `document` pole `FILES` pro držení PDF. Aplikace nahrávají
|
||||
do svých **vlastních** polí souborů, takže toto pole slouží jako trasa nahrávání:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Nyní vytvořte tento PDF. Aplikace je skutečný projekt uzlu, takže můžete přidat libovolný balíček npm
|
||||
, který potřebujete a importovat jako kdekoli jinde. Používáme **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
k nakreslení PDF a **[marked](https://marked.js.org/)** k rozepsání těla Markdown
|
||||
– CLI je nainstaluje do běhu funkce pro vás:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
Úplný pomocník je
|
||||
[`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).
|
||||
Parazuje Markdown do tokenů s `označeným. exer`, pak je stanoví s
|
||||
pdf-lib: skutečné nadpisy, **tučné**/*italic* běhy, odrážky a číslované seznamy,
|
||||
blokuje kotace a pravidla – leštěné, vícestránkové A4 vykreslování šablony
|
||||
samotné, nikoli zeď textu.
|
||||
|
||||
<Frame caption="Vygenerovaný PDF: skutečné typografie a Markdown formátování, vykreslování těla šablony.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="Leštěný, tržně generovaný PDF" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
Vestavěné fonty ve formátu pdf-lib, používají WinAnsi kódování, takže západoevropské akcenty vyřadí
|
||||
z krabice; pomocník mapy chytrých uvozovek a pomlček a kapne znaky, které nemůže kódovat
|
||||
. Vykreslování nelatinských skriptů (čínské, arabské, cyrilice) by znamenalo, že by
|
||||
vložil písmo Unicode.
|
||||
</Note>
|
||||
|
||||
Pak ho nahrajte a uložte odkaz do záznamu. `uploadFile` routy bytů
|
||||
do vašeho pole souborů vlastněných aplikací; vrácený `id` je to, co uložíte:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Vygenerovaný dokument má nyní stažitelný PDF:
|
||||
|
||||
<Frame caption="Vygenerovaný PDF uložený v poli Soubor dokumentu.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="Záznam dokladu s vygenerovaným souborem PDF" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` se zaměřuje pouze na **soubory vlastněné aplikací** (takže nahrávání vždy vyžaduje aplikaci
|
||||
, která toto pole vlastní, plus proměnnou role `UPLOAD_FILE`). To je důvod, proč PDF
|
||||
přistane na vlastním `file` pole záznamu — stejný vzor
|
||||
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
používá pro nahrávání.
|
||||
</Note>
|
||||
|
||||
**Po tomto kroku:** každý vygenerovaný dokument má skutečný, stahovatelný PDF. Ale
|
||||
nemůže *volat* generátor z uživatelského rozhraní - pro to potřebujeme HTTP trasu.
|
||||
|
||||
<Card title="Další: HTTP trasy →" icon="zeměkoule" href="/l/cs/developers/extend/apps/tutorials/document-generator/http-routes">
|
||||
Zapněte funkci přes HTTP a vykreslete dokumenty jako webové stránky.
|
||||
</Card>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: 3. HTTP trasy
|
||||
icon: globe
|
||||
description: Spuštění funkce přes HTTP a vykreslení dokumentů jako webových stránek.
|
||||
---
|
||||
|
||||
Stejný handler může také odpovědět na HTTP požadavky. Přidáme dva trasy:
|
||||
|
||||
* **POST** koncový bod uživatelského rozhraní volá, aby vytvořilo dokument, a
|
||||
* veřejný **GET** koncový bod, který vykresluje dokument jako tiskovou webovou stránku.
|
||||
|
||||
Oba použijte `httpRouteTriggerSettings`. Trasy aplikací jsou vedeny pod `/s` na vašem
|
||||
serveru (např. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## POST trasa – generovat na požádání
|
||||
|
||||
Toto znovu používá `generateDocumentHandler`, takže není logika opakovat - jen tenký
|
||||
adaptér, který čte tělo požadavku.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Sdílený handler vrátí navržený `status` při selhání, takže trasa může
|
||||
odpovědět správným `4xx`/`5xx` kódem. `isAuthRequd: true` znamená, že volající
|
||||
musí prezentovat platný token — přední komponenta v další kapitole automaticky prochází přístupovým tokenem uživatele
|
||||
.
|
||||
|
||||
## Cesta GET – vykreslit jako webovou stránku
|
||||
|
||||
Chcete-li vrátit HTML místo JSON, zabalte tělo do `Response` pomocí
|
||||
`Content-Type` hlavičky. Tato cesta je veřejná (`isAuthRequd: false`), takže
|
||||
generovaný dokument může být sdílen jako odkaz.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` vykresluje Markdown tělo na HTML (s [marked](https://marked.js.org/),
|
||||
zmaskoval) a klesne do čistého, vytisknutelná stránka, která zobrazuje pouze obsah šablony
|
||||
– stejný vzhled jako PDF a náhled v aplikaci.
|
||||
[Viz pomocník] (https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Vyzkoušejte
|
||||
|
||||
Pomocí šablony a osoby ve vašem pracovním prostoru zavolejte na trasu (získejte token z
|
||||
**Nastavení → API a Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Otevřete vrácený dokument ve vašem prohlížeči:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="Veřejná trasa GET vykresluje dokument jako tiskovou stránku.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Vykreslená webová stránka dokumentu" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
Během testování s
|
||||
`yarn twenty dev:function:logs`, nebo vyvolat přímo s
|
||||
`yarn twenty dev:exec`.
|
||||
</Tip>
|
||||
|
||||
**Po tomto kroku:** aplikace může generovat dokumenty přes HTTP a sloužit jako
|
||||
webové stránky. Nyní ho použijeme bez `curl`.
|
||||
|
||||
<Card title="Další: budování UI →" icon="table-columns" href="/l/cs/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Zobrazení, navigace, příkaz a přední součást.
|
||||
</Card>
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: "Tutoriál: Generátor dokumentů"
|
||||
icon: wand-magic-sparkles
|
||||
description: Vytvořte skutečnou aplikaci Twenty, která generuje personalizované dokumenty z vašich CRM dat.
|
||||
---
|
||||
|
||||
V tomto tutoriálu vytvoříte **Generátor dokumentů** — aplikaci, která převádí znovu použitelná šablonová nastavení na personalizované dokumenty pomocí dat, která už máte ve svém CRM.
|
||||
|
||||
Napište šablonu jednou pomocí `{{placeholders}}` a poté vygenerujte vyplněný dokument pro libovolnou osobu nebo společnost jedním kliknutím — z příkazové nabídky, od AI agenta nebo z workflow.
|
||||
|
||||
<Frame caption="Jeden šablonový dokument, vygenerovaný pro konkrétní osobu, otevřený jako stránka připravená k tisku.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Vygenerovaný dokument obchodního návrhu" />
|
||||
</Frame>
|
||||
|
||||
## Co se naučíte
|
||||
|
||||
Každá kapitola přidá jednu schopnost. Na konci se dotknete většiny SDK.
|
||||
|
||||
| Kapitola | Schopnost | Referenční dokumentace |
|
||||
| ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| [1. Datový model](/l/cs/developers/extend/apps/tutorials/document-generator/data-model) | Objekty, pole a relace | [Data](/l/cs/developers/extend/apps/data/overview) |
|
||||
| [2. Generování dokumentů](/l/cs/developers/extend/apps/tutorials/document-generator/generating-documents) | Logická funkce (nástroj AI + akce workflow), která vyplní šablonu v Markdownu a připojí vyladěné PDF | [Logické funkce](/l/cs/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. HTTP trasy](/l/cs/developers/extend/apps/tutorials/document-generator/http-routes) | Poskytování JSONu a sdílené HTML stránky z tras | [Logické funkce](/l/cs/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Vytváření uživatelského rozhraní](/l/cs/developers/extend/apps/tutorials/document-generator/building-the-ui) | Zobrazení, navigace, příkazová nabídka a front komponenty, které zobrazují náhled dokumentu a upravují šablonu | [Rozvržení](/l/cs/developers/extend/apps/layout/overview) |
|
||||
| [5. Agent AI](/l/cs/developers/extend/apps/tutorials/document-generator/ai-agent) | Agent + dovednost | [Dovednosti a agenti](/l/cs/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Publikování](/l/cs/developers/extend/apps/tutorials/document-generator/publishing) | Dodejte ho na marketplace | [Publikování](/l/cs/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Předpoklady
|
||||
|
||||
Měli byste mít hotový [rychlý start](/l/cs/developers/extend/apps/getting-started/quick-start):
|
||||
lokální server Twenty běžící na portu `2020` a CLI k němu přihlášené.
|
||||
|
||||
Pokud ne, nyní si jeden vytvořte a spusťte:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Raději byste si přečetli hotový kód? Celá aplikace je v
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Každý úryvek níže je z ní zkopírovaný.
|
||||
</Note>
|
||||
|
||||
## Jak do sebe aplikace zapadá
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="Šablona se zástupnými symboly je převedena na vyladěný dokument s PDF souborem, spuštěná z příkazové nabídky, agenta AI, workflow nebo sdíleného odkazu" />
|
||||
</Frame>
|
||||
|
||||
**Šablonu** napíšete jednou v rich-text editoru pomocí `{{placeholders}}`. Výběr
|
||||
šablony a CRM záznamu vyplní zástupné symboly a uloží vyladěný
|
||||
**dokument** (s PDF souborem). Všechno ostatní — příkazová nabídka, agent AI,
|
||||
krok workflow, sdílený odkaz — je jen jiný způsob, jak spustit ten
|
||||
jeden generátor.
|
||||
|
||||
## Udržujte tento cyklus v chodu
|
||||
|
||||
Nechte `yarn twenty dev` běžet v terminálu po celý návod. Pokaždé, když
|
||||
přidáte nebo upravíte soubor pod `src/`, během několika sekund se znovu synchronizuje s vaším serverem, takže můžete sledovat, jak se každá schopnost objevuje v uživatelském rozhraní, jak ji vytváříte.
|
||||
|
||||
<Card title="Začněte vytvářet →" icon="database" href="/l/cs/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
Kapitola 1: model dokumentů a šablon.
|
||||
</Card>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: 6. Publikování
|
||||
icon: rocket
|
||||
description: Přidejte metadata tržiště a publikujte svou aplikaci.
|
||||
---
|
||||
|
||||
Vaše aplikace funguje. Posledním krokem je popsat jej pro tržiště a publikovat.
|
||||
|
||||
## Přidat metadata tržiště
|
||||
|
||||
[application config](/l/cs/developers/extend/apps/config/application) nese
|
||||
identitu, která se objeví v tržišti: autor, kategorie, logo a podpora
|
||||
. Vložte logo do `public/` a odkazujte na `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/cs/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Výchozí role je deklarována s `defineApplicationRole()` ve svém vlastním souboru – vy
|
||||
zde již neprojdete `defaultRoleUniversalIdentifier`.
|
||||
</Tip>
|
||||
|
||||
Přidejte také klíčové slovo `dvacet app` do `package.json` tak, aby byla aplikace nalezena:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Přidat galerii snímků obrazovky
|
||||
|
||||
tržiště se prodává se snímky obrazovky. Přetáhněte několik PNGů do
|
||||
`public/gallery/` a odkazujte na ně `screenshots` — vykreslují se jako galerie
|
||||
na stránce seznamu.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Lead with the payoff: make the first screenshot the finished result (generated
|
||||
document), then show how it is triggered and authored. (Automatic Copy) Použít křiklavé, vysoké rozlišení
|
||||
snímky — jsou to první věc, kterou uživatel vidí.
|
||||
</Tip>
|
||||
|
||||
Dejte `README.md` stejné ošetření — je to přední stránka na npm a GitHub.
|
||||
Otevřete pomocí nabídky hodnot a snímku obrazovky, vyberte titulky,
|
||||
ponechte podrobnosti sestavení pod složkou.
|
||||
|
||||
## Zkontrolovat před odesláním
|
||||
|
||||
Spustit tytéž brány CI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
Suchý běh vypíše přesně to, co by se změnilo na serveru bez jeho použití –
|
||||
je dobrá závěrečná kontrola. Viz
|
||||
[Testing](/l/cs/developers/extend/apps/operations/testing) a
|
||||
[Synchronizace a obnovy](/l/cs/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Publikovat
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` ve výchozím nastavení sestavuje a publikuje do npm; `--private` nahraje
|
||||
tarball do privátního registru 20 serverů. Chcete-li zobrazit publikovanou aplikaci
|
||||
v obchodě instance, spustí synchronizaci katalogu:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Úplné podrobnosti a kontrolní seznam vydání:
|
||||
[Publishing](/l/cs/developers/extend/apps/operations/publishing).
|
||||
|
||||
## Vytvořili jste aplikaci 🎉
|
||||
|
||||
V šesti kapitolách jste použili většinu povrchu SDK:
|
||||
|
||||
* **Objekty, pole a vztahy** modelovat data
|
||||
* **Logická funkce** obklopená jako **nástroj AI**, **akce pracovního postupu** a **cesty HTTP**
|
||||
* **Zobrazení, navigace, příkaz a přední složka** pro UI
|
||||
* **agent + dovednost** pro generování přirozeného jazyka
|
||||
* **Metadata tržiště** a tok zveřejnění
|
||||
|
||||
Dokončená aplikace je na
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Kde jít dál
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Odkaz na údaje" icon="database" href="/l/cs/developers/extend/apps/data/overview">
|
||||
Každý typ pole, vztah a index.
|
||||
</Card>
|
||||
<Card title="Logický odkaz" icon="bolt" href="/l/cs/developers/extend/apps/logic/overview">
|
||||
Cron a databázová událost, klíčový obchod, připojení OAuth.
|
||||
</Card>
|
||||
<Card title="Odkaz na rozložení" icon="table-columns" href="/l/cs/developers/extend/apps/layout/overview">
|
||||
Rozvržení stránky, widgety hlavního panelu a další povrchy uživatelského rozhraní.
|
||||
</Card>
|
||||
<Card title="Operace" icon="rocket" href="/l/cs/developers/extend/apps/operations/overview">
|
||||
CLI, testování, dálkové ovládání a CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Začínáme"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Návod"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Konfigurace"
|
||||
},
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: Lassen Sie einen Agenten Dokumente mit Hilfe Ihres Tools aus einem Chat generieren.
|
||||
---
|
||||
|
||||
Weil `generate-document` als **Tool** exponiert wird, kann ein KI-Agent es aufrufen.
|
||||
Fügen wir einen Agenten und eine Fertigkeit hinzu, damit Benutzer einfach *"einen Vorschlag für
|
||||
Jeffery Griffin"* erstellen können.
|
||||
|
||||
## Die Fähigkeit
|
||||
|
||||
Eine [skill](/l/de/developers/extend/apps/logic/skills-and-agents) ist wiederverwendbare
|
||||
Anleitung — Wissen, das Sie Agenten anhängen. Unser lehrt das Modell, wie man das Werkzeug
|
||||
benutzt.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## Der Agent
|
||||
|
||||
Ein [agent](/l/de/developers/extend/apps/logic/skills-and-agents) paßt einen Prompt mit einem
|
||||
Modell. Setze `responseFormat` explizit, um eine Build-Warnung zu vermeiden.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
Der Agent kann das Werkzeug nur aufrufen, wenn es seine Rolle erlaubt. Wir haben bereits
|
||||
`canAccessAllTools: true` und `canBeAssignedToAgents: true` über die Rolle der App in
|
||||
[Chapter 2](/l/de/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Testen
|
||||
|
||||
Öffne einen Chat mit dem **Dokumenten-Assistent** und ersuche ihn, ein Dokument für eine
|
||||
-Person in deinem CRM zu erstellen. Es findet den Datensatz, ruft `generate-document` auf und meldet das erstellte Dokument
|
||||
zurück — das nun in der Ansicht **Dokumente** erscheint
|
||||
gleicht dem Kommandomenü und dem Workflow-Pfad.
|
||||
|
||||
Das ist die Auszahlung der Logik als Werkzeug: **eine Funktion, viele Eingangstüren** —
|
||||
Befehlsmenü, HTTP, Workflow-Schritt und jetzt natürliche Sprache.
|
||||
|
||||
**Nach diesem Schritt:** ist die App komplett und wirklich nützlich. Zeit bis
|
||||
es verschickt wird.
|
||||
|
||||
<Card title="Weiter: Publizieren →" icon="rocket" href="/l/de/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
Marktplatz-Metadaten hinzufügen und veröffentlichen.
|
||||
</Card>
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: 4. Erstelle die UI
|
||||
icon: table-columns
|
||||
description: Views, Sidebar Navigation, ein Befehl und Frontkomponenten.
|
||||
---
|
||||
|
||||
Im Moment sind die Objekte nur über Einstellungen erreichbar. Geben wir der App eine
|
||||
echte Präsenz in der Benutzeroberfläche: Listenansichten, Seitenleisteneinträge, ein Ein-Klick-
|
||||
**Dokumenten** Befehl generieren, eine Front-Komponente der Eintragsseite für **Vorschau** eines
|
||||
Dokuments und einen nativen Reiter für den Volltext **Editor** für Vorlagen.
|
||||
|
||||
## Ansichten und Navigation
|
||||
|
||||
Eine [view](/l/de/developers/extend/apps/layout/views) ist eine gespeicherte Liste eines bestimmten Objekts.
|
||||
Ein [Navigationsmenüeintrag](/l/de/developers/extend/apps/layout/navigation-menu-items)
|
||||
bringt diese Ansicht in die Sidebar.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Fügen Sie das gleiche Paar für Vorlagen hinzu. Beide zeigen nun in der Seitenleiste:
|
||||
|
||||
<Frame caption="Dokumente und Vorlagen in der Seitenleiste mit dem generierten Dokument.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Dokumentenansicht mit einem generierten Dokument" />
|
||||
</Frame>
|
||||
|
||||
## Eine Frontkomponente
|
||||
|
||||
Eine [Frontkomponent](/l/de/developers/extend/apps/layout/front-components) ist eine Reaktions-
|
||||
Komponente mit Sandkasten in Twenty. Wir liest den ausgewählten Datensatz ein, lädt die
|
||||
Personen-Vorlagen über `CoreApiClient` und POSTs aus dem letzten
|
||||
Kapitel.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Stil mit Inline-CSS-Variablen (`var(--t-color-blue)`), nicht aus
|
||||
`twenty-ui` importiert. Das SDK verspottet das Paket während des Builds, so dass Modul-Level-Importe von
|
||||
Theme-Konstanten `undefiniert` wären. Siehe
|
||||
[vollständige Komponente](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## Ein Befehl um es zu öffnen
|
||||
|
||||
Ein [Commandmenu item](/l/de/developers/extend/apps/layout/command-menu-items) mit
|
||||
`availabilityType: 'RECORD_SELECTION'` wird angezeigt, wenn eine Person ausgewählt ist, und
|
||||
öffnet die Komponente in der Seitenleiste.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Den gesamten Fluss testen
|
||||
|
||||
Öffne **People**, markiere eine Person und drücke <kbd>⌘K</kbd> / <kbd>Strg K</kbd>.
|
||||
"Dokument erstellen" erscheint, mit Ihrer App markiert:
|
||||
|
||||
<Frame caption="Der Befehl wird angezeigt, wenn eine Person ausgewählt ist.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Befehlsmenü mit Dokument generieren" />
|
||||
</Frame>
|
||||
|
||||
Führen Sie es aus — Ihre Komponente öffnet sich in der Seitenleiste. Wähle eine Vorlage, klicke auf
|
||||
**Generieren**, und ein neues Datensatzland in **Dokumenten**.
|
||||
|
||||
<Frame caption="Die Frontkomponente, das Laden von Vorlagen und das Generieren auf Klick.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Dokumentseite erstellen" />
|
||||
</Frame>
|
||||
|
||||
Jedes generierte Dokument speichert Ihre App als Autor auf:
|
||||
|
||||
<Frame caption="Erstellt vom Dokumentengenerator, Status generiert.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="Ein generierter Dokumentensatz" />
|
||||
</Frame>
|
||||
|
||||
## Vorschau eines Dokuments auf seiner Aufzeichnungsseite
|
||||
|
||||
Eine Frontkomponente ist nicht nur für Kommandomenüs – Sie können sie als **Tab auf einer
|
||||
Aufnahmeseite einhängen**. Fügen wir dem Dokument-Datensatz einen Tab *Vorschau* hinzu, der den
|
||||
Markdown-Text als polierte, druckbare Seite darstellt.
|
||||
|
||||
Die Komponente liest die aktuelle Datensatz-ID aus ihrem Ausführungskontext, lädt das
|
||||
-Dokument und gibt sie aus. Frontkomponenten laufen in einer **Sandbox**, die nur eine
|
||||
Whitelist von HTML-Tags erlaubt — Roh-HTML-Einspritzung (`dangerouslySetInnerHTML`) und
|
||||
`\<style>` werden blockiert — so dass wir Markdown als React-Elemente mit Inline-
|
||||
Styles über einen kleinen [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
Helfer machen.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Mounten Sie es mit einem [page layout](/l/de/developers/extend/apps/layout/page-layouts). Ein
|
||||
`RECORD_PAGE` Layout fügt Tabs zur Datensatzansicht eines Objekts hinzu; ein `FRONT_COMPONENT`
|
||||
Widget in einem `CANVAS` Tab Hosts die Komponent:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Öffne jedes Dokument — ein **Vorschau** Tab macht es wunderschön, mit Links zur
|
||||
freigegebenen Webseite und dem PDF:
|
||||
|
||||
<Frame caption="Auf der Registerkarte Vorschau wird das Dokument mit Inline-Styles und Schnelllinks dargestellt.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Front-Komponente des Dokuments in einem Reiter der Datensatzseite" />
|
||||
</Frame>
|
||||
|
||||
## Vorlage mit dem Rich-Text-Editor bearbeiten
|
||||
|
||||
Templates benötigen überhaupt keine eigene Komponente. Da der `body` ein
|
||||
`RICH_TEXT`-Feld ist, stellt Twenty bereits einen vollständigen Rich-Text-Editor dafür bereit – denselben, den auch die Standardobjekte Note und Task verwenden. Wir Oberflächen es nur auf der
|
||||
Template-Datensatzseite.
|
||||
|
||||
Füge einen Tab mit einem `FIELD` Widget im `EDITOR` Anzeigemodus hinzu und zeigt auf das Feld `body`
|
||||
über `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
Ein `RICH_TEXT` Feld speichert sowohl den Editorblock JSON als auch eine Markdown
|
||||
Projektion. Die Generation-Pipeline liest diese Markdown-Projektion, also
|
||||
Platzhalter, die PDF, und die freigebbare Webseite arbeiten alle unverändert —
|
||||
sehen Sie den vollständigen
|
||||
[`template-record. age-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Jetzt schreiben Editoren Vorlagen in einem korrekten Rich-Text-Editor:
|
||||
|
||||
<Frame caption="Die Registerkarte Template: Der native Rich-Text-Editor von 20 ist an das Bodyfeld gebunden.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Vorlageneintrag mit dem nativen Rich-Text-Editor-Tab" />
|
||||
</Frame>
|
||||
|
||||
**Nach diesem Schritt:** Dokumente Vorschau wunderschön und Vorlagen sind editierbar
|
||||
in-app. Lassen Sie als nächstes einen AI Agenten aus einem Chat generieren.
|
||||
|
||||
<Card title="Weiter: ein KI-Agent →" icon="robot" href="/l/de/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Fügen Sie einen Agenten und eine Fertigkeit hinzu, die Ihr Werkzeug aufruft.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Datenmodell
|
||||
icon: database
|
||||
description: Musterdokumente und Vorlagen mit Objekten, Feldern und einer Relation.
|
||||
---
|
||||
|
||||
Unsere App benötigt zwei benutzerdefinierte Objekte: **Dokumentvorlagen** (was geschrieben werden soll) und
|
||||
**Dokumente** (das generierte Ergebnis). Legen wir sie fest.
|
||||
|
||||
Jede Entitäts-Datei mit dem CLI entpacken — es erzeugt eine gültige UUID und den richtigen
|
||||
-Ordner für Sie:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Unten zeigen wir die fertigen Dateien.
|
||||
|
||||
<Note>
|
||||
Jede `*_UNIVERSAL_IDENTIFIER` Konstante lebt in
|
||||
`src/constants/universal-identifiers.ts` und wird importiert, wo verwendet. Die Snippets
|
||||
unten lassen diese importieren, um kurz zu sein – halten Sie sie in Ihren eigenen Dateien.
|
||||
</Note>
|
||||
|
||||
## Das Template-Objekt
|
||||
|
||||
Eine Vorlage hat einen `name`, einen `body` mit `{{placeholders}}`, und ein `target`, das
|
||||
sagt, ob es für eine Person oder ein Unternehmen geschrieben wurde. Das Feld `body` ist ein
|
||||
`RICH_TEXT` Feld. Zwanzig gibt ihm also einen vollwertigen Rich-Text-Editor.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` Option **Werte** muss `UPPER_CASE` (`PERSON`, nicht `person`) sein und die
|
||||
`defaultValue` ist in extra Anführungszeichen eingewickelt: `` `'PERSON'` ``. Das `label` ist das, was
|
||||
Benutzer sehen.
|
||||
</Warning>
|
||||
|
||||
## Das Dokumentenobjekt
|
||||
|
||||
Das generierte Dokument speichert den gerenderten `content` und einen `status`. Definiere es
|
||||
auf die gleiche Weise, mit einer `status` Auswahl von `DRAFT` / `GENERATED`. Vollständige Datei:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Verknüpfung mit einer Beziehung
|
||||
|
||||
Jedes Dokument sollte auf die Vorlage verweisen, aus der es stammt. Beziehungen sind
|
||||
**bidirectional** — Sie definieren beide Seiten, jede in ihrer eigenen Felddatei.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Die andere Seite (`template-documents-relation.field.ts`) ist ein
|
||||
`RelationType.ONE_TO_MANY` Feld mit dem Namen `documents`, das den entgegengesetzten Weg weist.
|
||||
Siehe [Relations](/l/de/developers/extend/apps/data/relations) für das vollständige Muster.
|
||||
|
||||
## Sehen Sie es in 20
|
||||
|
||||
Wenn `yarn twenty dev` läuft, öffnen Sie **Einstellungen → Datenmodell**. Beide Objekte erscheinen
|
||||
, markiert mit deiner App.
|
||||
|
||||
<Frame caption="Beide benutzerdefinierte Objekte, die der Document Generator App gehören.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Datenmodell-Einstellungen zeigen Dokumente und Dokumentvorlagen an" />
|
||||
</Frame>
|
||||
|
||||
Erstellen Sie eine Vorlage zum Testen mit — Name sie *Verkaufsvorschlag*, setzen Sie **Ziel** auf
|
||||
*Person*, und fügen Sie einen Körper mit ein paar Platzhaltern ein:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="Ein Vorlageneintrag. Der Körper behält seine Platzhalter bis ein Dokument generiert ist.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="Ein Verkaufsvorschlagvorlagen-Eintrag mit Platzhalterkörper" />
|
||||
</Frame>
|
||||
|
||||
**Nach diesem Schritt:** hast du `documentTemplate` und `document` Objekte, verlinkt von
|
||||
eine Relation, aus der eine Vorlage generiert werden kann. Als nächstes folgt die Logik, die sie ausfüllt.
|
||||
|
||||
<Card title="Weiter: Dokumente generieren →" icon="bolt" href="/l/de/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
Schreibe die logische Funktion, die das Template füllt.
|
||||
</Card>
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
---
|
||||
title: 2. Dokumente generieren
|
||||
icon: bolt
|
||||
description: Eine Logikfunktion, die als KI-Werkzeug und Workflow-Aktion dargestellt wird.
|
||||
---
|
||||
|
||||
Jetzt der Kern: eine [Logikfunktion](/l/de/developers/extend/apps/logic/logic-functions)
|
||||
, die eine Vorlage und einen Datensatz lädt, die Platzhalter füllt und ein neues
|
||||
Dokument speichert.
|
||||
|
||||
Wir werden die Geschäftslogik einmal als **Handler** schreiben und sie dann durch
|
||||
mehrere Trigger ausblenden. Dieses Kapitel verbindet zwei davon – ein **AI-Tool** und eine
|
||||
**Workflow-Aktion**.
|
||||
|
||||
## Der Rendering-Helfer
|
||||
|
||||
Behalten Sie die reine Logik in ihrer eigenen Datei, so dass es leicht zu Unit-Test ist. Dies flattert einen Eintrag
|
||||
in `{{dot.path}}` Token und ersetzt diese.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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>
|
||||
Da diese Datei keine Nebenwirkungen hat, können Sie sie mit schnellen Einheitstests
|
||||
(`Garn test:unit`) bedecken. Siehe [Testing](/l/de/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## Der Handler
|
||||
|
||||
Der Handler verwendet die generierte [`CoreApiClient`](/l/de/developers/extend/apps/logic/logic-functions)
|
||||
zum Lesen und Schreiben von CRM-Daten. Es lädt die Vorlage, lädt den Zieldatensatz, füllt den Körper
|
||||
und erstellt ein "Dokument".
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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` führt eine andere Abfrage für eine Person gegen eine Firma aus und flattert
|
||||
das Ergebnis — siehe
|
||||
[`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).
|
||||
|
||||
## Als Werkzeug und Workflow-Aktion anzeigen
|
||||
|
||||
Eine einzelne `defineLogicFunction` kann mehrere Trigger tragen. Hier macht `toolTriggerSettings`
|
||||
es von KI-Agenten aufrufbar und `workflowActionTriggerSettings` verwandelt es in einen
|
||||
Schritt im visuellen Workflow-Builder. Beide beschreiben ihre Eingabe mit einem JSON-Schema.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
Das Eingabeschema ist ein einfaches JSON-Schema, das `templateId` und `recordId` —
|
||||
siehe [`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).
|
||||
|
||||
## Zugriff gewähren
|
||||
|
||||
Die logischen Funktionen laufen als Rolle der App. Es muss Vorlagen lesen und Datensätze
|
||||
und Dokumente erstellen, also erlauben Sie dies in `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
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` lässt die Funktion das generierte PDF im nächsten Abschnitt hochladen.
|
||||
Siehe [Roles](/l/de/developers/extend/apps/config/roles) für feinkörnige Berechtigungen.
|
||||
|
||||
## Eine echte PDF-Datei anhängen
|
||||
|
||||
Ein gerendertes Textfeld ist nützlich, aber Benutzer wollen ein echtes Dokument. Generieren wir ein
|
||||
**PDF** und speichern es als herunterladbare Datei.
|
||||
|
||||
Gib zuerst das `document` Objekt ein `FILES`-Feld um die PDF zu halten. Apps laden
|
||||
in ihre **eigenen** Datei-Felder hoch. Daher ist dieses Feld was das Hochladen leitet:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Rendern Sie nun diese PDF. Eine App ist ein echtes Knoten-Projekt, so dass Sie jedes npm
|
||||
Paket hinzufügen und es wie überall sonst importieren können. Wir verwenden **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
um die PDF zu zeichnen und **[marked](https://marked.js.org/)** um den Markdown
|
||||
Körper zu analysieren — der CLI installiert sie in die Laufzeit der Funktion für Sie:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
Der ganze Helfer ist
|
||||
[`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).
|
||||
Es analysiert Markdown in Tokens mit `markiert. exer`, legt sie dann mit
|
||||
pdf-lib: echte Überschriften, **fett**/*kursiv* läuft, Kugel und nummerierte Listen,
|
||||
Blockzitate und Regeln — eine polierte, mehrseitige A4 Darstellung der Vorlage
|
||||
selbst statt einer Wand aus Text.
|
||||
|
||||
<Frame caption="Die generierte PDF: echte Typografie und Markdown Formatierung, Darstellung des Template-Körpers.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="Ein poliertes, markierbares PDF" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
Die in pdf-lib integrierten Schriftarten verwenden WinAnsi-Codierung, sodass westeuropäische Akzente direkt funktionieren; der Helfer ordnet typografische Anführungszeichen und Gedankenstriche zu und verwirft Zeichen, die er nicht codieren kann. Das Rendern nicht-lateinischer Skripte (chinesisch, arabisch, kyrillisch) würde bedeuten, dass
|
||||
eine Unicode-Schriftart einbettet.
|
||||
</Note>
|
||||
|
||||
Dann laden Sie es hoch und speichern Sie die Referenz auf dem Datensatz. `uploadFile` ruft Bytes
|
||||
in dein app-owned Datei-Feld zurück; die zurückgegebene `id` ist, was du speicherst:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Das generierte Dokument enthält jetzt ein herunterladbares PDF:
|
||||
|
||||
<Frame caption="Die erzeugte PDF, die im Dateifeld des Dokuments gespeichert ist.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="Ein Datensatz mit einer generierten PDF-Datei" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` zielt nur auf **app-besitzer** Datei-Felder (Uploads erfordern also immer eine
|
||||
-App, die das Feld besitzt, plus das `UPLOAD_FILE` Rollenflag). Aus diesem Grund landet das PDF
|
||||
auf das eigene Feld `file` des Datensatzes — das gleiche Muster wie die
|
||||
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
verwendet für Aufnahmen.
|
||||
</Note>
|
||||
|
||||
**Nach diesem Schritt:** Jedes generierte Dokument hat eine echte, herunterladbare PDF. Aber
|
||||
nichts kann den Generator noch von der Oberfläche *aufrufen* – dafür benötigen wir eine HTTP-Route.
|
||||
|
||||
<Card title="Weiter: HTTP-Routen →" icon="globe" href="/l/de/developers/extend/apps/tutorials/document-generator/http-routes">
|
||||
Servieren Sie die Funktion über HTTP und rendern Sie Dokumente als Webseiten.
|
||||
</Card>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: 3. HTTP-Routen
|
||||
icon: globe
|
||||
description: Trigger die Funktion über HTTP und rendern Sie Dokumente als Webseiten.
|
||||
---
|
||||
|
||||
Der gleiche Handler kann auch HTTP-Anfragen beantworten. Wir werden zwei Routen hinzufügen:
|
||||
|
||||
* ein **POST** Endpunkt der UI-Aufrufe, um ein Dokument zu generieren, und
|
||||
* ein öffentlicher **GET** Endpunkt, der ein Dokument als druckbare Webseite darstellt.
|
||||
|
||||
Beide verwenden `httpRouteTriggerSettings`. App-Routen werden unter `/s` auf Ihrem
|
||||
20 Server bedient (z.B. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## POST-Route — bei Bedarf generieren
|
||||
|
||||
Dies verwendet `generateDocumentHandler`, also gibt es keine Logik zu wiederholen — nur ein dünner
|
||||
-Adapter, der den Request-Körper liest.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Der Shared-Handler gibt einen vorgeschlagenen `status` bei einem Fehler zurück, so dass die Route
|
||||
mit einem richtigen `4xx`/`5xx` Code antworten kann. `isAuthRequired: true` bedeutet, dass der Anrufer
|
||||
ein gültiges Token vorweisen muss — die vorderste Komponente im nächsten Kapitel übergeht automatisch das Zugriffstoken des
|
||||
Benutzers.
|
||||
|
||||
## GET-Route — als Webseite rendern
|
||||
|
||||
Um HTML anstelle von JSON zurückzugeben, wickeln Sie den Körper in eine `Response` mit einem
|
||||
`Content-Type` Header. Diese Route ist öffentlich (`isAuthRequired: falsch`), so dass ein
|
||||
generiertes Dokument als Link freigegeben werden kann.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` stellt den Markdown-Text in HTML dar (mit [marked](https://marked.js.org/),
|
||||
bereinigt) und lässt ihn in ein Reinigen druckbare Seite, die nur die Inhalte der Vorlage
|
||||
zeigt — das gleiche Aussehen wie die PDF und die In-App-Vorschau.
|
||||
[Siehe den Helfer](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Testen
|
||||
|
||||
Mit einer Vorlage und einer Person im Arbeitsbereich rufen Sie die Route an (Benutzen Sie ein Token von
|
||||
**Einstellungen → APIs & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Öffnen Sie das zurückgegebene Dokument in Ihrem Browser:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="Die öffentliche GET-Route macht das Dokument als druckbare Seite.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Eine gerenderte Dokumenten-Webseite" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
Sie können auch Protokolle einer Funktion beim Testen mit
|
||||
`yarn zwanzig dev:function:logs` streamen oder direkt mit
|
||||
`yarn zwanzig dev:function:exec` aufrufen.
|
||||
</Tip>
|
||||
|
||||
**Nach diesem Schritt:** Die App kann Dokumente über HTTP generieren und sie als
|
||||
Webseiten bedienen. Jetzt machen wir es brauchbar ohne `curl`.
|
||||
|
||||
<Card title="Nächste: Bauen der UI →" icon="table-columns" href="/l/de/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Views, Navigation, ein Kommando und eine Frontkomponente.
|
||||
</Card>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Tutorial: Dokumentgenerator"
|
||||
icon: wand-magic-sparkles
|
||||
description: Erstelle eine echte Twenty-App, die personalisierte Dokumente aus deinen CRM-Daten generiert.
|
||||
---
|
||||
|
||||
In diesem Tutorial erstellst du **Document Generator** – eine App, die wiederverwendbare Vorlagen in personalisierte Dokumente umwandelt, indem sie die bereits in deinem CRM vorhandenen Daten nutzt.
|
||||
|
||||
Schreibe einmal eine Vorlage mit `{{placeholders}}` und generiere dann mit einem Klick ein ausgefülltes Dokument für jede Person oder jedes Unternehmen – über das Befehlsmenü, über einen KI-Agenten oder über einen Workflow.
|
||||
|
||||
<Frame caption="Eine Vorlage, die für eine bestimmte Person generiert wurde und als druckbare Seite geöffnet ist.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Ein generiertes Verkaufsangebotsdokument" />
|
||||
</Frame>
|
||||
|
||||
## Was Sie lernen werden
|
||||
|
||||
Jedes Kapitel fügt eine Funktion hinzu. Am Ende haben Sie den Großteil des SDK kennengelernt.
|
||||
|
||||
| Kapitel | Funktion | Referenz |
|
||||
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||
| [1. Datenmodell](/l/de/developers/extend/apps/tutorials/document-generator/data-model) | Objekte, Felder und eine Relation | [Daten](/l/de/developers/extend/apps/data/overview) |
|
||||
| [2. Dokumente generieren](/l/de/developers/extend/apps/tutorials/document-generator/generating-documents) | Eine Logikfunktion (KI-Tool + Workflow-Aktion), die eine Markdown-Vorlage ausfüllt und ein fertiges PDF anhängt | [Logikfunktionen](/l/de/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. HTTP-Routen](/l/de/developers/extend/apps/tutorials/document-generator/http-routes) | Ausliefern von JSON und einer teilbaren HTML-Seite über Routen | [Logikfunktionen](/l/de/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Die UI erstellen](/l/de/developers/extend/apps/tutorials/document-generator/building-the-ui) | Ansichten, Navigation, Befehlsmenü und Frontend-Komponenten, die ein Dokument anzeigen und eine Vorlage bearbeiten | [Layout](/l/de/developers/extend/apps/layout/overview) |
|
||||
| [5. Ein KI-Agent](/l/de/developers/extend/apps/tutorials/document-generator/ai-agent) | Agent + Skill | [Skills & Agenten](/l/de/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Veröffentlichen](/l/de/developers/extend/apps/tutorials/document-generator/publishing) | In den Marketplace veröffentlichen | [Veröffentlichen](/l/de/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Sie sollten den [Quick Start](/l/de/developers/extend/apps/getting-started/quick-start) abgeschlossen haben:
|
||||
einen lokalen Twenty-Server, der auf Port `2020` läuft, und die CLI, die damit authentifiziert ist.
|
||||
|
||||
Falls nicht, erstellen und starten Sie jetzt einen:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Lesen Sie lieber den fertigen Code? Die vollständige App befindet sich unter
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Jedes der folgenden Codebeispiele ist daraus kopiert.
|
||||
</Note>
|
||||
|
||||
## Wie die App zusammenpasst
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="Eine Vorlage mit Platzhaltern wird in ein fertiges Dokument mit PDF generiert, ausgelöst über das Befehlsmenü, einen KI-Agenten, einen Workflow oder einen teilbaren Link" />
|
||||
</Frame>
|
||||
|
||||
Sie schreiben eine **Vorlage** einmal in einem Rich-Text-Editor mit `{{placeholders}}`. Die Auswahl einer
|
||||
Vorlage und eines CRM-Datensatzes füllt die Platzhalter aus und speichert ein fertiges
|
||||
**Dokument** (mit einer PDF-Datei). Alles andere – das Befehlsmenü, der KI-Agent,
|
||||
der Workflow-Schritt, der teilbare Link – ist nur eine andere Möglichkeit, denselben
|
||||
Generator auszulösen.
|
||||
|
||||
## Diesen Kreislauf am Laufen halten
|
||||
|
||||
Lassen Sie `yarn twenty dev` während des gesamten Tutorials in einem Terminal laufen. Jedes Mal, wenn
|
||||
Sie eine Datei unter `src/` hinzufügen oder bearbeiten, wird sie innerhalb weniger
|
||||
Sekunden mit Ihrem Server synchronisiert, sodass Sie beobachten können, wie jede Funktion in der UI erscheint, während Sie sie entwickeln.
|
||||
|
||||
<Card title="Beginnen Sie mit dem Bauen →" icon="database" href="/l/de/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
Kapitel 1: Dokumente und Vorlagen modellieren.
|
||||
</Card>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: 6. Veröffentlichen
|
||||
icon: rocket
|
||||
description: Fügen Sie Marktplatz-Metadaten hinzu und veröffentlichen Sie Ihre App.
|
||||
---
|
||||
|
||||
Ihre App funktioniert. Der letzte Schritt ist, es für den Marktplatz zu beschreiben und zu veröffentlichen.
|
||||
|
||||
## Marktplatz-Metadaten hinzufügen
|
||||
|
||||
Die [Anwendung config](/l/de/developers/extend/apps/config/application) trägt die
|
||||
Identität, die im Marktplatz erscheint: Autor, Kategorie, Logo und Unterstützung von
|
||||
Links. Lege ein Logo in `public/` ein und verweise es mit `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/de/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Die Standardrolle wird mit `defineApplicationRole()` in der eigenen Datei deklariert — Sie
|
||||
geben hier nicht mehr den `defaultRoleUniversalIdentifier` an.
|
||||
</Tip>
|
||||
|
||||
Füge auch das Schlüsselwort "twenty-app" zu "package.json" hinzu, damit die App entdeckt werden kann:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Galerie Screenshots hinzufügen
|
||||
|
||||
Ein Marktplatz-Listing verkauft sich mit Screenshots. Legen Sie ein paar PNGs in
|
||||
`public/gallery/` und verweisen Sie sie mit `screenshots` — sie rendern als Galerie
|
||||
auf der Listenseite.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Lead with the payoff: make the first screenshot the finished result (a generated
|
||||
document), then show how it triggered and authored. Verwende scharfe, hochauflösende
|
||||
Aufnahmen – sie sind das Erste, was ein Benutzer sieht.
|
||||
</Tip>
|
||||
|
||||
Gib `README.md` die gleiche Behandlung — es ist die Titelseite auf npm und GitHub.
|
||||
Öffnen Sie mit dem Wertvorschlag und einem Screenshot, führen Sie die Überschrift auf,
|
||||
und halten Sie dann die Baudetails unter dem Ordner.
|
||||
|
||||
## Vor dem Versand prüfen
|
||||
|
||||
Führe die gleichen Tore CI aus:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
Der Trockenlauf druckt genau das, was sich auf dem Server ändern würde, ohne es anzuwenden —
|
||||
eine gute abschließende Vernunftprüfung. Siehe
|
||||
[Testing](/l/de/developers/extend/apps/operations/testing) und
|
||||
[Synchronisieren & Wiederherstellen](/l/de/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Veröffentlichen
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` baut und veröffentlicht standardmäßig in npm ; `--private` lädt stattdessen einen
|
||||
Tarball in die private Registry eines Zwanzig Servers hoch. Um eine veröffentlichte App
|
||||
auf dem Marktplatz einer Instanz aufzudecken, löst eine Katalog-Synchronisation aus:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Vollständige Details und die Freigabe-Checkliste:
|
||||
[Publishing](/l/de/developers/extend/apps/operations/publishing).
|
||||
|
||||
## Du hast eine App :party_popper erstellt:
|
||||
|
||||
In sechs Kapiteln hast du den Großteil der SDK-Oberfläche verwendet:
|
||||
|
||||
* **Objekte, Felder und eine Relation** um die Daten zu modellieren
|
||||
* Eine **Logikfunktion** als **AI-Tool**, eine **Workflow-Aktion** und **HTTP-Routen**
|
||||
* **Ansichten, Navigation, Befehl und Frontkomponent** für die Benutzeroberfläche
|
||||
* Ein **Agent + Fertigkeit** für die Erzeugung natürlicher Sprache
|
||||
* **Marketplace-Metadaten** und der Veröffentlichungsfluss
|
||||
|
||||
Die fertige App ist bei
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Wohin Sie weiter gehen
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Datenreferenz" icon="database" href="/l/de/developers/extend/apps/data/overview">
|
||||
Jeder Feldtyp, Relation und Index-Option.
|
||||
</Card>
|
||||
<Card title="Logische Referenz" icon="bolt" href="/l/de/developers/extend/apps/logic/overview">
|
||||
Cron- und Datenbankereignis-Trigger, der Schlüsselwert-Store, OAuth Verbindungen.
|
||||
</Card>
|
||||
<Card title="Layout-Referenz" icon="table-columns" href="/l/de/developers/extend/apps/layout/overview">
|
||||
Seitenlayouts, Dashboard-Widgets und mehr UI-Oberflächen.
|
||||
</Card>
|
||||
<Card title="Operationen" icon="rocket" href="/l/de/developers/extend/apps/operations/overview">
|
||||
CLI, Tests, Fernbedienungen und CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Erste Schritte"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Tutorial"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Konfiguration"
|
||||
},
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: Deja que un agente genere documentos de un chat, usando tu herramienta.
|
||||
---
|
||||
|
||||
Debido a que `generate-document` está expuesto como una **herramienta**, un agente de IA puede llamarlo.
|
||||
Añadamos un agente y una habilidad para que los usuarios puedan decir *"generar una propuesta para
|
||||
Grifo de Jeffery"*.
|
||||
|
||||
## La habilidad
|
||||
|
||||
Una [skill](/l/es/developers/extend/apps/logic/skills-and-agents) es reutilizable
|
||||
instrucciones: conocimiento que adjuntas a los agentes. La nuestra enseña al modelo cómo usar
|
||||
la herramienta.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## El agente
|
||||
|
||||
Un [agent](/l/es/developers/extend/apps/logic/skills-and-agents) empareja un símbolo de espera de órdenes con un modelo
|
||||
. Establece `responseFormat` explícitamente para evitar una advertencia de compilación.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
El agente sólo puede llamar a la herramienta si su papel lo permite. Ya establecemos
|
||||
`canAccessAllTools: true` y `canBeAssignedToAgents: true` en el rol de la aplicación en
|
||||
[Capítulo 2](/l/es/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Pruébalo
|
||||
|
||||
Abre un chat con **Asistente de documentos** y pídele que elabore un documento para una personaformat@@0
|
||||
en tu CRM. Encuentra el registro, llama a `generate-document` e informa
|
||||
el documento que creó, el cual ahora aparece en tu vista de **Documents**,
|
||||
exactamente igual que las rutas del menú de comandos y del flujo de trabajo.
|
||||
|
||||
Esa es la ganancia de exponer la lógica como una herramienta: **una función, muchas puertas delanteras** —
|
||||
menú de comandos, HTTP, paso de flujo de trabajo y ahora lenguaje natural.
|
||||
|
||||
**Después de este paso:** la aplicación es completa y genuinamente útil. Es hora de
|
||||
enviarlo.
|
||||
|
||||
<Card title="Siguiente: publicando →" icon="cohete" href="/Developopers/extend/apps/tutorials/document-generator/publishing">
|
||||
Añadir metadatos de mercado y publicar.
|
||||
</Card>
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: 4. Construyendo la interfaz de usuario
|
||||
icon: table-columns
|
||||
description: Visualizaciones, navegación en la barra lateral, un comando y componentes frontales.
|
||||
---
|
||||
|
||||
Ahora mismo los objetos sólo son accesibles a través de Configuración. Vamos a dar a la aplicación una presencia
|
||||
real en la interfaz de usuario: vistas de lista, entradas de la barra lateral, un comando de
|
||||
**Generar documento**, un componente frontal de página de registro para **previsualizar** un documento
|
||||
y una pestaña de **editor** de texto nativo para plantillas.
|
||||
|
||||
## Vistas y navegación
|
||||
|
||||
Una [view](/l/es/developers/extend/apps/layout/views) es una lista guardada de un objeto dado.
|
||||
Un [elemento del menú de navegación](/l/es/developers/extend/apps/layout/navigation-menu-items)
|
||||
pone esa vista en la barra lateral.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Añadir el mismo par para las plantillas. Ambos ahora se muestran en la barra lateral:
|
||||
|
||||
<Frame caption="Documentos y plantillas en la barra lateral con el documento generado listado.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/04-documents-view.png" alt="Vista de documentos con un documento generado" />
|
||||
</Frame>
|
||||
|
||||
## Un componente frontal
|
||||
|
||||
Un [componente delantero](/l/es/developers/extend/apps/layout/front-components) es un componente de React
|
||||
en el interior de Twenty. Nuestra nuestra lee el registro seleccionado, carga las plantillas
|
||||
persona a través de `CoreApiClient`, y POSTs a la ruta desde el último capítulo
|
||||
.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Estilo con variables CSS en línea (`var(--t-color-blue)`), no valores importados de
|
||||
`twenty-ui`. El SDK simula ese paquete durante la compilación, por lo que las importaciones a nivel de módulo de
|
||||
constantes de tema serían `undefined`. Ver el
|
||||
[componente completo](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## Un comando para abrirlo
|
||||
|
||||
Un [elemento del menú de comandos](/l/es/developers/extend/apps/layout/command-menu-items) con
|
||||
`availabilityType: 'RECORD_SELECTION'` aparece cuando se selecciona una Persona, y
|
||||
abre el componente en el panel lateral.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Prueba todo el flujo
|
||||
|
||||
Abre **Personas**, marca a una persona y pulsa <kbd>mañK</kbd> / <kbd>Ctrl K</kbd>.
|
||||
"Generar documento" aparece, etiquetado con tu aplicación:
|
||||
|
||||
<Frame caption="El comando se muestra cuando se selecciona una Persona.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/06-command-menu.png" alt="Menú de comandos con Generar documento" />
|
||||
</Frame>
|
||||
|
||||
Ejecutarlo — su componente se abre en el panel lateral. Elige una plantilla, haz clic en
|
||||
**Generar**, y un nuevo registro de tierras en **Documentos**.
|
||||
|
||||
<Frame caption="El componente frontal, cargando plantillas y generando al hacer clic.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/06b-front-component.png" alt="Generar panel lateral del documento" />
|
||||
</Frame>
|
||||
|
||||
Cada documento generado registra tu aplicación como su autor:
|
||||
|
||||
<Frame caption="Creado por Document Generator, estado Generado.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/05-document-record.png" alt="Un registro de documento generado" />
|
||||
</Frame>
|
||||
|
||||
## Vista previa de un documento en su página de registro
|
||||
|
||||
Un componente frontal no solo para los menús de comandos: puedes montar uno como una \*\*pestaña en una página de registro
|
||||
. Añadamos una pestaña de *Vista previa* al registro de documentos que renderiza el cuerpo de Markdown
|
||||
como una página pulida e imprimible.
|
||||
|
||||
El componente lee el id de registro actual de su contexto de ejecución, carga el documento
|
||||
y lo renderiza. Los componentes de Front se ejecutan en una **sandbox** que solo permite una
|
||||
lista blanca de etiquetas HTML: la inyección de HTML sin procesar (`dangerouslySetInnerHTML`) y
|
||||
`\<style>` están bloqueados, por lo que representamos el Markdown como elementos de React con estilos
|
||||
inline mediante un pequeño helper [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx).
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Montarlo con un [diseño de página](/l/es/developers/extend/apps/layout/page-layouts). Un diseño
|
||||
`RECORD_PAGE` añade pestañas a la vista de registro de un objeto; un widget `FRONT_COMPONENT`
|
||||
en una pestaña `CANVAS` aloja el componente:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Abre cualquier documento — una pestaña de **Vista previa** lo renderiza hermosamente, con enlaces a la página web compartible
|
||||
y el PDF:
|
||||
|
||||
<Frame caption="La pestaña Vista previa muestra el documento con estilos en línea, además de enlaces rápidos.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/09-document-viewer.png" alt="Componente frontal del visor de documentos en una pestaña de página de registro" />
|
||||
</Frame>
|
||||
|
||||
## Editar una plantilla con el editor de texto
|
||||
|
||||
Las plantillas no necesitan ningún componente personalizado. Porque el `body` es un campo
|
||||
`RICH_TEXT`, Veinte ya proporciona un editor de texto completo para él — el mismo
|
||||
que los objetos estándar de Nota y Task usan. Acabamos de superarlo en la página de registro
|
||||
plantilla.
|
||||
|
||||
Añade una pestaña con un widget `FIELD` en el modo de visualización `EDITOR`, apuntando al campo `body`
|
||||
a través de `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
Un campo `RICH_TEXT` almacena tanto el bloque JSON del editor como una proyección
|
||||
de Markdown. La generación de pipeline lee que Markdown proyecta, así que
|
||||
marcadores de posición, el PDF, y la página web compartible siguen funcionando sin cambios —
|
||||
vea el
|
||||
completo [`template-record. age-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Ahora editores escriben plantillas en un editor de texto rich:
|
||||
|
||||
<Frame caption="La pestaña Plantilla: Editor nativo de texto de texto de 20 años vinculado al campo del cuerpo.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/10-template-editor.png" alt="Registro de plantillas con la pestaña nativa del editor de texto" />
|
||||
</Frame>
|
||||
|
||||
**Después de este paso:** vista previa de documentos hermosamente y plantillas son editables
|
||||
en la aplicación. A continuación, deja que un agente de IA los genere a partir de un chat.
|
||||
|
||||
<Card title="Siguiente: un agente de IA →" icon="robot" href="/Developopers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Añade un agente y una habilidad que llame a tu herramienta.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Modelo de datos
|
||||
icon: database
|
||||
description: Modelo de documentos y plantillas con objetos, campos y una relación.
|
||||
---
|
||||
|
||||
Nuestra aplicación necesita dos objetos personalizados: **plantillas de documentos** (qué escribir) y
|
||||
**documentos** (el resultado generado). Vamos a definirlos.
|
||||
|
||||
Escaffold cada archivo de entidad con la CLI — genera un UUID válido y la carpeta
|
||||
correcta para ti:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
A continuación mostramos los archivos terminados.
|
||||
|
||||
<Note>
|
||||
Cada `*_UNIVERSAL_IDENTIFIER` vida constante en
|
||||
`src/constants/universal-identifiers.ts` y es importado donde se utiliza. Los fragmentos
|
||||
a continuación omiten las importaciones por brevedad — manténgalos en tus propios archivos.
|
||||
</Note>
|
||||
|
||||
## El objeto de plantilla
|
||||
|
||||
Una plantilla tiene un `nombre`, un `cuerpo` con `{{placeholders}}`, y un `target` que
|
||||
dice si está escrito para una persona o una empresa. El `body` es un campo
|
||||
`RICH_TEXT`, así que Veinte le da un editor completo de texto.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` option **values** debe ser `UPPER_CASE` (`PERSON`, no `person`), y el
|
||||
`defaultValue` está envuelto en comillas adicionales: `` `'PERSON'` ``. La `label` es lo que
|
||||
los usuarios ven.
|
||||
</Warning>
|
||||
|
||||
## El objeto del documento
|
||||
|
||||
El documento generado almacena el `contenido` renderizado y un `status`. Definirlo
|
||||
de la misma manera, con un `status` seleccionado de `DRAFT` / `GENERATED`. Archivo completo:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Vinculándolas con una relación
|
||||
|
||||
Cada documento debe apuntar de nuevo a la plantilla de la que proviene. Las relaciones son
|
||||
**bidireccionales** — defines ambos lados, cada uno en su propio archivo de campo.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
El otro lado (`template-documents-relation.field.ts`) es un campo
|
||||
`RelationType.ONE_TO_MANY` llamado `documents` que apunta a la manera opuesta.
|
||||
Ver [Relations](/l/es/developers/extend/apps/data/relations) para ver el patrón completo.
|
||||
|
||||
## Ver en Veinte
|
||||
|
||||
Con `yarn veinte dev` en ejecución, abre **Ajustes → Modelo de datos**. Ambos objetos
|
||||
aparecen, etiquetados con tu aplicación.
|
||||
|
||||
<Frame caption="Ambos objetos personalizados, propiedad del generador de documentos.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/01-data-model.png" alt="Configuración del modelo de datos mostrando plantillas de documentos y documentos" />
|
||||
</Frame>
|
||||
|
||||
Crea una plantilla para probar con — nombra la *propuesta de ventas*, establece el **objetivo** a
|
||||
*Persona*, y pega un cuerpo con unos pocos marcadores de posición:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="Un registro de plantilla. El cuerpo mantiene sus marcadores de posición hasta que se genere un documento.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/03-template-record.png" alt="Un registro de plantilla de propuesta de ventas con el cuerpo del marcador de posición" />
|
||||
</Frame>
|
||||
|
||||
**Después de este paso:** tienes los objetos `documentTemplate` y `document`, enlazados por
|
||||
una relación, y una plantilla para generar. Después, la lógica que lo llena.
|
||||
|
||||
<Card title="Siguiente: generando documentos →" icon="bolt" href="/developopers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
Escriba la función lógica que llena la plantilla.
|
||||
</Card>
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: 2. Generando documentos
|
||||
icon: bolt
|
||||
description: Una función lógica, expuesta como una herramienta de IA y una acción de flujo de trabajo.
|
||||
---
|
||||
|
||||
Ahora el núcleo: una [función lógica](/l/es/developers/extend/apps/logic/logic-functions)
|
||||
que carga una plantilla y un registro, rellena los marcadores de posición y guarda un nuevo documento
|
||||
.
|
||||
|
||||
Escribiremos la lógica de negocio una sola vez como un **handler**, y luego la expondremos mediante
|
||||
diversos desencadenadores. Este capítulo conecta dos de ellos: una **herramienta de IA** y una
|
||||
**acción de flujo de trabajo**.
|
||||
|
||||
## El ayudante de renderizado
|
||||
|
||||
Mantenga la lógica pura en su propio archivo para que sea fácil de unir-test. Esto arrastra un registro
|
||||
en `{{dot.path}}` tokens y los sustituye.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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>
|
||||
Debido a que este archivo no tiene efectos secundarios, puedes cubrirlo con pruebas unitarias rápidas
|
||||
(`yarn test:unit`). Ver [Testing](/l/es/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## El manejador
|
||||
|
||||
El manejador utiliza el [`CoreApiClient`](/l/es/developers/extend/apps/logic/logic-functions)
|
||||
generado para leer y escribir datos CRM. Carga la plantilla, carga el registro de destino, rellena
|
||||
el cuerpo y crea un `document`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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` ejecuta una consulta diferente para una Persona vs. una Empresa y aplana
|
||||
el resultado — vea
|
||||
[`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).
|
||||
|
||||
## Exponerlo como una herramienta y una acción de flujo de trabajo
|
||||
|
||||
Un solo `defineLogicFunction` puede llevar varios disparadores. Aquí, `toolTriggerSettings`
|
||||
hace que sea llamable por agentes IA, y `workflowActionTriggerSettings` lo convierte en un paso
|
||||
en el constructor de flujo de trabajo visual. Ambos describen su entrada con un esquema JSON.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
El esquema de entrada es un esquema JSON simple que describe `templateId` y `recordId` —
|
||||
see [`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).
|
||||
|
||||
## Conceder acceso
|
||||
|
||||
Funciones lógicas ejecutadas como el rol de la aplicación. Necesita leer plantillas y registrar
|
||||
y crear documentos, así que permita eso en `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
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` permite a la función cargar el PDF generado en la siguiente sección.
|
||||
Ver [Roles](/l/es/developers/extend/apps/config/roles) para obtener permisos más finos.
|
||||
|
||||
## Adjuntar un archivo PDF real
|
||||
|
||||
Un campo de texto renderizado es útil, pero los usuarios quieren un documento real. Vamos a generar un
|
||||
**PDF** y almacenarlo en el registro como un archivo descargable.
|
||||
|
||||
Primero, da al objeto `document` un campo `ARCHIVOS` para mantener el PDF. Las aplicaciones suben
|
||||
a sus campos de archivos **propios**, así que este campo es qué rutas cargar:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Ahora renderice ese PDF. Una aplicación es un proyecto de nodo real, así que puedes añadir cualquier paquete npm
|
||||
que necesites e importarlo como en cualquier otro lugar. Utilizamos **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
para dibujar el PDF y **[marked](https://marked.js.org/)** para analizar el cuerpo de Markdown
|
||||
— el CLI los instala en el tiempo de ejecución de la función para ti:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
El ayudante completo es
|
||||
[`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).
|
||||
Analiza el Markdown en tokens con `marked.lexer`, y luego los dispone con
|
||||
pdf-lib: encabezados reales, texto en **negrita**/*cursiva*, listas con viñetas y numeradas,
|
||||
citas en bloque y líneas de separación: una representación A4 pulida y de varias páginas de la propia plantilla,
|
||||
en lugar de un bloque de texto.
|
||||
|
||||
<Frame caption="El PDF generado: tipografía real y formato Markdown, renderizando el cuerpo de la plantilla.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/07b-generated-pdf.png" alt="Un PDF pulido y generado por marcadores" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
Las fuentes incorporadas de pdf-lib's usan la codificación WinAnsi, por lo que los acentos de WesternEuropean renderizan
|
||||
fuera de la caja el ayudante mapea comillas inteligentes y guiones y deja caer caracteres que
|
||||
no puede codificar. Renderizar escrituras no latinas (chino, árabe, cirílico) implicaría
|
||||
incrustar una fuente Unicode.
|
||||
</Note>
|
||||
|
||||
A continuación, suba y almacene la referencia en el registro. `uploadFile` rutas bytes
|
||||
a tu campo de archivos propiedad de la aplicación; el `id` devuelto es lo que guardas:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
El documento generado ahora contiene un PDF descargable:
|
||||
|
||||
<Frame caption="El PDF generado, almacenado en el campo Archivo del documento.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/08-document-with-pdf.png" alt="Un registro de documento con un archivo PDF generado" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` solo se dirige a campos de archivos **propiedad de la app** (por lo que las cargas siempre requieren una
|
||||
app que sea propietaria del campo, además del indicador de rol `UPLOAD_FILE`). Por eso el PDF
|
||||
aterriza en el propio campo `file` del registro — el mismo patrón que el
|
||||
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
usa para grabaciones.
|
||||
</Note>
|
||||
|
||||
**Después de este paso:** cada documento generado tiene un PDF real y descargable. Pero
|
||||
nada puede *llamar* al generador de la interfaz de usuario aún — para eso necesitamos una ruta HTTP.
|
||||
|
||||
<Card title="Siguiente: rutas HTTP →" icon="globo" href="/Developopers/extend/apps/tutorials/document-generator/http-routes">
|
||||
Servir la función sobre HTTP y representar documentos como páginas web.
|
||||
</Card>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: 3. Rutas HTTP
|
||||
icon: globe
|
||||
description: Activa la función sobre HTTP y renderiza documentos como páginas web.
|
||||
---
|
||||
|
||||
El mismo manejador también puede responder a peticiones HTTP. Añadiremos dos rutas:
|
||||
|
||||
* un endpoint **POST** para generar un documento, y
|
||||
* un endpoint público **GET** que renderiza un documento como una página web imprimible.
|
||||
|
||||
Ambos usan `httpRouteTriggerSettings`. Las rutas de la aplicación se sirven bajo `/s` en tu servidor
|
||||
Veenty (por ejemplo, `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## Ruta POST — generar bajo demanda
|
||||
|
||||
Esto reutiliza `generateDocumentHandler`, así que no hay lógica para repetir: solo un adaptador
|
||||
fino que lee el cuerpo de la solicitud.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
El manejador compartido devuelve un `status` sugerido en caso de fallo, así que la ruta puede
|
||||
responder con un código apropiado `4xx`/`5xx`. `isAuthRequired: true` significa que la persona que llama
|
||||
debe presentar un token válido — el componente frontal en el siguiente capítulo pasa el token de acceso del usuario
|
||||
automáticamente.
|
||||
|
||||
## Ruta GET — renderizar como una página web
|
||||
|
||||
Para devolver HTML en lugar de JSON, envuelve el cuerpo en un encabezado `Response` con una cabecera
|
||||
`Content-Type`. Esta ruta es pública (`isAuthRequired: false`) por lo que un documento generado
|
||||
puede ser compartido como un enlace.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` renderiza el cuerpo de Markdown a HTML (con [marked](https://marked.js.org/),
|
||||
saneado) y lo deja caer en una limpieza, página imprimible que muestra sólo el contenido
|
||||
plantilla — el mismo aspecto que el PDF y la vista previa dentro de la aplicación.
|
||||
[Ver el ayudante](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Pruébalo
|
||||
|
||||
Con una plantilla y una persona en tu espacio de trabajo, llama a la ruta (toma un token desde
|
||||
**Ajustes → APIs & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Abra el documento devuelto en su navegador:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="La ruta pública GET renderiza el documento como una página imprimible.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/07-rendered-document.png" alt="Una página web de documentos procesados" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
También puedes transmitir los registros de una función mientras pruebas con
|
||||
`yarn twenty dev:function:logs`, o invocarlo directamente con
|
||||
`yarn twenty dev:function:exec`.
|
||||
</Tip>
|
||||
|
||||
**Después de este paso:** la aplicación puede generar documentos a través de HTTP y servirlos como
|
||||
páginas web. Ahora hagámoslo utilizable sin `curl`.
|
||||
|
||||
<Card title="Siguiente: construyendo la interfaz de usuario →" icon="table-columns" href="/Developopers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Vistas, navegación, un comando y un componente frontal.
|
||||
</Card>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Tutorial: Generador de documentos"
|
||||
icon: wand-magic-sparkles
|
||||
description: Crea una aplicación real de Twenty que genere documentos personalizados a partir de los datos de tu CRM.
|
||||
---
|
||||
|
||||
En este tutorial crearás **Document Generator**, una aplicación que convierte plantillas reutilizables en documentos personalizados usando los datos que ya tienes en tu CRM.
|
||||
|
||||
Escribe una plantilla una vez con `{{placeholders}}`, luego genera un documento completado para cualquier Persona o Empresa con un solo clic, desde el menú de comandos, desde un agente de IA o desde un flujo de trabajo.
|
||||
|
||||
<Frame caption="Una plantilla, generada para una persona específica, abierta como una página imprimible.">
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/07-rendered-document.png" alt="Un documento generado de propuesta de ventas" />
|
||||
</Frame>
|
||||
|
||||
## Lo que aprenderás
|
||||
|
||||
Cada capítulo añade una capacidad. Al final habrás tocado la mayoría del SDK.
|
||||
|
||||
| Capítulo | Capacidad | Referencia |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| [1. Modelo de datos](/l/es/developers/extend/apps/tutorials/document-generator/data-model) | Objetos, campos y una relación | [Data](/l/es/developers/extend/apps/data/overview) |
|
||||
| [2. Generando documentos](/l/es/developers/extend/apps/tutorials/document-generator/generating-documents) | Una función lógica (herramienta (AI + acción de flujo de trabajo) que rellena una plantilla de Markdown y adjunta un PDF pulido | [Funciones lógicas](/l/es/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. Rutas HTTP](/l/es/developers/extend/apps/tutorials/document-generator/http-routes) | Ejecutando JSON y una página HTML compartible desde rutas | [Funciones lógicas](/l/es/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Construir la interfaz de usuario](/l/es/developers/extend/apps/tutorials/document-generator/building-the-ui) | Vistas, navegación, menú de comandos y componentes frontales que previsualizan un documento y editan una plantilla | [Diseño](/l/es/developers/extend/apps/layout/overview) |
|
||||
| [5. An AI agent](/l/es/developers/extend/apps/tutorials/document-generator/ai-agent) | Agente + habilidad | [Habilidades y agentes](/l/es/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Publicación](/l/es/developers/extend/apps/tutorials/document-generator/publishing) | Envíalo al mercado | [Publicación](/l/es/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Prerrequisitos
|
||||
|
||||
Deberías haber terminado el [Inicio rápido](/l/es/developers/extend/apps/getting-started/quick-start):
|
||||
un servidor Veinte local corriendo en el puerto `2020` y el CLI autenticado en él.
|
||||
|
||||
Si no, andamio e inicia uno ahora:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
¿Prefieres leer el código terminado? La aplicación completa vive en
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Cada fragmento de abajo es copiado de él.
|
||||
</Note>
|
||||
|
||||
## Cómo encaja la aplicación
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/desarrolladores/extends/apps/document-generator/how-it-fits.svg" alt="Una plantilla con marcadores de posición se genera en un documento pulido con un PDF, activado desde el menú de comandos, un agente IA, un flujo de trabajo o un enlace compartible" />
|
||||
</Frame>
|
||||
|
||||
Escribes una **plantilla** una vez en un editor de texto rich, con `{{placeholders}}`. Al elegir una plantilla
|
||||
y un registro CRM rellenan los marcadores de posición y almacenan un **documento** pulido
|
||||
(con un archivo PDF). Todo lo demás — el menú de comandos, el agente IA,
|
||||
el paso del flujo de trabajo, el enlace compartible — es sólo una manera diferente de activar que
|
||||
un generador.
|
||||
|
||||
## Mantener este ciclo en ejecución
|
||||
|
||||
Deja `yarn twenty dev` corriendo en un terminal para el tutorial completo. Cada vez que
|
||||
agregas o editas un archivo en `src/`, se vuelve a sincronizar con tu servidor en unos
|
||||
segundos, para que puedas ver cómo cada capacidad aparece en la interfaz de usuario a medida que la construyes.
|
||||
|
||||
<Card title="Comenzar construcción →" icon="database" href="/Developopers/extend/apps/tutorials/document-generator/data-model">
|
||||
Capítulo 1: documentos de modelo y plantillas.
|
||||
</Card>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: 6. Publicación
|
||||
icon: rocket
|
||||
description: Añade metadatos de mercado y publica tu aplicación.
|
||||
---
|
||||
|
||||
Tu app funciona. El último paso es describirlo para el mercado y publicarlo.
|
||||
|
||||
## Añadir metadatos de mercado
|
||||
|
||||
La [configuración de la aplicación](/l/es/developers/extend/apps/config/application) lleva los enlaces
|
||||
que se muestran en el mercado: autor, categoría, logotipo y soporte
|
||||
. Pon un logotipo en `public/` y referencialo con `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/es/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
El rol por defecto se declara con `defineApplicationRole()` en su propio archivo — usted
|
||||
ya no pase `defaultRoleUniversalIdentifier` aquí.
|
||||
</Tip>
|
||||
|
||||
También añade la palabra clave `twenty-app` a `package.json` para que la aplicación sea detectable:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Añadir capturas de pantalla de galería
|
||||
|
||||
Un listado de mercado se vende a sí mismo con capturas de pantalla. Soltar unos pocos PNGs en
|
||||
`public/gallery/` y referenciarlos con `capturas de pantalla` — se renderizan como una galería
|
||||
en la página de listado.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Lance con la ganancia: haga la primera captura de pantalla el resultado terminado (un documento
|
||||
generado), luego muestre cómo se activa y se autoriza. Usa capturas
|
||||
nítidas y de alta resolución, son lo primero que un usuario ve.
|
||||
</Tip>
|
||||
|
||||
Dale a `README.md` el mismo tratamiento: es la página principal de npm y GitHub.
|
||||
Abre con la proposición de valor y una captura de pantalla, muestra las características de la línea de cabecera,
|
||||
y mantén los detalles de construcción debajo del plano.
|
||||
|
||||
## Comprobar antes de enviar
|
||||
|
||||
Ejecuta las mismas puertas que CI hace:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
La ejecución seca imprime exactamente lo que cambiaría en el servidor sin aplicarlo —
|
||||
una buena comprobación final de sanidad. Ver
|
||||
[Testing](/l/es/developers/extend/apps/operations/testing) y
|
||||
[Sincronizando y recuperando](/l/es/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Publicar
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` construye y publica a npm por defecto; `--private` sube un tarball
|
||||
a un registro privado del servidor Veinte en su lugar. Para superficiar una aplicación publicada
|
||||
en el mercado de una instancia, dispara una sincronización de catálogo:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Detalles completos y la lista de verificación de lanzamiento:
|
||||
[Publishing](/l/es/developers/extend/apps/operations/publishing).
|
||||
|
||||
## Construiste una aplicación 🎉
|
||||
|
||||
En seis capítulos utilizaste la mayor parte de la superficie SDK:
|
||||
|
||||
* **Objetos, campos y una relación** para modelar los datos
|
||||
* Una **función lógica** expuesta como una **herramienta de IA**, una **acción de flujo de trabajo**, y **rutas HTTP**
|
||||
* **Vistas, navegación, un comando y un componente frontal** para la interfaz de usuario
|
||||
* Un **agente + habilidad** para generación natural
|
||||
* **Metadatos del mercado** y el flujo de publicación
|
||||
|
||||
La aplicación terminada está en
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## A dónde ir siguiente
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Referencia de datos" icon="database" href="/l/es/developers/extend/apps/data/overview">
|
||||
Cada tipo de campo, relación y opción de índice.
|
||||
</Card>
|
||||
<Card title="Referencia lógica" icon="bolt" href="/l/es/developers/extend/apps/logic/overview">
|
||||
Activadores de eventos Cron y base de datos, el almacén clave-valor, conexiones OAuth.
|
||||
</Card>
|
||||
<Card title="Referencia del diseño" icon="table-columns" href="/l/es/developers/extend/apps/layout/overview">
|
||||
Diseños de páginas, widgets de tablero y más superficies de interfaz.
|
||||
</Card>
|
||||
<Card title="Operaciones" icon="rocket" href="/l/es/developers/extend/apps/operations/overview">
|
||||
CLI, prueba, control remoto, y CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Primeros pasos"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Tutorial"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Configuración"
|
||||
},
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: Laissez un agent générer des documents à partir d'un chat, à l'aide de votre outil.
|
||||
---
|
||||
|
||||
Parce que `generate-document` est exposé comme un **outil**, un agent AI peut l'appeler.
|
||||
Ajoutons un agent et une compétence pour que les utilisateurs puissent simplement dire *"générer une proposition pour
|
||||
Jeffery Griffin"*.
|
||||
|
||||
## La compétence
|
||||
|
||||
Un [skill](/l/fr/developers/extend/apps/logic/skills-and-agents) est des instructions réutilisables
|
||||
— des connaissances que vous attachez aux agents. Le nôtre enseigne au modèle comment utiliser
|
||||
l'outil.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## L'agent
|
||||
|
||||
Un [agent](/l/fr/developers/extend/apps/logic/skills-and-agents) paie une invite avec un modèle
|
||||
. Définissez explicitement `responseFormat` pour éviter un avertissement de construction.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
L'agent ne peut appeler l'outil que si son rôle le permet. Nous avons déjà défini
|
||||
`canAccessAllTools: true` et `canBeAssignedToAgents: true` sur le rôle de l'application dans
|
||||
[Chapitre 2](/l/fr/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Essayez-le
|
||||
|
||||
Ouvrez une conversation avec **Document Assistant** et demandez-lui de rédiger un document pour une personne dans votre CRM. Il trouve l'enregistrement, appelle `generate-document`, et rapporte
|
||||
le document qu'il a créé — qui apparaît maintenant dans votre vue **Documents**
|
||||
exactement comme les chemins du menu de commande et du workflow.
|
||||
|
||||
C'est le payoff de l'exposition de la logique en tant qu'outil : **une fonction, de nombreuses portes frontales** — Menu de commande
|
||||
, HTTP, étape de workflow et maintenant langage naturel.
|
||||
|
||||
**Après cette étape:** l'application est complète et vraiment utile. Il est temps de
|
||||
l’expédier.
|
||||
|
||||
<Card title="Suivant : publication →" icon="rocket" href="/fr/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
Ajouter des métadonnées de marketplace et publier.
|
||||
</Card>
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: 4. Construire l'interface utilisateur
|
||||
icon: table-columns
|
||||
description: Vues, navigation dans la barre latérale, une commande et des composants frontaux.
|
||||
---
|
||||
|
||||
Pour le moment, les objets ne sont accessibles que dans les paramètres. Donnons à l'application une présence
|
||||
réelle dans l'interface utilisateur : vues de la liste, entrées de la barre latérale,
|
||||
**Générer un document** en un clic, un composant frontal de la page d'enregistrement pour **prévisualiser** un document
|
||||
et un onglet d'onglet de texte natif **éditeur** pour les modèles.
|
||||
|
||||
## Vues et navigation
|
||||
|
||||
Une [vue](/l/fr/developers/extend/apps/layout/views) est une liste enregistrée d’un objet donné.
|
||||
Un [élément du menu de navigation](/l/fr/developers/extend/apps/layout/navigation-menu-items)
|
||||
place cette vue dans la barre latérale.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Ajouter la même paire pour les modèles. Les deux affichent maintenant dans la barre latérale :
|
||||
|
||||
<Frame caption="Documents et gabarits dans la barre latérale, avec le document généré listé.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Vue des documents avec un document généré" />
|
||||
</Frame>
|
||||
|
||||
## Un composant frontal
|
||||
|
||||
Un [composant frontal](/l/fr/developers/extend/apps/layout/front-components) est un composant React
|
||||
bac à sable à l'intérieur de Twenty. Notre lecture de l'enregistrement sélectionné, charge les gabarits
|
||||
par l'intermédiaire de `CoreApiClient`, et POSTs vers la route à partir du dernier chapitre
|
||||
.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Style avec des variables CSS en ligne (`var(--t-color-blue)`), pas de valeurs importées de
|
||||
`21ui`. Les mocks SDK que ce paquet pendant la compilation, donc les importations au niveau des modules de constantes de thème
|
||||
seraient `indéfinies`. Voir le
|
||||
[composant complet](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## Une commande pour l'ouvrir
|
||||
|
||||
Un [lien de menu de commande](/l/fr/developers/extend/apps/layout/command-menu-items) avec
|
||||
`availabilityType: 'RECORD_SELECTION'` s'affiche quand une personne est sélectionnée, et
|
||||
ouvre le composant dans le panneau latéral.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Essayer tout le flux
|
||||
|
||||
Ouvrez **People**, cochez une personne et appuyez sur <kbd>ΩK</kbd> / <kbd>Ctrl K</kbd>.
|
||||
"Générer un document" apparaît, marqué avec votre application :
|
||||
|
||||
<Frame caption="La commande s'affiche lorsqu'une personne est sélectionnée.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Menu de commande avec Générer un document" />
|
||||
</Frame>
|
||||
|
||||
Exécutez-le — votre composant s'ouvre dans le panneau latéral. Choisissez un modèle, cliquez sur
|
||||
**Générer**, et un nouveau record se trouve dans **Documents**.
|
||||
|
||||
<Frame caption="Le composant avant, le chargement des modèles et la génération au clic.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Générer le panneau latéral du document" />
|
||||
</Frame>
|
||||
|
||||
Chaque document généré enregistre votre application comme auteur:
|
||||
|
||||
<Frame caption="Créé par le générateur de documents, statut généré.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="Un enregistrement de document généré" />
|
||||
</Frame>
|
||||
|
||||
## Aperçu d'un document sur sa page de dossier
|
||||
|
||||
Un composant frontal n’est pas seulement destiné aux menus de commandes — vous pouvez en monter un comme **onglet sur une page d’enregistrement**. Ajoutons un onglet *Aperçu* à l'enregistrement du document qui rend le corps
|
||||
Markdown comme une page lisse et imprimable.
|
||||
|
||||
Le composant lit l'id de l'enregistrement courant depuis son contexte d'exécution, charge le document
|
||||
et le rendu. Les composants frontaux s'exécutent dans un **sandbox** qui n'autorise qu'une liste
|
||||
de balises HTML — l'injection HTML brute (`dangerouslySetInnerHTML`) et
|
||||
`\<style>` sont bloqués — donc nous rendons le Markdown comme des éléments React avec des styles
|
||||
en ligne via un petit [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Montez-le avec une [mise en page de la page] (/developers/extend/apps/layout/page-layouts). Une mise en page
|
||||
`RECORD_PAGE` ajoute des onglets à la vue d'un enregistrement d'un objet ; un widget `FRONT_COMPONENT`
|
||||
dans un onglet `CANVAS` héberge le composant:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Ouvrez n'importe quel document — un onglet **Aperçu** le rend magnifiquement, avec des liens vers la page
|
||||
et le PDF :
|
||||
|
||||
<Frame caption="L'onglet Aperçu affiche le document avec des styles en ligne, plus des liens rapides.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Composant frontal de la visionneuse de documents dans un onglet de page d'enregistrement" />
|
||||
</Frame>
|
||||
|
||||
## Modifier un modèle avec l'éditeur de texte riche
|
||||
|
||||
Les gabarits n'ont pas du tout besoin d'un composant personnalisé. Parce que le `body` est un champ
|
||||
`RICH_TEXT`, Vingt fournissent déjà un éditeur de texte complet — le
|
||||
est le même que les objets standard Note et Tâche. Nous nous contentons de le mettre en surface sur la page d'enregistrement du gabarit
|
||||
.
|
||||
|
||||
Ajouter un onglet avec un widget `FIELD` en mode d'affichage `EDITOR`, pointant vers le champ `body`
|
||||
via `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
Un champ `RICH_TEXT` stocke à la fois le bloc JSON de l'éditeur et une projection Markdown
|
||||
. Le pipeline de génération lit que Markdown projection, donc
|
||||
placeholders, le PDF, et la page web partageable fonctionnent tous de façon inchangée —
|
||||
voir
|
||||
[`template-record. age-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Maintenant, les éditeurs écrivent des modèles dans un éditeur de texte riche approprié:
|
||||
|
||||
<Frame caption="Onglet Modèle : l'éditeur natif de texte riche de Vingt lié au champ corps.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Enregistrement du modèle avec l'onglet de l'éditeur natif de texte riche" />
|
||||
</Frame>
|
||||
|
||||
**Après cette étape:** les documents d'aperçu et les modèles sont modifiables
|
||||
dans l'application. Ensuite, laissez un agent IA les générer depuis un chat.
|
||||
|
||||
<Card title="Suivant : un agent IA →" icon="robot" href="/fr/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Ajoutez un agent et une compétence qui appellent votre outil.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Modèle de données
|
||||
icon: database
|
||||
description: Modèles de documents et de modèles avec des objets, des champs et une relation.
|
||||
---
|
||||
|
||||
Notre application a besoin de deux objets personnalisés : **modèles de documents** (quoi écrire) et
|
||||
**documents** (le résultat généré). Définissons-les.
|
||||
|
||||
Échappez chaque fichier d'entité avec le CLI — il génère un UUID valide et le dossier
|
||||
correct pour vous :
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Ci-dessous nous montrons les fichiers finis.
|
||||
|
||||
<Note>
|
||||
Chaque constante `*_UNIVERSAL_IDENTIFIER` vit dans
|
||||
`src/constants/universal-identifiers.ts` et est importée quand elle est utilisée. Les snippets
|
||||
ci-dessous omettent ces importations par souci de brièveté — conservez-les dans vos propres fichiers.
|
||||
</Note>
|
||||
|
||||
## L'objet modèle
|
||||
|
||||
Un modèle a un `nom`, un `body` avec `{{placeholders}}`, et un `target` que
|
||||
dit s'il est écrit pour une personne ou une entreprise. Le `body` est un champ
|
||||
`RICH_TEXT`. Vingt lui donne donc un éditeur de texte complet.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
L'option `SELECT` **values** doit être `UPPER_CASE` (`PERSON`, pas `person`), et le
|
||||
`defaultValue` est enveloppé par des guillemets supplémentaires : `` `'PERSON'` ``. Le `label` est ce que les utilisateurs de
|
||||
voient.
|
||||
</Warning>
|
||||
|
||||
## L'objet du document
|
||||
|
||||
Le document généré stocke le contenu `content` et un `status`. Définissez
|
||||
de la même manière, avec un `status` de `DRAFT` / `GENERATED`. Fichier complet :
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Les lier avec une relation
|
||||
|
||||
Chaque document devrait revenir au modèle dont il provient. Les relations sont
|
||||
**bidirectionnelles** — vous définissez les deux côtés, chacun dans son propre fichier de champs.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
L'autre côté (`template-documents-relation.field.ts`) est un champ
|
||||
`RelationType.ONE_TO_MANY` nommé `documents` qui pointe dans la direction opposée.
|
||||
Voir [Relations](/l/fr/developers/extend/apps/data/relations) pour le modèle complet.
|
||||
|
||||
## Voyez-la en 20
|
||||
|
||||
Avec `yarn twenty dev` en cours d'exécution, ouvrez **Paramètres → Modèle de données**. Les deux objets
|
||||
apparaissent, taggés avec votre application.
|
||||
|
||||
<Frame caption="Les deux objets personnalisés, détenus par l'application Générateur de documents.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Réglages du modèle de données montrant les modèles de documents et de documents" />
|
||||
</Frame>
|
||||
|
||||
Créez un modèle avec lequel tester — nommez-le *proposition de vente*, définissez **Cible** à
|
||||
*Personne*, et collez un corps avec quelques marqueurs :
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="Un enregistrement de gabarit. Le corps garde ses espaces réservés jusqu'à ce qu'un document soit généré.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="Un enregistrement de modèle de proposition de vente avec le corps du placeholder" />
|
||||
</Frame>
|
||||
|
||||
**Après cette étape :** vous avez des objets `documentTemplate` et `document`, liés par
|
||||
par une relation, et un modèle à générer. Ensuite, la logique qui le remplit.
|
||||
|
||||
<Card title="Suivant : génération de documents →" icon="bolt" href="/fr/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
Écrit la fonction logique qui remplit le modèle.
|
||||
</Card>
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: 2. Génération des documents
|
||||
icon: bolt
|
||||
description: Une fonction logique, exposée comme un outil AI et une action de workflow.
|
||||
---
|
||||
|
||||
Maintenant le cœur : une [fonction logique](/l/fr/developers/extend/apps/logic/logic-functions)
|
||||
qui charge un modèle et un enregistrement, remplit les marqueurs et enregistre un nouveau document
|
||||
.
|
||||
|
||||
Nous allons écrire la logique commerciale une fois en tant que **gestionnaire**, puis l'exposer à travers
|
||||
plusieurs déclencheurs. Ce chapitre connecte deux d’entre eux — un **outil d’IA** et une
|
||||
**action de workflow**.
|
||||
|
||||
## L'assistant de rendu
|
||||
|
||||
Gardez une logique pure dans son propre fichier donc il est facile de le tester. Cela aplanit un record
|
||||
en jetons `{{dot.path}}` et les substitue.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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>
|
||||
Comme ce fichier n'a pas d'effets secondaires, vous pouvez le couvrir avec des tests unitaires rapides
|
||||
(`yarn test:unit`). Voir [Testing](/l/fr/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## Le gestionnaire
|
||||
|
||||
Le gestionnaire utilise le [`CoreApiClient`](/l/fr/developers/extend/apps/logic/logic-functions)
|
||||
généré pour lire et écrire les données CRM. Il charge le modèle, charge l'enregistrement cible, remplit
|
||||
le corps, et crée un `document`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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` exécute une requête différente pour une Person par rapport à une Company et aplatit
|
||||
le résultat — voir
|
||||
[`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).
|
||||
|
||||
## Exposer comme un outil et une action de workflow
|
||||
|
||||
Une seule `defineLogicFunction` peut contenir plusieurs déclencheurs. Ici, `toolTriggerSettings`
|
||||
le rend appelable par les agents AI, et `workflowActionTriggerSettings` le transforme en une étape
|
||||
dans le constructeur de workflow visuel. Les deux décrivent leur entrée avec un schéma JSON.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
Le schéma d'entrée est un schéma JSON simple décrivant `templateId` et `recordId` —
|
||||
voir [`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).
|
||||
|
||||
## Accorder l'accès
|
||||
|
||||
Les fonctions logiques s'exécutent en tant que rôle de l'application. Il a besoin de lire les modèles et les enregistrements
|
||||
et de créer des documents, donc permettez cela dans `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
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` permet à la fonction d'envoyer le PDF généré dans la section suivante.
|
||||
Voir [Roles](/l/fr/developers/extend/apps/config/roles) pour des autorisations plus fines.
|
||||
|
||||
## Joindre un vrai fichier PDF
|
||||
|
||||
Un champ de texte rendu est utile, mais les utilisateurs veulent un vrai document. Nous allons générer un
|
||||
**PDF** et le stocker dans l'enregistrement en tant que fichier téléchargeable.
|
||||
|
||||
Premièrement, donnez à l'objet `document` un champ `FILES` pour contenir le PDF. Les applications téléchargent
|
||||
dans leurs champs **propres** de fichiers, donc ce champ permet de router le téléchargement:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Maintenant, renvoie ce PDF. Une application est un vrai projet Node, vous pouvez donc ajouter n'importe quel package npm
|
||||
dont vous avez besoin et l'importer n'importe où ailleurs. Nous utilisons **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
pour dessiner le PDF et **[marked](https://marked.js.org/)** pour analyser le corps du Markdown
|
||||
— le CLI les installe dans le runtime de la fonction pour vous :
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
L'aide complète est
|
||||
[`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).
|
||||
Il analyse le Markdown en jetons avec `marked. exer`, puis les pose avec
|
||||
pdf-lib: de vraies rubriques, **gras**/*italique* exécute, puces et listes numérotées,
|
||||
blockquotes et règles — un rendu A4 polyvalent et polyvalent du modèle
|
||||
lui-même, plutôt qu'un mur de texte.
|
||||
|
||||
<Frame caption="Le PDF généré : typographie réelle et mise en forme Markdown, rendant le corps du modèle.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="Un PDF brossé et commercialisable généré" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
Les polices intégrées de pdf-lib's utilisent l'encodage WinAnsi, de sorte que les accents WesternEuropean rendent
|
||||
hors de la boite; l'aide mappe les guillemets intelligents et les tirets et drops les caractères qu'elle
|
||||
ne peut pas encoder. Le rendu de scripts non latins (chinois, arabe, cyrillique) impliquerait
|
||||
d’intégrer une police Unicode.
|
||||
</Note>
|
||||
|
||||
Ensuite téléchargez-le et stockez la référence sur l'enregistrement. `uploadFile` achemine les octets
|
||||
vers votre champ de fichiers appartenant à l'application; le `id` retourné est ce que vous sauvegardez:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Le document généré possède maintenant un PDF téléchargeable:
|
||||
|
||||
<Frame caption="Le PDF généré, stocké dans le champ Fichier du document.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="Un enregistrement de document avec un fichier PDF généré" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` ne cible que les champs de fichiers **app-owned** (donc les téléchargements nécessitent toujours une application
|
||||
qui possède le champ, plus le paramètre `UPLOAD_FILE`). C'est pourquoi le PDF
|
||||
se trouve sur le propre champ `file` de l'enregistrement — le même motif que l'application
|
||||
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
utilise pour les enregistrements.
|
||||
</Note>
|
||||
|
||||
**Après cette étape:** chaque document généré a un PDF réel, téléchargeable. Mais
|
||||
rien ne peut *appeler* le générateur de l'interface utilisateur — pour cela nous avons besoin d'une route HTTP.
|
||||
|
||||
<Card title="Suivant : Routes HTTP →" icon="globe" href="/fr/developers/extend/apps/tutorials/document-generator/http-routes">
|
||||
Servez la fonction via HTTP et rendez les documents en tant que pages Web.
|
||||
</Card>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: 3. Routes HTTP
|
||||
icon: globe
|
||||
description: Déclencher la fonction sur HTTP et afficher les documents en tant que pages Web.
|
||||
---
|
||||
|
||||
Le même gestionnaire peut également répondre aux requêtes HTTP. Nous allons ajouter deux itinéraires :
|
||||
|
||||
* un point de terminaison **POST** que l'interface utilisateur appelle pour générer un document, et
|
||||
* un point de terminaison public **GET** qui rend un document en tant que page web imprimable.
|
||||
|
||||
Les deux utilisent `httpRouteTriggerSettings`. Les routes des applis sont servies dans `/s` sur votre
|
||||
Serveur Vingt (par exemple `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## Itinéraire POST — générer à la demande
|
||||
|
||||
Ceci réutilise `generateDocumentHandler`, donc il n'y a pas de logique à répéter — juste un adaptateur
|
||||
mince qui lit le corps de la requête.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Le gestionnaire partagé renvoie un `status` suggéré en cas d'échec, donc la route peut
|
||||
répondre avec un code approprié `4xx`/`5xx`. `isAuthRequired: true` signifie que l'appelant
|
||||
doit présenter un jeton valide — le composant frontal dans le chapitre suivant passe automatiquement le jeton d'accès de l'utilisateur
|
||||
.
|
||||
|
||||
## Obtenir la route - afficher en tant que page web
|
||||
|
||||
Pour retourner du HTML au lieu de JSON, enveloppez le corps dans un en-tête `Response` avec un
|
||||
`Content-Type`. Cette route est publique (`isAuthRequired: false`) donc un document généré par
|
||||
peut être partagé comme un lien.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` rend le corps de Markdown en HTML (avec [marked](https://marked.js.org/),
|
||||
assaini et le dépose dans une netteté page imprimable qui ne montre que le contenu du modèle
|
||||
— la même apparence que le PDF et l'aperçu dans l'application.
|
||||
[Voir l'aide](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Essayez-le
|
||||
|
||||
Avec un modèle et une personne dans votre espace de travail, appelez la route (récupérez un jeton à partir de
|
||||
**Paramètres → APIs & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Ouvrez le document retourné dans votre navigateur :
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="L'itinéraire public GET rend le document en tant que page imprimable.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Une page web de document rendu" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
Vous pouvez également diffuser les logs d'une fonction en testant avec
|
||||
`yarn twenty dev:function:logs`, ou l'appeler directement avec
|
||||
`yarn twenty dev:function:exec`.
|
||||
</Tip>
|
||||
|
||||
**Après cette étape :** l'application peut générer des documents via HTTP et les servir comme
|
||||
pages web. Maintenant, rendons-le utilisable sans `curl`.
|
||||
|
||||
<Card title="Prochaine étape : construire l'interface utilisateur →" icon="table-columns" href="/fr/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Vues, navigation, commande et composant frontal.
|
||||
</Card>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Tutoriel : Générateur de documents"
|
||||
icon: wand-magic-sparkles
|
||||
description: Créez une véritable application Twenty qui génère des documents personnalisés à partir des données de votre CRM.
|
||||
---
|
||||
|
||||
Dans ce tutoriel, vous allez créer **Document Generator** — une application qui transforme des modèles réutilisables
|
||||
en documents personnalisés en utilisant les données déjà présentes dans votre CRM.
|
||||
|
||||
Rédigez un modèle une seule fois avec `{{placeholders}}`, puis générez un document rempli
|
||||
pour n’importe quelle personne ou entreprise en un clic — depuis le menu de commande, depuis un
|
||||
agent IA ou depuis un workflow.
|
||||
|
||||
<Frame caption="Un modèle, généré pour une personne spécifique, ouvert comme une page imprimable.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Un document de proposition commerciale généré" />
|
||||
</Frame>
|
||||
|
||||
## Ce que vous apprendrez
|
||||
|
||||
Chaque chapitre ajoute une capacité. À la fin, vous aurez utilisé la plupart du SDK.
|
||||
|
||||
| Chapitre | Capacité | Référence |
|
||||
| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| [1. Modèle de données](/l/fr/developers/extend/apps/tutorials/document-generator/data-model) | Objets, champs et une relation | [Données](/l/fr/developers/extend/apps/data/overview) |
|
||||
| [2. Générer des documents](/l/fr/developers/extend/apps/tutorials/document-generator/generating-documents) | Une fonction logique (outil IA + action de workflow) qui remplit un modèle Markdown et y joint un PDF soigné | [Fonctions logiques](/l/fr/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. Routes HTTP](/l/fr/developers/extend/apps/tutorials/document-generator/http-routes) | Servir du JSON et une page HTML partageable depuis des routes | [Fonctions logiques](/l/fr/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Créer l’interface utilisateur](/l/fr/developers/extend/apps/tutorials/document-generator/building-the-ui) | Vues, navigation, menu de commandes et composants frontaux qui prévisualisent un document et modifient un modèle | [Mise en page](/l/fr/developers/extend/apps/layout/overview) |
|
||||
| [5. Un agent IA](/l/fr/developers/extend/apps/tutorials/document-generator/ai-agent) | Agent + compétence | [Compétences et agents](/l/fr/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Publication](/l/fr/developers/extend/apps/tutorials/document-generator/publishing) | Livrez-la sur la place de marché | [Publication](/l/fr/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Prérequis
|
||||
|
||||
Vous devez avoir terminé le [démarrage rapide](/l/fr/developers/extend/apps/getting-started/quick-start) :
|
||||
un serveur Twenty local en cours d’exécution sur le port `2020` et le CLI authentifié dessus.
|
||||
|
||||
Sinon, générez-en un et lancez-le maintenant :
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Vous préférez lire le code finalisé ? L’application complète se trouve dans
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Chaque extrait ci-dessous en est tiré.
|
||||
</Note>
|
||||
|
||||
## Comment l’application s’assemble
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="Un modèle avec des espaces réservés est transformé en un document soigné avec un PDF, déclenché depuis le menu de commandes, un agent IA, un workflow ou un lien partageable" />
|
||||
</Frame>
|
||||
|
||||
Vous rédigez un **modèle** une seule fois dans un éditeur de texte enrichi, avec `{{placeholders}}`. Choisir un
|
||||
modèle et un enregistrement CRM remplit les espaces réservés et stocke un
|
||||
**document** soigné (avec un fichier PDF). Tout le reste — le menu de commandes, l’agent IA,
|
||||
l’étape de workflow, le lien partageable — n’est qu’une façon différente de déclencher ce
|
||||
même générateur.
|
||||
|
||||
## Gardez cette boucle en marche
|
||||
|
||||
Laissez `yarn twenty dev` s’exécuter dans un terminal pendant tout le tutoriel. Chaque fois que
|
||||
vous ajoutez ou modifiez un fichier sous `src/`, il est resynchronisé avec votre serveur en quelques
|
||||
secondes, afin que vous puissiez voir chaque capacité apparaître dans l’interface utilisateur au fur et à mesure que vous la construisez.
|
||||
|
||||
<Card title="Commencez à construire →" icon="database" href="/l/fr/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
Chapitre 1 : modéliser des documents et des modèles.
|
||||
</Card>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: 6. Publication
|
||||
icon: rocket
|
||||
description: Ajoutez des métadonnées de marketplace et publiez votre application.
|
||||
---
|
||||
|
||||
Votre application fonctionne. La dernière étape consiste à la décrire pour le marché et à la publier.
|
||||
|
||||
## Ajouter des métadonnées de marketplace
|
||||
|
||||
La [configuration de l'application](/l/fr/developers/extend/apps/config/application) porte l'identité
|
||||
qui apparaît sur le marché : auteur, catégorie, logo et liens de support
|
||||
. Mettez un logo dans `public/` et faites-le référence avec `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/fr/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Le rôle par défaut est déclaré avec `defineApplicationRole()` dans son propre fichier — vous
|
||||
ne passez plus ici `defaultRoleUniversalIdentifier`.
|
||||
</Tip>
|
||||
|
||||
Ajoute également le mot-clé `21app` à `package.json` pour que l'application soit découverte:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Ajouter des captures d'écran de la galerie
|
||||
|
||||
Une annonce boursière se vend avec des captures d'écran. Déposez quelques PNGs dans
|
||||
`public/gallery/` et référencez-les avec `screenshots` — ils apparaissent comme une galerie
|
||||
sur la page de liste.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Menez avec le gain : faire la première capture d'écran le résultat fini (un document
|
||||
généré), puis montrez comment il est déclenché et créé. Utilisez des captures
|
||||
à haute résolution - c'est la première chose qu'un utilisateur voit.
|
||||
</Tip>
|
||||
|
||||
Donnez le même traitement à `README.md` - c'est la première page sur npm et GitHub.
|
||||
Ouvrez avec la proposition de valeur et une capture d'écran, listez les fonctionnalités du titre,
|
||||
puis gardez les détails de construction sous le pli.
|
||||
|
||||
## Vérifiez avant d'expédier
|
||||
|
||||
Exécuter les mêmes portes CI :
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
La course à sec imprime exactement ce qui pourrait changer sur le serveur sans l'appliquer —
|
||||
une bonne vérification de l'état d'esprit. Voir
|
||||
[Testing](/l/fr/developers/extend/apps/operations/testing) et
|
||||
[Synchronisation et récupération](/l/fr/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Publier
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` construit et publie sur npm par défaut; `--private` télécharge une archive
|
||||
vers un registre privé du serveur Vingt à la place. Pour faire surface à une application publiée
|
||||
dans une instance, déclenchez une synchronisation de catalogue :
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Tous les détails et la liste de vérification de la version :
|
||||
[Publishing](/l/fr/developers/extend/apps/operations/publishing).
|
||||
|
||||
## Vous avez construit une application 🎉
|
||||
|
||||
Dans six chapitres, vous avez utilisé la plupart des surfaces du SDK :
|
||||
|
||||
* **Objets, champs et relations** pour modéliser les données
|
||||
* Une **fonction logique** exposée comme un **outil IA**, une **action de workflow**, et des **routes HTTP**
|
||||
* **Voires, navigation, une commande et un composant frontal** pour l'interface utilisateur
|
||||
* Un **agent + compétence** pour la génération de langage naturel
|
||||
* **Métadonnées du Marketplace** et le flux de publication
|
||||
|
||||
L'application terminée est à
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Où aller ensuite
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Référence des données" icon="database" href="/l/fr/developers/extend/apps/data/overview">
|
||||
Chaque type de champ, chaque relation et chaque option d'index.
|
||||
</Card>
|
||||
<Card title="Référence logique" icon="bolt" href="/l/fr/developers/extend/apps/logic/overview">
|
||||
Cron et les déclencheurs d'événements de base de données, le stockage de valeurs clés, les connexions OAuth.
|
||||
</Card>
|
||||
<Card title="Référence de mise en page" icon="table-columns" href="/l/fr/developers/extend/apps/layout/overview">
|
||||
Mise en page des pages, widgets du tableau de bord et plus de surfaces de l'interface utilisateur.
|
||||
</Card>
|
||||
<Card title="Opérations" icon="rocket" href="/l/fr/developers/extend/apps/operations/overview">
|
||||
CLI, tests, télécommandes et CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Prise en main"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Tutoriel"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Configuration"
|
||||
},
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: Lascia che un agente generi documenti da una chat, usando il tuo strumento.
|
||||
---
|
||||
|
||||
Poiché `generate-document` è esposto come **strumento**, un agente AI può chiamarlo.
|
||||
Aggiungiamo un agente e un'abilità in modo che gli utenti possano solo dire *"generare una proposta per
|
||||
Jeffery Griffin"*.
|
||||
|
||||
## L'abilità
|
||||
|
||||
A [skill](/l/it/developers/extend/apps/logic/skills-and-agents) è riutilizzabile
|
||||
istruzioni — conoscenza che si lega agli agenti. Il nostro insegna al modello come usare
|
||||
lo strumento.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## L'agente
|
||||
|
||||
Un [agent](/l/it/developers/extend/apps/logic/skills-and-agents) coppia un prompt con un modello
|
||||
. Imposta esplicitamente `responseFormat` per evitare un avviso di generazione.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
L'agente può chiamare lo strumento solo se il suo ruolo lo permette. Abbiamo già impostato
|
||||
`canAccessAllTools: true` e `canBeAssignedToAgents: true` sul ruolo dell'app in
|
||||
[Capitolo 2](/l/it/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Provalo
|
||||
|
||||
Apri una chat con **Document Assistant** e chiedi di redigere un documento per una persona
|
||||
nel tuo CRM. Trova il record, chiama `documento generato`, e riporta
|
||||
indietro il documento che ha creato — che ora appare nella tua vista **Documenti**,
|
||||
esattamente come il menu di comando e i percorsi del flusso di lavoro.
|
||||
|
||||
Questo è il payoff della logica di esposizione come uno strumento: **una funzione, molte porte d'ingresso** —
|
||||
menu di comando, HTTP, passo del flusso di lavoro, e ora il linguaggio naturale.
|
||||
|
||||
**Dopo questo passaggio:** l'app è completa e genuinamente utile. Tempo per
|
||||
spedirlo.
|
||||
|
||||
<Card title="Successivo: pubblicazione →" icon="rocket" href="/l/it/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
Aggiungi metadati di mercato e pubblica.
|
||||
</Card>
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: 4. Costruire l'interfaccia utente
|
||||
icon: table-columns
|
||||
description: Viste, navigazione della barra laterale, un comando e componenti anteriori.
|
||||
---
|
||||
|
||||
In questo momento gli oggetti sono raggiungibili solo attraverso le Impostazioni. Diamo all'app una presenza
|
||||
reale nell'UI: viste elenco, voci sidebar, un comando con un solo clic
|
||||
**Genera documento** un componente frontale della record-page per **anteprima** un documento
|
||||
e una scheda **editor** di testo ricco nativo per i modelli.
|
||||
|
||||
## Visualizzazioni e navigazione
|
||||
|
||||
Un [view](/l/it/developers/extend/apps/layout/views) è una lista salvata di un dato oggetto.
|
||||
Una [voce del menu di navigazione](/l/it/developers/extend/apps/layout/navigation-menu-items)
|
||||
mette quella vista nella barra laterale.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Aggiungi la stessa coppia per i modelli. Entrambi ora mostrano nella barra laterale:
|
||||
|
||||
<Frame caption="Documenti e Modelli nella barra laterale, con il documento generato elencato.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Vista documenti con un documento generato" />
|
||||
</Frame>
|
||||
|
||||
## Un componente anteriore
|
||||
|
||||
Un [componente anteriore](/l/it/developers/extend/apps/layout/front-components) è un componente React
|
||||
sabbiato all'interno di Twenty. La nostra legge il record selezionato, carica i modelli di persona
|
||||
tramite `CoreApiClient`, e POSTs sul percorso dall'ultimo capitolo
|
||||
.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Stile con variabili CSS in linea (`var(--t-color-blue)`), non valori importati da
|
||||
`twenty-ui`. Gli SDK mocks che il pacchetto durante la build, quindi le importazioni a livello di modulo di costanti del tema
|
||||
sarebbero `indefinite`. Vedi il [componente completo]
|
||||
(https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## Un comando per aprirlo
|
||||
|
||||
Una [voce del menu di comando](/l/it/developers/extend/apps/layout/command-menu-items) con
|
||||
`disponibilitàTipo: 'RECORD_SELECTION'` viene visualizzata quando una persona è selezionata, e
|
||||
apre il componente nel pannello laterale.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Prova l'intero flusso
|
||||
|
||||
Aprire **Persone**, spuntare una persona e premere <kbd>국K</kbd> / <kbd>Ctrl K</kbd>.
|
||||
"Genera documento" appare, taggato con la tua app:
|
||||
|
||||
<Frame caption="Il comando viene visualizzato quando una persona è selezionata.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Menu comandi con Genera documento" />
|
||||
</Frame>
|
||||
|
||||
Eseguire — il componente si apre nel pannello laterale. Scegli un modello, fai clic su
|
||||
**Genera**, e un nuovo record atterra in **Documenti**.
|
||||
|
||||
<Frame caption="Il componente anteriore, il caricamento dei modelli e la generazione al clic.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Genera pannello laterale documento" />
|
||||
</Frame>
|
||||
|
||||
Ogni documento generato registra la tua app come suo autore:
|
||||
|
||||
<Frame caption="Creato da Generatore di documenti, stato generato.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="Un documento generato" />
|
||||
</Frame>
|
||||
|
||||
## Anteprima di un documento nella sua pagina di record
|
||||
|
||||
Un componente frontale non è solo per i menu di comando: puoi montarne uno come \*\*scheda su una pagina di record
|
||||
\*\*. Aggiungiamo una scheda *Anteprima* al record di documenti che rende il corpo
|
||||
Markdown come una pagina lucida e stampabile.
|
||||
|
||||
Il componente legge l'id del record corrente dal suo contesto di esecuzione, carica il documento
|
||||
e lo rende. I componenti anteriori vengono eseguiti in una **sandbox** che permette solo una whitelist
|
||||
di tag HTML — iniezione HTML grezza (`dangerouslySetInnerHTML`) e
|
||||
`\<style>` sono bloccati — quindi rendiamo Markdown come elementi React con stili inline
|
||||
tramite un piccolo [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
helper.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Monta con un [layout pagina](/l/it/developers/extend/apps/layout/page-layouts). Un layout
|
||||
`RECORD_PAGE` aggiunge schede alla vista record di un oggetto; un widget `FRONT_COMPONENT`
|
||||
in una scheda `CANVAS` ospita il componente:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Apri qualsiasi documento — una scheda **Anteprima** lo rende splendido, con link alla pagina web
|
||||
condivisibile e il PDF:
|
||||
|
||||
<Frame caption="La scheda Anteprima rende il documento con stili in linea, più collegamenti rapidi.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Componente frontale del visualizzatore documenti in una scheda record-page" />
|
||||
</Frame>
|
||||
|
||||
## Modifica un modello con l'editor di testo
|
||||
|
||||
I modelli non hanno bisogno di un componente personalizzato. Perché il `body` è un campo
|
||||
`RICH_TEXT`, Venti fornisce già un editor di testo completo per esso — l'
|
||||
stesso degli oggetti Note e Attività standard utilizzati. Lo superficiamo solo sulla pagina di record di modello
|
||||
.
|
||||
|
||||
Aggiungi una scheda con un widget `FIELD` in modalità di visualizzazione `EDITOR`, puntando sul campo `body`
|
||||
tramite `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
Un campo `RICH_TEXT` memorizza sia il blocco dell'editor JSON che una proiezione Markdown
|
||||
. La pipeline di generazione legge che Markdown proiezione, quindi
|
||||
segnaposti, il PDF, e la pagina web condivisibile tutti continuano a lavorare invariato —
|
||||
vedere il completo
|
||||
[`template-record. age-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Ora gli editori scrivono i modelli in un corretto editor di testo ricco:
|
||||
|
||||
<Frame caption="La scheda Modello: Editor nativo di testo ricco di venti legato al campo corpo.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Template record con la scheda nativa editor di testo ricco" />
|
||||
</Frame>
|
||||
|
||||
**Dopo questo passaggio:** i documenti in anteprima magnificamente e i modelli sono modificabili
|
||||
in-app. Poi, lasciare che un agente AI li generi da una chat.
|
||||
|
||||
<Card title="Il prossimo: un agente AI →" icon="robot" href="/l/it/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Aggiungi un agente e un'abilità che chiama il tuo strumento.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Modello dati
|
||||
icon: database
|
||||
description: Modelli di documenti e modelli con oggetti, campi e una relazione.
|
||||
---
|
||||
|
||||
La nostra app ha bisogno di due oggetti personalizzati: **modelli di documenti** (cosa scrivere) e
|
||||
**documenti** (il risultato generato). Le definiamo.
|
||||
|
||||
Scaffold each entity file with the CLI — genera un UUID valido e la cartella
|
||||
giusta per te:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Di seguito mostriamo i file finiti.
|
||||
|
||||
<Note>
|
||||
Ogni costante `*_UNIVERSAL_IDENTIFIER` vive in
|
||||
`src/constants/universal-identifiers.ts` ed è importata dove utilizzata. Gli snippet
|
||||
qui sotto omettono quelle importazioni per brevità — tenerli nei propri file.
|
||||
</Note>
|
||||
|
||||
## L'oggetto del modello
|
||||
|
||||
Un modello ha un `name`, un `body` con `{{placeholders}}`, e un `target` che
|
||||
dice se è scritto per una persona o un'azienda. Il `body` è un campo
|
||||
`RICH_TEXT`, quindi Twenty gli dà un editor completo di testo ricco.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
L'opzione `SELECT` **values** deve essere `UPPER_CASE` (`PERSON`, non `person`), e il `defaultValue`
|
||||
è racchiuso in virgolette extra: `` `'PERSON'` ``. Il `label` è quello che vedono gli utenti
|
||||
.
|
||||
</Warning>
|
||||
|
||||
## L'oggetto del documento
|
||||
|
||||
Il documento generato memorizza il `content` renderizzato e un `status`. Definisci
|
||||
allo stesso modo, con una selezione `status` di `DRAFT` / `GENERATED`. File completo:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Collegarli con una relazione
|
||||
|
||||
Ogni documento deve riportare il modello da cui proveniva. Le relazioni sono
|
||||
**bidirezionali** — si definiscono entrambi i lati, ciascuno nel proprio file di campo.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
L'altro lato (`template-documents-relation.field.ts`) è un campo
|
||||
`RelationType.ONE_TO_MANY` chiamato `documents` che punta il senso opposto.
|
||||
Vedi [Relations](/l/it/developers/extend/apps/data/relations) per il modello completo.
|
||||
|
||||
## Vedere in venti
|
||||
|
||||
Con `yarn venti dev` in esecuzione, apri **Impostazioni → Modello dati**. Entrambi gli oggetti
|
||||
appariranno, etichettati con la tua app.
|
||||
|
||||
<Frame caption="Entrambi gli oggetti personalizzati, di proprietà dell'app Generatore di documenti.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Impostazioni del modello di dati che mostrano i modelli di documenti e documenti" />
|
||||
</Frame>
|
||||
|
||||
Crea un modello da testare con — chiamalo *Proposta vendite*, imposta **Obiettivo** su
|
||||
*Persona*, e incolla un corpo con alcuni segnaposti:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="Un record di modello. Il corpo mantiene i segnaposto finché non viene generato un documento.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="Un record modello di proposta di vendita con corpo segnaposto" />
|
||||
</Frame>
|
||||
|
||||
**Dopo questo passaggio:** hai oggetti `documentTemplate` e `document`, collegati da
|
||||
una relazione e un modello da cui generare. Successivamente, la logica che lo riempie.
|
||||
|
||||
<Card title="Successivo: generare documenti →" icon="bolt" href="/l/it/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
Scrivi la funzione logica che riempie il modello.
|
||||
</Card>
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: 2. Generazione documenti
|
||||
icon: bolt
|
||||
description: Una funzione logica, esposta come strumento AI e un'azione di flusso di lavoro.
|
||||
---
|
||||
|
||||
Ora il core: una [funzione logica](/l/it/developers/extend/apps/logic/logic-functions)
|
||||
che carica un modello e un record, riempie i segnaposti, e salva un nuovo documento
|
||||
.
|
||||
|
||||
Scriveremo la logica aziendale una volta come **handler**, quindi esponendola attraverso
|
||||
diversi trigger. Questo capitolo ne collega due — uno **strumento di intelligenza artificiale** e un'azione di workflow
|
||||
\*\*.
|
||||
|
||||
## L'helper del rendering
|
||||
|
||||
Mantenere la logica pura nel proprio file in modo che sia facile da unit-test. Questo appiattisce un record
|
||||
in `{{dot.path}}` token e li sostituisce.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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>
|
||||
Poiché questo file non ha effetti collaterali, puoi coprirlo con i test veloci di unità
|
||||
(`yarn test:unit`). Vedi [Testing](/l/it/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## Il gestore
|
||||
|
||||
Il gestore utilizza il [`CoreApiClient`](/l/it/developers/extend/apps/logic/logic-functions)
|
||||
generato per leggere e scrivere i dati CRM. Carica il modello, carica il record di destinazione, riempie
|
||||
il corpo e crea un `documento`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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` esegue una query diversa per una Persona contro una Società e appiattisce
|
||||
il risultato — vedi
|
||||
[`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).
|
||||
|
||||
## Esporre come strumento e come azione del flusso di lavoro
|
||||
|
||||
Un singolo `defineLogicFunction` può portare diversi trigger. Qui, `toolTriggerSettings`
|
||||
lo rende chiamabile da agenti AI, e `workflowActionTriggerSettings` lo trasforma in un passaggio
|
||||
nel costruttore di flussi di lavoro visivi. Entrambi descrivono il loro input con uno schema JSON.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
Lo schema di input è uno schema JSON semplice che descrive `templateId` e `recordId` —
|
||||
vedi [`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).
|
||||
|
||||
## Concedi l'accesso
|
||||
|
||||
Le funzioni Logica vengono eseguite come ruolo dell'app. Ha bisogno di leggere i modelli e registra
|
||||
e creare documenti, in modo da consentire che in `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
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` permette alla funzione di caricare il PDF generato nella prossima sezione.
|
||||
Vedi [Roles](/l/it/developers/extend/apps/config/roles) per i permessi a grana più fine.
|
||||
|
||||
## Allega un file PDF reale
|
||||
|
||||
Un campo di testo renderizzato è utile, ma gli utenti vogliono un documento reale. Generiamo un file
|
||||
**PDF** e memorizzalo sul record come file scaricabile.
|
||||
|
||||
Innanzitutto, dai al `document` object un campo `FILES` per tenere il PDF. Le app caricano
|
||||
nei loro **propri** campi di file, quindi questo campo è quello che il caricamento:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Ora renderizza quel PDF. Un'app è un vero progetto Node, quindi puoi aggiungere qualsiasi pacchetto npm
|
||||
che ti serve e importarlo come altrove. Usiamo **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
per disegnare il PDF e **[marked](https://marked.js.org/)** per analizzare il corpo Markdown
|
||||
— il CLI li installa nel runtime della funzione per te:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
L'helper completo è
|
||||
[`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).
|
||||
Analizza il Markdown in token con `marked. exer`, poi li mette fuori con
|
||||
pdf-lib: intestazioni reali, **bold**/*italic* runs, proiettile e liste numerate,
|
||||
blockquotes e regole — un rendering A4 lucido e multi-pagina del modello
|
||||
stesso, piuttosto che un muro di testo.
|
||||
|
||||
<Frame caption="Il PDF generato: la tipografia reale e la formattazione Markdown, rendendo il corpo del modello.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="Un PDF generato lucido e commerciabile" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
pdf-lib's built-in font utilizzare WinAnsi encoding, così gli accenti occidentali europei rendono
|
||||
fuori dalla scatola; le mappe helper citazioni intelligenti e trattini e lascia i caratteri
|
||||
non possono codificare. Rendering non latino script (Cinese, Arabo, Cirillico) significherebbe
|
||||
incorporare un carattere Unicode.
|
||||
</Note>
|
||||
|
||||
Quindi caricarlo e memorizzare il riferimento sul record. `uploadFile` percorre bytes
|
||||
nel campo dei file di proprietà dell'app; il `id` restituito è quello che salvi:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Il documento generato ora contiene un PDF scaricabile:
|
||||
|
||||
<Frame caption="Il PDF generato, memorizzato nel campo File del documento.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="Un record di documento con un file PDF generato" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` si rivolge solo ai campi di file **app-owned** (quindi i caricamenti richiedono sempre un'app
|
||||
che possiede il campo, più il flag ruolo `UPLOAD_FILE`). Ecco perché il PDF
|
||||
atterra sul campo `file` del record - lo stesso modello utilizzato dall'app
|
||||
[call-recorder](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
per le registrazioni.
|
||||
</Note>
|
||||
|
||||
**Dopo questo passaggio:** ogni documento generato ha un PDF reale e scaricabile. Ma
|
||||
niente può *chiamare* il generatore dall'interfaccia utente ancora — per questo abbiamo bisogno di un percorso HTTP.
|
||||
|
||||
<Card title="Il prossimo: percorsi HTTP →" icon="globo" href="/l/it/developers/extend/apps/tutorials/document-generator/http-route">
|
||||
Servire la funzione su HTTP e rendere i documenti come pagine web.
|
||||
</Card>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: 3. Percorsi HTTP
|
||||
icon: globe
|
||||
description: Attivare la funzione su HTTP e rendere i documenti come pagine web.
|
||||
---
|
||||
|
||||
Lo stesso gestore può anche rispondere alle richieste HTTP. Aggiungeremo due percorsi:
|
||||
|
||||
* un endpoint **POST** per generare un documento, e
|
||||
* un endpoint pubblico **GET** che rende un documento come una pagina web stampabile.
|
||||
|
||||
Entrambi usano `httpRouteTriggerSettings`. Gli itinerari delle app sono serviti sotto `/s` sul tuo server
|
||||
Twenty (es. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## Percorso POST — generare su richiesta
|
||||
|
||||
Questo riutilizza `generateDocumentHandler`, quindi non c'è alcuna logica da ripetere — solo un sottile adattatore
|
||||
che legge il corpo della richiesta.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Il gestore condiviso restituisce un suggerito `status` al fallimento, quindi il percorso può rispondere
|
||||
con un corretto codice `4xx`/`5xx`. `isAuthRequired: true` significa che il chiamante
|
||||
deve presentare un token valido — il componente anteriore nel capitolo successivo passa automaticamente il token di accesso dell'utente
|
||||
.
|
||||
|
||||
## OTTIENI il percorso — renderizza come pagina web
|
||||
|
||||
Per restituire HTML invece di JSON, avvolgi il corpo in un `Response` con un'intestazione
|
||||
`Content-Type`. Questo percorso è pubblico (`isAuthRequired: false`) così un documento generato
|
||||
può essere condiviso come link.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` rende il corpo Markdown in HTML (con [marked](https://marked.js.org/),
|
||||
sanitizzato) e lo lascia in un pulito, pagina stampabile che mostra solo il contenuto del template
|
||||
— lo stesso aspetto del PDF e l'anteprima in-app.
|
||||
[Vedi l'helper](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Provalo
|
||||
|
||||
Con un modello e una persona nel tuo workspace, chiama il percorso (prendi un token da
|
||||
**Impostazioni → API & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Apri il documento restituito nel tuo browser:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="Il percorso GET pubblico rende il documento come una pagina stampabile.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Una pagina web di documento renderizzato" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
Puoi anche trasmettere i log di una funzione durante il test con
|
||||
`yarn venti dev:function:logs`, o invocarlo direttamente con
|
||||
`yarn venti dev:function:exec`.
|
||||
</Tip>
|
||||
|
||||
**Dopo questo passaggio:** l'app può generare documenti su HTTP e servirli come pagine web
|
||||
. Ora rendiamo utilizzabile senza `curl`.
|
||||
|
||||
<Card title="Il prossimo: costruire l'interfaccia utente →" icon="table-columns" href="/l/it/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Viste, navigazione, un comando e un componente anteriore.
|
||||
</Card>
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: "Tutorial: Generatore di documenti"
|
||||
icon: wand-magic-sparkles
|
||||
description: Crea una vera app Twenty che genera documenti personalizzati a partire dai dati del tuo CRM.
|
||||
---
|
||||
|
||||
In questo tutorial creerai **Document Generator**, un'app che trasforma modelli riutilizzabili in documenti personalizzati usando i dati già presenti nel tuo CRM.
|
||||
|
||||
Scrivi una volta un modello con `{{placeholders}}`, quindi genera un documento compilato per qualsiasi Persona o Azienda con un solo clic, dal command menu, da un agente AI o da un workflow.
|
||||
|
||||
<Frame caption="Un modello, generato per una persona specifica, aperto come pagina stampabile.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Un documento di proposta di vendita generato" />
|
||||
</Frame>
|
||||
|
||||
## Cosa imparerai
|
||||
|
||||
Ogni capitolo aggiunge una funzionalità. Alla fine avrai toccato la maggior parte dell’SDK.
|
||||
|
||||
| Capitolo | Funzionalità | Riferimento |
|
||||
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| [1. Modello dati](/l/it/developers/extend/apps/tutorials/document-generator/data-model) | Oggetti, campi e una relazione | [Dati](/l/it/developers/extend/apps/data/overview) |
|
||||
| [2. Generare documenti](/l/it/developers/extend/apps/tutorials/document-generator/generating-documents) | Una funzione logica (strumento IA + azione del workflow) che compila un modello Markdown e allega un PDF rifinito | [Funzioni logiche](/l/it/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. Route HTTP](/l/it/developers/extend/apps/tutorials/document-generator/http-routes) | Servire JSON e una pagina HTML condivisibile dalle route | [Funzioni logiche](/l/it/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Creare l’interfaccia utente](/l/it/developers/extend/apps/tutorials/document-generator/building-the-ui) | Viste, navigazione, menu dei comandi e componenti front-end che mostrano l’anteprima di un documento e modificano un modello | [Layout](/l/it/developers/extend/apps/layout/overview) |
|
||||
| [5. Un agente IA](/l/it/developers/extend/apps/tutorials/document-generator/ai-agent) | Agente + abilità | [Abilità e agenti](/l/it/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Pubblicazione](/l/it/developers/extend/apps/tutorials/document-generator/publishing) | Pubblicala nel marketplace | [Pubblicazione](/l/it/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Prerequisiti
|
||||
|
||||
Dovresti aver completato il [Quick Start](/l/it/developers/extend/apps/getting-started/quick-start):
|
||||
un server Twenty locale in esecuzione sulla porta `2020` e la CLI autenticata ad esso.
|
||||
|
||||
In caso contrario, esegui ora lo scaffold e avvialo:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Preferisci leggere il codice finito? L’app completa si trova in
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Ogni snippet qui sotto è copiato da lì.
|
||||
</Note>
|
||||
|
||||
## Come si integra l’app
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="Un modello con segnaposto viene trasformato in un documento rifinito con un PDF, attivato dal menu dei comandi, da un agente IA, da un workflow o da un link condivisibile" />
|
||||
</Frame>
|
||||
|
||||
Scrivi una **template** una sola volta in un editor rich text, con `{{placeholders}}`. Scegliere una
|
||||
template e un record CRM compila i segnaposto e memorizza un
|
||||
**documento** rifinito (con un file PDF). Tutto il resto — il menu dei comandi, l’agente IA,
|
||||
lo step del workflow, il link condivisibile — è solo un modo diverso di attivare quell’unico generatore.
|
||||
|
||||
## Mantieni attivo questo ciclo
|
||||
|
||||
Lascia `yarn twenty dev` in esecuzione in un terminale per l’intero tutorial. Ogni volta che
|
||||
aggiungi o modifichi un file sotto `src/`, viene nuovamente sincronizzato con il tuo server entro pochi
|
||||
secondi, così puoi vedere ogni funzionalità comparire nell’interfaccia utente mentre la costruisci.
|
||||
|
||||
<Card title="Inizia a sviluppare →" icon="database" href="/l/it/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
Capitolo 1: modella documenti e template.
|
||||
</Card>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: 6. Pubblicazione
|
||||
icon: rocket
|
||||
description: Aggiungi metadati di mercato e pubblica la tua app.
|
||||
---
|
||||
|
||||
La tua app funziona. L'ultimo passo è descriverlo per il mercato e pubblicarlo.
|
||||
|
||||
## Aggiungi metadati di mercato
|
||||
|
||||
La [application config](/l/it/developers/extend/apps/config/application) contiene l'identità
|
||||
che appare nel marketplace: autore, categoria, logo e supporta i link
|
||||
. Metti un logo in `public/` e fai riferimento con `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/it/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Il ruolo predefinito viene dichiarato con `defineApplicationRole()` nel proprio file — non passi più `defaultRoleUniversalIdentifier` qui.
|
||||
</Tip>
|
||||
|
||||
Aggiungi anche la parola chiave `twenty-app` a `package.json` così l'app è scopribile:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Aggiungi screenshot galleria
|
||||
|
||||
Una quotazione di mercato si vende con screenshot. Trascina alcuni PNG nella `public/gallery/` di
|
||||
e li referenzia con `screenshots` — sono una galleria
|
||||
nella pagina di inserimento.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Lead with the payoff: make the first screenshot the finished result (un generated
|
||||
document), then show how it's triggered and authored. Usa cattura
|
||||
croccanti e ad alta risoluzione — sono la prima cosa che un utente vede.
|
||||
</Tip>
|
||||
|
||||
Dare `README.md` lo stesso trattamento — è la prima pagina su npm e GitHub.
|
||||
Apri con la proposizione del valore e uno screenshot, elenca le caratteristiche del titolo,
|
||||
quindi tieni i dettagli della build sotto la piega.
|
||||
|
||||
## Controllare prima di spedire
|
||||
|
||||
Eseguire lo stesso cancelli CI fa:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
L'esecuzione a secco stampa esattamente quello che cambierebbe sul server senza applicarlo —
|
||||
un buon controllo finale di sanità. Vedi
|
||||
[Testing](/l/it/developers/extend/apps/operations/testing) e
|
||||
[Sincronizzazione & recupero](/l/it/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Pubblica
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` costruisce e pubblica a npm per impostazione predefinita; `--private` carica un tarball
|
||||
su un registro privato di Twenty server. Per superficiare un'app pubblicata
|
||||
nel marketplace di un'istanza, attiva una sincronizzazione del catalogo:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Dettagli completi e la lista di controllo rilascio:
|
||||
[Publishing](/l/it/developers/extend/apps/operations/publishing).
|
||||
|
||||
## Hai costruito un'app 🎉
|
||||
|
||||
In sei capitoli hai usato la maggior parte della superficie SDK:
|
||||
|
||||
* **Oggetti, campi e una relazione** per modellare i dati
|
||||
* Una **funzione logica** esposta come **strumento di intelligenza artificiale**, un **workflow action**, e **HTTP routes**
|
||||
* **Viste, navigazione, un comando e un componente frontale** per l'interfaccia utente
|
||||
* Un **agente + abilità** per la generazione di linguaggio naturale
|
||||
* **Marketplace metadata** e il flusso di pubblicazione
|
||||
|
||||
L'app completata è su
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Dove andare avanti
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Riferimento dei dati" icon="database" href="/l/it/developers/extend/apps/data/overview">
|
||||
Ogni tipo di campo, relazione e opzione indice.
|
||||
</Card>
|
||||
<Card title="Riferimento logico" icon="bolt" href="/l/it/developers/extend/apps/logic/overview">
|
||||
Cron e database-event triggers, il key-value store, connessioni OAuth.
|
||||
</Card>
|
||||
<Card title="Riferimento del layout" icon="table-columns" href="/l/it/developers/extend/apps/layout/overview">
|
||||
Layout di pagina, widget dashboard e più superfici dell'interfaccia utente.
|
||||
</Card>
|
||||
<Card title="Operazioni" icon="rocket" href="/l/it/developers/extend/apps/operations/overview">
|
||||
CLI, prove, telecomandi e IC.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Per iniziare"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Tutorial"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Configurazione"
|
||||
},
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: エージェントに、あなたのツールを使用して、チャットからドキュメントを生成させます。
|
||||
---
|
||||
|
||||
`generate-document` は **tool** として公開されているため、AI エージェントはそれを呼び出すことができます。
|
||||
ユーザーが \*"
|
||||
Jeffery Griffinの提案を生成する"\*と言えるように、エージェントとスキルを追加しましょう。
|
||||
|
||||
## スキル
|
||||
|
||||
[skill](/l/ja/developers/extend/apps/logic/skills-and-agents) は
|
||||
命令を再利用できます — エージェントに付与する知識です。
|
||||
ツールの使い方をモデルに教えます。
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## エージェント
|
||||
|
||||
[agent](/l/ja/developers/extend/apps/logic/skills-and-agents) はプロンプトを
|
||||
モデルとペアリングします。 ビルド警告を避けるため、明示的に `responseFormat` を設定してください。
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
エージェントは、そのロールが許可している場合にのみツールを呼び出すことができます。 すでにアプリのロールに対して
|
||||
`canAccessAllTools: true` と `canBeAssignedToAgents: true` を設定済みです。
|
||||
詳しくは、[第2章](/l/ja/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access) を参照してください。
|
||||
</Note>
|
||||
|
||||
## お試しください
|
||||
|
||||
**ドキュメントアシスタント** とのチャットを開き、CRM内の
|
||||
人のためのドキュメントを下書きするように依頼します。 この処理はレコードを見つけて `generate-document` を呼び出し、作成したドキュメントを報告します。これにより、そのドキュメントは **Documents** ビューに表示されるようになり、コマンドメニューやワークフロー経路とまったく同じように扱えるようになります。
|
||||
|
||||
これは、ロジックをツールとして公開するための報酬です: **1つの関数、多くのフロントドア** —
|
||||
コマンドメニュー、HTTP、ワークフローステップ、そして現在の自然言語。
|
||||
|
||||
**このステップの後:** アプリは機能が完了し、本当に便利です。
|
||||
に出荷しましょう。
|
||||
|
||||
<Card title="次へ: 公開 →" icon="rocket" href="/l/ja/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
マーケットプレイスのメタデータを追加して公開します。
|
||||
</Card>
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
---
|
||||
title: 4. UI の構築
|
||||
icon: table-columns
|
||||
description: ビュー、サイドバーナビゲーション、コマンド、フロントコンポーネント。
|
||||
---
|
||||
|
||||
現在、オブジェクトは設定を通じてのみアクセス可能です。 アプリにUI上での
|
||||
本格的な存在感を持たせましょう。リストビュー、サイドバーエントリ、ワンクリックで実行できる
|
||||
**Generate document** コマンド、レコードページの先頭コンポーネントによるドキュメントの**プレビュー**、そしてテンプレート用のネイティブなリッチテキスト**エディタ**タブを追加します。
|
||||
|
||||
## ビューとナビゲーション
|
||||
|
||||
[view](/l/ja/developers/extend/apps/layout/views) は、指定されたオブジェクトのリストです。
|
||||
[navigation menu item](/l/ja/developers/extend/apps/layout/navigation-menu-items)
|
||||
はそのビューをサイドバーに配置します。
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
テンプレートに同じペアを追加します。 両方ともサイドバーに表示されます:
|
||||
|
||||
<Frame caption="サイドバーにあるドキュメントとテンプレート。生成されたドキュメントが一覧表示されます。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="生成されたドキュメントを持つドキュメントビュー" />
|
||||
</Frame>
|
||||
|
||||
## フロントコンポーネント
|
||||
|
||||
[front component](/l/ja/developers/extend/apps/layout/front-components) は Twenty 内でサンドボックス化された React
|
||||
コンポーネントです。 選択したレコードを読み込み、`CoreApiClient` 経由で
|
||||
person テンプレートを読み込み、最後の
|
||||
チャプターからルートに POST を読み込みます。
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
インラインCSS変数(`var(--t-color-blue)`)のスタイルで、
|
||||
`21ui`からインポートされた値ではありません。 ビルド中にSDKがパッケージをモックするので、
|
||||
テーマ定数のモジュールレベルのインポートは`undefined`になります。
|
||||
[full component](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx)を参照してください。
|
||||
</Warning>
|
||||
|
||||
## 開くためのコマンド
|
||||
|
||||
People が選択されているときに表示され、サイドパネルでコンポーネントを開く
|
||||
`availabilityType: 'RECORD_SELECTION'` を指定した [command menu item](/l/ja/developers/extend/apps/layout/command-menu-items) を追加します。
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## フロー全体を試してみてください
|
||||
|
||||
**People**を開き、 <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd> を押します。
|
||||
アプリでタグ付けされた「ドキュメントの生成」が表示されます:
|
||||
|
||||
<Frame caption="Person が選択されるとコマンドが表示されます。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="ドキュメントを生成するコマンドメニュー" />
|
||||
</Frame>
|
||||
|
||||
実行: コンポーネントがサイドパネルで開きます。 テンプレートを選択し、
|
||||
**生成**をクリックし、**ドキュメント**に新しいレコードの土地を追加します。
|
||||
|
||||
<Frame caption="フロントコンポーネント、テンプレートをロードし、クリック時に生成します。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="ドキュメントサイドパネルを生成する" />
|
||||
</Frame>
|
||||
|
||||
生成されたすべてのドキュメントはアプリを作成者として記録します:
|
||||
|
||||
<Frame caption="作成された文書ジェネレータ、ステータスが生成されました。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="生成されたドキュメント レコード" />
|
||||
</Frame>
|
||||
|
||||
## レコードページにドキュメントをプレビュー
|
||||
|
||||
フロントコンポーネントはコマンドメニューだけではありません —
|
||||
レコードページに**タブ**としてマウントできます。
|
||||
Markdown 本文を洗練された印刷可能なページとしてレンダリングするドキュメントレコードに *Preview* タブを追加しましょう。
|
||||
|
||||
コンポーネントは現在のレコード ID を実行コンテキストから読み込み、
|
||||
ドキュメントを読み込み、レンダリングします。 フロントコンポーネントは、HTMLタグの
|
||||
ホワイトリストのみを許可する**サンドボックス**で実行されます。生のHTMLインジェクション (`dangerouslySetInnerHTML`) と
|
||||
`\<style>`はブロックされているため、小さな[`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
ヘルパーを介して、マークダウンをインライン
|
||||
スタイルでレンダリングします。
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
[page layout](/l/ja/developers/extend/apps/layout/page-layouts) でマウントします。
|
||||
`RECORD_PAGE` レイアウトは、オブジェクトのレコードビューにタブを追加します。`CANVAS` タブの `FronT_COMPONENT`
|
||||
ウィジェットはコンポーネントをホストします。
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
**プレビュー**タブを開くと、
|
||||
共有可能なウェブページとPDFへのリンクが美しくレンダリングされます。
|
||||
|
||||
<Frame caption="format@@0 タブでは、インラインスタイルとクイックリンクを使用してドキュメントをレンダリングします。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="レコードページタブ内のドキュメントビューアフロントコンポーネント" />
|
||||
</Frame>
|
||||
|
||||
## リッチテキストエディタでテンプレートを編集
|
||||
|
||||
テンプレートにはカスタムコンポーネントは一切必要ありません。 `body` は
|
||||
`RICH_TEXT` フィールドなので、Twenty にはすでに完全なリッチテキストエディタが用意されています。これは、標準の Note および Task オブジェクトが使用しているものと同じです。
|
||||
テンプレートのレコードページでサーフェスを作成します。
|
||||
|
||||
`EDITOR`ディスプレイモードで`FIELD`ウィジェットを持つタブを追加します。`fieldMetadataId`を介して`body`
|
||||
フィールドを指しています:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
`RICH_TEXT` フィールドには、エディタのブロック JSON と Markdown
|
||||
プロジェクションの両方が格納されます。 生成パイプラインはその Markdown プロジェクションを読み取るため、プレースホルダー、PDF、共有可能なウェブページはすべて変更なしで動作し続けます。詳しくは、
|
||||
[`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts) を参照してください。
|
||||
編集者は適切なリッチテキストエディタでテンプレートを書き込むようになりました:
|
||||
|
||||
<Frame caption="format@@0タブ:本文フィールドにバインドされている、Twentyのネイティブリッチテキストエディタ。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="ネイティブリッチテキストエディタタブのテンプレートレコード" />
|
||||
</Frame>
|
||||
|
||||
**このステップの完了後:** ドキュメントは美しくプレビューされ、テンプレートはアプリ内で編集可能になります。 次に、AIエージェントがチャットからそれらを生成します。
|
||||
|
||||
<Card title="次へ: AI エージェント →" icon="robot" href="/l/ja/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
エージェントとツールと呼ぶスキルを追加します。
|
||||
</Card>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: 1. データモデル
|
||||
icon: database
|
||||
description: オブジェクト、フィールド、および関連を持つモデルドキュメントとテンプレート。
|
||||
---
|
||||
|
||||
アプリには2つのカスタムオブジェクトが必要です: **ドキュメントテンプレート** (書き込み内容) と
|
||||
**ドキュメント** (生成された結果). 定義してみましょう
|
||||
|
||||
CLI で各エンティティファイルをScaffold — 有効な UUID と適切な
|
||||
フォルダーを生成します。
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
以下に完成したファイルを示します。
|
||||
|
||||
<Note>
|
||||
すべての `*_UNIVERSAL_IDENTIFIER` 定数は
|
||||
`src/constants/universal-identifiers.ts` にあり、使用される場所で import されます。 下のスニペット
|
||||
は、簡潔にインポートするためにそれらを省略します。あなた自身のファイルに保存します。
|
||||
</Note>
|
||||
|
||||
## テンプレートオブジェクト
|
||||
|
||||
テンプレートには `name`、`{{placeholders}}` を含む `body`、そして Person 向けか Company 向けかを示す `target` があります。 `body` は
|
||||
`RICH_TEXT` フィールドです。したがって、20 はリッチテキストエディタを提供します。
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` オプション **values** は `UPPER_CASE` (`Person`ではなく`PERSON`) でなければなりません。そして
|
||||
`defaultValue` は余分な引用符で包まれています: \`\`'PERSON'` ``` 。 `label\`は
|
||||
ユーザーが見るものです。
|
||||
</Warning>
|
||||
|
||||
## ドキュメントオブジェクト
|
||||
|
||||
生成されたドキュメントには、レンダリングされた `content` と `status` が保存されます。 `DRAFT` / `ジェネレータ`を`status`で選択し、同じ方法で
|
||||
を定義します。 フルファイル:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## リレーションとリンクする
|
||||
|
||||
各ドキュメントは、それがから来たテンプレートを指す必要があります。 リレーションは
|
||||
**双方向** で、それぞれ独自のフィールドファイルで両側を定義します。
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
反対側(`template-documents-relation.field.ts`)は、逆方向を指す「ドキュメント」という名前の
|
||||
`RelationType.ONE_TO_MANY` フィールドです。
|
||||
フルパターンについては [Relations](/l/ja/developers/extend/apps/data/relations) を参照してください。
|
||||
|
||||
## 二十日で見る
|
||||
|
||||
`yarn tin dev` が実行されている場合は、 **Settings → Data model** を開きます。 両方のオブジェクト
|
||||
がアプリでタグ付けされて表示されます。
|
||||
|
||||
<Frame caption="ドキュメントジェネレータアプリが所有するカスタムオブジェクトの両方。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="ドキュメントとドキュメントテンプレートを表示するデータモデルの設定" />
|
||||
</Frame>
|
||||
|
||||
1つのテンプレートを作成してテストするには、*セールスプロポーザル*という名前を付けてください。**ターゲット**を
|
||||
*Person*に設定し、いくつかのプレースホルダーを持つ本体を貼り付けてください:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="テンプレートレコード。 本文は、ドキュメントが生成されるまでプレースホルダを保持します。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="プレースホルダー ボディを持つ販売提案テンプレートの記録" />
|
||||
</Frame>
|
||||
|
||||
**このステップの後:** `documentTemplate` と `document` オブジェクト、
|
||||
リレーションによってリンクされているオブジェクト、 そして生成するテンプレートが 1 つあります。 次に、それを埋めるロジック。
|
||||
|
||||
<Card title="次へ: ドキュメントの生成 →" icon="bolt" href="/l/ja/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
テンプレートを満たすロジック機能を記述します。
|
||||
</Card>
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
---
|
||||
title: 2. ドキュメントの生成
|
||||
icon: bolt
|
||||
description: AIツールとワークフローアクションとして公開されるロジック機能の1つ。
|
||||
---
|
||||
|
||||
コア: [ロジック関数](/l/ja/developers/extend/apps/logic/logic-functions)
|
||||
テンプレートとレコードをロードし、プレースホルダを埋め、新しい
|
||||
ドキュメントを保存します。
|
||||
|
||||
**ハンドラ**としてビジネスロジックを記述し、
|
||||
いくつかのトリガーを通してそれを公開します。 この章では、**AI ツール** と
|
||||
**ワークフローアクション** の 2 つを配線します。
|
||||
|
||||
## レンダリングヘルパー
|
||||
|
||||
単体テストを簡単に行えるように、純粋なロジックを独自のファイルに保存します。 これにより、レコード
|
||||
を `{{dot.path}}` トークンに平坦化し、それらを置き換えます。
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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/ja/developers/extend/apps/operations/testing) を参照してください。
|
||||
</Tip>
|
||||
|
||||
## The handler
|
||||
|
||||
ハンドラは生成された [`CoreApiClient`](/l/ja/developers/extend/apps/logic/logic-functions)
|
||||
を使用してCRMデータを読み書きします。 テンプレートをロードし、ターゲットレコードをロードし、
|
||||
本文を埋め、`document`を作成します。
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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 vs.a 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)を参照してください。
|
||||
|
||||
## ツールとワークフローアクションとして公開する
|
||||
|
||||
一つの `defineLogicFunction` は複数のトリガーを持つことができます。 ここでは、`toolTriggerSettings`
|
||||
をAIエージェントから呼び出すことができ、`workflowActionTriggerSettings` をビジュアルワークフロービルダーの
|
||||
ステップに変換します。 どちらもJSONスキーマを使用して入力を記述します。
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
入力スキーマは `templateId` と `recordId` を説明するプレーンな JSON スキーマです —
|
||||
は [`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
|
||||
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/ja/developers/extend/apps/config/roles) を参照してください。
|
||||
|
||||
## 実際の PDF ファイルを添付する
|
||||
|
||||
レンダリングされたテキストフィールドは役に立ちますが、ユーザは実際のドキュメントを望んでいます。
|
||||
**PDF**を生成して、ダウンロード可能なファイルとしてレコードに保存しましょう。
|
||||
|
||||
まず、`document` オブジェクトに `FILES` フィールドを指定し、PDFを保持します。 アプリはファイルフィールドに
|
||||
をアップロードするので、このフィールドはアップロードのルートとなります。
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
今度はそのPDFをレンダリングします。 アプリは本物の Node プロジェクトなので、必要な npm パッケージを自由に追加し、他の場所と同じようにインポートできます。 私たちは **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
を使って PDF と **[marked](https://marked.js.org/)** を Markdown
|
||||
本文を解析します。 — CLI はそれらを関数のランタイムにインストールします。
|
||||
|
||||
```bash filename="Terminal"
|
||||
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 を `marked.lexer` でトークンにパースし、その後 pdf-lib を使ってレイアウトします。実際の見出し、**太字**/*斜体* のラン、箇条書きおよび番号付きリスト、引用ブロックや罫線などを備え、単なるテキストの羅列ではなく、テンプレート自体を洗練された複数ページの A4 レイアウトとしてレンダリングします。
|
||||
|
||||
<Frame caption="生成されたPDF:実際の組版とマークダウン書式、テンプレート本文をレンダリングします。">
|
||||
<img src="/images/docs/developers/extends/apps/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"
|
||||
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="ドキュメントのformat@@0フィールドに保存されている生成されたPDF。">
|
||||
<img src="/images/docs/developers/extends/apps/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/ja/developers/extend/apps/tutorials/document-generator/http-routes">
|
||||
HTTP 経由で関数を提供し、ドキュメントを Web ページとして表示します。
|
||||
</Card>
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: 3. HTTPルート
|
||||
icon: globe
|
||||
description: HTTP 経由で関数をトリガーし、ドキュメントを Web ページとしてレンダリングします。
|
||||
---
|
||||
|
||||
同じハンドラは HTTP リクエストに応答することもできます。 2つのルートを追加します:
|
||||
|
||||
* ドキュメントを生成するUI呼び出しの **POST** エンドポイントと
|
||||
* ドキュメントを印刷可能なウェブページとしてレンダリングするパブリック**GET** エンドポイント。
|
||||
|
||||
どちらも `httpRouteTriggerSettings` を使用します。 アプリのルートはあなたの
|
||||
20のサーバーの`/s`の下で提供されます(例:`http://localhost:2020/s/documents/generate`)。
|
||||
|
||||
## POST route — オンデマンドで生成
|
||||
|
||||
ここでは `generateDocumentHandler` を再利用しているため、ロジックを繰り返す必要はありません。リクエストボディを読み取るだけの薄いアダプターになっています。
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
共有ハンドラーは失敗時に提案された `status` を返すので、ルートは適切な `4xx`/`5xx` コードで
|
||||
応答できます。 `isAuthRequired: true` は、呼び出し元が有効なトークンを提示しなければならないことを意味します。次の章のフロントコンポーネントは、ユーザーのアクセス トークンを自動的に渡します。
|
||||
|
||||
## GET route — ウェブページとしてレンダリング
|
||||
|
||||
JSON の代わりに HTML を返すには、本文を
|
||||
`Content-Type` ヘッダーで囲みます。 このルートはパブリックなので(`isAuthRequired: false`) 、
|
||||
生成されたドキュメントをリンクとして共有できます。
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` は、Markdown 本文を HTML にレンダリングし([marked](https://marked.js.org/) を使用し、サニタイズ済み)、テンプレートコンテンツだけが表示される、きれいで印刷可能なページに差し込みます。これは PDF やアプリ内プレビューと同じ見た目です。
|
||||
[ヘルパーを参照](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts)。
|
||||
|
||||
## お試しください
|
||||
|
||||
テンプレートとワークスペース内の人を使用して、ルートを呼び出します(トークンを
|
||||
**設定 → APIs & Webhook**から取得します)
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
返されたドキュメントをブラウザで開きます:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="公開 GET ルートは、ドキュメントを印刷可能なページとしてレンダリングします。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="レンダリングされたドキュメントの Web ページ" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
`yarn tindev:function:logs` でテスト中に関数のログをストリーミングしたり、
|
||||
`yarn tindev:function:exec` で直接呼び出したりすることもできます。
|
||||
</Tip>
|
||||
|
||||
**このステップの後:** アプリは HTTP 経由でドキュメントを生成し、Web ページとして配信できるようになります。 `curl`なしで使えるようにしましょう。
|
||||
|
||||
<Card title="次へ: UIの構築 →" icon="table-columns" href="/l/ja/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
表示、ナビゲーション、コマンド、およびフロントコンポーネント。
|
||||
</Card>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: "チュートリアル: Document Generator"
|
||||
icon: wand-magic-sparkles
|
||||
description: 実際の Twenty アプリを構築し、CRM データからパーソナライズされたドキュメントを生成しましょう。
|
||||
---
|
||||
|
||||
このチュートリアルでは、再利用可能なテンプレートを、既に CRM にあるデータを使ってパーソナライズされたドキュメントに変換するアプリ **Document Generator** を作成します。
|
||||
|
||||
一度テンプレートを作成し、`{{placeholders}}` を記述しておけば、コマンドメニュー、AI エージェント、またはワークフローからワンクリックで、任意の Person または Company 用の入力済みドキュメントを生成できます。
|
||||
|
||||
<Frame caption="特定の人物向けに生成され、印刷可能なページとして開かれる 1 つのテンプレート。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="生成された販売提案書ドキュメント" />
|
||||
</Frame>
|
||||
|
||||
## 学べること
|
||||
|
||||
各章で 1 つずつ機能を追加していきます。 最後には、SDK のほとんどの部分に触れ終えていることになります。
|
||||
|
||||
| 章 | 機能 | リファレンス |
|
||||
| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| [1. データモデル](/l/ja/developers/extend/apps/tutorials/document-generator/data-model) | オブジェクト、フィールド、およびリレーション | [Data](/l/ja/developers/extend/apps/data/overview) |
|
||||
| [2. ドキュメント生成](/l/ja/developers/extend/apps/tutorials/document-generator/generating-documents) | Markdown テンプレートを埋めて、整えられた PDF を添付するロジック関数(AI ツール + ワークフローアクション) | [ロジック関数](/l/ja/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. HTTP ルート](/l/ja/developers/extend/apps/tutorials/document-generator/http-routes) | ルートから JSON と共有可能な HTML ページを配信する | [ロジック関数](/l/ja/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. UI 構築](/l/ja/developers/extend/apps/tutorials/document-generator/building-the-ui) | Views、ナビゲーション、コマンドメニュー、およびドキュメントをプレビューしてテンプレートを編集するフロントコンポーネント | [レイアウト](/l/ja/developers/extend/apps/layout/overview) |
|
||||
| [5. AI エージェント](/l/ja/developers/extend/apps/tutorials/document-generator/ai-agent) | エージェント + スキル | [スキルとエージェント](/l/ja/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. 公開](/l/ja/developers/extend/apps/tutorials/document-generator/publishing) | マーケットプレイスに出荷する | [公開](/l/ja/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## 前提条件
|
||||
|
||||
[クイックスタート](/l/ja/developers/extend/apps/getting-started/quick-start)を完了している必要があります。
|
||||
ローカルの Twenty サーバーがポート `2020` で動作しており、CLI がそのサーバーに対して認証済みになっている状態です。
|
||||
|
||||
まだであれば、今すぐスキャフォールドして起動してください:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
完成版のコードを読みたいですか? 完成したアプリは
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator) にあります。
|
||||
以下のすべてのスニペットは、そこからコピーしたものです。
|
||||
</Note>
|
||||
|
||||
## アプリ全体の構成
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="プレースホルダー付きテンプレートが、コマンドメニュー、AI エージェント、ワークフロー、または共有可能なリンクからのトリガーによって、PDF 付きの整えられたドキュメントに生成されます" />
|
||||
</Frame>
|
||||
|
||||
リッチテキストエディタで `{{placeholders}}` を使って **テンプレート** を一度作成します。 テンプレートと CRM レコードを選ぶと、プレースホルダーが埋められ、整えられた
|
||||
**ドキュメント**(PDF ファイル付き)が保存されます。 その他のもの — コマンドメニュー、AI エージェント、
|
||||
ワークフローステップ、共有可能なリンク — はすべて、その 1 つのジェネレーターをトリガーする別々の手段にすぎません。
|
||||
|
||||
## このループを動かし続ける
|
||||
|
||||
チュートリアル全体を通して、ターミナルで `yarn twenty dev` を実行したままにしておいてください。 `src/` 配下のファイルを追加または編集するたびに、数秒以内にサーバーへ再同期されるため、ビルドしながら各機能が UI に現れていく様子を確認できます。
|
||||
|
||||
<Card title="構築を開始する →" icon="database" href="/l/ja/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
第 1 章: ドキュメントとテンプレートをモデリングする。
|
||||
</Card>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: 6. 公開
|
||||
icon: rocket
|
||||
description: マーケットプレイスのメタデータを追加し、アプリを公開します。
|
||||
---
|
||||
|
||||
アプリは動作します。 最後のステップは、マーケットプレイスのためにそれを説明し、公開することです。
|
||||
|
||||
## マーケットプレイスのメタデータを追加
|
||||
|
||||
[アプリケーション config](/l/ja/developers/extend/apps/config/application) は、マーケットプレイスに表示される
|
||||
アイデンティティを持ちます。これは、作成者、カテゴリ、ロゴ、サポート
|
||||
リンクです。 `public/`にロゴを入れ、`logoUrl`で参照してください。
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/ja/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
既定のロールは、専用のファイル内で `defineApplicationRole()` を使って宣言します。ここでは `defaultRoleUniversalIdentifier` を渡す必要はもうありません。
|
||||
</Tip>
|
||||
|
||||
`package.json`に`20app`キーワードを追加すると、アプリが見つかります。
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## ギャラリースクリーンショットを追加
|
||||
|
||||
マーケットプレイスのリストはスクリーンショットで自分自身を販売します。
|
||||
`public/gallery/` にPNGをいくつかドロップして、`screenshots` を使って参照します。リストページでギャラリー
|
||||
としてレンダリングします。
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
ペイオフでリード: 最初のスクリーンショットを完成結果(生成された
|
||||
ドキュメント)にし、それがどのようにトリガーされ、作成されたかを表示します。 鮮明で高解像度の
|
||||
キャプチャを使用する — これはユーザーが最初に目にするものです。
|
||||
</Tip>
|
||||
|
||||
`README.md` にも同じ処理を与えます。npm と GitHub のトップページです。
|
||||
値のプロポジションとスクリーンショットで開き、見出しの機能
|
||||
を一覧表示してから、ビルドの詳細を折り目以下に保ちます。
|
||||
|
||||
## 出荷前に確認
|
||||
|
||||
CI と同じゲートを実行します。
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
ドライランは、それを適用せずにサーバー上で何が変更されるかを正確にプリントします —
|
||||
良い最終正常性チェックです。
|
||||
[Testing](/l/ja/developers/extend/apps/operations/testing) と
|
||||
[Syncing & recovery](/l/ja/developers/extend/apps/operations/sync-and-recovery) を参照してください。
|
||||
|
||||
## 公開
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` はデフォルトで npm にビルドおよび公開します。`--private` は代わりに
|
||||
tarball を Twenty サーバーのプライベートレジストリにアップロードします。 公開されたアプリ
|
||||
をマーケットプレイスで表示するには、カタログ同期をトリガーします。
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
詳細とリリースのチェックリスト:
|
||||
[Publishing](/l/ja/developers/extend/apps/operations/publishing).
|
||||
|
||||
## あなたはアプリを構築しました 🎉
|
||||
|
||||
6つのチャプターでは、SDKのほとんどを使用しました。
|
||||
|
||||
* **データをモデル化するためのオブジェクト、フィールド、リレーション**
|
||||
* **AIツール**、**ワークフローアクション**、**HTTPルート**として公開される**ロジック関数**
|
||||
* **UIの表示、ナビゲーション、コマンド、フロントコンポーネント**
|
||||
* 自然言語生成のための **エージェント + スキル**
|
||||
* **マーケットプレースのメタデータ** とパブリッシュフロー
|
||||
|
||||
完成したアプリは
|
||||
[`packages/20apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)にあります。
|
||||
|
||||
## 次の場所へ
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="データ参照" icon="database" href="/l/ja/developers/extend/apps/data/overview">
|
||||
すべてのフィールドタイプ、リレーション、インデックスオプション。
|
||||
</Card>
|
||||
<Card title="ロジックリファレンス" icon="bolt" href="/l/ja/developers/extend/apps/logic/overview">
|
||||
Cronとdatabase-event トリガー、キー値ストア、OAuth 接続。
|
||||
</Card>
|
||||
<Card title="レイアウト参照" icon="table-columns" href="/l/ja/developers/extend/apps/layout/overview">
|
||||
ページレイアウト、ダッシュボードウィジェット、およびより多くのUIサーフェス。
|
||||
</Card>
|
||||
<Card title="オペレーション" icon="rocket" href="/l/ja/developers/extend/apps/operations/overview">
|
||||
CLI、テスト、リモコン、CI。
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "始めに"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "チュートリアル"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "設定"
|
||||
},
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: 5. AI 에이전트 하나
|
||||
icon: robot
|
||||
description: 에이전트가 당신의 도구를 사용해서, 채팅으로부터 문서를 생성하도록 하세요.
|
||||
---
|
||||
|
||||
`generate-document`가 **tool**로 노출되어 있기 때문에, AI 에이전트가 이를 호출할 수 있습니다.
|
||||
사용자가 그냥 \*"generate a proposal for
|
||||
Jeffery Griffin"\*이라고 말하기만 하면 되도록 에이전트와 스킬을 추가해 봅시다.
|
||||
|
||||
## 스킬
|
||||
|
||||
[skill](/l/ko/developers/extend/apps/logic/skills-and-agents)은 재사용 가능한
|
||||
지시 사항으로, 에이전트에 연결하는 지식입니다. 우리 스킬은 모델에게 그 도구를 사용하는 방법을 가르칩니다.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## 에이전트
|
||||
|
||||
[agent](/l/ko/developers/extend/apps/logic/skills-and-agents)는 프롬프트와 모델을 짝지어 줍니다. 빌드 경고를 피하려면 `responseFormat`을 명시적으로 설정하세요.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
에이전트는 자신의 역할이 허용할 때만 그 도구를 호출할 수 있습니다. 우리는 이미 [2장](/l/ko/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access)에서 앱의 역할에 `canAccessAllTools: true` 및 `canBeAssignedToAgents: true`를 설정했습니다.
|
||||
</Note>
|
||||
|
||||
## 직접 해보기
|
||||
|
||||
**Document Assistant**와 채팅을 열고, CRM에 있는 사람을 위한 문서를 작성해 달라고 요청하세요. 에이전트는 레코드를 찾고 `generate-document`를 호출한 다음, 생성한 문서를 보고합니다. 이 문서는 이제 **Documents** 보기에도 표시되며, 명령 메뉴와 워크플로 경로를 통해 생성된 문서와 완전히 동일하게 취급됩니다.
|
||||
|
||||
로직을 도구로 노출하는 것의 수확은 이것입니다: **하나의 함수, 여러 개의 진입점** — 명령 메뉴, HTTP, 워크플로 단계, 그리고 이제는 자연어까지.
|
||||
|
||||
**이 단계를 마치면:** 앱은 기능이 완전해지고 실제로 유용해집니다. 이제 배포할 시간입니다.
|
||||
|
||||
<Card title="다음: 게시 →" icon="rocket" href="/l/ko/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
마켓플레이스 메타데이터를 추가하고 게시하세요.
|
||||
</Card>
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
---
|
||||
title: 4. UI 구성하기
|
||||
icon: table-columns
|
||||
description: 뷰, 사이드바 내비게이션, 커맨드, 그리고 프런트 컴포넌트.
|
||||
---
|
||||
|
||||
현재는 객체에 설정(Settings)을 통해서만 접근할 수 있습니다. 이제 앱이 UI 에서 실제로 보이도록 해 봅시다. 리스트 뷰, 사이드바 항목, 원클릭 **Generate document** 커맨드, 문서를 **미리 보기(preview)** 위한 레코드 페이지 프런트 컴포넌트, 템플릿용 네이티브 리치 텍스트 **editor** 탭을 추가합니다.
|
||||
|
||||
## 뷰와 내비게이션
|
||||
|
||||
[뷰](/l/ko/developers/extend/apps/layout/views)는 특정 객체에 대한 저장된 리스트입니다.
|
||||
[내비게이션 메뉴 항목](/l/ko/developers/extend/apps/layout/navigation-menu-items)은 그 뷰를 사이드바에 배치합니다.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
템플릿에 대해서도 동일한 쌍을 추가하세요. 이제 둘 다 사이드바에 표시됩니다:
|
||||
|
||||
<Frame caption="사이드바의 Documents 및 Templates, 생성된 문서가 리스트에 표시됨.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="생성된 문서가 있는 Documents 뷰" />
|
||||
</Frame>
|
||||
|
||||
## 프런트 컴포넌트
|
||||
|
||||
[프런트 컴포넌트](/l/ko/developers/extend/apps/layout/front-components)는 Twenty 안에 샌드박스된 React 컴포넌트입니다. 우리 컴포넌트는 선택된 레코드를 읽고, `CoreApiClient`를 통해 person 템플릿을 로드한 뒤, 이전 장에서 만든 라우트로 POST 요청을 보냅니다.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
값을 `twenty-ui`에서 임포트하지 말고 인라인 CSS 변수(`var(--t-color-blue)`)로 스타일을 지정하세요. SDK 가 빌드 중에 해당 패키지를 모킹하므로, 테마 상수를 모듈 레벨에서 임포트하면 `undefined`가 됩니다. 전체
|
||||
[컴포넌트 전문](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx)을 참고하세요.
|
||||
</Warning>
|
||||
|
||||
## 이를 여는 커맨드
|
||||
|
||||
`availabilityType: 'RECORD_SELECTION'`이 있는 [커맨드 메뉴 항목](/l/ko/developers/extend/apps/layout/command-menu-items)은 Person 이 선택되었을 때 표시되며, 컴포넌트를 사이드 패널에서 엽니다.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## 전체 플로우 사용해 보기
|
||||
|
||||
**People**을 열고, 한 사람을 체크한 다음 <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>를 누르세요.
|
||||
"Generate document"가 앱 태그와 함께 표시됩니다:
|
||||
|
||||
<Frame caption="Person 이 선택되었을 때 이 커맨드가 표시됩니다.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Generate document 커맨드가 있는 커맨드 메뉴" />
|
||||
</Frame>
|
||||
|
||||
이 커맨드를 실행하면 컴포넌트가 사이드 패널에서 열립니다. 템플릿을 선택하고 **Generate**를 클릭하면, 새 레코드가 **Documents**에 생성됩니다.
|
||||
|
||||
<Frame caption="프런트 컴포넌트가 템플릿을 로드하고 클릭 시 문서를 생성합니다.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Generate document 사이드 패널" />
|
||||
</Frame>
|
||||
|
||||
생성된 모든 문서는 앱을 작성자로 기록합니다:
|
||||
|
||||
<Frame caption="Document Generator 가 생성했으며, 상태는 Generated.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="생성된 문서 레코드" />
|
||||
</Frame>
|
||||
|
||||
## 레코드 페이지에서 문서 미리 보기
|
||||
|
||||
프런트 컴포넌트는 커맨드 메뉴에만 쓰이는 것이 아닙니다. **레코드 페이지의 탭**으로 마운트할 수도 있습니다. 문서 레코드에 *Preview* 탭을 추가해, Markdown 본문을 다듬어진 인쇄용 페이지로 렌더링해 봅시다.
|
||||
|
||||
컴포넌트는 실행 컨텍스트에서 현재 레코드 ID 를 읽어 문서를 로드하고 렌더링합니다. 프런트 컴포넌트는 허용된 HTML 태그 화이트리스트만 허용하는 **샌드박스**에서 실행됩니다. 원시 HTML 인젝션(`dangerouslySetInnerHTML`)과 `\<style>`은 차단되므로, 작은 [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx) 헬퍼를 통해 Markdown 을 인라인 스타일이 적용된 React 요소로 렌더링합니다.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
[페이지 레이아웃](/l/ko/developers/extend/apps/layout/page-layouts)으로 이를 마운트합니다. `RECORD_PAGE` 레이아웃은 객체의 레코드 뷰에 탭을 추가합니다. `CANVAS` 탭의 `FRONT_COMPONENT` 위젯이 컴포넌트를 호스팅합니다:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
아무 문서나 열면 **Preview** 탭이 문서를 보기 좋게 렌더링하고, 공유 가능한 웹 페이지와 PDF 로 가는 링크를 함께 제공합니다:
|
||||
|
||||
<Frame caption="Preview 탭은 인라인 스타일로 문서를 렌더링하고, 빠른 링크도 제공합니다.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="레코드 페이지 탭 안의 문서 뷰어 프런트 컴포넌트" />
|
||||
</Frame>
|
||||
|
||||
## 리치 텍스트 에디터로 템플릿 편집하기
|
||||
|
||||
템플릿에는 별도의 커스텀 컴포넌트가 전혀 필요하지 않습니다. `body`가 `RICH_TEXT` 필드이기 때문에 Twenty 는 이미 완전한 리치 텍스트 에디터를 제공합니다. 표준 Note 및 Task 객체에서 사용하는 것과 동일한 에디터입니다. 우리는 이 에디터를 템플릿 레코드 페이지에 노출하기만 하면 됩니다.
|
||||
|
||||
`EDITOR` 디스플레이 모드의 `FIELD` 위젯을 가진 탭을 추가하고, `fieldMetadataId`를 통해 `body` 필드를 가리키도록 설정하세요:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
`RICH_TEXT` 필드는 에디터의 블록 JSON 과 Markdown 프로젝션을 둘 다 저장합니다. 생성 파이프라인은 그 Markdown 프로젝션을 읽으므로, 플레이스홀더, PDF, 공유 가능한 웹 페이지가 모두 변경 없이 계속 동작합니다. 전체 코드는
|
||||
[`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts)에서 확인하세요.
|
||||
이제 에디터들은 제대로 된 리치 텍스트 에디터에서 템플릿을 작성할 수 있습니다:
|
||||
|
||||
<Frame caption="Template 탭: Twenty 의 네이티브 리치 텍스트 에디터가 body 필드에 연결되어 있습니다.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="네이티브 리치 텍스트 에디터 탭이 있는 템플릿 레코드" />
|
||||
</Frame>
|
||||
|
||||
**이 단계를 마치면:** 문서는 보기 좋게 미리 볼 수 있고, 템플릿은 앱 안에서 편집할 수 있습니다. 이제 AI 에이전트가 채팅으로부터 문서를 생성하도록 해 봅시다.
|
||||
|
||||
<Card title="다음: AI 에이전트 →" icon="robot" href="/l/ko/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
도구를 호출하는 에이전트와 스킬을 추가하세요.
|
||||
</Card>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: 1. 데이터 모델
|
||||
icon: database
|
||||
description: 문서와 템플릿을 객체, 필드, 관계로 모델링합니다.
|
||||
---
|
||||
|
||||
우리 앱에는 두 개의 커스텀 객체가 필요합니다: **document templates**(무엇을 쓸지)와
|
||||
**documents**(생성된 결과)입니다. 이들을 정의해 보겠습니다.
|
||||
|
||||
각 엔티티 파일을 CLI로 스캐폴딩하세요 — 그러면 유효한 UUID와 올바른
|
||||
폴더가 자동으로 생성됩니다:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
아래에 완성된 파일들을 보여 줍니다.
|
||||
|
||||
<Note>
|
||||
모든 `*_UNIVERSAL_IDENTIFIER` 상수는
|
||||
`src/constants/universal-identifiers.ts`에 있으며, 사용하는 곳에서 import됩니다. 아래 코드 조각에서는 설명을 위해 해당 import를 생략했지만 — 실제 파일에서는 반드시 포함해야 합니다.
|
||||
</Note>
|
||||
|
||||
## 템플릿 객체
|
||||
|
||||
템플릿에는 `name`, `{{placeholders}}`가 포함된 `body`, 그리고 Person 또는 Company 중
|
||||
어느 쪽을 대상으로 작성되었는지 나타내는 `target`이 있습니다. `body`는
|
||||
`RICH_TEXT` 필드이므로, Twenty는 여기에 완전한 리치 텍스트 에디터를 제공합니다.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` 옵션 **values**는 반드시 `UPPER_CASE`(`person`이 아니라 `PERSON`)여야 하며,
|
||||
`defaultValue`는 따옴표로 한 번 더 감싸야 합니다: `` `'PERSON'` ``. `label`은
|
||||
사용자에게 표시되는 값입니다.
|
||||
</Warning>
|
||||
|
||||
## 문서 객체
|
||||
|
||||
생성된 문서는 렌더링된 `content`와 `status`를 저장합니다. `status`가 `DRAFT` / `GENERATED`인 `select` 필드로
|
||||
같은 방식으로 정의합니다. 전체 파일:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## 관계를 사용해 둘을 연결하기
|
||||
|
||||
각 문서는 자신이 생성된 템플릿을 가리켜야 합니다. 관계는
|
||||
항상 **양방향**이며, 각 필드 파일에서 각각 한쪽씩 정의합니다.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
반대편(`template-documents-relation.field.ts`)은
|
||||
반대 방향을 가리키는 `documents`라는 이름의 `RelationType.ONE_TO_MANY` 필드입니다.
|
||||
전체 패턴은 [Relations](/l/ko/developers/extend/apps/data/relations)를 참조하세요.
|
||||
|
||||
## Twenty에서 확인하기
|
||||
|
||||
`yarn twenty dev`를 실행한 상태에서 **Settings → Data model**을 엽니다. 두 객체가
|
||||
모두 표시되며, 여러분의 앱으로 태깅되어 있습니다.
|
||||
|
||||
<Frame caption="Document Generator 앱이 소유한 두 개의 커스텀 객체.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Documents와 Document templates가 표시된 데이터 모델 설정" />
|
||||
</Frame>
|
||||
|
||||
테스트용으로 템플릿 하나를 생성하세요 — 이름을 *Sales proposal*로 지정하고, **Target**을
|
||||
*Person*으로 설정한 뒤, placeholder 몇 개가 포함된 body를 붙여넣습니다:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="템플릿 레코드입니다. 문서가 생성될 때까지 body에는 placeholder가 유지됩니다.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="플레이스홀더 본문이 있는 Sales proposal 템플릿 레코드" />
|
||||
</Frame>
|
||||
|
||||
**이 단계를 마치면:** relation으로 연결된 `documentTemplate` 및 `document` 객체가 있고,
|
||||
생성을 위한 템플릿이 하나 준비된 상태입니다. 다음은 이를 채워 넣는 로직입니다.
|
||||
|
||||
<Card title="다음: 문서 생성 →" icon="bolt" href="/l/ko/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
템플릿을 채우는 로직 함수를 작성합니다.
|
||||
</Card>
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: 2. 문서 생성하기
|
||||
icon: bolt
|
||||
description: 하나의 로직 함수로, AI 도구이자 워크플로 작업으로 노출됩니다.
|
||||
---
|
||||
|
||||
이제 핵심입니다. 템플릿과 레코드를 불러와 플레이스홀더를 채우고 새 문서를 저장하는 [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions)입니다.
|
||||
|
||||
비즈니스 로직은 **핸들러**로 한 번만 작성한 다음, 여러 트리거를 통해 노출합니다. 이 장에서는 그중 두 가지, 즉 **AI 도구**와 **워크플로 작업**을 연결합니다.
|
||||
|
||||
## 렌더링 헬퍼
|
||||
|
||||
순수 로직은 별도의 파일에 유지해 단위 테스트를 쉽게 할 수 있도록 하세요. 이는 레코드를 `{{dot.path}}` 토큰으로 평탄화하고, 해당 토큰을 치환합니다.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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`)로 커버할 수 있습니다. [테스트](/l/ko/developers/extend/apps/operations/testing)를 참조하세요.
|
||||
</Tip>
|
||||
|
||||
## 핸들러
|
||||
|
||||
핸들러는 생성된 [`CoreApiClient`](/l/ko/developers/extend/apps/logic/logic-functions)를 사용해 CRM 데이터를 읽고 씁니다. 템플릿을 불러오고, 대상 레코드를 불러온 뒤, 본문을 채우고 `document`를 생성합니다.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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)를 참조하세요.
|
||||
|
||||
## 도구와 워크플로 작업으로 노출하기
|
||||
|
||||
하나의 `defineLogicFunction`에 여러 트리거를 실을 수 있습니다. 여기서는 `toolTriggerSettings`로 AI 에이전트가 호출할 수 있게 하고, `workflowActionTriggerSettings`로 시각적 워크플로 빌더에서 하나의 단계가 되도록 합니다. 둘 모두 JSON 스키마로 입력을 설명합니다.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
입력 스키마는 `templateId`와 `recordId`를 설명하는 일반 JSON 스키마입니다. 자세한 내용은 [`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
|
||||
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/ko/developers/extend/apps/config/roles)를 참조하세요.
|
||||
|
||||
## 실제 PDF 파일 첨부하기
|
||||
|
||||
렌더링된 텍스트 필드만으로도 유용하지만, 사용자들은 실제 문서를 원합니다. 이제 **PDF**를 생성하여 레코드에 다운로드 가능한 파일로 저장해 봅시다.
|
||||
|
||||
먼저, `document` 객체에 PDF를 담을 `FILES` 필드를 추가합니다. 앱은 **자신의** 파일 필드로 업로드하므로, 이 필드가 업로드 경로를 결정합니다.
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
이제 해당 PDF를 렌더링합니다. 앱은 실제 Node 프로젝트이므로, 필요한 npm 패키지를 자유롭게 추가하고 다른 곳과 마찬가지로 import할 수 있습니다. 여기서는 \*\*[pdf-lib](https://pdf-lib.js.org/)\*\*로 PDF를 그리고, \*\*[marked](https://marked.js.org/)\*\*로 Markdown 본문을 파싱합니다. CLI가 이들을 함수 런타임에 설치해 줍니다.
|
||||
|
||||
```bash filename="Terminal"
|
||||
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)에 있습니다.
|
||||
이 헬퍼는 `marked.lexer`로 Markdown을 토큰으로 파싱한 뒤, pdf-lib으로 레이아웃합니다. 실제 제목, **굵게**/**기울임** 처리, 글머리 기호 및 번호 목록, 인용 블록과 가로줄 등, 텍스트 덩어리가 아니라 템플릿 자체를 다듬어진 다중 페이지 A4 렌더링으로 만들어 줍니다.
|
||||
|
||||
<Frame caption="생성된 PDF: 실제 타이포그래피와 Markdown 서식을 사용해 템플릿 본문을 렌더링합니다.">
|
||||
<img src="/images/docs/developers/extends/apps/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"
|
||||
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="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="생성된 PDF 파일이 연결된 문서 레코드" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile`은 **앱이 소유한** 파일 필드만을 대상으로 합니다(따라서 업로드에는 항상 해당 필드를 소유한 앱과 `UPLOAD_FILE` 역할 플래그가 필요합니다). 그래서 PDF는 레코드의 자체 `file` 필드에 저장됩니다. 이는
|
||||
[call-recorder 앱](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)이 녹음을 위해 사용하는 것과 동일한 패턴입니다.
|
||||
</Note>
|
||||
|
||||
**이 단계를 마치면:** 각 생성된 문서에 실제 다운로드 가능한 PDF가 포함됩니다. 하지만 아직 UI에서 생성기를 *호출*할 수는 없습니다. 그러려면 HTTP 경로가 필요합니다.
|
||||
|
||||
<Card title="다음: HTTP 경로 →" icon="globe" href="/l/ko/developers/extend/apps/tutorials/document-generator/http-routes">
|
||||
HTTP를 통해 함수를 제공하고 문서를 웹 페이지로 렌더링합니다.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 3. HTTP 경로들
|
||||
icon: globe
|
||||
description: HTTP를 통해 함수를 트리거하고 문서를 웹 페이지로 렌더링합니다.
|
||||
---
|
||||
|
||||
같은 핸들러가 HTTP 요청에도 응답할 수 있습니다. 두 개의 경로를 추가하겠습니다:
|
||||
|
||||
* UI가 문서를 생성하기 위해 호출하는 **POST** 엔드포인트, 그리고
|
||||
* 문서를 인쇄 가능한 웹 페이지로 렌더링하는 공개 **GET** 엔드포인트입니다.
|
||||
|
||||
둘 다 `httpRouteTriggerSettings`를 사용합니다. 앱 경로는 Twenty 서버의 `/s` 아래에서 제공됩니다 (예: `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## POST 경로 — 온디맨드로 생성하기
|
||||
|
||||
이는 `generateDocumentHandler`를 재사용하므로, 반복해야 할 로직은 없고 요청 본문을 읽는 얇은 어댑터만 있으면 됩니다.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
공유 핸들러는 실패 시 제안된 `status`를 반환하므로, 경로가 적절한 `4xx`/`5xx` 코드로 응답할 수 있습니다. `isAuthRequired: true`는 호출자가 유효한 토큰을 제공해야 함을 의미합니다. 다음 장의 프런트 컴포넌트가 사용자의 액세스 토큰을 자동으로 전달합니다.
|
||||
|
||||
## GET 경로 — 웹 페이지로 렌더링하기
|
||||
|
||||
JSON 대신 HTML을 반환하려면, 본문을 `Content-Type` 헤더가 있는 `Response`로 감싸면 됩니다. 이 경로는 공개(`isAuthRequired: false`)되어 있으므로 생성된 문서를 링크로 공유할 수 있습니다.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage`는 Markdown 본문을 HTML로 렌더링하고([marked](https://marked.js.org/)로, sanitization 적용), 템플릿 콘텐츠만 표시되는 깔끔하고 인쇄 가능한 페이지에 이를 삽입합니다. 이는 PDF와 앱 내 미리보기와 동일한 모습입니다.
|
||||
[헬퍼를 확인하세요](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## 사용해 보기
|
||||
|
||||
워크스페이스에 템플릿과 Person이 준비되면, 경로를 호출하세요(**Settings → APIs & Webhooks**에서 토큰을 가져오세요):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
반환된 문서를 브라우저에서 여세요:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="공개 GET 경로는 문서를 인쇄 가능한 페이지로 렌더링합니다.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="렌더링된 문서 웹 페이지" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
테스트하는 동안 `yarn twenty dev:function:logs`로 함수의 로그를 스트리밍하거나, `yarn twenty dev:function:exec`로 직접 호출할 수도 있습니다.
|
||||
</Tip>
|
||||
|
||||
**이 단계를 마치면:** 앱은 HTTP를 통해 문서를 생성하고 이를 웹 페이지로 제공할 수 있습니다. 이제 `curl` 없이도 사용할 수 있도록 만들어 봅시다.
|
||||
|
||||
<Card title="다음: UI 빌드하기 →" icon="table-columns" href="/l/ko/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
뷰, 내비게이션, 커맨드, 그리고 프런트 컴포넌트.
|
||||
</Card>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "튜토리얼: 문서 생성기"
|
||||
icon: wand-magic-sparkles
|
||||
description: 실제 Twenty 앱을 만들어 CRM 데이터에서 개인 맞춤형 문서를 생성해 보세요.
|
||||
---
|
||||
|
||||
이 튜토리얼에서는 재사용 가능한 템플릿을 CRM에 이미 있는 데이터를 사용해 개인 맞춤형 문서로 변환하는 앱인 **Document Generator**를 만들어 보겠습니다.
|
||||
|
||||
`{{placeholders}}`로 한 번 템플릿을 작성한 다음, 명령 메뉴, AI 에이전트 또는 워크플로에서 한 번의 클릭으로 어떤 사람(Person)이나 회사(Company)에 대해서도 내용이 채워진 문서를 생성할 수 있습니다.
|
||||
|
||||
<Frame caption="특정 사람을 위해 생성된 하나의 템플릿이 인쇄 가능한 페이지로 열립니다.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="생성된 영업 제안(Sales proposal) 문서" />
|
||||
</Frame>
|
||||
|
||||
## 학습하게 될 내용
|
||||
|
||||
각 챕터는 하나의 기능을 추가합니다. 마지막에는 SDK의 대부분을 한 번씩 다뤄 보게 됩니다.
|
||||
|
||||
| 챕터 | 기능 | 참고 |
|
||||
| ------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| [1. 데이터 모델](/l/ko/developers/extend/apps/tutorials/document-generator/data-model) | 객체, 필드, 그리고 관계 | [데이터](/l/ko/developers/extend/apps/data/overview) |
|
||||
| [2. 문서 생성](/l/ko/developers/extend/apps/tutorials/document-generator/generating-documents) | Markdown 템플릿을 채우고 다듬어진 PDF를 첨부하는 로직 함수(AI 도구 + 워크플로 작업) | [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. HTTP 경로](/l/ko/developers/extend/apps/tutorials/document-generator/http-routes) | 경로에서 JSON과 공유 가능한 HTML 페이지 제공 | [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. UI 빌드](/l/ko/developers/extend/apps/tutorials/document-generator/building-the-ui) | 뷰, 내비게이션, 명령 메뉴, 그리고 문서를 미리 보고 템플릿을 편집하는 프런트 컴포넌트 | [레이아웃](/l/ko/developers/extend/apps/layout/overview) |
|
||||
| [5. AI 에이전트](/l/ko/developers/extend/apps/tutorials/document-generator/ai-agent) | 에이전트 + 스킬 | [스킬 및 에이전트](/l/ko/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. 게시하기](/l/ko/developers/extend/apps/tutorials/document-generator/publishing) | 마켓플레이스에 출시하기 | [게시하기](/l/ko/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## 사전 준비
|
||||
|
||||
[빠른 시작](/l/ko/developers/extend/apps/getting-started/quick-start)을 이미 완료했다고 가정합니다.
|
||||
포트 `2020`에서 실행 중인 로컬 Twenty 서버와, 해당 서버에 인증된 CLI가 있어야 합니다.
|
||||
|
||||
아직 아니라면, 지금 스캐폴딩하고 서버를 시작하세요:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
완성된 코드를 먼저 보고 싶으신가요? 전체 앱은
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)에 있습니다.
|
||||
아래의 모든 코드 스니펫은 이 앱에서 가져온 것입니다.
|
||||
</Note>
|
||||
|
||||
## 앱이 어떻게 구성되는지
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="플레이스홀더가 있는 템플릿은 명령 메뉴, AI 에이전트, 워크플로, 또는 공유 가능한 링크에서 트리거되어 PDF가 포함된 다듬어진 문서로 생성됩니다." />
|
||||
</Frame>
|
||||
|
||||
리치 텍스트 편집기에서 `{{placeholders}}`가 포함된 **템플릿**을 한 번 작성합니다. 템플릿과 CRM 레코드를 선택하면 플레이스홀더가 채워지고, 다듬어진 **문서**(PDF 파일 포함)가 저장됩니다. 나머지 — 명령 메뉴, AI 에이전트, 워크플로 단계, 공유 가능한 링크 — 는 모두 해당 하나의 생성기를 트리거하는 서로 다른 방식일 뿐입니다.
|
||||
|
||||
## 이 루프를 계속 유지하세요
|
||||
|
||||
튜토리얼 전체 동안 터미널에서 `yarn twenty dev`를 실행 상태로 두세요. `src/` 아래에 파일을 추가하거나 편집할 때마다 몇 초 안에 서버와 다시 동기화되므로, 빌드하면서 각 기능이 UI에 나타나는 과정을 확인할 수 있습니다.
|
||||
|
||||
<Card title="빌드 시작 →" icon="database" href="/l/ko/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
챕터 1: 문서와 템플릿 모델링.
|
||||
</Card>
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: 6. 게시
|
||||
icon: rocket
|
||||
description: 마켓플레이스 메타데이터를 추가하고 앱을 게시하세요.
|
||||
---
|
||||
|
||||
앱이 작동합니다. 마지막 단계는 마켓플레이스를 위한 설명을 추가하고 게시하는 것입니다.
|
||||
|
||||
## 마켓플레이스 메타데이터 추가
|
||||
|
||||
[application config](/l/ko/developers/extend/apps/config/application)는 마켓플레이스에 표시되는 식별 정보를 포함합니다. 작성자, 카테고리, 로고, 지원 링크 등이 여기에 포함됩니다. `public/`에 로고를 넣고 `logoUrl`로 참조하세요.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/ko/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
기본 역할은 자체 파일에서 `defineApplicationRole()`로 선언합니다. 이제 여기에서는 `defaultRoleUniversalIdentifier`를 더 이상 전달하지 않습니다.
|
||||
</Tip>
|
||||
|
||||
앱을 쉽게 찾을 수 있도록 `package.json`에 `twenty-app` 키워드도 추가하세요:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## 갤러리 스크린샷 추가
|
||||
|
||||
마켓플레이스 목록은 스크린샷만으로도 스스로를 설명합니다. 몇 개의 PNG 파일을 `public/gallery/`에 넣고 `screenshots`로 참조하세요. 그러면 목록 페이지에서 갤러리로 렌더링됩니다.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
핵심 효과부터 보여 주세요. 첫 번째 스크린샷은 최종 결과(생성된 문서)로 두고, 그다음에 어떻게 트리거되고 작성되는지를 보여 주세요. 선명한 고해상도 캡처를 사용하세요. 사용자가 가장 먼저 보게 되는 요소입니다.
|
||||
</Tip>
|
||||
|
||||
`README.md`도 동일한 방식으로 다뤄 주세요. npm과 GitHub에서의 첫 페이지 역할을 합니다.
|
||||
가치 제안과 스크린샷으로 시작하고, 핵심 기능을 나열한 다음, 빌드 세부 정보는 아래로 접어두세요.
|
||||
|
||||
## 출시 전 점검
|
||||
|
||||
CI가 수행하는 것과 동일한 게이트를 실행하세요:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
드라이 런은 서버에서 실제로 적용하지 않고 무엇이 변경될지를 그대로 출력합니다. 마지막으로 확인하기에 좋은 방법입니다. [테스트](/l/ko/developers/extend/apps/operations/testing)와
|
||||
[동기화 및 복구](/l/ko/developers/extend/apps/operations/sync-and-recovery)를 참조하세요.
|
||||
|
||||
## 게시
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish`는 기본적으로 빌드하고 npm에 게시합니다. `--private`는 대신 Twenty 서버의 프라이빗 레지스트리에 tarball을 업로드합니다. 배포된 앱을 인스턴스의 마켓플레이스에 표시하려면 카탈로그 동기화를 트리거하세요:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
자세한 내용과 릴리스 체크리스트는
|
||||
[게시](/l/ko/developers/extend/apps/operations/publishing)를 참조하세요.
|
||||
|
||||
## 앱을 만들었습니다 🎉
|
||||
|
||||
여섯 개의 장에서 SDK 표면 대부분을 사용했습니다:
|
||||
|
||||
* 데이터를 모델링하기 위한 **오브젝트, 필드, 그리고 관계**
|
||||
* **AI 도구**로 노출되는 **로직 함수**, **워크플로 동작**, 그리고 **HTTP 라우트**
|
||||
* UI를 위한 **뷰, 내비게이션, 커맨드, 프런트 컴포넌트**
|
||||
* 자연어 생성을 위한 **에이전트 + 스킬**
|
||||
* **마켓플레이스 메타데이터**와 게시 플로우
|
||||
|
||||
완성된 앱은
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)에 있습니다.
|
||||
|
||||
## 다음 단계
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="데이터 참고" icon="database" href="/l/ko/developers/extend/apps/data/overview">
|
||||
모든 필드 타입, 관계, 인덱스 옵션.
|
||||
</Card>
|
||||
<Card title="로직 참고" icon="bolt" href="/l/ko/developers/extend/apps/logic/overview">
|
||||
Cron 및 데이터베이스 이벤트 트리거, 키-값 저장소, OAuth 연결.
|
||||
</Card>
|
||||
<Card title="레이아웃 참고" icon="table-columns" href="/l/ko/developers/extend/apps/layout/overview">
|
||||
페이지 레이아웃, 대시보드 위젯, 그 외 다양한 UI 영역.
|
||||
</Card>
|
||||
<Card title="작업" icon="rocket" href="/l/ko/developers/extend/apps/operations/overview">
|
||||
CLI, 테스트, 리모트, CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "시작하기"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "튜토리얼"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "설정"
|
||||
},
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: Deixe um agente gerar documentos a partir de um chat, usando sua ferramenta.
|
||||
---
|
||||
|
||||
Porque `generate-document` é exposto como uma **ferramenta**, um agente de IA pode chamá-lo.
|
||||
Vamos adicionar um agente e uma habilidade para que os usuários possam dizer apenas *"generate a proposal for
|
||||
Jeffery Griffin"*.
|
||||
|
||||
## A habilidade
|
||||
|
||||
A [skill](/l/pt/developers/extend/apps/logic/skills-and-agents) é reutilizável
|
||||
instruções — conhecimento que anexa a agentes. Nós ensinamos o modelo como usar
|
||||
a ferramenta.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## O agente
|
||||
|
||||
Um [agent](/l/pt/developers/extend/apps/logic/skills-and-agents) emparelha um prompt com um modelo
|
||||
. Defina `responseFormat` explicitamente para evitar um aviso de construção.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
O agente só pode chamar a ferramenta se o seu papel lhe permitir. Nós já definimos
|
||||
`canAccessAllTools: true` and `canBeAssignedToAgents: true` on the app's role in
|
||||
[Capítulo 2](/l/pt/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Experimente isso
|
||||
|
||||
Abra um chat com o **Assistente de Documentos** e peça para ele redigir um documento para uma
|
||||
pessoa no seu CRM. Ele encontra o registro, chama `gerar documento` e relata
|
||||
o documento que ele criou — que agora aparece na visão **Documentos**,
|
||||
exatamente como o menu de comando e caminhos do fluxo de trabalho.
|
||||
|
||||
Esse é o benefício de expor a lógica como uma ferramenta: **uma função, muitas portas da frente** —
|
||||
menu de comando, HTTP, passo do fluxo de trabalho e agora a linguagem natural.
|
||||
|
||||
**Após este passo:** o aplicativo está cheio de recursos e realmente útil. Hora de
|
||||
enviar.
|
||||
|
||||
<Card title="Próximo: publicação →" icon="rocket" href="/desenvolvedores/adicionar/apps/tutorials/document-gerador/publicação">
|
||||
Adicionar metadados do mercado e publicar.
|
||||
</Card>
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: 4. Construindo a interface
|
||||
icon: table-columns
|
||||
description: Views, navegação da barra lateral, um comando e componentes iniciais.
|
||||
---
|
||||
|
||||
No momento, os objetos só podem ser acessados através de Configurações. Vamos dar ao aplicativo uma presença
|
||||
real na UI: listar exibições, entradas da barra lateral, um clique
|
||||
**Gerar documento** comando, um componente frontal de record-page para **visualizar** um documento
|
||||
e uma guia de **editor** nativa para templates.
|
||||
|
||||
## Visualizações e navegação
|
||||
|
||||
A [view](/l/pt/developers/extend/apps/layout/views) é uma lista salva de um determinado objeto.
|
||||
Um [item de menu de navegação](/l/pt/developers/extend/apps/layout/navigation-menu-items)
|
||||
coloca essa visualização na barra lateral.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Adicionar o mesmo par para modelos. Ambos agora aparecem na barra lateral:
|
||||
|
||||
<Frame caption="Documentos e Modelos na barra lateral, com o documento gerado listado.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Visualização de documentos com um documento gerado" />
|
||||
</Frame>
|
||||
|
||||
## Um componente frontal
|
||||
|
||||
A [front component](/l/pt/developers/extend/apps/layout/front-components) é um componente React
|
||||
sandboxed dentro de Twenty. Ours lê o registro selecionado, carrega o modelo de pessoa
|
||||
via `CoreApiClient`, e POSTs até a rota do último capítulo
|
||||
.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Estilo com variáveis CSS inline (`var(--t-color-blue)`), não valores importados de
|
||||
`25ui`. O SDK simula esse pacote durante a build, portanto, imports em nível de módulo de
|
||||
constantes de tema seriam `undefined`. Veja o
|
||||
[componente inteiro](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## Um comando para abri-lo
|
||||
|
||||
Um [item de menu de comando](/l/pt/developers/extend/apps/layout/command-menu-items) com
|
||||
`availabilityType: 'RECORD_SELECTION'` aparece quando uma pessoa é selecionada, e
|
||||
abre o componente no painel lateral.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Experimente todo o fluxo
|
||||
|
||||
Abra **Pessoas**, marque uma pessoa e pressione <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>.
|
||||
"Gerar documento" aparece, marcado com seu aplicativo:
|
||||
|
||||
<Frame caption="O comando aparece quando uma pessoa é selecionada.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Menu de comando com o documento gerado" />
|
||||
</Frame>
|
||||
|
||||
Executá-lo — seu componente abre no painel lateral. Escolha um modelo, clique
|
||||
**Gerar** e uma nova terra em **Documentos**.
|
||||
|
||||
<Frame caption="O componente inicial, carregando templates e gerando no clique.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Gerar painel lateral do documento" />
|
||||
</Frame>
|
||||
|
||||
Cada documento gerado grava o seu aplicativo como seu autor:
|
||||
|
||||
<Frame caption="Criado pelo Gerador do Documento: Status Gerado.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="Um registro de documento gerado" />
|
||||
</Frame>
|
||||
|
||||
## Pré-visualizar um documento em sua página de registro
|
||||
|
||||
Um componente frontal não é apenas para menus de comando. Você pode montar um como uma \*\*aba de uma página de registro
|
||||
. Vamos adicionar uma aba *Pré-visualização* ao registro de documento que renderiza o corpo
|
||||
Markdown como uma página polida e impressa.
|
||||
|
||||
O componente lê o id de registro atual de seu contexto de execução, carrega o documento
|
||||
e o renderiza. Componentes frontais executados em uma **sandbox** que só permite uma lista
|
||||
branca de tags HTML — injeção HTML bruta (`dangerouslySetInnerHTML`) e
|
||||
`\<style>` estão bloqueados — então renderizamos o Markdown como elementos React com estilos inline
|
||||
através de um pequeno [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
helper.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Monte-o com um [layout da página](/l/pt/developers/extend/apps/layout/page-layouts). Um layout
|
||||
`RECORD_PAGE` adiciona abas à vista de registro de um objeto; um widget `FRONT_COMPONENT`
|
||||
em uma aba `CANVAS` hospeda o componente:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Abra qualquer documento — uma guia **Pré-visualizar** renderiza belíssima, com links para a página web compartilhável de
|
||||
e o PDF:
|
||||
|
||||
<Frame caption="A aba de Pré-visualização renderiza o documento com estilos embutidos, mais links rápidos.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Visualizador de documentos do componente frontal em uma aba de registros" />
|
||||
</Frame>
|
||||
|
||||
## Edite um modelo com o editor de texto rico
|
||||
|
||||
Os templates não precisam de um componente personalizado. Como o `body` é um campo
|
||||
`RICH_TEXT`, Twenty já fornece um editor de rich text completo para ele — o
|
||||
mesmo que os objetos padrão Note e Task usam. Nós apenas a apresentamos na
|
||||
página de registro de template.
|
||||
|
||||
Adicione uma aba com um widget `FIELD` no modo de exibição `EDITOR`, apontando para o campo `body`
|
||||
através de `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
Um campo `RICH_TEXT` armazena tanto o JSON de blocos do editor quanto uma
|
||||
projeção em Markdown. O pipeline de geração lê essa projeção Markdown, então
|
||||
espaços reservados, o PDF, e a página da web compartilhável continuam funcionando inalterado —
|
||||
veja o
|
||||
[`template-record. leiaute-idade.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Agora editores escrevem modelos em um editor de texto rico:
|
||||
|
||||
<Frame caption="A aba Modelo: editor de texto rico nativo de 20 vinculado ao campo de corpo.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Registro de modelo com a aba de editor de texto rico nativo" />
|
||||
</Frame>
|
||||
|
||||
**Após este passo:** os documentos pré-visualizar lindo e os templates são editáveis
|
||||
dentro do aplicativo. Em seguida, deixe um atendente da IA gerá-los a partir de um chat.
|
||||
|
||||
<Card title="Próximo: um atendente de IA →" icon="robot" href="/desenvolvedores/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Adicione um agente e uma habilidade que chama sua ferramenta.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Modelo de dados
|
||||
icon: database
|
||||
description: Modela documentos e templates com objetos, campos e uma relação.
|
||||
---
|
||||
|
||||
Nosso aplicativo precisa de dois objetos personalizados: **modelos de documentos** (o que escrever) e
|
||||
**documentos** (o resultado gerado). Vamos defini-los.
|
||||
|
||||
Crie cada arquivo de entidade com o CLI — gera uma pasta UUID válida e
|
||||
correta para você:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Abaixo nós mostramos os arquivos concluídos.
|
||||
|
||||
<Note>
|
||||
Todo `*_UNIVERSAL_IDENTIFIER` constante vive em
|
||||
`src/constants/universal-identifiers.ts` e é importado onde usado. Os trechos
|
||||
abaixo omitem essas importações por brevidade — mantenha-as em seus próprios arquivos.
|
||||
</Note>
|
||||
|
||||
## O objeto modelo
|
||||
|
||||
Um template tem um `nome`, um `body` com `{{placeholders}}`, e um `alvo` que
|
||||
diz se é escrito para uma pessoa ou para uma empresa. O `body` é um campo
|
||||
`RICH_TEXT`, então Vinte dá um editor completo de texto rico.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` opção **valores** deve ser `UPPER_CASE` (`PERSON`, não `person`), e o
|
||||
`defaultValue` está entre aspas extras: `` `'PERSON'` ``. A `etiqueta` é o que
|
||||
usuários veem.
|
||||
</Warning>
|
||||
|
||||
## O objeto do documento
|
||||
|
||||
O documento gerado armazena o `conteúdo` renderizado e um `estado`. Defina
|
||||
da mesma forma, com uma seleção `status` de `DRAFT` / `GENERATED`. Arquivo completo:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Vinculando eles com uma relação
|
||||
|
||||
Cada documento deve apontar para o modelo de onde veio. Relações são
|
||||
**bidirecionais** — você define ambos os lados, cada um em seu próprio arquivo de campo.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
O outro lado (`template-documents-relation.field.ts`) é um campo
|
||||
`RelationType.ONE_TO_MANY` chamado `documentos` que aponta para o lado oposto.
|
||||
Ver [Relations](/l/pt/developers/extend/apps/data/relations) para o padrão completo.
|
||||
|
||||
## Veja em Vinte
|
||||
|
||||
Com `yarn 20 dev` executando, abra **Settings → Data model**. Ambos os objetos
|
||||
aparecem, marcados com seu aplicativo.
|
||||
|
||||
<Frame caption="Ambos os objetos personalizados, pertencentes ao aplicativo Gerador de Documentos.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Configurações do modelo de dados mostrando documentos e modelos de documento" />
|
||||
</Frame>
|
||||
|
||||
Crie um modelo para testar com — nomeie-a *proposta de vendas*, defina **destino** para
|
||||
*Personagem*, e cole um corpo com alguns espaços reservados:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="Um registro de template. O corpo mantém seus espaços reservados até que um documento seja gerado.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="Um registro de modelo de proposta de vendas com corpo de placeholder" />
|
||||
</Frame>
|
||||
|
||||
**Após essa etapa:** você tem objetos `documentTemplate` e `documento`, ligados por
|
||||
a relação e um modelo para gerar. Em seguida, a lógica que a preenche.
|
||||
|
||||
<Card title="Próximo: gerando documentos →" icon="bolt" href="/desenvolvedores/extend/apps/tutorials/documento-gerador/documentos">
|
||||
Escreva a função lógica que preenche o template.
|
||||
</Card>
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: 2. Gerando documentos
|
||||
icon: bolt
|
||||
description: Uma função lógica, exposta como uma ferramenta de IA e uma ação de fluxo de trabalho.
|
||||
---
|
||||
|
||||
Agora o núcleo: uma [função lógica](/l/pt/developers/extend/apps/logic/logic-functions)
|
||||
que carrega um modelo e um registro, preenche os espaços reservados e salva um novo documento
|
||||
.
|
||||
|
||||
Vamos escrever a lógica do negócio uma vez na forma de **manipulador** e então expô-la através de
|
||||
vários gatilhos. Este capítulo conecta duas delas — uma **ferramenta de IA** e uma
|
||||
**ação de fluxo de trabalho**.
|
||||
|
||||
## O auxiliar de renderização
|
||||
|
||||
Mantenha uma lógica pura em seu próprio arquivo, para que seja fácil de testar unidades. Este encolher os tokens de um record
|
||||
em `{{dot.path}}` e substitui-los.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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>
|
||||
Como este arquivo não tem efeitos colaterais, você pode resolvê-lo com testes de unidade
|
||||
(`yarn test:unit`). Ver [Testing](/l/pt/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## O manipulador
|
||||
|
||||
O manipulador usa o [`CoreApiClient`](/l/pt/developers/extend/apps/logic/logic-functions)
|
||||
para ler e escrever dados de CRM. Ele carrega o modelo, carrega o registro de destino, preenche
|
||||
o corpo e cria um `documento`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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` executa uma consulta diferente para uma Pessoa vs. Uma Empresa e flattens
|
||||
o resultado — veja
|
||||
[`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).
|
||||
|
||||
## Expo-na como uma ferramenta e uma ação de fluxo de trabalho
|
||||
|
||||
Uma única `defineLogicFunction` pode carregar vários gatilhos. Aqui, `toolTriggerSettings`
|
||||
faz com que seja chamável por agentes IA e `workflowActionTriggerSettings` o transforma em
|
||||
passo do construtor de fluxo de trabalho. Ambos descrevem suas informações com um esquema JSON.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
O esquema de entrada é um esquema JSON simples que descreve `templateId` e `recordId` —
|
||||
vê [`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).
|
||||
|
||||
## Concede-o acesso
|
||||
|
||||
Funções lógicas são executadas como o papel do aplicativo. Ele precisa ler templates e registrar
|
||||
e criar documentos, então permitir que em `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
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` permite que a função envie o PDF gerado na próxima seção.
|
||||
Ver [Roles](/l/pt/developers/extend/apps/config/roles) para permissões refinadas.
|
||||
|
||||
## Anexar arquivo PDF real
|
||||
|
||||
Um campo de texto renderizado é útil, mas os usuários querem um documento real. Vamos gerar um
|
||||
**PDF** e armazená-lo no registro como um arquivo para download.
|
||||
|
||||
Primeiro, dê ao objeto `documento` um campo `ARQUIVO` para segurar o PDF. Aplicativos enviam
|
||||
para seus **próprios** campos de arquivos, então este campo é o que encaminha o upload:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Agora renderize esse PDF. Uma app é um projeto Node real, então você pode adicionar qualquer pacote npm
|
||||
que você precisa e importá-lo como em qualquer outro lugar. Nós usamos **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
para desenhar o PDF e **[marked](https://marked.js.org/)** para analisar o corpo do Markdown
|
||||
— a CLI os instala no tempo de execução da função para você:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
O auxiliar completo é
|
||||
[`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).
|
||||
Ele analisa o Markdown em tokens com `marcado. exer`, então as envia com
|
||||
pdf-lib: títulos reais, execuções **bold**/*italic*, listas de marcadores e numerados,
|
||||
bloqueios e regras — uma renderização A4 polida e multi-página do modelo
|
||||
em si, ao invés de uma parede de texto.
|
||||
|
||||
<Frame caption="O PDF: tipografia real e formatação Markdown, renderizando o corpo do modelo.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="Um PDF gerado polido e comercializável" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
As fontes internas do pdf-lib usam codificação WinAnsi, portanto acentos da Europa Ocidental são renderizados
|
||||
prontos para uso; o helper mapeia aspas tipográficas e travessões e descarta caracteres que
|
||||
não consegue codificar. Renderizar scripts não-latinos (chinês, árabe, cirílico) significaria que
|
||||
incorporando uma fonte Unicode.
|
||||
</Note>
|
||||
|
||||
Em seguida, carregue-a e armazene a referência no registro. Rotas `uploadFile` bytes
|
||||
para o campo de arquivos de propriedade; o `id` retornado é o que você salva:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
O documento gerado agora carrega um PDF:
|
||||
|
||||
<Frame caption="O PDF gerado, armazenado no campo Arquivo do documento.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="Um registro de documento com um arquivo PDF gerado" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` destina-se apenas a campos de arquivos **propriedade do aplicativo** (então o upload sempre requer um aplicativo
|
||||
que possui o campo, mais o sinalizador de papéis `UPLOAD_FILE`). É por isso que o PDF
|
||||
fica no campo `file` do próprio registro - o mesmo padrão que o
|
||||
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
usa para gravações.
|
||||
</Note>
|
||||
|
||||
**Após este passo:** cada documento gerado possui um PDF real e para download. Mas
|
||||
nada pode *chamar* o gerador a partir da interface de usuário — por isso precisamos de uma rota HTTP.
|
||||
|
||||
<Card title="Próximo: Rotas HTTP →" icon="globo" href="/desenvolvedores/extend/apps/tutorials/document-gerator/http-routes">
|
||||
Servir a função sobre HTTP e renderizar documentos como páginas da web.
|
||||
</Card>
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: 3. Rotas HTTP
|
||||
icon: globe
|
||||
description: Acionar a função sobre HTTP e renderizar documentos como páginas da web.
|
||||
---
|
||||
|
||||
O mesmo manipulador também pode responder solicitações HTTP. Vamos adicionar duas rotas:
|
||||
|
||||
* um terminal **POST** aponta as chamadas da UI para gerar um documento e
|
||||
* um endpoint de **GET** público que renderiza um documento como uma página web impressa.
|
||||
|
||||
Ambos usam `httpRouteTriggerSettings`. As rotas de aplicativos são servidas em `/s` no seu servidor
|
||||
Vinte (por exemplo, `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## Rota POST - gerar sob demanda
|
||||
|
||||
Isso reusa `generateDocumentHandler`, então não há lógica para repetir — apenas um adaptador
|
||||
fino que lê o corpo da solicitação.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
O manipulador compartilhado retorna um 'status' sugerido durante a falha, então a rota pode
|
||||
responder com um código `4xx`/`5xx` apropriado. `isAuthRequired: true` significa que o chamador
|
||||
deve apresentar um token válido — o componente frontal no próximo capítulo passa o token de acesso do usuário
|
||||
automaticamente.
|
||||
|
||||
## Via GET - renderizar como uma página da web
|
||||
|
||||
Para retornar HTML em vez de JSON, encapsule o corpo em um `Response` com um cabeçalho
|
||||
`Content-Type`. Esta rota é pública (`isAuthRequired: false`) para que um documento
|
||||
gerado possa ser compartilhado como um link.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` renderiza o corpo do Markdown para HTML (com [marked](https://marked.js.org/),
|
||||
sanitizado) e o coloca em um limpo, página imprimível que mostra apenas o conteúdo do modelo- a mesma aparência que o PDF e a visualização no aplicativo.
|
||||
[Veja o ajudante](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Experimente isso
|
||||
|
||||
Com um modelo e uma pessoa em seu espaço de trabalho, chame a rota (pegue um token do
|
||||
**Settings → APIs & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Abra o documento retornado no seu navegador:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="A rota GET pública renderiza o documento como uma página impressa.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Uma página renderizada do documento web" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
Você também pode transmitir logs de uma função enquanto está testando com
|
||||
`yarn twenty dev:function:logs`, ou invocá-los diretamente com
|
||||
`yarn twenty dev:function:exec`.
|
||||
</Tip>
|
||||
|
||||
**Após esse passo:** o aplicativo pode gerar documentos via HTTP e servi-los como páginas
|
||||
web. Agora vamos torná-lo utilizável sem `curl`.
|
||||
|
||||
<Card title="Próximo: Construindo a interface do usuário →" icon="table-columns" href="/desenvolvedores/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Exibir, navegação, um comando e um componente inicial.
|
||||
</Card>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Tutorial: Gerador de Documentos"
|
||||
icon: wand-magic-sparkles
|
||||
description: Crie um aplicativo Twenty real que gera documentos personalizados a partir dos seus dados de CRM.
|
||||
---
|
||||
|
||||
Neste tutorial, você vai criar o **Gerador de Documentos** — um aplicativo que transforma modelos reutilizáveis
|
||||
em documentos personalizados usando os dados que você já tem no seu CRM.
|
||||
|
||||
Escreva um modelo uma vez com `{{placeholders}}` e, em seguida, gere um documento preenchido
|
||||
para qualquer Pessoa ou Empresa com um clique — a partir do menu de comando, de um
|
||||
agente de IA ou de um workflow.
|
||||
|
||||
<Frame caption="Um modelo, gerado para uma pessoa específica, aberto como uma página para impressão.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Um documento de proposta de vendas gerado" />
|
||||
</Frame>
|
||||
|
||||
## O que você vai aprender
|
||||
|
||||
Cada capítulo adiciona uma capacidade. Ao final, você terá usado a maior parte do SDK.
|
||||
|
||||
| Capítulo | Capacidade | Referência |
|
||||
| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| [1. Modelo de Dados](/l/pt/developers/extend/apps/tutorials/document-generator/data-model) | Objetos, campos e uma relação | [Dados](/l/pt/developers/extend/apps/data/overview) |
|
||||
| [2. Geração de documentos](/l/pt/developers/extend/apps/tutorials/document-generator/generating-documents) | Uma função lógica (ferramenta de IA + ação de workflow) que preenche um modelo Markdown e anexa um PDF finalizado | [Funções lógicas](/l/pt/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. Rotas HTTP](/l/pt/developers/extend/apps/tutorials/document-generator/http-routes) | Servindo JSON e uma página HTML compartilhável a partir de rotas | [Funções lógicas](/l/pt/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Criando a UI](/l/pt/developers/extend/apps/tutorials/document-generator/building-the-ui) | Vistas, navegação, menu de comandos e componentes de front-end que preveem um documento e editam um modelo | [Layout](/l/pt/developers/extend/apps/layout/overview) |
|
||||
| [5. Um agente de IA](/l/pt/developers/extend/apps/tutorials/document-generator/ai-agent) | Agente + habilidade | [Habilidades e Agentes](/l/pt/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Publicação](/l/pt/developers/extend/apps/tutorials/document-generator/publishing) | Envie-o para o marketplace | [Publicação](/l/pt/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
Você deve ter concluído o [Quick Start](/l/pt/developers/extend/apps/getting-started/quick-start):
|
||||
um servidor Twenty local em execução na porta `2020` e o CLI autenticado nele.
|
||||
|
||||
Se não, gere a estrutura e inicie um agora:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Prefere ler o código finalizado? O aplicativo completo está em
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Cada trecho abaixo foi copiado de lá.
|
||||
</Note>
|
||||
|
||||
## Como o aplicativo se encaixa
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="Um modelo com placeholders é transformado em um documento finalizado com um PDF, acionado a partir do menu de comandos, de um agente de IA, de um workflow ou de um link compartilhável" />
|
||||
</Frame>
|
||||
|
||||
Você escreve um **modelo** uma vez em um editor de rich text, com `{{placeholders}}`. Escolher um
|
||||
modelo e um registro de CRM preenche os placeholders e armazena um
|
||||
**documento** finalizado (com um arquivo PDF). Todo o resto — o menu de comandos, o agente de IA,
|
||||
o passo de workflow, o link compartilhável — é apenas uma forma diferente de acionar aquele
|
||||
único gerador.
|
||||
|
||||
## Mantenha este ciclo em execução
|
||||
|
||||
Deixe `yarn twenty dev` rodando em um terminal durante todo o tutorial. Toda vez que
|
||||
você adicionar ou editar um arquivo em `src/`, ele será ressíncronizado com o seu servidor em alguns
|
||||
segundos, para que você possa ver cada capacidade aparecer na UI conforme a constrói.
|
||||
|
||||
<Card title="Comece a construir →" icon="database" href="/l/pt/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
Capítulo 1: modele documentos e modelos.
|
||||
</Card>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: 6. Publicação
|
||||
icon: rocket
|
||||
description: Adicione metadados da loja e publique seu aplicativo.
|
||||
---
|
||||
|
||||
Seu aplicativo funciona. O último passo é descrevê-lo para o mercado e publicar.
|
||||
|
||||
## Adicionar metadados de mercado
|
||||
|
||||
A [aplicação config](/l/pt/developers/extend/apps/config/application) transporta a identidade
|
||||
que aparece no mercado: autor, categoria, logotipo e suporte links
|
||||
. Coloque um logotipo em `public/` e referencie-o com `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/pt/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
A função padrão é declarada com `defineApplicationRole()` em seu próprio arquivo — você
|
||||
não passa mais `defaultRoleUniversalIdentifier` aqui.
|
||||
</Tip>
|
||||
|
||||
Também adicione a palavra-chave `vinte app` ao `package.json` para que o aplicativo seja detectável:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Adicionar capturas de tela da galeria
|
||||
|
||||
Uma lista de mercado vende a si mesma com screenshots. Colocar alguns PNGs em
|
||||
`public/gallery/` e referenciá-los com `screenshots` — eles renderizam como galeria
|
||||
na página de listagem.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Conduzir com o pagamento: fazer a primeira captura de tela o resultado finalizado (um documento
|
||||
gerado), depois mostrar como é acionado e autorado. Use capturas
|
||||
crocantes e de alta resolução — elas são a primeira coisa que um usuário vê.
|
||||
</Tip>
|
||||
|
||||
Dê ao `README.md` o mesmo tratamento — é a página inicial em npm e GitHub.
|
||||
Abra com a proposição de valor e uma captura de tela, liste os recursos do título,
|
||||
e então mantenha os detalhes de compilação abaixo da dobra.
|
||||
|
||||
## Verifique antes de enviar
|
||||
|
||||
Executar os mesmos portões CI do portão:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
A corrida seca imprime exatamente o que mudaria no servidor sem aplicá-lo —
|
||||
uma boa verificação de sanidade final. Veja
|
||||
[Testing](/l/pt/developers/extend/apps/operations/testing) e
|
||||
[Sincronização e recuperação](/l/pt/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Publicar
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` compila e publica para o npm por padrão; `--private` envia uma tarball
|
||||
para um registro privado de vinte servidores. Para superfíciar um aplicativo publicado
|
||||
no mercado de uma instância, acione uma sincronização de catálogo:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Detalhes completos e o checklist de lançamento:
|
||||
[Publishing](/l/pt/developers/extend/apps/operations/publishing).
|
||||
|
||||
## Você construiu um aplicativo 🎉
|
||||
|
||||
Em seis capítulos você usou a maior parte da superfície SDK:
|
||||
|
||||
* **Objetos, campos e uma relação** para modelar os dados
|
||||
* Uma **função lógica** exposta como uma **ferramenta de Inteligência**, uma **ação de fluxo de trabalho** e **rotas HTTP**
|
||||
* **Exibir, navegação, um comando e um componente frontal** para a interface do usuário
|
||||
* Um **agente + habilidade** para a geração de idioma natural
|
||||
* **Metadados do Marketplace** e o fluxo de publicação
|
||||
|
||||
O aplicativo finalizado está em
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Para onde ir
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Referência de dados" icon="database" href="/l/pt/developers/extend/apps/data/overview">
|
||||
Todos os tipos de campo, relação e opção de índice.
|
||||
</Card>
|
||||
<Card title="Referência lógica" icon="bolt" href="/l/pt/developers/extend/apps/logic/overview">
|
||||
Gatilhos Cron e database-event trigers, a loja chave-valor, conexões OAuth
|
||||
</Card>
|
||||
<Card title="Referência do layout" icon="table-columns" href="/l/pt/developers/extend/apps/layout/overview">
|
||||
Layouts da página, widgets do painel e mais superfícies da UI.
|
||||
</Card>
|
||||
<Card title="Operações" icon="rocket" href="/l/pt/developers/extend/apps/operations/overview">
|
||||
CLI, testes, controles remotos e CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Primeiros passos"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Tutorial"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Configuração"
|
||||
},
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: Permiteți unui agent să genereze documente dintr-o conversație, utilizând instrumentul dvs.
|
||||
---
|
||||
|
||||
Pentru că `generate-document` este expus ca **unealtă**, un agent AI îl poate chema.
|
||||
Hai să adăugăm un agent și o abilitate astfel încât utilizatorii să poată spune *"generează o propunere pentru
|
||||
Jeffery Griffin"*.
|
||||
|
||||
## Abilitatea
|
||||
|
||||
Un [skill](/l/ro/developers/extend/apps/logic/skills-and-agents) este reutilizabil
|
||||
instrucțiuni - cunoștințele atașate la agenți. Noi învață modelul cum să folosești
|
||||
unealta.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## Agentul
|
||||
|
||||
Un [agent](/l/ro/developers/extend/apps/logic/skills-and-agents) împerechează o recomandare cu un model
|
||||
. Setați în mod explicit `responseFormat` pentru a evita un avertisment de construire.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
Agentul poate apela instrumentul doar dacă rolul său îi permite acest lucru. Am setat deja
|
||||
`canAccessAllTools: true` și `canBeAssignedToAgents: true` pe rolul aplicației în
|
||||
[capitolul 2](/l/ro/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Încearcă-l
|
||||
|
||||
Deschideți o conversație cu **Asistentul pentru documente** și cereți-i să elaboreze un document pentru o persoană
|
||||
din CRM. Găsește înregistrarea, apelează `generate-document`, și raportează
|
||||
înapoi documentul pe care l-a creat — care apare acum în **documentele**,
|
||||
exact ca meniul de comenzi și căile fluxului de lucru.
|
||||
|
||||
Aceasta este rezultatul prezentării logicii ca unealtă: **o funcție, multe uși din față** -
|
||||
meniul de comandă, HTTP, pasul fluxului de lucru, și acum limbajul natural.
|
||||
|
||||
**După acest pas:** aplicația este completă și cu adevărat utilă. Timpul până la expedierea lui
|
||||
.
|
||||
|
||||
<Card title="Următorul: publicare →" icon="rocket" href="/dezvoltatori/extindere/aplicații/tutoriale/generator documente/publicare">
|
||||
Adăugați metadate de bazar și publicați.
|
||||
</Card>
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: 4. Construirea interfeței
|
||||
icon: table-columns
|
||||
description: Vizualizări, navigare bară laterală, o comandă și componente frontale.
|
||||
---
|
||||
|
||||
În acest moment, obiectele sunt accesibile doar prin Setări. Hai să oferim aplicației o prezență reală
|
||||
în UI: vizualizare listă, intrări sidebar, un singur click
|
||||
**Generează document** comandă, o filă de înregistrare a componentei frontale la **previzualizarea** un document
|
||||
şi o filă nativă bog-text **editor** pentru şabloane.
|
||||
|
||||
## Vizualizări și navigare
|
||||
|
||||
Un [view](/l/ro/developers/extend/apps/layout/views) este o listă salvată a unui obiect dat.
|
||||
Un [element meniu de navigare](/l/ro/developers/extend/apps/layout/navigation-menu-items)
|
||||
introduce această vizualizare în bara laterală.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Adaugă aceeași pereche pentru șabloane. Ambele sunt prezentate acum pe partea laterală:
|
||||
|
||||
<Frame caption="Documente şi şabloane din bara laterală, documentul generat fiind enumerat.">
|
||||
<img src="/imagini/documente/dezvoltatori/extinderi/aplicații/generator/04-documents-view.png" alt="Vizualizare documente cu un document generat" />
|
||||
</Frame>
|
||||
|
||||
## O componentă față
|
||||
|
||||
O [componentă frontală](/l/ro/developers/extend/apps/layout/front-components) este o componentă React
|
||||
inserată în Twenty. Ours citeşte înregistrarea selectată, încarcă şabloanele pentru persoane
|
||||
prin `CoreApiClient`, şi POST-uri pe ruta de la ultimul capitol
|
||||
.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Stilul cu variabilele CSS înline (`var(--t-color-blue)`), nu valorile importate din
|
||||
`douăzeci enty-ui`. SDK se dublează că pachetul a fost construit, astfel încât importurile constantelor de temă
|
||||
la nivel de modul ar fi `nedefinită`. Vezi
|
||||
[componenta completă](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## O comandă pentru a o deschide
|
||||
|
||||
Un [element meniu de comandă](/l/ro/developers/extend/apps/layout/command-menu-items) cu
|
||||
\`availabilityType: 'RECORD_SELECTION'' apare atunci când o Persoană este selectată, și
|
||||
deschide componenta în panoul lateral.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Încercați întregul flux
|
||||
|
||||
Deschideți **oameni**, bifați o persoană și apăsați <kbd>£K</kbd> / <kbd>Ctrl K</kbd>.
|
||||
"Generează document" apare, etichetat cu aplicația ta:
|
||||
|
||||
<Frame caption="Comanda apare atunci când o persoană este selectată.">
|
||||
<img src="/imagini/documente/dezvoltatori/extinderi/aplicații/generator/06-command-menu.png" alt="Meniul de comenzi cu Generarea documentului" />
|
||||
</Frame>
|
||||
|
||||
Rulează — componenta ta se deschide în panoul lateral. Alege un șablon, fă clic pe
|
||||
**Generează** și pe un nou teren de înregistrări în **documente**.
|
||||
|
||||
<Frame caption="Componenta frontală, șabloanele de încărcare și generarea unui clic.">
|
||||
<img src="/imagini/documente/dezvoltatori/extinderi/aplicații/generator/06b-front-componente.png" alt="Generare panou de document" />
|
||||
</Frame>
|
||||
|
||||
Fiecare document generat înregistrează aplicația dvs. ca autor:
|
||||
|
||||
<Frame caption="Creat de Generatorul de Document Stare generată.">
|
||||
<img src="/imagini/documente/dezvoltatori/extensii/aplicații/generator/document-document-record.png" alt="Înregistrare de documente generată" />
|
||||
</Frame>
|
||||
|
||||
## Previzualizați un document pe pagina sa de înregistrare
|
||||
|
||||
O componentă frontală nu este doar pentru meniurile de comenzi — poți canta una ca \*\*tab pe pagina de înregistrare
|
||||
\*\*. Hai să adăugăm o filă *Preview* la înregistrarea de document care face corpul
|
||||
Markdown ca o pagină șlefuită.
|
||||
|
||||
Componenta citește id-ul de înregistrare curent din contextul de execuție, încarcă documentul
|
||||
și îl redă. Componentele frontului rulează într-un **sandbox** care permite doar o albire
|
||||
a etichetelor HTML - injectarea brută HTML (`dangerouslySetInnerHTML`) și
|
||||
`\<style>` sunt blocate - așa că am redat Markdown ca elemente React cu linia
|
||||
stiluri mici [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
hel.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Montaţi cu un [aspect de pagină](/l/ro/developers/extend/apps/layout/page-layouts). Un layout
|
||||
`RECORD_PAGE` adaugă file la vizualizarea unui obiect de înregistrare; un widget `FRONT_COMPONENT`
|
||||
într-o filă `CANVAS` găzduieşte componenta:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Deschide orice document — o filă **Preview** o face frumoasă, cu link-uri către pagina web partajabilă
|
||||
și PDF:
|
||||
|
||||
<Frame caption="Tab-ul de Previzualizare face documentul cu stiluri inline, plus link-uri rapide.">
|
||||
<img src="/imagini/documente/dezvoltatori/extensii/aplicații/generator/09-document-viewer.png" alt="Vizualizator document componenta frontală într-o filă de înregistrare pagină" />
|
||||
</Frame>
|
||||
|
||||
## Editați un șablon cu editorul de text bogat
|
||||
|
||||
Șabloanele nu au nevoie deloc de o componentă personalizată. Deoarece `body` este un câmp
|
||||
`RICH_TEXT`, Douăzeci de ani oferă deja un editor complet de text pentru el — același
|
||||
cu Nota standard și obiectul de activitate. Îl înfățișăm doar pe pagina de înregistrare a șablonului
|
||||
|
||||
Adăugați o filă cu un widget `FIELD` în modul de afișare `EDITOR`, îndreptat în câmpul `body`
|
||||
prin `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
Un câmp `RICH_TEXT` stochează atât blocul editorului JSON cât și un proiecție Markdown
|
||||
. Conducta de generare citeşte că Markdown projection, deci
|
||||
placeholders, PDF, și pagina web partajabilă continuă să funcționeze neschimbat —
|
||||
vezi tot
|
||||
[`template-record. layout-ul vârstei`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Editorii scriu șabloane într-un adevărat editor de text bogat:
|
||||
|
||||
<Frame caption="Tab-ul modelului: Editorul nativ de text bogat al Twenty's legat de câmpul corpului.">
|
||||
<img src="/images/documente/dezvoltatori/extends/apps/document-generator/10-template-editor.png" alt="Șablon de înregistrare cu fila nativă de editor de text bogat" />
|
||||
</Frame>
|
||||
|
||||
**După acest pas:** documentele previzualizează frumos și șabloanele sunt editabile
|
||||
în aplicație. Apoi, lasă un agent AI să le genereze dintr-o conversație.
|
||||
|
||||
<Card title="Următorul: un agent AI →" icon="robot" href="/dezvoltator/extindere/aplicații/tutoriale/document-generator/ai-agent">
|
||||
Adăugați un agent și o abilitate care vă sună unelta.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Model de date
|
||||
icon: database
|
||||
description: Modelul de documente și șabloane cu obiecte, câmpuri și o relație.
|
||||
---
|
||||
|
||||
Aplicația noastră are nevoie de două obiecte personalizate: **șabloane de documente** (ce să scrie) și
|
||||
**documente** (rezultatul generat). Să le definim.
|
||||
|
||||
Scaffold fiecare fișier de entitate cu CLI - generează un UUID valid și dosarul corect
|
||||
pentru tine:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Mai jos arătăm fișierele terminate.
|
||||
|
||||
<Note>
|
||||
Fiecare "\*_UNIVERSAL_IDENTIFIER" trăieşte în
|
||||
`src/constants/universal-identificfiers.ts` şi este importat unde este folosit. Fragmentele
|
||||
de mai jos omit acele importuri pentru încurcare - le păstrează-le în propriile fișiere.
|
||||
</Note>
|
||||
|
||||
## Obiectul şablonului
|
||||
|
||||
Un şablon are un `name`, un `body` cu `{{placeholders}}`, şi un `target` care spune
|
||||
dacă este scris pentru o Persoană sau o companie. `body` este un câmp
|
||||
`RICH_TEXT`, deci douăzeci îi dă un editor complet bogat.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Opţiunea `SELECT` **valori** trebuie să fie `UPPER_CASE` (`PERSON", nu `persoană`), şi
|
||||
`valoarea implicită`este împachetată în ghilimele suplimentare: `` 'PERSON''.`label\` este ceea ce văd utilizatorii
|
||||
.
|
||||
</Warning>
|
||||
|
||||
## Obiectul documentului
|
||||
|
||||
Documentul generat stochează `conținutul` și `status`. Definiţi-l
|
||||
în acelaşi mod, cu `status` selectează din `DRAFT` / `GENERATED`. Fişier complet:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Conectarea lor cu o relație
|
||||
|
||||
Fiecare document ar trebui să indice modelul de la care a venit. Relațiile sunt
|
||||
**bidirecționale** — definiți ambele părți, fiecare în propriul fișier de câmp.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Cealaltă parte (`template-documents-relation.field.ts`) este un câmp
|
||||
`RelationType.ONE_TO_MANY` numit `documents` care arată invers.
|
||||
Vezi [Relations](/l/ro/developers/extend/apps/data/relations) pentru întregul model.
|
||||
|
||||
## Vezi în Douăzeci
|
||||
|
||||
Cu `yarn douăzeci de` rulează, deschide **Setări → Modelul de date**. Ambele obiecte
|
||||
apar, etichetate cu aplicația ta.
|
||||
|
||||
<Frame caption="Ambele obiecte personalizate, deținute de aplicația Document Generator.">
|
||||
<img src="/imagini/documente/dezvoltatori/extinderi/aplicații/generator/01-data-model.png" alt="Setările modelului de date care afișează documente și șabloane de documente" />
|
||||
</Frame>
|
||||
|
||||
Creați un șablon pentru a testa cu - denumiți *Propunere de vânzări*, setați **Ținta**
|
||||
*Person*, și lipiți un corp cu câțiva substituenți:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="O înregistrare șablon. Organismul îşi păstrează substituenţii până când se generează un document.">
|
||||
<img src="/imagini/documente/dezvoltatori/extensii/aplicații/generator document/03-template-record.png" alt="Un model de înregistrare a propunerii de vânzare cu organismul substituent" />
|
||||
</Frame>
|
||||
|
||||
**După acest pas:** aveţi obiecte `documentTemplate` şi `document`, legate de
|
||||
o relaţie, şi un şablon din care să se genereze. Apoi, logica care o umple.
|
||||
|
||||
<Card title="Următorul: generarea de documente →" icon="bolt" href="/dezvoltator/extindere/aplicații/tutoriale/generator documentare/generare-documente">
|
||||
Scrieți funcția logică care completează șablonul.
|
||||
</Card>
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: 2. Generarea documentelor
|
||||
icon: bolt
|
||||
description: O funcţie logică, expusă ca instrument AI şi acţiune a fluxului de lucru.
|
||||
---
|
||||
|
||||
Acum, nucleul: o [funcţie logică](/l/ro/developers/extend/apps/logic/logic-functions)
|
||||
care încarcă un şablon şi o înregistrare, completează substituenţii şi salvează un nou document
|
||||
.
|
||||
|
||||
Vom scrie logica de afaceri o dată ca **manipulator**, apoi o vom expune prin
|
||||
mai mulţi declanşatori. Acest capitol le leagă pe două - un instrument **IA** și un
|
||||
**acțiune flux de lucru**.
|
||||
|
||||
## Ajutor de redare
|
||||
|
||||
Păstrați logica pură în propriul fișier, astfel încât să fie ușor de testat unitar. Acest lucru nivelează o înregistrare
|
||||
în `{{dot.path}}` îi jefuiește și le înlocuiește.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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>
|
||||
Deoarece acest fișier nu are reacții adverse, îl puteți acoperi cu teste rapide de unitate
|
||||
('test yarn:unit'). Vezi [Testing](/l/ro/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## Gestionarul
|
||||
|
||||
Gestionarul folosește [`CoreApiClient`](/l/ro/developers/extend/apps/logic/logic-functions)
|
||||
pentru a citi și scrie date CRM. Încarcă șablonul, încarcă înregistrarea țintă, umple numărul
|
||||
și creează un `document`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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` rulează o interogare diferită pentru o Persoană vs. o companie și aplatizează
|
||||
rezultatul - vezi
|
||||
[`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).
|
||||
|
||||
## Expuneți-l ca instrument și acțiune de flux de lucru
|
||||
|
||||
Un singur `defineLogicFunction` poate purta mai multe declanşatoare. Aici, `toolTriggerSettings`
|
||||
îl face apelabil de către agenții AI, iar `workflowActionTriggerSettings` îl transformă într-un pas
|
||||
în constructorul fluxului de lucru vizual. Amândoi descriu datele lor cu ajutorul unei scheme JSON.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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 de introducere este o schemă JSON simplă care descrie `templateId` și `recordId` —
|
||||
vezi [`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).
|
||||
|
||||
## Acordă acces
|
||||
|
||||
Funcțiile logice funcționează ca rolul aplicației. Trebuie să citească șabloane și înregistrări
|
||||
și să creeze documente, așa că permiteți acest lucru în `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
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` lasă funcţia să încarce PDF-ul generat în secţiunea următoare.
|
||||
Vezi [Roles](/l/ro/developers/extend/apps/config/roles) pentru permisiunile cu bobul superior.
|
||||
|
||||
## Atașează un fișier PDF real
|
||||
|
||||
Un câmp text redat este util, dar utilizatorii doresc un document adevărat. Hai să generăm un
|
||||
**PDF** şi să-l stocăm în înregistrare ca un fişier ce poate fi descărcat.
|
||||
|
||||
În primul rând, daţi "document" obiectului `FILES` un câmp pentru a ţine fișierul PDF. Aplicațiile încarcă
|
||||
în câmpurile lor **proprii** de fișiere, astfel încât acest câmp este ruta încărcării:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Acum redă acel PDF. O aplicație este un proiect node real, astfel încât puteți adăuga pachetul npm
|
||||
de care aveți nevoie și îl puteți importa ca oriunde altundeva. Folosim **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
pentru a desena PDF şi **[marked](https://marked.js.org/)** pentru a analiza corpul Markdown- CLI le instalează în rularea funcţiei:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
Ajutorul complet este
|
||||
[`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).
|
||||
Analizează Markdown-ul în tokeni cu `marked.lexer`, apoi îl aranjează cu
|
||||
pdf-lib: titluri reale, pasaje **bold**/*italic*, liste cu buline și liste numerotate,
|
||||
blocuri de citat și linii — o redare A4 finisată, pe mai multe pagini, a șablonului
|
||||
în sine, mai degrabă decât un zid de text.
|
||||
|
||||
<Frame caption="PDF-ul generat: tipografie reală şi formatare Markdown, redând corpul şablonului.">
|
||||
<img src="/imagini/documente/dezvoltatori/extinderi/aplicații/generator/07b-generat-pdf.png" alt="Un PDF generat lustruit si vanzabil" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
fonturile încorporate ale pdf-lib-ului folosesc codificarea WinAnsi, astfel încât accentele europene occidentale dau
|
||||
din cutie; Hărțile ajutătoare nu pot codifica ghilimele, ghilimele și scadea cu caracterele
|
||||
. Redarea scripturilor non-latine (chineză, arabă, chirilică) ar însemna
|
||||
încorporarea unui font Unicode.
|
||||
</Note>
|
||||
|
||||
Apoi încărcați-l și stocați referința în înregistrare. `uploadFile` trasee bytes
|
||||
către câmpul de fișiere deținute de aplicație; `id` returnat este ceea ce salvați:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Documentul generat poartă acum un PDF care poate fi descărcat:
|
||||
|
||||
<Frame caption="Fișierul PDF generat, stocat pe câmpul Fișier al documentului.">
|
||||
<img src="/imagini/documente/dezvoltatori/extensii/aplicații/document-generator/08-document-with-pdf.png" alt="O înregistrare document cu un fișier PDF generat" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` țintește doar câmpurile fișierelor **app-owned** (astfel încât încărcările necesită întotdeauna o aplicație
|
||||
care deține câmpul, plus steagul de rol `UPLOAD_FILE`). Acesta este motivul pentru care PDF
|
||||
aterizează pe câmpul `fișier` al înregistrării - același model
|
||||
[aplicația pentru înregistrare](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
folosit pentru înregistrări.
|
||||
</Note>
|
||||
|
||||
**După acest pas:** fiecare document generat are un PDF real, descărcabil. Dar
|
||||
nimic nu poate *apela* generatorul din interfață — pentru asta avem nevoie de un traseu HTTP.
|
||||
|
||||
<Card title="Următorul: Rute HTTP →" icon="glob" href="/dezvoltatori/extindere/aplicații/tutoriale/document-generator/http-routes">
|
||||
Serviți funcția de HTTP și redați documentele ca pagini web.
|
||||
</Card>
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: 3. Rute HTTP
|
||||
icon: globe
|
||||
description: Declanșați funcția de HTTP și redați documentele ca pagini web.
|
||||
---
|
||||
|
||||
Același gestionar poate răspunde și la solicitările HTTP. Vom adăuga două rute:
|
||||
|
||||
* a **POST** final apeluri interfață pentru a genera un document, și
|
||||
* un obiectiv public **GET** care face un document ca o pagină web printabilă.
|
||||
|
||||
Ambele folosesc `httpRouteTriggerSettings`. Rutele aplicațiilor sunt servite sub `/s` pe
|
||||
Douăzeci de servere (ex. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## Ruta POST – generarea la cerere
|
||||
|
||||
Aceasta refolosește `generateDocumentHandler`, deci nu există logică de repetat — doar un adaptor subțire
|
||||
care citește corpul cererii.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Managerul partajat returnează un cod `status` sugerat la eşec, astfel încât ruta poate să răspundă
|
||||
cu codul `4xx`/`5xx`. `isAuthRequired: true` înseamnă că apelantul
|
||||
trebuie să prezinte un token valid - componenta frontală din capitolul următor trece automat indicativul de acces al utilizatorului
|
||||
.
|
||||
|
||||
## Ruta GET - redare ca pagină web
|
||||
|
||||
Pentru a returna HTML în loc de JSON, înfăşuraţi corpul într-un `Răspuns` cu headerul
|
||||
`Content-Type`. Această rută este publică (`isAuthRequired: false`) astfel încât un document generat
|
||||
poate fi partajat ca un link.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` redă corpul Markdown în HTML (cu [marked](https://marked.js.org/),
|
||||
sanitizat) şi îl introduce într-o curată, pagina printabilă care afișează doar conținutul șablonului- aceeași imagine ca PDF și previzualizarea in-app.
|
||||
[Vezi ajutorul](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Încearcă-l
|
||||
|
||||
Cu un șablon și o Persoană în spațiul de lucru, sunați ruta (apucați un token de la
|
||||
**Setări → API-uri & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Deschide documentul returnat în browser-ul tău:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="Ruta publică GET face ca documentul să fie o pagină imprimabilă.">
|
||||
<img src="/imagini/documente/dezvoltatori/extinderi/aplicații/generator/07-rendered-document.png" alt="O pagină web de document redată" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
De asemenea, poți viziona jurnalele unei funcții în timp ce testezi cu
|
||||
`yarn douăzeci dev:function:logs`, sau să o invoci direct cu
|
||||
`yarn douăzeci dev:function:exec`.
|
||||
</Tip>
|
||||
|
||||
**După acest pas:** aplicația poate genera documente prin HTTP și le poate servi ca
|
||||
pagini web. Acum hai să îl facem utilizabil fără 'curl'.
|
||||
|
||||
<Card title="Următorul: construirea interfeței →" icon="table-columns" href="/dezvoltatori/extindere/aplicații/tutoriale/document-generator/building-the-ui">
|
||||
Vizualizări, navigare, o comandă și o componentă frontală.
|
||||
</Card>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Tutorial: Generator de documente"
|
||||
icon: wand-magic-sparkles
|
||||
description: Construiește o aplicație Twenty reală care generează documente personalizate din datele tale CRM.
|
||||
---
|
||||
|
||||
În acest tutorial vei construi **Document Generator** — o aplicație care transformă șabloanele reutilizabile
|
||||
în documente personalizate folosind datele deja existente în CRM-ul tău.
|
||||
|
||||
Scrie un șablon o singură dată cu `{{placeholders}}`, apoi generează un document completat
|
||||
pentru orice Persoană sau Companie cu un singur clic — din meniul de comenzi, de la un
|
||||
agent AI sau dintr-un flux de lucru.
|
||||
|
||||
<Frame caption="Un șablon, generat pentru o anumită persoană, deschis ca o pagină imprimabilă.">
|
||||
<img src="/imagini/documente/dezvoltatori/extinderi/aplicații/generator/07-rendered-document.png" alt="Un document al propunerii de vanzari generat" />
|
||||
</Frame>
|
||||
|
||||
## Ce vei învăța
|
||||
|
||||
Fiecare capitol adaugă o capacitate. Până la sfârşit veţi fi atins cea mai mare parte a SDK.
|
||||
|
||||
| Capitol | Capabilitate | Referință |
|
||||
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| [1. Model dată](/l/ro/developers/extend/apps/tutorials/document-generator/data-model) | Obiecte, câmpuri și o relație | [Data](/l/ro/developers/extend/apps/data/overview) |
|
||||
| [2. Generare documente](/l/ro/developers/extend/apps/tutorials/document-generator/generating-documents) | O funcție logică (unealta AI + acțiunea fluxului de lucru) care completează un șablon Markdown și atașează un PDF șablon șters | [Funcții logice](/l/ro/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. Rute HTTP](/l/ro/developers/extend/apps/tutorials/document-generator/http-routes) | Se servește JSON și o pagină HTML partajabilă din rute | [Funcții logice](/l/ro/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Construirea UI](/l/ro/developers/extend/apps/tutorials/document-generator/building-the-ui) | Vizualizări, navigare, meniu, și componentele frontale care previzualizează un document și editează un șablon | [Aspect](/l/ro/developers/extend/apps/layout/overview) |
|
||||
| [5. An AI agent](/l/ro/developers/extend/apps/tutorials/document-generator/ai-agent) | Agent + abilitate | [Competențe & agenții](/l/ro/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Publicare](/l/ro/developers/extend/apps/tutorials/document-generator/publishing) | Trimiteți-l la piață | [Publicare](/l/ro/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Cerințe
|
||||
|
||||
Trebuie să fi terminat [Quick Start](/l/ro/developers/extend/apps/getting-started/quick-start):
|
||||
un server local de douăzeci care rulează pe portul `2020` şi CLI autentificat la acesta.
|
||||
|
||||
Dacă nu, schildează şi începe acum unul:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Preferă să citești codul finalizat? Aplicația completă trăiește în
|
||||
[`pachete/douăzeci de aplicații/exemple/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Fiecare snippet de mai jos este copiat din el.
|
||||
</Note>
|
||||
|
||||
## Cum se potrivește aplicația împreună
|
||||
|
||||
<Frame>
|
||||
<img src="/imagini/documente/dezvoltatori/extinderi/aplicații/generator/how-it-fits.svg" alt="Un model cu substituenți este generat într-un document șters cu un PDF, declanșat din meniul de comandă, un agent AI, un flux de lucru, sau un link partajabil" />
|
||||
</Frame>
|
||||
|
||||
Scrii un **șablon** o dată într-un editor bogat cu `{{placeholders}}`. Alegerea unui șablon
|
||||
și a unei înregistrări CRM completează locațiile și stochează un
|
||||
**document** (cu un fișier PDF). Toate celelalte - meniul de comandă, agentul AI,
|
||||
pasul fluxului de lucru, link-ul partajabil - este doar o modalitate diferită de a declanșa acel generator
|
||||
.
|
||||
|
||||
## Păstrează această buclă rulantă
|
||||
|
||||
Lăsați `yarn douăzeci de` să ruleze într-un terminal pentru întregul tutorial. De fiecare dată când
|
||||
adaugi sau editezi un fişier sub `src/`, el re-sincronizează pe serverul tău în câteva
|
||||
secunde, pentru ca fiecare capabilitate să apară în interfață pe măsură ce o construiești.
|
||||
|
||||
<Card title="Începe construirea →" icon="database" href="/dezvoltator/extensie/aplicații/tutoriale/document-generator/model de date">
|
||||
Capitolul 1: modele de documente și modele.
|
||||
</Card>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: 6. Publicare
|
||||
icon: rocket
|
||||
description: Adăugați metadate de bazar și publicați aplicația dvs.
|
||||
---
|
||||
|
||||
Aplicația dvs. funcționează. Ultimul pas este să îl descriem pentru piață și publicare.
|
||||
|
||||
## Adaugă metadate marketplace
|
||||
|
||||
[application config](/l/ro/developers/extend/apps/config/application) posedă identitatea
|
||||
care apare în bazar: author, categorie, logo, and support
|
||||
link-uri. Puneți un logo în `public/` și trimiteți-l cu `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/ro/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Rolul implicit este declarat cu `defineApplicationRole()` în propriul său fișier —
|
||||
nu mai pasa `defaultRoleUniversalIdentifier`.
|
||||
</Tip>
|
||||
|
||||
Adăugați de asemenea cuvântul cheie `douăzeci și douăzeci de aplicații` în `package.json` astfel încât aplicația să poată fi descoperită:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Adaugă capturi de ecran galerie
|
||||
|
||||
O listă de marketplace se vinde cu capturi de ecran. Plasați câteva PNG-uri în
|
||||
`public/gallery/` și trimiteți-le cu `capturi de ecran` - ele se redau ca galerie
|
||||
pe pagina de listare.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Plumb cu plata: fă prima captură de ecran rezultatul final (un document
|
||||
generat), apoi arată cum este declanșat și autentic. Utilizează capturi crisp, de înaltă rezoluție- ele sunt primul lucru pe care îl vede utilizatorul.
|
||||
</Tip>
|
||||
|
||||
Dă același tratament `README.md` — este prima pagină pe npm și GitHub.
|
||||
Deschideți cu o propunere de valoare și o captură de ecran, listați funcțiile titlului,
|
||||
și păstrați detaliile de construcție sub îndoit.
|
||||
|
||||
## Verifică înainte de livrare
|
||||
|
||||
Rulează aceleași porți CI face:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
Derularea uscată tipărește exact ce s-ar schimba pe server fără a o aplica —
|
||||
un bun control sanitar final. Vezi
|
||||
[Testing](/l/ro/developers/extend/apps/operations/testing) şi
|
||||
[Sincronizare şi Recuperare](/l/ro/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Publicare
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` construiește și publică în npm în mod implicit; `--private` încarcă în schimb o tarball
|
||||
către un registru privat de douăzeci de servere. Pentru a întinde o aplicație publicată
|
||||
într-o piață de instanță, activați o sincronizare de catalog:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Detalii complete şi lista de verificare a versiunii:
|
||||
[Publishing](/l/ro/developers/extend/apps/operations/publishing).
|
||||
|
||||
## Ai construit o aplicație 🎉
|
||||
|
||||
În şase capitole aţi folosit cea mai mare parte a suprafeţei SDK:
|
||||
|
||||
* **Obiecte, câmpuri și o relație** pentru a modela datele
|
||||
* O **funcţie logică** expusă ca o unealtă \*\*IA, o **acţiune de flux de lucru**, şi **rute HTTP**
|
||||
* **Vezi, navigare, o comandă și o componentă față** pentru UI
|
||||
* Un **agent + abilitate** pentru generarea limbajului natural
|
||||
* \*\*metadate Marketplace \*\* și fluxul de publicare
|
||||
|
||||
Aplicația finalizată este la
|
||||
[`pachete/douăzeci de aplicații/exemple/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Unde să mergi mai departe
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Referință date" icon="database" href="/l/ro/developers/extend/apps/data/overview">
|
||||
Fiecare tip de câmp, relatie si optiune indice.
|
||||
</Card>
|
||||
<Card title="Referință logică" icon="bolt" href="/l/ro/developers/extend/apps/logic/overview">
|
||||
Cron and database-event triggers, the key-value store, OAuth connections.
|
||||
</Card>
|
||||
<Card title="Referință schemă" icon="table-columns" href="/l/ro/developers/extend/apps/layout/overview">
|
||||
Aspectele paginii, widget-urile tabloului de bord și mai multe suprafețe ale interfeței.
|
||||
</Card>
|
||||
<Card title="Operațiuni" icon="rocket" href="/l/ro/developers/extend/apps/operations/overview">
|
||||
CLI, testare, îndepărtări și CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Începeți"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Tutorial"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Configurare"
|
||||
},
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: 5. Bir yapay zekâ ajanı
|
||||
icon: robot
|
||||
description: Bir ajanın, sizin aracınızı kullanarak bir sohbetten belgeler oluşturmasına izin verin.
|
||||
---
|
||||
|
||||
`generate-document` bir **araç** olarak sunulduğu için, bir yapay zekâ ajanı onu çağırabilir.
|
||||
Kullanıcıların yalnızca *"Jeffery Griffin için bir teklif hazırla"* diyebilmesi için bir ajan ve bir beceri ekleyelim.
|
||||
|
||||
## Beceri
|
||||
|
||||
Bir [skill](/l/tr/developers/extend/apps/logic/skills-and-agents), ajanlara eklediğiniz bilgi — yeniden kullanılabilir talimatlardır. Bizimki modele aracı nasıl kullanacağını öğretir.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## Ajan
|
||||
|
||||
Bir [ajan](/l/tr/developers/extend/apps/logic/skills-and-agents), bir istemi bir modelle eşleştirir. Bir derleme uyarısından kaçınmak için `responseFormat` değerini açıkça ayarlayın.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
Ajan yalnızca rolü izin veriyorsa aracı çağırabilir. Uygulamanın rolünde `canAccessAllTools: true` ve `canBeAssignedToAgents: true` değerlerini [Bölüm 2](/l/tr/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access) içinde zaten ayarladık.
|
||||
</Note>
|
||||
|
||||
## Deneyin
|
||||
|
||||
**Document Assistant** ile bir sohbet açın ve CRM’inizdeki bir kişi için bir belge taslağı hazırlamasını isteyin. Kaydı bulur, `generate-document` çağrısını yapar ve oluşturduğu belgeyi size bildirir — bu belge artık **Documents** görünümünüzde, komut menüsü ve iş akışı yollarındakiyle tam olarak aynı şekilde görünür.
|
||||
|
||||
Mantığı bir araç olarak açığa çıkarmanın getirisi budur: **tek fonksiyon, birçok ön kapı** — komut menüsü, HTTP, iş akışı adımı ve şimdi de doğal dil.
|
||||
|
||||
**Bu adımdan sonra:** uygulama özellik açısından tamamlanmış ve gerçekten kullanışlıdır. Artık onu yayımlama zamanı.
|
||||
|
||||
<Card title="Sonraki: yayımlama →" icon="rocket" href="/l/tr/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
Pazar yeri meta verilerini ekleyin ve yayımlayın.
|
||||
</Card>
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: 4. UI'yi oluşturma
|
||||
icon: table-columns
|
||||
description: Görünümler, kenar çubuğu gezinmesi, bir komut ve ön uç bileşenleri.
|
||||
---
|
||||
|
||||
Şu anda nesnelere yalnızca Settings üzerinden erişilebiliyor. Uygulamaya UI'de gerçek bir varlık kazandıralım: liste görünümleri, kenar çubuğu girişleri, tek tıkla **Generate document** komutu, bir belgeyi **önizlemek** için kayıt sayfası ön uç bileşeni ve şablonlar için yerel zengin metin **editor** sekmesi.
|
||||
|
||||
## Görünümler ve gezinme
|
||||
|
||||
Bir [görünüm](/l/tr/developers/extend/apps/layout/views), belirli bir nesnenin kaydedilmiş bir listesidir.
|
||||
Bir [gezinme menüsü öğesi](/l/tr/developers/extend/apps/layout/navigation-menu-items)
|
||||
bu görünümü kenar çubuğuna yerleştirir.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Aynı çifti şablonlar için de ekleyin. İkisi de artık kenar çubuğunda görünüyor:
|
||||
|
||||
<Frame caption="Kenar çubuğunda Documents ve Templates, oluşturulmuş belge listelenmiş şekilde.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Oluşturulmuş belgeli Documents görünümü" />
|
||||
</Frame>
|
||||
|
||||
## Bir ön uç bileşeni
|
||||
|
||||
Bir [ön uç bileşeni](/l/tr/developers/extend/apps/layout/front-components), Twenty içinde izole edilmiş bir React bileşenidir. Bizimki seçili kaydı okur, kişi şablonlarını `CoreApiClient` aracılığıyla yükler ve bir önceki bölümdeki rotaya POST isteği gönderir.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Değerleri `twenty-ui` içe aktarmak yerine satır içi CSS değişkenleriyle (`var(--t-color-blue)`) stillendirin. SDK, derleme sırasında bu paketi taklit eder, bu nedenle tema sabitlerinin modül düzeyindeki içe aktarımları `undefined` olur. Tam bileşene bakın:
|
||||
[full component](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## Onu açacak bir komut
|
||||
|
||||
Bir [komut menüsü öğesi](/l/tr/developers/extend/apps/layout/command-menu-items) `availabilityType: 'RECORD_SELECTION'` ile, bir Person seçildiğinde görünür ve bileşeni yan panelde açar.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Tüm akışı deneyin
|
||||
|
||||
**People**'ı açın, bir kişiyi işaretleyin ve <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd> tuşlarına basın.
|
||||
"Generate document" komutu, uygulamanızla etiketlenmiş şekilde görünür:
|
||||
|
||||
<Frame caption="Bir Person seçildiğinde komut görünür.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Generate document ile komut menüsü" />
|
||||
</Frame>
|
||||
|
||||
Çalıştırın — bileşeniniz yan panelde açılır. Bir şablon seçin, **Generate**'a tıklayın ve **Documents** içinde yeni bir kayıt oluşsun.
|
||||
|
||||
<Frame caption="Ön uç bileşeni, şablonları yüklüyor ve tıklamayla oluşturuyor.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Generate document yan paneli" />
|
||||
</Frame>
|
||||
|
||||
Oluşturulan her belge, uygulamanızı yazarı olarak kaydeder:
|
||||
|
||||
<Frame caption="Document Generator tarafından oluşturuldu, durum Generated.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="Oluşturulmuş bir belge kaydı" />
|
||||
</Frame>
|
||||
|
||||
## Bir belgenin kayıt sayfasında önizleme
|
||||
|
||||
Bir ön uç bileşeni sadece komut menüleri için değildir — onu bir **kayıt sayfasında sekme** olarak da bağlayabilirsiniz. Belge kaydına, Markdown gövdesini şık, yazdırılabilir bir sayfa olarak işleyen bir *Preview* sekmesi ekleyelim.
|
||||
|
||||
Bileşen, çalışma bağlamından geçerli kayıt kimliğini okur, belgeyi yükler ve işler. Ön uç bileşenleri, yalnızca belirli bir HTML etiketleri beyaz listesine izin veren bir **sandbox** içinde çalışır — ham HTML enjeksiyonu (`dangerouslySetInnerHTML`) ve `\<style>` engellenir — bu nedenle Markdown'ı, satır içi stillerle, küçük bir [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx) yardımcısı aracılığıyla React öğeleri olarak işliyoruz.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Bunu bir [sayfa düzeni](/l/tr/developers/extend/apps/layout/page-layouts) ile bağlayın. Bir
|
||||
`RECORD_PAGE` düzeni, bir nesnenin kayıt görünümüne sekmeler ekler; `CANVAS` sekmesindeki bir `FRONT_COMPONENT` widget'ı bileşeni barındırır:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Herhangi bir belgeyi açın — **Preview** sekmesi, belgeyi paylaşılabilir web sayfasına ve PDF'e bağlantılarla birlikte, güzel bir şekilde işler:
|
||||
|
||||
<Frame caption="Preview sekmesi, belgeyi satır içi stillerle ve hızlı bağlantılarla işler.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Kayıt sayfası sekmesinde belge görüntüleyici ön uç bileşeni" />
|
||||
</Frame>
|
||||
|
||||
## Zengin metin editörüyle bir şablonu düzenleme
|
||||
|
||||
Şablonların özel bir bileşene hiç ihtiyacı yoktur. `body` bir
|
||||
`RICH_TEXT` alanı olduğu için, Twenty bunun için zaten tam özellikli bir zengin metin editörü sunar — standart Note ve Task nesnelerinin kullandığı editörün aynısı. Biz sadece onu şablon kayıt sayfasında görünür hale getiriyoruz.
|
||||
|
||||
`FIELD` widget'lı, `EDITOR` görüntü modunda ve `fieldMetadataId` aracılığıyla `body` alanını işaret eden bir sekme ekleyin:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
Bir `RICH_TEXT` alanı, hem editörün blok JSON'unu hem de bir Markdown izdüşümünü saklar. Oluşturma hattı bu Markdown izdüşümünü okur, böylece yer tutucular, PDF ve paylaşılabilir web sayfası hiç değişmeden çalışmaya devam eder — tam [`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts) dosyasına bakın.
|
||||
Artık editörler, şablonları düzgün bir zengin metin editöründe yazıyor:
|
||||
|
||||
<Frame caption="Template sekmesi: Twenty'nin yerel zengin metin editörü, body alanına bağlı.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Yerel zengin metin editörü sekmesine sahip şablon kaydı" />
|
||||
</Frame>
|
||||
|
||||
**Bu adımdan sonra:** belgeler harika bir şekilde önizlenir ve şablonlar uygulama içinde düzenlenebilir. Sırada, bir AI temsilcisinin bunları bir sohbetten oluşturmasını sağlamak var.
|
||||
|
||||
<Card title="Sırada: bir AI temsilcisi →" icon="robot" href="/l/tr/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Aracınızı çağıran bir temsilci ve bir yetenek ekleyin.
|
||||
</Card>
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: 1. Veri modeli
|
||||
icon: database
|
||||
description: Belgeleri ve şablonları nesneler, alanlar ve bir ilişki ile modelleyin.
|
||||
---
|
||||
|
||||
Uygulamamızın iki özel nesneye ihtiyacı var: **belge şablonları** (ne yazılacağını belirten) ve
|
||||
**belgeler** (oluşturulan sonuç). Bunları tanımlayalım.
|
||||
|
||||
CLI ile her varlık dosyasını iskelet olarak oluşturun — sizin için geçerli bir UUID ve doğru klasörü oluşturur:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Aşağıda tamamlanmış dosyaları gösteriyoruz.
|
||||
|
||||
<Note>
|
||||
Her `*_UNIVERSAL_IDENTIFIER` sabiti
|
||||
`src/constants/universal-identifiers.ts` içinde bulunur ve kullanıldığı yerde içe aktarılır. Aşağıdaki kod parçacıkları
|
||||
kısalık için bu içe aktarmaları atlar — kendi dosyalarınızda bunları eklemeyi unutmayın.
|
||||
</Note>
|
||||
|
||||
## Şablon nesnesi
|
||||
|
||||
Bir şablonun bir `name` alanı, `{{placeholders}}` içeren bir `body` alanı ve bunun bir Person mı yoksa bir Company için mi yazıldığını belirten bir `target` alanı vardır. `body` bir
|
||||
`RICH_TEXT` alanıdır, bu nedenle Twenty ona tam özellikli bir zengin metin düzenleyicisi verir.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` seçenek **değerleri** `UPPER_CASE` (`PERSON`, `person` değil) olmalıdır ve
|
||||
`defaultValue` fazladan tırnak içine alınır: `` `'PERSON'` ``. `label`,
|
||||
kullanıcıların gördüğü şeydir.
|
||||
</Warning>
|
||||
|
||||
## Belge nesnesi
|
||||
|
||||
Oluşturulan belge, işlenmiş `content` ve bir `status` saklar. Bunu
|
||||
yine aynı şekilde tanımlayın; `status` için `DRAFT` / `GENERATED` seçenekli bir select alanı kullanın. Tam dosya:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Bir ilişkiyle bunları birbirine bağlama
|
||||
|
||||
Her belge, geldiği şablonu işaret etmelidir. İlişkiler
|
||||
**çift yönlüdür** — her iki tarafı da tanımlarsınız ve her birini kendi alan dosyasında belirtirsiniz.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Diğer taraf (`template-documents-relation.field.ts`),
|
||||
ters yönde işaret eden ve adı `documents` olan bir `RelationType.ONE_TO_MANY` alanıdır.
|
||||
Tam desen için [Relations](/l/tr/developers/extend/apps/data/relations) bölümüne bakın.
|
||||
|
||||
## Bunu Twenty'de görün
|
||||
|
||||
`yarn twenty dev` çalışırken **Settings → Data model** bölümünü açın. Her iki nesne de
|
||||
uygulamanızla etiketlenmiş olarak görünür.
|
||||
|
||||
<Frame caption="Her iki özel nesne, Document Generator uygulamasına aittir.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Veri modeli ayarları, Documents ve Document templates nesnelerini gösteriyor" />
|
||||
</Frame>
|
||||
|
||||
Test etmek için bir şablon oluşturun — adını *Sales proposal* koyun, **Target** alanını
|
||||
*Person* olarak ayarlayın ve birkaç yer tutucu içeren bir gövde yapıştırın:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="Bir şablon kaydı. Gövde, bir belge oluşturulana kadar yer tutucularını korur.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="Yer tutucu gövdesine sahip bir Sales proposal şablon kaydı" />
|
||||
</Frame>
|
||||
|
||||
**Bu adımdan sonra:** bir ilişkiyle birbirine bağlı `documentTemplate` ve `document` nesnelerine ve bunlardan belge oluşturmak için bir şablona sahipsiniz. Sırada, onu dolduran mantık var.
|
||||
|
||||
<Card title="Sıradaki: belgeleri oluşturma →" icon="bolt" href="/l/tr/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
Şablonu dolduran mantık fonksiyonunu yazın.
|
||||
</Card>
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: 2. Belgeleri oluşturma
|
||||
icon: bolt
|
||||
description: Bir mantık fonksiyonu, bir yapay zekâ aracı ve bir iş akışı eylemi olarak sunulur.
|
||||
---
|
||||
|
||||
Şimdi asıl kısma geçelim: bir şablon ve bir kaydı yükleyen, yer tutucuları dolduran ve yeni bir belge kaydeden bir [mantık fonksiyonu](/l/tr/developers/extend/apps/logic/logic-functions).
|
||||
|
||||
İş mantığını **handler** olarak bir kez yazacağız, sonra bunu birkaç tetikleyici üzerinden kullanıma sunacağız. Bu bölüm bunlardan ikisini birbirine bağlıyor — bir **AI aracı** ve bir **iş akışı eylemi**.
|
||||
|
||||
## Oluşturma yardımcısı
|
||||
|
||||
Saf mantığı kendi dosyasında tutun ki birim testlerini yazmak kolay olsun. Bu, bir kaydı `{{dot.path}}` belirteçlerine düzleştirir ve bunların yerine değerlerini koyar.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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>
|
||||
Bu dosyanın yan etkisi olmadığından, onu hızlı birim testleriyle kapsayabilirsiniz (`yarn test:unit`). Bkz. [Testing](/l/tr/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## Handler
|
||||
|
||||
Handler, CRM verilerini okumak ve yazmak için oluşturulmuş [`CoreApiClient`](/l/tr/developers/extend/apps/logic/logic-functions) kullanır. Şablonu yükler, hedef kaydı yükler, gövdeyi doldurur ve bir `document` oluşturur.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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`, Kişi ile Şirket için farklı bir sorgu çalıştırır ve sonucu düzleştirir — bkz.
|
||||
[`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).
|
||||
|
||||
## Bunu bir araç ve bir iş akışı eylemi olarak kullanıma sunma
|
||||
|
||||
Tek bir `defineLogicFunction` birden çok tetikleyici barındırabilir. Burada, `toolTriggerSettings` onu AI aracılarının çağırabileceği hâle getirir ve `workflowActionTriggerSettings` onu görsel iş akışı oluşturucusundaki bir adıma dönüştürür. Her ikisi de girdilerini bir JSON şemasıyla tanımlar.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
Girdi şeması, `templateId` ve `recordId` değerlerini tanımlayan basit bir JSON şemasıdır — bkz. [`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).
|
||||
|
||||
## Erişim verin
|
||||
|
||||
Mantık fonksiyonları, uygulamanın rolüyle çalışır. Şablonları ve kayıtları okuması ve belgeler oluşturması gerektiğinden, `src/roles/default-role.ts` içinde buna izin verin:
|
||||
|
||||
```ts
|
||||
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`, bir sonraki bölümde fonksiyonun oluşturulan PDF'yi yüklemesine olanak tanır.
|
||||
Daha ayrıntılı izinler için bkz. [Roles](/l/tr/developers/extend/apps/config/roles).
|
||||
|
||||
## Gerçek bir PDF dosyası ekleyin
|
||||
|
||||
Oluşturulmuş bir metin alanı kullanışlıdır, ancak kullanıcılar gerçek bir belge ister. Bir **PDF** oluşturalım ve onu kayıt üzerinde indirilebilir bir dosya olarak saklayalım.
|
||||
|
||||
Önce, PDF'yi tutması için `document` nesnesine bir `FILES` alanı verin. Uygulamalar **kendi** dosya alanlarına yükleme yapar, dolayısıyla yüklemenin yönlendirildiği yer bu alandır:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Şimdi o PDF'yi oluşturun. Bir uygulama gerçek bir Node projesidir, bu yüzden ihtiyaç duyduğunuz herhangi bir npm paketini ekleyebilir ve onu başka yerlerde olduğu gibi içe aktarabilirsiniz. Biz, PDF'yi çizmek için **[pdf-lib](https://pdf-lib.js.org/)** ve Markdown gövdesini ayrıştırmak için **[marked](https://marked.js.org/)** kullanıyoruz — CLI bunları fonksiyonun çalışma zamanına sizin için kurar:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
Tam yardımcı işlev şuradadır: [`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).
|
||||
Bu yardımcı, Markdown'ı `marked.lexer` ile belirteçlere ayrıştırır, ardından onları pdf-lib ile yerleştirir: gerçek başlıklar, **kalın**/*italik* kısımlar, madde ve numaralı listeler, alıntı blokları ve çizgiler — şablonun kendisinin, sadece bir metin yığını yerine, cilalı, çok sayfalı A4 formatında bir oluşturması.
|
||||
|
||||
<Frame caption="Oluşturulan PDF: gerçek tipografi ve Markdown biçimlendirmesiyle şablon gövdesinin oluşturulması.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="Cilalı, pazarlanabilir oluşturulmuş bir PDF" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
pdf-lib'in yerleşik yazı tipleri WinAnsi kodlaması kullanır, bu nedenle Batı Avrupa aksanları kutudan çıktığı gibi oluşturulur; yardımcı, akıllı tırnak işaretlerini ve tireleri eşler ve kodlayamadığı karakterleri atar. Latin olmayan yazı sistemlerini (Çince, Arapça, Kiril) oluşturmak, bir Unicode yazı tipi gömmek anlamına gelir.
|
||||
</Note>
|
||||
|
||||
Sonra onu yükleyin ve başvurusunu kayıt üzerinde saklayın. `uploadFile`, baytları uygulamaya ait dosya alanınıza yönlendirir; döndürülen `id`, kaydettiğiniz değerdir:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Artık oluşturulan belge indirilebilir bir PDF taşıyor:
|
||||
|
||||
<Frame caption="Oluşturulan PDF, belgenin Dosya alanında saklanır.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="Oluşturulmuş PDF dosyasına sahip bir belge kaydı" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` yalnızca **uygulamaya ait** dosya alanlarını hedefler (bu nedenle yüklemeler her zaman alanın sahibi olan bir uygulama ve ayrıca `UPLOAD_FILE` rol bayrağı gerektirir). Bu nedenle PDF, kaydın kendi `file` alanına düşer — [call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder) uygulamasının kayıtlar için kullandığıyla aynı desen.
|
||||
</Note>
|
||||
|
||||
**Bu adımdan sonra:** her oluşturulan belgenin gerçek, indirilebilir bir PDF'si vardır. Ancak henüz hiçbir şey oluşturucuyu arayüzden *çağır*amıyor — bunun için bir HTTP rotasına ihtiyacımız var.
|
||||
|
||||
<Card title="Sırada: HTTP rotaları →" icon="küre" href="/l/tr/developers/extend/apps/tutorials/document-generator/http-routes">
|
||||
Fonksiyonu HTTP üzerinden sunun ve belgeleri web sayfaları olarak oluşturun.
|
||||
</Card>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 3. HTTP rotaları
|
||||
icon: globe
|
||||
description: İşlevi HTTP üzerinden tetikleyin ve belgeleri web sayfaları olarak oluşturun.
|
||||
---
|
||||
|
||||
Aynı işleyici HTTP isteklerine de yanıt verebilir. İki rota ekleyeceğiz:
|
||||
|
||||
* belge oluşturmak için UI’ın çağırdığı bir **POST** uç noktası ve
|
||||
* belgeyi yazdırılabilir bir web sayfası olarak oluşturan herkese açık bir **GET** uç noktası.
|
||||
|
||||
Her ikisi de `httpRouteTriggerSettings` kullanır. Uygulama rotaları Twenty sunucunuzda `/s` altında sunulur (ör. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## POST rotası — isteğe bağlı oluşturma
|
||||
|
||||
Bu, `generateDocumentHandler`’ı yeniden kullanır, bu nedenle tekrarlanacak bir mantık yoktur — yalnızca istek gövdesini okuyan ince bir adaptör vardır.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Paylaşılan işleyici, hata durumunda önerilen bir `status` döndürür; böylece rota uygun bir `4xx`/`5xx` koduyla yanıt verebilir. `isAuthRequired: true`, çağıranın geçerli bir belirteç sunması gerektiği anlamına gelir — bir sonraki bölümdeki ön bileşen, kullanıcının erişim belirtecini otomatik olarak iletir.
|
||||
|
||||
## GET rotası — web sayfası olarak oluşturma
|
||||
|
||||
JSON yerine HTML döndürmek için gövdeyi `Content-Type` başlığıyla birlikte bir `Response` içine alın. Bu rota herkese açıktır (`isAuthRequired: false`), böylece oluşturulan bir belge bağlantı olarak paylaşılabilir.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage`, Markdown gövdesini HTML’ye dönüştürür ([marked](https://marked.js.org/) ile, temizlenmiş) ve onu yalnızca şablon içeriğini gösteren, temiz, yazdırılabilir bir sayfaya yerleştirir — PDF ve uygulama içi önizleme ile aynı görünüme sahiptir.
|
||||
[Yardımcıyı inceleyin](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Deneyin
|
||||
|
||||
Çalışma alanınızda bir şablon ve bir Kişi ile rotayı çağırın (**Settings → APIs & Webhooks** bölümünden bir belirteç alın):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Döndürülen belgeyi tarayıcınızda açın:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="Herkese açık GET rotası, belgeyi yazdırılabilir bir sayfa olarak oluşturur.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Oluşturulmuş bir belge web sayfası" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
Ayrıca test ederken bir işlevin günlüklerini `yarn twenty dev:function:logs` ile gerçek zamanlı izleyebilir veya `yarn twenty dev:function:exec` ile doğrudan çağırabilirsiniz.
|
||||
</Tip>
|
||||
|
||||
**Bu adımdan sonra:** uygulama HTTP üzerinden belgeler oluşturabilir ve bunları web sayfaları olarak sunabilir. Şimdi bunu `curl` olmadan kullanılabilir hale getirelim.
|
||||
|
||||
<Card title="Sıradaki: arayüzü oluşturma →" icon="table-columns" href="/l/tr/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Görünümler, gezinme, komut ve bir ön bileşen.
|
||||
</Card>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "Eğitim: Belge Oluşturucu"
|
||||
icon: wand-magic-sparkles
|
||||
description: CRM verilerinizden kişiselleştirilmiş belgeler oluşturan gerçek bir Twenty uygulaması geliştirin.
|
||||
---
|
||||
|
||||
Bu eğitimde, yeniden kullanılabilir şablonları CRM’inizde zaten bulunan verileri kullanarak kişiselleştirilmiş belgelere dönüştüren bir uygulama olan **Belge Oluşturucu**’yu geliştireceksiniz.
|
||||
|
||||
`{{placeholders}}` ile bir kez şablon yazın, sonra komut menüsünden, bir yapay zeka aracısından veya bir iş akışından tek tıklamayla herhangi bir Kişi veya Şirket için doldurulmuş bir belge oluşturun.
|
||||
|
||||
<Frame caption="Belirli bir kişi için oluşturulmuş, yazdırılabilir bir sayfa olarak açılan bir şablon.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="Oluşturulmuş bir Satış teklifi belgesi" />
|
||||
</Frame>
|
||||
|
||||
## Neler öğreneceksiniz
|
||||
|
||||
Her bölüm bir yetenek ekler. Sonunda SDK’nin çoğuna dokunmuş olacaksınız.
|
||||
|
||||
| Bölüm | Yetenek | Başvuru |
|
||||
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| [1. Veri modeli](/l/tr/developers/extend/apps/tutorials/document-generator/data-model) | Nesneler, alanlar ve bir ilişki | [Veri](/l/tr/developers/extend/apps/data/overview) |
|
||||
| [2. Belgeler oluşturma](/l/tr/developers/extend/apps/tutorials/document-generator/generating-documents) | Bir Markdown şablonunu dolduran ve cilalı bir PDF ekleyen mantık işlevi (Yapay zekâ aracı + iş akışı eylemi) | [Mantık işlevleri](/l/tr/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. HTTP rotaları](/l/tr/developers/extend/apps/tutorials/document-generator/http-routes) | Rotalardan JSON ve paylaşılabilir bir HTML sayfası sunma | [Mantık işlevleri](/l/tr/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Kullanıcı arayüzünü oluşturma](/l/tr/developers/extend/apps/tutorials/document-generator/building-the-ui) | Görünümler, gezinme, komut menüsü ve bir belgeyi önizleyen ve bir şablonu düzenleyen ön bileşenler | [Düzen](/l/tr/developers/extend/apps/layout/overview) |
|
||||
| [5. Bir yapay zekâ aracısı](/l/tr/developers/extend/apps/tutorials/document-generator/ai-agent) | Aracı + beceri | [Beceriler ve aracılar](/l/tr/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Yayınlama](/l/tr/developers/extend/apps/tutorials/document-generator/publishing) | Onu pazaryerine gönderin | [Yayınlama](/l/tr/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Ön Gereksinimler
|
||||
|
||||
[Hızlı Başlangıç](/l/tr/developers/extend/apps/getting-started/quick-start) bölümünü tamamlamış olmalısınız:
|
||||
`2020` portunda çalışan yerel bir Twenty sunucusu ve ona kimlik doğrulaması yapılmış CLI.
|
||||
|
||||
Değilse, şimdi bir tane çatısını oluşturup başlatın:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Bitmiş kodu okumayı mı tercih edersiniz? Tam uygulama şurada bulunur:
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Aşağıdaki her kod parçası oradan kopyalanmıştır.
|
||||
</Note>
|
||||
|
||||
## Uygulamanın nasıl bir araya geldiği
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="Yer tutucular içeren bir şablon, komut menüsünden, bir yapay zekâ aracısından, bir iş akışından veya paylaşılabilir bir bağlantıdan tetiklenerek PDF’li cilalı bir belgeye dönüştürülür" />
|
||||
</Frame>
|
||||
|
||||
`{{placeholders}}` ile zengin metin düzenleyicide bir **şablonu** bir kez yazarsınız. Bir şablon ve bir CRM kaydı seçmek, yer tutucuları doldurur ve cilalı bir **belgeyi** (PDF dosyasıyla birlikte) depolar. Geri kalan her şey — komut menüsü, yapay zekâ aracısı, iş akışı adımı, paylaşılabilir bağlantı — o tek oluşturucuyu tetiklemenin sadece farklı bir yoludur.
|
||||
|
||||
## Bu döngüyü çalışır durumda tutun
|
||||
|
||||
Tüm eğitim boyunca bir terminalde `yarn twenty dev` komutunu çalışır durumda bırakın. `src/` altında her dosya eklediğinizde veya düzenlediğinizde, birkaç saniye içinde sunucunuzla yeniden eşitlenir; böylece siz oluştururken her yeteneğin kullanıcı arayüzünde nasıl ortaya çıktığını izleyebilirsiniz.
|
||||
|
||||
<Card title="Oluşturmaya başlayın →" icon="database" href="/l/tr/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
1. Bölüm: belgeleri ve şablonları modelleyin.
|
||||
</Card>
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
---
|
||||
title: 6. Yayımlama
|
||||
icon: rocket
|
||||
description: Pazaryeri meta verilerini ekleyin ve uygulamanızı yayımlayın.
|
||||
---
|
||||
|
||||
Uygulamanız çalışıyor. Son adım, onu pazaryeri için tanımlamak ve yayımlamaktır.
|
||||
|
||||
## Pazaryeri meta verilerini ekleyin
|
||||
|
||||
[Application config](/l/tr/developers/extend/apps/config/application), pazaryerinde görünen kimliği taşır: yazar, kategori, logo ve destek bağlantıları. `public/` içine bir logo yerleştirin ve `logoUrl` ile referans verin.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/tr/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Varsayılan rol, kendi dosyasında `defineApplicationRole()` ile tanımlanır — artık burada `defaultRoleUniversalIdentifier` iletmiyorsunuz.
|
||||
</Tip>
|
||||
|
||||
Ayrıca uygulamanın bulunabilir olması için `package.json` dosyasına `twenty-app` anahtar sözcüğünü ekleyin:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Galeri ekran görüntüleri ekleyin
|
||||
|
||||
Bir pazaryeri listesi, kendini ekran görüntüleriyle satar. Birkaç PNG dosyasını `public/gallery/` içine bırakın ve bunlara `screenshots` ile referans verin — listeleme sayfasında bir galeri olarak görüntülenirler.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Kazançla başlayın: ilk ekran görüntüsünü bitmiş sonuç (oluşturulmuş bir belge) yapın, ardından nasıl tetiklendiğini ve hazırlandığını gösterin. Net, yüksek çözünürlüklü görüntüler kullanın — bunlar bir kullanıcının gördüğü ilk şeydir.
|
||||
</Tip>
|
||||
|
||||
`README.md` dosyasına da aynı özeni gösterin — npm ve GitHub üzerindeki ön sayfadır.
|
||||
Değer önerisi ve bir ekran görüntüsüyle başlayın, öne çıkan özellikleri listeleyin ve derleme ayrıntılarını sayfanın alt kısmında tutun.
|
||||
|
||||
## Yayımlamadan önce kontrol edin
|
||||
|
||||
CI ile aynı denetimleri çalıştırın:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
Taslak çalıştırma, sunucuda neyin değişeceğini uygulamadan, tam olarak yazdırır — iyi bir son akıl sağlığı kontrolüdür. Bkz.
|
||||
[Testing](/l/tr/developers/extend/apps/operations/testing) ve
|
||||
[Syncing & recovery](/l/tr/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Yayımla
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` varsayılan olarak oluşturur ve npm'e yayımlar; `--private` bunun yerine bir tarball dosyasını bir Twenty sunucusunun özel kaydına yükler. Yayımlanmış bir uygulamayı bir örneğin pazaryerinde görünür kılmak için, bir katalog eşitlemesini tetikleyin:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Tüm ayrıntılar ve yayımlama kontrol listesi:
|
||||
[Publishing](/l/tr/developers/extend/apps/operations/publishing).
|
||||
|
||||
## Bir uygulama geliştirdiniz 🎉
|
||||
|
||||
Altı bölümde SDK yüzeyinin büyük kısmını kullandınız:
|
||||
|
||||
* Verileri modellemek için **nesneler, alanlar ve bir ilişki**
|
||||
* Bir **mantık fonksiyonu**nun **AI aracı**, bir **iş akışı eylemi** ve **HTTP yolları** olarak sunulması
|
||||
* Arayüz için **görünümler, gezinme, bir komut ve bir ön bileşen**
|
||||
* Doğal dil üretimi için bir **ajan + beceri**
|
||||
* **Pazaryeri meta verileri** ve yayımlama akışı
|
||||
|
||||
Bitmiş uygulama şurada bulunur:
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Sırada nereye gidebilirsiniz
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Veri başvurusu" icon="database" href="/l/tr/developers/extend/apps/data/overview">
|
||||
Her alan türü, ilişki ve indeks seçeneği.
|
||||
</Card>
|
||||
<Card title="Mantık başvurusu" icon="bolt" href="/l/tr/developers/extend/apps/logic/overview">
|
||||
Cron ve veritabanı olayı tetikleyicileri, anahtar-değer deposu, OAuth bağlantıları.
|
||||
</Card>
|
||||
<Card title="Düzen başvurusu" icon="table-columns" href="/l/tr/developers/extend/apps/layout/overview">
|
||||
Sayfa düzenleri, pano bileşenleri ve daha fazla arayüz yüzeyi.
|
||||
</Card>
|
||||
<Card title="İşlemler" icon="rocket" href="/l/tr/developers/extend/apps/operations/overview">
|
||||
CLI, test, uzak depolar ve CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Başlarken"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Öğretici"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Yapılandırma"
|
||||
},
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: robot
|
||||
description: 让代理人使用您的工具从聊天室生成文档。
|
||||
---
|
||||
|
||||
因为`生成文档` 暴露于一个 **工具**\*,故AI 代理人可以调用它。
|
||||
让我们添加一个代理人和技能,用户可以说\*"生成一个
|
||||
Jeffery Griffin"\*。
|
||||
|
||||
## 技能
|
||||
|
||||
[skill](/l/zh/developers/extend/apps/logic/skills-and-agents) 是可重复使用的
|
||||
说明 — — 您附加到代理的知识。 我们教的模型如何使用
|
||||
这个工具。
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'To generate a document, call the `generate-document` tool with:',
|
||||
'- `templateId`: the id of the document template to use.',
|
||||
'- `recordId`: the id of the Person or Company the document is for.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## 代理
|
||||
|
||||
[agent](/l/zh/developers/extend/apps/logic/skills-and-agents) 与
|
||||
模型配对一个提示。 明确设置 "responseFormat" 以避免构建警告。
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-assistant',
|
||||
label: 'Document Assistant',
|
||||
description: 'Generates documents from your templates and CRM records.',
|
||||
icon: 'IconFileText',
|
||||
responseFormat: { type: 'text' },
|
||||
prompt: [
|
||||
'You are the Document Assistant for a CRM.',
|
||||
'You help users generate personalized documents from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
代理只能在其角色允许的情况下调用工具。 我们已经在应用的角色中设置了
|
||||
`canAccessAllTools: true` 和 `canBeAssignedToAgents: true`,
|
||||
详见[第 2 章](/l/zh/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access)。
|
||||
</Note>
|
||||
|
||||
## 试试
|
||||
|
||||
打开与 **Document Assistant** 的聊天,并要求它为您的 CRM 中的
|
||||
人起草一份文档。 它找到记录, 调用 \`generate-document', 并报告
|
||||
返回它创建的文档 — 现在出现在你的 **Documents** 视图中。
|
||||
就像命令菜单和工作流路径。
|
||||
|
||||
这是显示逻辑作为工具的回报:**一个函数、多个前门** -
|
||||
命令菜单、HTTP、工作流步骤以及现在的自然语言。
|
||||
|
||||
**在这一步之后:** 应用程序是功能完整和真正有用的。
|
||||
送货时间。
|
||||
|
||||
<Card title="下一步:发布 →" icon="rocket" href="/l/zh/developers/extend/apps/tutorials/document-generator/发布">
|
||||
添加市场元数据和发布。
|
||||
</Card>
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
---
|
||||
title: 4. 构建界面
|
||||
icon: table-columns
|
||||
description: 查看、侧边栏导航、命令和前部件。
|
||||
---
|
||||
|
||||
现在,对象只能通过设置访问。 让这个应用在 UI 中真正“现身”:列表视图、侧边栏条目、一键式 **Generate document** 命令、用于**预览**文档的记录页面 front 组件,以及用于模板的原生富文本 **editor** 选项卡。
|
||||
|
||||
## 视图和导航
|
||||
|
||||
[view](/l/zh/developers/extend/apps/layout/views) 是一个已保存的对象列表。
|
||||
[导航菜单项](/l/zh/developers/extend/apps/layout/navigation-menu-items)
|
||||
将此视图放置在侧边栏中。
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Documents',
|
||||
icon: 'IconFile',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
为模板添加相同的配对。 两者现在都显示在侧边栏:
|
||||
|
||||
<Frame caption="侧边栏中的文档和模板,并列出生成的文档。">
|
||||
<img src="/images/docs/developers/extends/apps/documents/document-generator/04-documents-view.png" alt="使用生成文档的文档视图" />
|
||||
</Frame>
|
||||
|
||||
## 前台组件
|
||||
|
||||
[front component](/l/zh/developers/extend/apps/layout/front-components) 是在 Twenty 内部沙盒运行的 React 组件。 我们读取了选中的记录,通过 `CoreApiClient` 和 POSTs 将
|
||||
人模板加载到最后一章的
|
||||
路线。
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
内联 CSS 变量的样式(`var(--t-color-blu)`),不是从
|
||||
`twai`导入的值。 构建过程中的 SDK 模型,所以模块级导入的
|
||||
主题常量将是“未定义的”。 请参阅
|
||||
[完整组件](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx)。
|
||||
</Warning>
|
||||
|
||||
## 打开它的命令
|
||||
|
||||
当选中一个 Person 时,带有 `availabilityType: 'RECORD_SELECTION'` 的 [command menu item](/l/zh/developers/extend/apps/layout/command-menu-items) 会显示出来,并在侧边面板中打开该组件。
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## 尝试整个流程
|
||||
|
||||
打开 **People**,勾选一个人,然后按下 <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>。
|
||||
“生成文档”出现,标记为您的应用:
|
||||
|
||||
<Frame caption="命令显示何时选中某人。">
|
||||
<img src="/images/docs/developers/extends/apps/documents/document-generator/06-command-menu.png" alt="带生成文档的命令菜单" />
|
||||
</Frame>
|
||||
|
||||
运行它 — — 您的组件在侧面板中打开. 选择一个模板,点击
|
||||
**生成**,以及在 **Documents** 中选择一个新的记录土地。
|
||||
|
||||
<Frame caption="前端组件,加载模板并生成点击。">
|
||||
<img src="/images/docs/developers/extends/apps/documents/document-generator/06b-front-compon.png" alt="生成文档侧面板" />
|
||||
</Frame>
|
||||
|
||||
每个生成的文档都将您的应用记录为其作者:
|
||||
|
||||
<Frame caption="由文档生成器创建,状态已生成。">
|
||||
<img src="/images/docs/developers/extends/apps/documents/document-generator/05-document-record.png" alt="生成的文档记录" />
|
||||
</Frame>
|
||||
|
||||
## 预览其记录页面上的文档
|
||||
|
||||
前面的组件不仅仅是命令菜单 — — 你可以在
|
||||
录制页面上挂载一个 \*\*选项卡。 让我们在文档记录中添加一个 *Preview* 选项卡,使得
|
||||
Markdown 物体作为一个可打印的打印页面。
|
||||
|
||||
组件从执行上下文读取当前记录ID,加载
|
||||
文档并将其渲染。 Front 组件运行在一个只允许白名单 HTML 标签的**沙盒**中——原始 HTML 注入(`dangerouslySetInnerHTML`)和 `\<style>` 会被阻止——因此我们通过一个小型 [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx) helper,将 Markdown 渲染为带有内联样式的 React 元素。
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
用[页面布局](/l/zh/developers/extend/apps/layout/page-layouts)挂载它。 `RECORD_PAGE` 布局会在对象的记录视图中添加选项卡;`CANVAS` 选项卡中的 `FRONT_COMPONENT` 小部件则承载该组件:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Document preview',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
打开任何文档 — **预览** 选项卡使它美丽,并链接到
|
||||
可分享的网页和 PDF :
|
||||
|
||||
<Frame caption="预览选项卡使用内联样式以及快速链接打开文档。">
|
||||
<img src="/images/docs/developers/extends/apps/documents/document-generator/09-document-viewer.png" alt="记录页面选项卡中的文档查看器前置组件" />
|
||||
</Frame>
|
||||
|
||||
## 使用富文本编辑器编辑模板
|
||||
|
||||
模板根本不需要自定义组件。 因为`body` 是一个
|
||||
`RICH_TEXT` 字段 20个已经为此提供了一个完整的文本编辑器——
|
||||
与标准注释和任务对象相同。 我们只是在
|
||||
模板记录页面上显示。
|
||||
|
||||
在 `EDITOR` 显示模式中添加一个 `FIELD` 小部件的标签,通过 `field MetadataId` 指向`body`
|
||||
字段:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
一个`RICH_TEXT`字段既存储编辑器块JSON也存储Markdown
|
||||
投影。 生成管道读取Markdown 投影, 所以
|
||||
占位符, PDF, 和可共享的网页都保持正常工作状态 —
|
||||
查看完整的
|
||||
[“模板-记录”。 年龄布局。s\`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts)。
|
||||
现在编辑器在一个合适的富文本编辑器中写入模板:
|
||||
|
||||
<Frame caption="模板选项卡:20个本地的富文本编辑器绑定到实体字段。">
|
||||
<img src="/images/docs/developers/extends/apps/documents/document-generator/10-template-editor.png" alt="本地富文本编辑器选项卡的模板记录" />
|
||||
</Frame>
|
||||
|
||||
**在这一步之后:** 文档预览,模板是可编辑的
|
||||
在应用中。 接下来,让AI 代理人从聊天室中生成它们。
|
||||
|
||||
<Card title="下一步:一个 AI 代理 →" icon="robot" href="/l/zh/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
添加代理和技能来呼叫您的工具。
|
||||
</Card>
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: 1. 数据模型
|
||||
icon: database
|
||||
description: 带有对象、字段和关系的模型文档和模板。
|
||||
---
|
||||
|
||||
我们的应用需要两个自定义对象:**文档模板**(写什么)和
|
||||
**文档**(生成的结果)。 让我们来定义它们。
|
||||
|
||||
用CLI扫描每个实体的文件 — — 它为您生成一个有效的 UUID 和
|
||||
文件夹:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
在下方显示完成的文件。
|
||||
|
||||
<Note>
|
||||
每一个 `*_UNIVERSAL_IDENTIFIER' 常住寿命为
|
||||
`src/constants/universal-identifiers.ts\` 并且在使用时导入。 下面的
|
||||
代码片段省略了这些导入的简洁度 - 将它们保留在您自己的文件中。
|
||||
</Note>
|
||||
|
||||
## 模板对象
|
||||
|
||||
一个模板具有一个 `name`、一个包含 `{{placeholders}}` 的 `body`,以及一个 `target`,用于指明它是为 Person 还是 Company 编写的。 `body` 是一个
|
||||
`RICH_TEXT` 字段,所以二十个字段给它一个完整的文本编辑器。
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` 选项 **值** 必须是 `UPPER_CASE` (`PERSON`, 而不是`person`),并且
|
||||
`defaultValue` 用额外引号包裹:`` `PERSON` ``。 "label" 是
|
||||
用户所看到的。
|
||||
</Warning>
|
||||
|
||||
## 文档对象
|
||||
|
||||
生成的文档存储渲染的 `content` 和 `status` 。 以相同的方式定义它,并添加一个 `status` 选择字段,取值为 `DRAFT` / `GENERATED`。 完整文件:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts)。
|
||||
|
||||
## 将它们与关联到关系
|
||||
|
||||
每个文档都应该回到它所产生的模板。 关系是
|
||||
**双向** — — 您在自己的字段文件中定义双方。
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
另一边(`template-documents-relation.field.ts`) 是一个名为`documents`的
|
||||
`RelationType.ONE_TO_MANY` 字段,它指出相反的方向。
|
||||
完整模式请参阅 [Relations](/l/zh/developers/extend/apps/data/relations)。
|
||||
|
||||
## 在 20 中查看
|
||||
|
||||
用 `yarn 20dev` 运行,打开 **设置 -> 数据模型**。 这两个对象都会出现,并带有你的应用标签。
|
||||
|
||||
<Frame caption="两个自定义对象,由文档生成器应用程序所拥有。">
|
||||
<img src="/images/docs/developers/extends/apps/documents/document-generator/01-data-model.png" alt="显示文档和文档模板的数据模型设置" />
|
||||
</Frame>
|
||||
|
||||
创建一个模板来测试 — 名称是 *销售建议*, 设置 **Target** 为
|
||||
*Person*, 并粘贴一个几个占位符的机构:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="模板记录。 该机构保留其占位符直到文档生成为止。">
|
||||
<img src="/images/docs/developers/extends/apps/documents/document-generator/03-template-record.png" alt="带占位符主体的销售建议模板记录" />
|
||||
</Frame>
|
||||
|
||||
**在这个步骤之后:** 你有 `documentTemplate` 和 `document` 对象,用
|
||||
的关系链接和一个模板生成它们。 接下来,填充它的逻辑。
|
||||
|
||||
<Card title="下一步:生成文档 →" icon="bolt" href="/l/zh/developers/extend/apps/tutorials/document-generator/generating-document">
|
||||
写下填充模板的逻辑函数。
|
||||
</Card>
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: 2. 正在生成文档
|
||||
icon: bolt
|
||||
description: 一个逻辑函数作为一个 AI 工具和工作流动作暴露。
|
||||
---
|
||||
|
||||
现在核心:一个[逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions)
|
||||
加载模板和记录,填充占位符,并保存一个新的
|
||||
文档。
|
||||
|
||||
我们将把业务逻辑写成一个**处理器**,然后透露它通过
|
||||
几个触发器。 本章将其中的两个线路连接起来——一个 **AI 工具** 和
|
||||
**Workflow 操作** 。
|
||||
|
||||
## 渲染帮助器
|
||||
|
||||
将纯逻辑保留在它自己的文件中,这样便于拆除测试。 这会将一个记录
|
||||
展平成 `{{dot.path}}` 标记并对其进行替换。
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
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"
|
||||
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"
|
||||
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
|
||||
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"
|
||||
{
|
||||
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"
|
||||
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="/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"
|
||||
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="/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>
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: 3. HTTP 路由
|
||||
icon: globe
|
||||
description: 通过 HTTP 触发函数并将文档渲染为 web 页面。
|
||||
---
|
||||
|
||||
相同的处理程序也可以回答 HTTP 请求。 我们将添加两个路由:
|
||||
|
||||
* a **POST** 让UI 调用来生成文档的端点,和
|
||||
* 一个公开的 **GET** 端点,将文档作为可打印的网页。
|
||||
|
||||
两者都使用 `httpRouteTriggerSettings` 。 App rough are served under `/s` under your
|
||||
20 server (e.g. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## POST 路由 — 按需生成
|
||||
|
||||
这会重用\`generateDocuments Handler',所以没有重复的逻辑——只是一个能读取请求正文的薄
|
||||
适配器。
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: result.success ? 200 : (result.status ?? 400),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
共享处理程序在失败时会返回建议的 `status`,因此路由可以使用适当的 `4xx`/`5xx` 状态码进行响应。 `isauth:true`是指调用者
|
||||
必须提供一个有效的令牌——下一章的前面组件自动通过
|
||||
用户的访问令牌。
|
||||
|
||||
## GET 路由 — 渲染为网页
|
||||
|
||||
若要返回HTML而不是JSON,将物体用
|
||||
`Content-Type`标头包装`Response`。 此路由是公开的(`isauth:必填:false`),所以
|
||||
生成的文档可以作为链接共享。
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { documentHtmlPage } from 'src/utils/render-document';
|
||||
|
||||
const htmlResponse = (html: string, status = 200): Response =>
|
||||
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: documentId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, content: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const document = documents?.edges?.[0]?.node;
|
||||
if (!document?.id) {
|
||||
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
|
||||
}
|
||||
|
||||
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'view-document',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage`将Markdown体变成HTML(带 [marked](https://marked.js.org/),
|
||||
净化) 只显示模板
|
||||
内容的可打印页面——与 PDF 和应用程序内预览相同。
|
||||
[参见辅助函数](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts)。
|
||||
|
||||
## 试试
|
||||
|
||||
在您的工作区内有一个模板和一个人, 调用路由(从
|
||||
**设置 -> API 和 Webhooks**获取一个令牌):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
在您的浏览器中打开返回的文档:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="公开的 GET 路由使文档成为一个可打印的页面。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="呈现的文档页面" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
您也可以在测试
|
||||
`yarn 20dev:functions:logs`时串流函数日志,或直接通过
|
||||
`yarn 20dev:function:exec` 直接调用。
|
||||
</Tip>
|
||||
|
||||
**在这一步之后:** 应用程序可以通过 HTTP 生成文档并以
|
||||
网页服务。 现在让我们让它在没有“curl”的情况下可以使用。
|
||||
|
||||
<Card title="下一步:构建界面→" icon="table-columns" href="/l/zh/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
查看、导航、命令和前端组件。
|
||||
</Card>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: 教程:文档生成器
|
||||
icon: wand-magic-sparkles
|
||||
description: 构建一个真实的 Twenty 应用,从你的 CRM 数据生成个性化文档。
|
||||
---
|
||||
|
||||
在本教程中,你将构建 **Document Generator** —— 一个应用,它将可复用的模板转换为使用你现有 CRM 数据生成的个性化文档。
|
||||
|
||||
使用 `{{placeholders}}` 一次性编写模板,然后即可为任意 Person 或 Company 一键生成已填充的文档——可从命令菜单、AI 代理或工作流中发起。
|
||||
|
||||
<Frame caption="为特定人员生成的单个模板,以可打印页面的形式打开。">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="生成的销售提案文档" />
|
||||
</Frame>
|
||||
|
||||
## 您将会学到什么
|
||||
|
||||
每一章都会增加一项功能。 在本教程结束时,您将接触到 SDK 的大部分内容。
|
||||
|
||||
| 章节 | 功能 | 参考 |
|
||||
| ------------------------------------------------------------------------------------ | -------------------------------------------------- | --------------------------------------------------------- |
|
||||
| [1. 数据模型](/l/zh/developers/extend/apps/tutorials/document-generator/data-model) | 对象、字段和一个关系 | [数据](/l/zh/developers/extend/apps/data/overview) |
|
||||
| [2. 生成文档](/l/zh/developers/extend/apps/tutorials/document-generator/generating-documents) | 一个逻辑函数(AI 工具 + 工作流操作),用于填充 Markdown 模板并附加一份精美的 PDF | [逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. HTTP 路由](/l/zh/developers/extend/apps/tutorials/document-generator/http-routes) | 从路由提供 JSON 和可分享的 HTML 页面 | [逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. 构建 UI](/l/zh/developers/extend/apps/tutorials/document-generator/building-the-ui) | 视图、导航、命令菜单,以及用于预览文档和编辑模板的前端组件 | [布局](/l/zh/developers/extend/apps/layout/overview) |
|
||||
| [5. 一个 AI 智能体](/l/zh/developers/extend/apps/tutorials/document-generator/ai-agent) | 智能体 + 技能 | [技能与智能体](/l/zh/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. 发布](/l/zh/developers/extend/apps/tutorials/document-generator/publishing) | 把它发布到应用市场 | [发布](/l/zh/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## 先决条件
|
||||
|
||||
您应该已经完成了[快速开始](/l/zh/developers/extend/apps/getting-started/quick-start):
|
||||
一个在端口 `2020` 上运行的本地 Twenty 服务器,以及已通过身份验证并连接到它的 CLI。
|
||||
|
||||
如果还没有,现在就创建并启动一个:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
更想直接阅读完成版代码? 完整应用位于
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)。
|
||||
下面的每个代码片段都来自该应用。
|
||||
</Note>
|
||||
|
||||
## 应用如何协同运作
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="带有占位符的模板会被生成为一份带 PDF 的精美文档,可以通过命令菜单、AI 智能体、工作流或可分享链接来触发" />
|
||||
</Frame>
|
||||
|
||||
您只需在富文本编辑器中编写一次**模板**,其中包含 `{{placeholders}}`。 选择一个
|
||||
模板和一条 CRM 记录,就会填充占位符,并存储一份精美的
|
||||
**文档**(带有一个 PDF 文件)。 其他所有内容——命令菜单、AI 智能体、工作流步骤、可分享链接——都只是触发同一个生成器的不同方式。
|
||||
|
||||
## 让这个循环持续运行
|
||||
|
||||
在整个教程期间,在一个终端中保持运行 `yarn twenty dev`。 每当您在 `src/` 下添加或编辑文件时,它都会在几秒钟内重新同步到服务器,这样您就可以在构建的同时,在 UI 中看到每项功能逐步出现。
|
||||
|
||||
<Card title="开始构建 →" icon="database" href="/l/zh/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
第 1 章:为文档和模板建模。
|
||||
</Card>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: 6. 发布
|
||||
icon: rocket
|
||||
description: 添加市场元数据并发布您的应用。
|
||||
---
|
||||
|
||||
您的应用正常工作。 最后一步是为市场描述并发布。
|
||||
|
||||
## 添加市场元数据
|
||||
|
||||
[应用程序配置](/l/zh/developers/extend/apps/config/application) 带有显示在市场中的
|
||||
身份:作者、类别、标志和支持
|
||||
链接。 在 `public/` 中放置一个徽标,然后使用 `logoUrl` 引用。
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/l/zh/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
默认角色在其自身文件中使用 `defineApplicationRole()` 声明——你不再在这里传递 `defaultRoleUniversalIdentifier`。
|
||||
</Tip>
|
||||
|
||||
也将 `twentapp` 关键字添加到 `package.json` 中,所以应用程序是可以发现的:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## 添加相册截图
|
||||
|
||||
市场列表用屏幕截图销售自己。 在
|
||||
`public/gallery/` 中拖放几个PNGs,然后使用 `screshots` - 它们在列表页面上渲染成一个相册
|
||||
。
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
领取付款: 让第一个屏幕截图完成的结果(生成的
|
||||
文档),然后显示它是如何触发和编写的。 使用简洁的高分辨率
|
||||
抓取 — — 他们是用户看到的第一件事。
|
||||
</Tip>
|
||||
|
||||
给`README.md`同样的处理方法——它是npm 和 GitHub 的首页。
|
||||
打开时使用值建议和屏幕截图,列出标题功能,
|
||||
然后保留折叠下方的构建详细信息。
|
||||
|
||||
## 在您发货前检查
|
||||
|
||||
运行相同的 CI 门:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
干线运行正是在不应用它的情况下打印服务器上会改变的内容——
|
||||
是一个很好的最后智能检查。 见
|
||||
[Testing](/l/zh/developers/extend/apps/operations/testing) 和
|
||||
[同步和恢复](/l/zh/developers/extend/apps/operations/sync-and-recovery)。
|
||||
|
||||
## 发布
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` 构建并默认向npm 发布;`--private`上传一个
|
||||
tarball到20个服务器的私人注册表。 要在一个实例的市场上显示一个已发布的应用
|
||||
,触发一个目录同步:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
详细信息和发布检查列表:
|
||||
[Publishing](/l/zh/developers/extend/apps/operations/publishing)。
|
||||
|
||||
## 您构建了一个应用 :party_popper:
|
||||
|
||||
在六章中,你使用了大部分SDK表面:
|
||||
|
||||
* **对象、字段和关系** 以模拟数据
|
||||
* 一个**逻辑函数** 显示为 **AI 工具**,一个 **Workflow 动作** 和 **HTTP 路由**
|
||||
* 界面**查看、导航、命令和前面组件**
|
||||
* 用于生成自然语言的 **agent + 技能**
|
||||
* **市场元数据** 和发布流
|
||||
|
||||
已完成的应用位于
|
||||
[\`软件包/二十个应用/示例/文件生成器'](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)。
|
||||
|
||||
## 下一步
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="数据参考" icon="database" href="/l/zh/developers/extend/apps/data/overview">
|
||||
每个字段类型、关系和索引选项。
|
||||
</Card>
|
||||
<Card title="逻辑引用" icon="bolt" href="/l/zh/developers/extend/apps/logic/overview">
|
||||
Cron 和数据库事件触发了密钥价值存储,OAuth 连接。
|
||||
</Card>
|
||||
<Card title="布局引用" icon="table-columns" href="/l/zh/developers/extend/apps/layout/overview">
|
||||
页面布局、仪表板小部件和更多用户界面。
|
||||
</Card>
|
||||
<Card title="操作" icon="rocket" href="/l/zh/developers/extend/apps/operations/overview">
|
||||
CLI, test, remotes, and CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "开始使用"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "教程"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "配置"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user