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
+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>
|
||||
Reference in New Issue
Block a user