Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571f79978b | ||
|
|
6aedeb1cb2 | ||
|
|
4cc3deb937 | ||
|
|
e15feda3c3 | ||
|
|
9f95c4763c | ||
|
|
e6fe48b66d | ||
|
|
20ac5b7e84 | ||
|
|
136f362b24 | ||
|
|
291792f864 | ||
|
|
9054d3aef6 | ||
|
|
19dd4d6c1b | ||
|
|
a0c6727a61 | ||
|
|
b002930554 | ||
|
|
aa0ea96582 | ||
|
|
eefc5c1914 | ||
|
|
36dece43c7 | ||
|
|
ac8e0d4217 | ||
|
|
ee3ebd0ca0 | ||
|
|
c11e4ece39 | ||
|
|
5bbfce7789 | ||
|
|
887e0283c5 | ||
|
|
94e019f012 | ||
|
|
c23961fa81 |
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "twenty-front",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["nx", "start", "twenty-front"],
|
||||
"port": 3001
|
||||
},
|
||||
{
|
||||
"name": "twenty-server",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["nx", "start", "twenty-server"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "twenty-worker",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["nx", "run", "twenty-server:worker"],
|
||||
"port": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,15 +35,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
image: postgres:18
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -53,16 +48,10 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.8.8
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
@@ -421,12 +410,6 @@ jobs:
|
||||
|
||||
echo "valid=$valid" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Install OpenAPI Diff Tool
|
||||
run: |
|
||||
# Using the Java-based OpenAPITools/openapi-diff via Docker
|
||||
|
||||
@@ -36,15 +36,10 @@ jobs:
|
||||
runs-on: ubuntu-latest-4-cores
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
image: postgres:18
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -54,9 +49,6 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
|
||||
@@ -25,15 +25,10 @@ jobs:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
image: postgres:18
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -43,9 +38,6 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
|
||||
@@ -53,15 +53,10 @@ jobs:
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
image: postgres:18
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -71,9 +66,6 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
|
||||
@@ -83,15 +83,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
image: postgres:18
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -101,9 +96,6 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
@@ -231,15 +223,10 @@ jobs:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
image: postgres:18
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -249,16 +236,10 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.8.8
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
|
||||
@@ -26,15 +26,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
image: postgres:18
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
|
||||
@@ -29,15 +29,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
image: postgres:18
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -47,9 +42,6 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"graphql": "16.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
"typescript": "5.9.2",
|
||||
"nodemailer": "8.0.4",
|
||||
"graphql-redis-subscriptions/ioredis": "^5.6.0",
|
||||
"@lingui/core": "5.1.2",
|
||||
"@types/qs": "6.9.16",
|
||||
|
||||
@@ -1720,12 +1720,12 @@ enum FeatureFlagKey {
|
||||
IS_UNIQUE_INDEXES_ENABLED
|
||||
IS_JSON_FILTER_ENABLED
|
||||
IS_AI_ENABLED
|
||||
IS_COMMAND_MENU_ITEM_ENABLED
|
||||
IS_MARKETPLACE_ENABLED
|
||||
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED
|
||||
IS_PUBLIC_DOMAIN_ENABLED
|
||||
IS_EMAILING_DOMAIN_ENABLED
|
||||
IS_JUNCTION_RELATIONS_ENABLED
|
||||
IS_COMMAND_MENU_ITEM_ENABLED
|
||||
IS_DRAFT_EMAIL_ENABLED
|
||||
IS_USAGE_ANALYTICS_ENABLED
|
||||
IS_RICH_TEXT_V1_MIGRATED
|
||||
@@ -3768,8 +3768,13 @@ input UpsertFieldsWidgetGroupInput {
|
||||
}
|
||||
|
||||
input UpsertFieldsWidgetFieldInput {
|
||||
"""The id of the view field"""
|
||||
viewFieldId: UUID!
|
||||
"""The id of the view field. Required if fieldMetadataId is not provided."""
|
||||
viewFieldId: UUID
|
||||
|
||||
"""
|
||||
The id of the field metadata. Used to create a new view field when viewFieldId is not provided.
|
||||
"""
|
||||
fieldMetadataId: UUID
|
||||
isVisible: Boolean!
|
||||
position: Float!
|
||||
}
|
||||
|
||||
@@ -1422,7 +1422,7 @@ export interface PublicFeatureFlag {
|
||||
__typename: 'PublicFeatureFlag'
|
||||
}
|
||||
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_GRAPHQL_QUERY_TIMING_ENABLED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
|
||||
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_GRAPHQL_QUERY_TIMING_ENABLED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
|
||||
|
||||
export interface ClientConfig {
|
||||
appVersion?: Scalars['String']
|
||||
@@ -6262,8 +6262,10 @@ fields?: (UpsertFieldsWidgetFieldInput[] | null)}
|
||||
export interface UpsertFieldsWidgetGroupInput {id: Scalars['UUID'],name: Scalars['String'],position: Scalars['Float'],isVisible: Scalars['Boolean'],fields: UpsertFieldsWidgetFieldInput[]}
|
||||
|
||||
export interface UpsertFieldsWidgetFieldInput {
|
||||
/** The id of the view field */
|
||||
viewFieldId: Scalars['UUID'],isVisible: Scalars['Boolean'],position: Scalars['Float']}
|
||||
/** The id of the view field. Required if fieldMetadataId is not provided. */
|
||||
viewFieldId?: (Scalars['UUID'] | null),
|
||||
/** The id of the field metadata. Used to create a new view field when viewFieldId is not provided. */
|
||||
fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float']}
|
||||
|
||||
export interface CreateApiKeyInput {name: Scalars['String'],expiresAt: Scalars['String'],revokedAt?: (Scalars['String'] | null),roleId: Scalars['UUID']}
|
||||
|
||||
@@ -8951,12 +8953,12 @@ export const enumFeatureFlagKey = {
|
||||
IS_UNIQUE_INDEXES_ENABLED: 'IS_UNIQUE_INDEXES_ENABLED' as const,
|
||||
IS_JSON_FILTER_ENABLED: 'IS_JSON_FILTER_ENABLED' as const,
|
||||
IS_AI_ENABLED: 'IS_AI_ENABLED' as const,
|
||||
IS_COMMAND_MENU_ITEM_ENABLED: 'IS_COMMAND_MENU_ITEM_ENABLED' as const,
|
||||
IS_MARKETPLACE_ENABLED: 'IS_MARKETPLACE_ENABLED' as const,
|
||||
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' as const,
|
||||
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
|
||||
IS_EMAILING_DOMAIN_ENABLED: 'IS_EMAILING_DOMAIN_ENABLED' as const,
|
||||
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
|
||||
IS_COMMAND_MENU_ITEM_ENABLED: 'IS_COMMAND_MENU_ITEM_ENABLED' as const,
|
||||
IS_DRAFT_EMAIL_ENABLED: 'IS_DRAFT_EMAIL_ENABLED' as const,
|
||||
IS_USAGE_ANALYTICS_ENABLED: 'IS_USAGE_ANALYTICS_ENABLED' as const,
|
||||
IS_RICH_TEXT_V1_MIGRATED: 'IS_RICH_TEXT_V1_MIGRATED' as const,
|
||||
|
||||
@@ -9562,6 +9562,9 @@ export default {
|
||||
"viewFieldId": [
|
||||
3
|
||||
],
|
||||
"fieldMetadataId": [
|
||||
3
|
||||
],
|
||||
"isVisible": [
|
||||
6
|
||||
],
|
||||
|
||||
+59
-24
@@ -55,11 +55,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -79,11 +80,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -91,12 +121,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -108,15 +138,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -124,7 +154,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -135,8 +165,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -156,14 +191,14 @@ export const main = async (
|
||||
|
||||
#### لإرفاقه بكائن مختلف
|
||||
|
||||
استبدل `companyId` بالحقل المناسب:
|
||||
استبدل `targetCompanyId` بالحقل المناسب:
|
||||
|
||||
| كائن | اسم الحقل |
|
||||
| ---------- | -------------------- |
|
||||
| الشركة | `companyId` |
|
||||
| شخص | `personId` |
|
||||
| الفرصة | `opportunityId` |
|
||||
| كائن مخصّص | `yourCustomObjectId` |
|
||||
| كائن | اسم الحقل |
|
||||
| ---------- | -------------------------- |
|
||||
| الشركة | `targetCompanyId` |
|
||||
| شخص | `targetPersonId` |
|
||||
| الفرصة | `targetOpportunityId` |
|
||||
| كائن مخصّص | `targetYourCustomObjectId` |
|
||||
|
||||
حدّث كل من معلمة الوظيفة وكائن `variables.data` في عملية الـ mutation الخاصة بالمرفق.
|
||||
|
||||
|
||||
+59
-24
@@ -55,11 +55,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -79,11 +80,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -91,12 +121,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -108,15 +138,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -124,7 +154,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -135,8 +165,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -156,14 +191,14 @@ export const main = async (
|
||||
|
||||
#### Chcete-li připojit k jinému objektu
|
||||
|
||||
Nahraďte `companyId` příslušným polem:
|
||||
Nahraďte `targetCompanyId` příslušným polem:
|
||||
|
||||
| Objekt | Název pole |
|
||||
| -------------- | -------------------- |
|
||||
| Společnost | `companyId` |
|
||||
| Osoba | `personId` |
|
||||
| Příležitost | `opportunityId` |
|
||||
| Vlastní objekt | `yourCustomObjectId` |
|
||||
| Objekt | Název pole |
|
||||
| -------------- | -------------------------- |
|
||||
| Společnost | `targetCompanyId` |
|
||||
| Osoba | `targetPersonId` |
|
||||
| Příležitost | `targetOpportunityId` |
|
||||
| Vlastní objekt | `targetYourCustomObjectId` |
|
||||
|
||||
Aktualizujte jak parametr funkce, tak objekt `variables.data` v mutaci přílohy.
|
||||
|
||||
|
||||
+59
-24
@@ -55,11 +55,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -79,11 +80,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -91,12 +121,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -108,15 +138,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -124,7 +154,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -135,8 +165,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -156,14 +191,14 @@ export const main = async (
|
||||
|
||||
#### An ein anderes Objekt anhängen
|
||||
|
||||
Ersetzen Sie `companyId` durch das entsprechende Feld:
|
||||
Ersetzen Sie `targetCompanyId` durch das entsprechende Feld:
|
||||
|
||||
| Objekt | Feldname |
|
||||
| -------------------------- | -------------------- |
|
||||
| Unternehmen | `companyId` |
|
||||
| Person | `personId` |
|
||||
| Opportunity | `opportunityId` |
|
||||
| Benutzerdefiniertes Objekt | `yourCustomObjectId` |
|
||||
| Objekt | Feldname |
|
||||
| -------------------------- | -------------------------- |
|
||||
| Unternehmen | `targetCompanyId` |
|
||||
| Person | `targetPersonId` |
|
||||
| Opportunity | `targetOpportunityId` |
|
||||
| Benutzerdefiniertes Objekt | `targetYourCustomObjectId` |
|
||||
|
||||
Aktualisieren Sie sowohl den Funktionsparameter als auch das Objekt `variables.data` in der Attachment-Mutation.
|
||||
|
||||
|
||||
+59
-24
@@ -55,11 +55,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -79,11 +80,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -91,12 +121,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -108,15 +138,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -124,7 +154,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -135,8 +165,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -156,14 +191,14 @@ export const main = async (
|
||||
|
||||
#### Per allegare a un oggetto diverso
|
||||
|
||||
Sostituisci `companyId` con il campo appropriato:
|
||||
Sostituisci `targetCompanyId` con il campo appropriato:
|
||||
|
||||
| Oggetto | Nome del campo |
|
||||
| ---------------------- | -------------------- |
|
||||
| Azienda | `companyId` |
|
||||
| Persona | `personId` |
|
||||
| Opportunità | `opportunityId` |
|
||||
| Oggetto personalizzato | `yourCustomObjectId` |
|
||||
| Oggetto | Nome del campo |
|
||||
| ---------------------- | -------------------------- |
|
||||
| Azienda | `targetCompanyId` |
|
||||
| Persona | `targetPersonId` |
|
||||
| Opportunità | `targetOpportunityId` |
|
||||
| Oggetto personalizzato | `targetYourCustomObjectId` |
|
||||
|
||||
Aggiorna sia il parametro della funzione sia l'oggetto `variables.data` nella mutation dell'allegato.
|
||||
|
||||
|
||||
+59
-24
@@ -55,11 +55,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -79,11 +80,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -91,12 +121,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -108,15 +138,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -124,7 +154,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -135,8 +165,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -156,14 +191,14 @@ export const main = async (
|
||||
|
||||
#### Pentru a atașa la un alt obiect
|
||||
|
||||
Înlocuiți `companyId` cu câmpul corespunzător:
|
||||
Înlocuiți `targetCompanyId` cu câmpul corespunzător:
|
||||
|
||||
| Obiect | Nume câmp |
|
||||
| ------------------- | -------------------- |
|
||||
| Companie | `companyId` |
|
||||
| Persoană | `personId` |
|
||||
| Oportunitate | `opportunityId` |
|
||||
| Obiect personalizat | `yourCustomObjectId` |
|
||||
| Obiect | Nume câmp |
|
||||
| ------------------- | -------------------------- |
|
||||
| Companie | `targetCompanyId` |
|
||||
| Persoană | `targetPersonId` |
|
||||
| Oportunitate | `targetOpportunityId` |
|
||||
| Obiect personalizat | `targetYourCustomObjectId` |
|
||||
|
||||
Actualizați atât parametrul funcției, cât și obiectul `variables.data` în mutația de atașare.
|
||||
|
||||
|
||||
+59
-24
@@ -55,11 +55,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -79,11 +80,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -91,12 +121,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -108,15 +138,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -124,7 +154,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -135,8 +165,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -156,14 +191,14 @@ export const main = async (
|
||||
|
||||
#### Чтобы прикреплять к другому объекту
|
||||
|
||||
Замените `companyId` на соответствующее поле:
|
||||
Замените `targetCompanyId` на соответствующее поле:
|
||||
|
||||
| Объект | Имя поля |
|
||||
| ----------------------- | -------------------- |
|
||||
| Компания | `companyId` |
|
||||
| Контакт | `personId` |
|
||||
| Сделка | `opportunityId` |
|
||||
| Пользовательский объект | `yourCustomObjectId` |
|
||||
| Объект | Имя поля |
|
||||
| ----------------------- | -------------------------- |
|
||||
| Компания | `targetCompanyId` |
|
||||
| Контакт | `targetPersonId` |
|
||||
| Сделка | `targetOpportunityId` |
|
||||
| Пользовательский объект | `targetYourCustomObjectId` |
|
||||
|
||||
Обновите и параметр функции, и объект `variables.data` в мутации вложения.
|
||||
|
||||
|
||||
+59
-24
@@ -55,11 +55,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -79,11 +80,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -91,12 +121,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -108,15 +138,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -124,7 +154,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -135,8 +165,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -156,14 +191,14 @@ export const main = async (
|
||||
|
||||
#### Farklı bir nesneye eklemek için
|
||||
|
||||
`companyId` değerini uygun alanla değiştirin:
|
||||
`targetCompanyId` değerini uygun alanla değiştirin:
|
||||
|
||||
| Nesne | Alan Adı |
|
||||
| ---------- | -------------------- |
|
||||
| Şirket | `companyId` |
|
||||
| Kişi | `personId` |
|
||||
| Fırsat | `opportunityId` |
|
||||
| Özel Nesne | `yourCustomObjectId` |
|
||||
| Nesne | Alan Adı |
|
||||
| ---------- | -------------------------- |
|
||||
| Şirket | `targetCompanyId` |
|
||||
| Kişi | `targetPersonId` |
|
||||
| Fırsat | `targetOpportunityId` |
|
||||
| Özel Nesne | `targetYourCustomObjectId` |
|
||||
|
||||
Ek oluşturma mutasyonunda hem işlev parametresini hem de `variables.data` nesnesini güncelleyin.
|
||||
|
||||
|
||||
+59
-24
@@ -55,11 +55,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -79,11 +80,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -91,12 +121,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -108,15 +138,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -124,7 +154,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -135,8 +165,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -156,14 +191,14 @@ export const main = async (
|
||||
|
||||
#### 要附加到其他对象
|
||||
|
||||
将 `companyId` 替换为相应的字段:
|
||||
将 `targetCompanyId` 替换为相应的字段:
|
||||
|
||||
| 对象 | 字段名称 |
|
||||
| ----- | -------------------- |
|
||||
| 公司 | `companyId` |
|
||||
| 人员 | `personId` |
|
||||
| 机会 | `opportunityId` |
|
||||
| 自定义对象 | `yourCustomObjectId` |
|
||||
| 对象 | 字段名称 |
|
||||
| ----- | -------------------------- |
|
||||
| 公司 | `targetCompanyId` |
|
||||
| 人员 | `targetPersonId` |
|
||||
| 机会 | `targetOpportunityId` |
|
||||
| 自定义对象 | `targetYourCustomObjectId` |
|
||||
|
||||
同时更新函数参数以及附件的 mutation 中的 `variables.data` 对象。
|
||||
|
||||
|
||||
+57
-22
@@ -54,11 +54,12 @@ export const main = async (
|
||||
params: { companyId: string },
|
||||
) => {
|
||||
const { companyId } = params;
|
||||
|
||||
// Replace with your Twenty GraphQL endpoint
|
||||
// Replace with your Twenty GraphQL endpoints (/metadata for metadata and files or /graphql for your records)
|
||||
// Cloud: https://api.twenty.com/graphql
|
||||
// Self-hosted: https://your-domain.com/graphql
|
||||
const graphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
const metadataGraphqlEndpoint = 'https://api.twenty.com/metadata';
|
||||
const dataGraphqlEndpoint = 'https://api.twenty.com/graphql';
|
||||
|
||||
// Replace with your API key from Settings → APIs
|
||||
const authToken = 'YOUR_API_KEY';
|
||||
@@ -78,11 +79,40 @@ export const main = async (
|
||||
const pdfBlob = await pdfResponse.blob();
|
||||
const pdfFile = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 2: Upload the file via GraphQL multipart upload
|
||||
const fieldMetadataIdQuery = `
|
||||
query FindUploadFileFieldMetadataId {
|
||||
objects {
|
||||
edges {
|
||||
node {
|
||||
nameSingular
|
||||
fieldsList {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Step 2: Find a fieldMetadataId of "Attachment file" field in Attachments object with GraphQL API
|
||||
const response = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
query: fieldMetadataIdQuery,
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const uploadFileFieldMetadataId = result.data.objects.edges.find(object => object.node.nameSingular === 'attachment').node.fieldsList.find(field => field.name === 'file').id;
|
||||
|
||||
// Step 3: Upload the file via GraphQL multipart upload
|
||||
const uploadMutation = `
|
||||
mutation UploadFile($file: Upload!, $fileFolder: FileFolder) {
|
||||
uploadFile(file: $file, fileFolder: $fileFolder) {
|
||||
path
|
||||
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
|
||||
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -90,12 +120,12 @@ export const main = async (
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('operations', JSON.stringify({
|
||||
query: uploadMutation,
|
||||
variables: { file: null, fileFolder: 'Attachment' },
|
||||
variables: { file: null, fieldMetadataId: uploadFileFieldMetadataId },
|
||||
}));
|
||||
uploadForm.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
uploadForm.append('0', pdfFile);
|
||||
|
||||
const uploadResponse = await fetch(graphqlEndpoint, {
|
||||
const uploadResponse = await fetch(metadataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
body: uploadForm,
|
||||
@@ -107,15 +137,15 @@ export const main = async (
|
||||
throw new Error(`Upload failed: ${uploadResult.errors[0].message}`);
|
||||
}
|
||||
|
||||
const filePath = uploadResult.data?.uploadFile?.path;
|
||||
const fileId = uploadResult.data?.uploadFilesFieldFile?.id;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path returned from upload');
|
||||
if (!fileId) {
|
||||
throw new Error('No file id returned from upload');
|
||||
}
|
||||
|
||||
// Step 3: Create the attachment linked to the company
|
||||
// Step 4: Create the attachment linked to the company
|
||||
const attachmentMutation = `
|
||||
mutation CreateAttachment($data: AttachmentCreateInput!) {
|
||||
mutation CreateOneAttachment($data: AttachmentCreateInput!) {
|
||||
createAttachment(data: $data) {
|
||||
id
|
||||
name
|
||||
@@ -123,7 +153,7 @@ export const main = async (
|
||||
}
|
||||
`;
|
||||
|
||||
const attachmentResponse = await fetch(graphqlEndpoint, {
|
||||
const attachmentResponse = await fetch(dataGraphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
@@ -134,8 +164,13 @@ export const main = async (
|
||||
variables: {
|
||||
data: {
|
||||
name: filename,
|
||||
fullPath: filePath,
|
||||
companyId,
|
||||
targetCompanyId: companyId,
|
||||
file: [
|
||||
{
|
||||
fileId: fileId,
|
||||
label: filename
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -155,14 +190,14 @@ export const main = async (
|
||||
|
||||
#### To attach to a different object
|
||||
|
||||
Replace `companyId` with the appropriate field:
|
||||
Replace `targetCompanyId` with the appropriate field:
|
||||
|
||||
| Object | Field Name |
|
||||
|--------|------------|
|
||||
| Company | `companyId` |
|
||||
| Person | `personId` |
|
||||
| Opportunity | `opportunityId` |
|
||||
| Custom Object | `yourCustomObjectId` |
|
||||
| Company | `targetCompanyId` |
|
||||
| Person | `targetPersonId` |
|
||||
| Opportunity | `targetOpportunityId` |
|
||||
| Custom Object | `targetYourCustomObjectId` |
|
||||
|
||||
Update both the function parameter and the `variables.data` object in the attachment mutation.
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"workerDirectory": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "3.0.99",
|
||||
"@apollo/client": "^4.0.0",
|
||||
"@blocknote/mantine": "^0.47.1",
|
||||
"@blocknote/react": "^0.47.1",
|
||||
|
||||
@@ -5516,10 +5516,12 @@ export type UpsertFieldPermissionsInput = {
|
||||
};
|
||||
|
||||
export type UpsertFieldsWidgetFieldInput = {
|
||||
/** The id of the field metadata. Used to create a new view field when viewFieldId is not provided. */
|
||||
fieldMetadataId?: InputMaybe<Scalars['UUID']>;
|
||||
isVisible: Scalars['Boolean'];
|
||||
position: Scalars['Float'];
|
||||
/** The id of the view field */
|
||||
viewFieldId: Scalars['UUID'];
|
||||
/** The id of the view field. Required if fieldMetadataId is not provided. */
|
||||
viewFieldId?: InputMaybe<Scalars['UUID']>;
|
||||
};
|
||||
|
||||
export type UpsertFieldsWidgetGroupInput = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { useAuthenticatedFetch } from '@/ai/hooks/useAuthenticatedFetch';
|
||||
import { agentChatQueuedMessagesComponentFamilyState } from '@/ai/states/agentChatQueuedMessagesComponentFamilyState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
@@ -46,12 +47,13 @@ const StyledQueuedText = styled.span`
|
||||
|
||||
export const AIChatQueuedMessages = () => {
|
||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
const queuedMessages = useAtomComponentFamilyStateValue(
|
||||
const { authenticatedFetch } = useAuthenticatedFetch();
|
||||
const agentChatQueuedMessages = useAtomComponentFamilyStateValue(
|
||||
agentChatQueuedMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
if (!isDefined(currentAIChatThread) || queuedMessages.length === 0) {
|
||||
if (!isDefined(currentAIChatThread) || agentChatQueuedMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,15 +64,12 @@ export const AIChatQueuedMessages = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(
|
||||
`${REST_API_BASE_URL}/agent-chat/${currentAIChatThread}/queue/${messageId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenPair.accessOrWorkspaceAgnosticToken.token}`,
|
||||
},
|
||||
authenticatedFetch(`${REST_API_BASE_URL}/agent-chat/queue/${messageId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenPair.accessOrWorkspaceAgnosticToken.token}`,
|
||||
},
|
||||
)
|
||||
})
|
||||
.then(() => {
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
})
|
||||
@@ -79,8 +78,10 @@ export const AIChatQueuedMessages = () => {
|
||||
|
||||
return (
|
||||
<StyledQueueContainer>
|
||||
<StyledQueueLabel>{queuedMessages.length} Queued</StyledQueueLabel>
|
||||
{queuedMessages.map((message) => {
|
||||
<StyledQueueLabel>
|
||||
{agentChatQueuedMessages.length} Queued
|
||||
</StyledQueueLabel>
|
||||
{agentChatQueuedMessages.map((message) => {
|
||||
const textPart = message.parts?.find((part) => part.type === 'text');
|
||||
const displayText = textPart && 'text' in textPart ? textPart.text : '';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AgentChatAiSdkStreamEffect } from '@/ai/components/AgentChatAiSdkStreamEffect';
|
||||
import { AgentChatStreamSubscriptionEffect } from '@/ai/components/AgentChatStreamSubscriptionEffect';
|
||||
import { AgentChatMessagesFetchEffect } from '@/ai/components/AgentChatMessagesFetchEffect';
|
||||
import { AgentChatSessionStartTimeEffect } from '@/ai/components/AgentChatSessionStartTimeEffect';
|
||||
|
||||
@@ -20,7 +20,7 @@ export const AgentChatProviderContent = ({
|
||||
>
|
||||
<AgentChatThreadInitializationEffect />
|
||||
<AgentChatMessagesFetchEffect />
|
||||
<AgentChatAiSdkStreamEffect />
|
||||
<AgentChatStreamSubscriptionEffect />
|
||||
<AgentChatStreamingPartsDiffSyncEffect />
|
||||
<AgentChatSessionStartTimeEffect />
|
||||
<AgentChatStreamingAutoScrollEffect />
|
||||
|
||||
+31
-72
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME } from '@/ai/constants/AgentChatEnsureThreadForDraftEventName';
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { useAgentChat } from '@/ai/hooks/useAgentChat';
|
||||
import { useAgentChatSubscription } from '@/ai/hooks/useAgentChatSubscription';
|
||||
import { useCreateAgentChatThread } from '@/ai/hooks/useCreateAgentChatThread';
|
||||
import { useEnsureAgentChatThreadExistsForDraft } from '@/ai/hooks/useEnsureAgentChatThreadExistsForDraft';
|
||||
import { useEnsureAgentChatThreadIdForSend } from '@/ai/hooks/useEnsureAgentChatThreadIdForSend';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
||||
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
|
||||
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
|
||||
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
|
||||
@@ -14,23 +16,14 @@ import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMess
|
||||
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
|
||||
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { mergeAgentChatFetchedAndStreamingMessages } from '@/ai/utils/mergeAgentChatFetchedAndStreamingMessages';
|
||||
import { normalizeAiSdkError } from '@/ai/utils/normalizeAiSdkError';
|
||||
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
||||
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
export const AgentChatAiSdkStreamEffect = () => {
|
||||
export const AgentChatStreamSubscriptionEffect = () => {
|
||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
const agentChatFetchedMessages = useAtomComponentFamilyStateValue(
|
||||
agentChatFetchedMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
const { createChatThread } = useCreateAgentChatThread();
|
||||
|
||||
@@ -45,47 +38,29 @@ export const AgentChatAiSdkStreamEffect = () => {
|
||||
onBrowserEvent: ensureThreadExistsForDraft,
|
||||
});
|
||||
|
||||
const onStreamingComplete = useCallback(() => {
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
}, []);
|
||||
useAgentChat(ensureThreadIdForSend);
|
||||
|
||||
const chatState = useAgentChat(
|
||||
agentChatFetchedMessages,
|
||||
ensureThreadIdForSend,
|
||||
onStreamingComplete,
|
||||
// Subscribe to the thread's event stream
|
||||
const subscriptionThreadId =
|
||||
currentAIChatThread !== null && isValidUuid(currentAIChatThread)
|
||||
? currentAIChatThread
|
||||
: null;
|
||||
|
||||
useAgentChatSubscription(subscriptionThreadId);
|
||||
|
||||
// Sync fetched messages to the displayed messages atom when no stream is active
|
||||
const agentChatFetchedMessages = useAtomComponentFamilyStateValue(
|
||||
agentChatFetchedMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
// Attempt to resume an active stream when navigating to an existing
|
||||
// thread. We call resumeStream() manually instead of using useChat's
|
||||
// resume:true option so that the stop button can coexist with
|
||||
// resumption (resume:true is incompatible with abort signals).
|
||||
// Only resume when the thread already has fetched messages — this
|
||||
// avoids resuming on newly created threads where the thread ID
|
||||
// transitions from a placeholder to a real UUID mid-conversation.
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentAIChatThread === null ||
|
||||
!isValidUuid(currentAIChatThread) ||
|
||||
agentChatFetchedMessages.length === 0 ||
|
||||
chatState.status === 'streaming' ||
|
||||
chatState.status === 'submitted'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatState.resumeStream();
|
||||
// We intentionally omit chatState.resumeStream and status from deps
|
||||
// to avoid resume loops. We do include agentChatFetchedMessages.length
|
||||
// so that resume fires once messages are fetched (they may arrive
|
||||
// after the thread ID is set).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentAIChatThread, agentChatFetchedMessages.length]);
|
||||
|
||||
const setAgentChatMessages = useSetAtomComponentFamilyState(
|
||||
agentChatMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
|
||||
|
||||
const agentChatDisplayedThread = useAtomStateValue(
|
||||
agentChatDisplayedThreadState,
|
||||
);
|
||||
@@ -99,22 +74,21 @@ export const AgentChatAiSdkStreamEffect = () => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mergedMessages = mergeAgentChatFetchedAndStreamingMessages(
|
||||
agentChatFetchedMessages,
|
||||
chatState.messages,
|
||||
);
|
||||
if (agentChatIsStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentChatMessages(mergedMessages);
|
||||
setAgentChatMessages(agentChatFetchedMessages);
|
||||
|
||||
if (currentAIChatThread !== agentChatDisplayedThread) {
|
||||
if (mergedMessages.length > 0) {
|
||||
if (agentChatFetchedMessages.length > 0) {
|
||||
setAgentChatIsInitialScrollPendingOnThreadChange(true);
|
||||
}
|
||||
setAgentChatDisplayedThread(currentAIChatThread);
|
||||
}
|
||||
}, [
|
||||
agentChatFetchedMessages,
|
||||
chatState.messages,
|
||||
agentChatIsStreaming,
|
||||
setAgentChatMessages,
|
||||
currentAIChatThread,
|
||||
agentChatDisplayedThread,
|
||||
@@ -130,31 +104,16 @@ export const AgentChatAiSdkStreamEffect = () => {
|
||||
agentChatMessagesLoadingState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLoadingChange = useCallback(() => {
|
||||
const combinedIsLoading =
|
||||
chatState.isLoading ||
|
||||
agentChatMessagesLoading ||
|
||||
agentChatThreadsLoading;
|
||||
agentChatMessagesLoading || agentChatThreadsLoading;
|
||||
|
||||
setAgentChatIsLoading(combinedIsLoading);
|
||||
}, [
|
||||
chatState.isLoading,
|
||||
agentChatMessagesLoading,
|
||||
agentChatThreadsLoading,
|
||||
setAgentChatIsLoading,
|
||||
]);
|
||||
|
||||
const setAgentChatError = useSetAtomState(agentChatErrorState);
|
||||
}, [agentChatMessagesLoading, agentChatThreadsLoading, setAgentChatIsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatError(normalizeAiSdkError(chatState.error));
|
||||
}, [chatState.error, setAgentChatError]);
|
||||
|
||||
const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatIsStreaming(chatState.status === 'streaming');
|
||||
}, [chatState.status, setAgentChatIsStreaming]);
|
||||
handleLoadingChange();
|
||||
}, [handleLoadingChange]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const AGENT_CHAT_INSTANCE_ID = 'agentChatComponentInstance';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ON_AGENT_CHAT_EVENT = gql`
|
||||
subscription OnAgentChatEvent($threadId: String!) {
|
||||
onAgentChatEvent(threadId: $threadId) {
|
||||
threadId
|
||||
event
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,60 +1,47 @@
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSendMessageEventName';
|
||||
import { useApolloClient } from '@apollo/client/react';
|
||||
|
||||
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
|
||||
import { AGENT_CHAT_RETRY_EVENT_NAME } from '@/ai/constants/AgentChatRetryEventName';
|
||||
import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName';
|
||||
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
|
||||
import { useAuthenticatedFetch } from '@/ai/hooks/useAuthenticatedFetch';
|
||||
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
|
||||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
agentChatDraftsByThreadIdState,
|
||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
|
||||
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatQueuedMessagesComponentFamilyState } from '@/ai/states/agentChatQueuedMessagesComponentFamilyState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||
import { renewToken } from '@/auth/services/AuthService';
|
||||
import { tokenPairState } from '@/auth/states/tokenPairState';
|
||||
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
||||
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
export const useAgentChat = (
|
||||
uiMessages: ExtendedUIMessage[],
|
||||
ensureThreadIdForSend: () => Promise<string | null>,
|
||||
onStreamingComplete?: () => void,
|
||||
) => {
|
||||
const setTokenPair = useSetAtomState(tokenPairState);
|
||||
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
|
||||
|
||||
const { modelIdForRequest } = useAgentChatModelId();
|
||||
const { authenticatedFetch } = useAuthenticatedFetch();
|
||||
const { getBrowsingContext } = useGetBrowsingContext();
|
||||
const setCurrentAIChatThreadTitle = useSetAtomState(
|
||||
currentAIChatThreadTitleState,
|
||||
);
|
||||
const setCurrentAIChatThread = useSetAtomState(currentAIChatThreadState);
|
||||
const apolloClient = useApolloClient();
|
||||
const store = useStore();
|
||||
|
||||
const agentChatSelectedFiles = useAtomStateValue(agentChatSelectedFilesState);
|
||||
|
||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
|
||||
const [, setPendingThreadIdAfterFirstSend] = useState<string | null>(null);
|
||||
|
||||
const [agentChatUploadedFiles, setAgentChatUploadedFiles] = useAtomState(
|
||||
@@ -66,184 +53,6 @@ export const useAgentChat = (
|
||||
agentChatDraftsByThreadIdState,
|
||||
);
|
||||
|
||||
const retryFetchWithRenewedToken = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
const tokenPair = getTokenPair();
|
||||
|
||||
if (!isDefined(tokenPair)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const renewedTokens = await renewToken(
|
||||
`${REACT_APP_SERVER_BASE_URL}/metadata`,
|
||||
tokenPair,
|
||||
);
|
||||
|
||||
if (!isDefined(renewedTokens)) {
|
||||
setTokenPair(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const renewedAccessToken =
|
||||
renewedTokens.accessOrWorkspaceAgnosticToken?.token;
|
||||
|
||||
if (!isDefined(renewedAccessToken)) {
|
||||
setTokenPair(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
setTokenPair(renewedTokens);
|
||||
|
||||
const updatedHeaders = new Headers(init?.headers ?? {});
|
||||
updatedHeaders.set('Authorization', `Bearer ${renewedAccessToken}`);
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: updatedHeaders,
|
||||
});
|
||||
} catch {
|
||||
setTokenPair(null);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport({
|
||||
api: `${REST_API_BASE_URL}/agent-chat/stream`,
|
||||
headers: () => ({
|
||||
Authorization: `Bearer ${getTokenPair()?.accessOrWorkspaceAgnosticToken.token}`,
|
||||
}),
|
||||
prepareReconnectToStreamRequest: ({ id }) => ({
|
||||
api: `${REST_API_BASE_URL}/agent-chat/${id}/stream`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${getTokenPair()?.accessOrWorkspaceAgnosticToken.token}`,
|
||||
},
|
||||
}),
|
||||
fetch: async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
|
||||
if (response.status === 401) {
|
||||
const retriedResponse = await retryFetchWithRenewedToken(
|
||||
input,
|
||||
init,
|
||||
);
|
||||
|
||||
return retriedResponse ?? response;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({}));
|
||||
const error = new Error(
|
||||
errorBody.messages?.[0] ||
|
||||
`Request failed with status ${response.status}`,
|
||||
) as Error & { code?: string };
|
||||
|
||||
if (isDefined(errorBody.code)) {
|
||||
error.code = errorBody.code;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
}),
|
||||
// Intentionally created once — closures inside (getTokenPair, etc.)
|
||||
// read fresh values via function references, not stale captures.
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
messages,
|
||||
status,
|
||||
error,
|
||||
regenerate,
|
||||
stop,
|
||||
resumeStream,
|
||||
} = useChat({
|
||||
transport,
|
||||
messages: uiMessages,
|
||||
id: currentAIChatThread ?? undefined,
|
||||
experimental_throttle: 100,
|
||||
onFinish: ({ message }) => {
|
||||
type UsageMetadata = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
inputCredits: number;
|
||||
outputCredits: number;
|
||||
conversationSize: number;
|
||||
};
|
||||
type ModelMetadata = {
|
||||
contextWindowTokens: number;
|
||||
};
|
||||
const metadata = message.metadata as
|
||||
| { usage?: UsageMetadata; model?: ModelMetadata }
|
||||
| undefined;
|
||||
const usage = metadata?.usage;
|
||||
const model = metadata?.model;
|
||||
|
||||
if (isDefined(usage) && isDefined(model)) {
|
||||
setAgentChatUsage((prev) => ({
|
||||
lastMessage: {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedInputTokens: usage.cachedInputTokens,
|
||||
inputCredits: usage.inputCredits,
|
||||
outputCredits: usage.outputCredits,
|
||||
},
|
||||
conversationSize: usage.conversationSize,
|
||||
contextWindowTokens: model.contextWindowTokens,
|
||||
inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens,
|
||||
outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens,
|
||||
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
|
||||
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
|
||||
}));
|
||||
}
|
||||
|
||||
const titlePart = message.parts.find(
|
||||
(part) => part.type === 'data-thread-title',
|
||||
);
|
||||
|
||||
setPendingThreadIdAfterFirstSend((pendingId) => {
|
||||
const threadIdForTitle =
|
||||
pendingId ?? store.get(currentAIChatThreadState.atom);
|
||||
if (isDefined(titlePart) && titlePart.type === 'data-thread-title') {
|
||||
setCurrentAIChatThreadTitle(titlePart.data.title);
|
||||
if (isDefined(threadIdForTitle)) {
|
||||
const threadRef = apolloClient.cache.identify({
|
||||
__typename: 'AgentChatThread',
|
||||
id: threadIdForTitle,
|
||||
});
|
||||
if (isDefined(threadRef)) {
|
||||
apolloClient.cache.modify({
|
||||
id: threadRef,
|
||||
fields: {
|
||||
title: () => titlePart.data.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isDefined(pendingId)) {
|
||||
setCurrentAIChatThread(pendingId);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
onStreamingComplete?.();
|
||||
},
|
||||
});
|
||||
|
||||
const isStreaming = status === 'streaming';
|
||||
const isBusy = isStreaming || status === 'submitted';
|
||||
const isLoading = isBusy || agentChatSelectedFiles.length > 0;
|
||||
|
||||
const handleSendMessage = useCallback(async () => {
|
||||
const draftKey =
|
||||
store.get(currentAIChatThreadState.atom) ??
|
||||
@@ -261,51 +70,15 @@ export const useAgentChat = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBusy) {
|
||||
const threadId = store.get(currentAIChatThreadState.atom);
|
||||
|
||||
if (!isDefined(threadId) || !isValidUuid(threadId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenPair = getTokenPair();
|
||||
|
||||
if (!isDefined(tokenPair)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue message on the server
|
||||
fetch(`${REST_API_BASE_URL}/agent-chat/${threadId}/queue`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenPair.accessOrWorkspaceAgnosticToken.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: contentToSend,
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
// Refetch messages to show the queued message in the UI
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
setAgentChatInput('');
|
||||
setAgentChatDraftsByThreadId((prev) => ({
|
||||
...prev,
|
||||
[draftKey]: '',
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
const isLoading = agentChatSelectedFiles.length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = await ensureThreadIdForSend();
|
||||
if (!threadId) {
|
||||
|
||||
if (!isDefined(threadId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -321,35 +94,126 @@ export const useAgentChat = (
|
||||
|
||||
const browsingContext = getBrowsingContext();
|
||||
|
||||
sendMessage(
|
||||
{
|
||||
text: contentToSend,
|
||||
files: agentChatUploadedFiles,
|
||||
},
|
||||
{
|
||||
body: {
|
||||
threadId,
|
||||
browsingContext,
|
||||
...(isDefined(modelIdForRequest) && {
|
||||
modelId: modelIdForRequest,
|
||||
}),
|
||||
},
|
||||
},
|
||||
const fetchedMessages = store.get(
|
||||
agentChatFetchedMessagesComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
}),
|
||||
);
|
||||
|
||||
const optimisticUserMessage: ExtendedUIMessage = {
|
||||
id: v4(),
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ type: 'text' as const, text: contentToSend },
|
||||
...agentChatUploadedFiles,
|
||||
],
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
status: 'sent',
|
||||
};
|
||||
|
||||
const atomKey = {
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
};
|
||||
|
||||
// If already streaming, place the optimistic message in the queue
|
||||
// to mirror the server's queue decision and avoid a visible flash.
|
||||
const isCurrentlyStreaming = store.get(agentChatIsStreamingState.atom);
|
||||
const targetAtom = isCurrentlyStreaming
|
||||
? agentChatQueuedMessagesComponentFamilyState
|
||||
: agentChatMessagesComponentFamilyState;
|
||||
|
||||
const currentTargetMessages = store.get(targetAtom.atomFamily(atomKey));
|
||||
|
||||
store.set(targetAtom.atomFamily(atomKey), [
|
||||
...currentTargetMessages,
|
||||
optimisticUserMessage,
|
||||
]);
|
||||
|
||||
setAgentChatUploadedFiles([]);
|
||||
|
||||
const tokenPair = getTokenPair();
|
||||
|
||||
if (!isDefined(tokenPair)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allMessages = [...fetchedMessages, optimisticUserMessage];
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${REST_API_BASE_URL}/agent-chat/${threadId}/message`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenPair.accessOrWorkspaceAgnosticToken.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: contentToSend,
|
||||
messages: allMessages,
|
||||
browsingContext,
|
||||
...(isDefined(modelIdForRequest) && {
|
||||
modelId: modelIdForRequest,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.queued) {
|
||||
if (!isCurrentlyStreaming) {
|
||||
// Server queued it but we optimistically placed in main thread.
|
||||
// Move it to the queue (edge case: streaming started between
|
||||
// our check and the server's decision).
|
||||
const latestMessages = store.get(
|
||||
agentChatMessagesComponentFamilyState.atomFamily(atomKey),
|
||||
);
|
||||
|
||||
store.set(
|
||||
agentChatMessagesComponentFamilyState.atomFamily(atomKey),
|
||||
latestMessages.filter(
|
||||
(message) => message.id !== optimisticUserMessage.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
}
|
||||
|
||||
setPendingThreadIdAfterFirstSend((pendingId) => {
|
||||
if (isDefined(pendingId)) {
|
||||
setCurrentAIChatThread(pendingId);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
} catch {
|
||||
const latestMessages = store.get(targetAtom.atomFamily(atomKey));
|
||||
|
||||
store.set(
|
||||
targetAtom.atomFamily(atomKey),
|
||||
latestMessages.filter(
|
||||
(message) => message.id !== optimisticUserMessage.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
store,
|
||||
isLoading,
|
||||
isBusy,
|
||||
agentChatSelectedFiles,
|
||||
ensureThreadIdForSend,
|
||||
setAgentChatInput,
|
||||
getBrowsingContext,
|
||||
sendMessage,
|
||||
agentChatUploadedFiles,
|
||||
setAgentChatUploadedFiles,
|
||||
setAgentChatDraftsByThreadId,
|
||||
modelIdForRequest,
|
||||
setCurrentAIChatThread,
|
||||
]);
|
||||
|
||||
useListenToBrowserEvent({
|
||||
@@ -358,8 +222,6 @@ export const useAgentChat = (
|
||||
});
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
stop();
|
||||
|
||||
const threadId = store.get(currentAIChatThreadState.atom);
|
||||
|
||||
if (!isDefined(threadId) || !isValidUuid(threadId)) {
|
||||
@@ -372,31 +234,25 @@ export const useAgentChat = (
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${REST_API_BASE_URL}/agent-chat/${threadId}/stream`, {
|
||||
authenticatedFetch(`${REST_API_BASE_URL}/agent-chat/${threadId}/stream`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenPair.accessOrWorkspaceAgnosticToken.token}`,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}, [stop, store]);
|
||||
}, [store, authenticatedFetch]);
|
||||
|
||||
useListenToBrowserEvent({
|
||||
eventName: AGENT_CHAT_STOP_EVENT_NAME,
|
||||
onBrowserEvent: handleStop,
|
||||
});
|
||||
|
||||
useListenToBrowserEvent({
|
||||
eventName: AGENT_CHAT_RETRY_EVENT_NAME,
|
||||
onBrowserEvent: regenerate,
|
||||
});
|
||||
// TODO: implement proper retry (re-send last user message to /message)
|
||||
// The AIChatErrorMessage UI still dispatches AGENT_CHAT_RETRY_EVENT_NAME
|
||||
// but no listener is wired until retry is properly implemented.
|
||||
|
||||
return {
|
||||
messages,
|
||||
handleSendMessage,
|
||||
handleStop,
|
||||
resumeStream,
|
||||
isLoading,
|
||||
error,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { readUIMessageStream } from 'ai';
|
||||
import { type UIMessageChunk } from 'ai';
|
||||
import { print, type ExecutionResult } from 'graphql';
|
||||
import { useStore } from 'jotai';
|
||||
import { type AgentChatSubscriptionEvent, type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { ON_AGENT_CHAT_EVENT } from '@/ai/graphql/subscriptions/OnAgentChatEvent';
|
||||
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
||||
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { sseClientState } from '@/sse-db-event/states/sseClientState';
|
||||
|
||||
const THROTTLE_MS = 100;
|
||||
|
||||
type AgentChatEventPayload = {
|
||||
onAgentChatEvent: {
|
||||
threadId: string;
|
||||
event: AgentChatSubscriptionEvent;
|
||||
};
|
||||
};
|
||||
|
||||
export const useAgentChatSubscription = (threadId: string | null) => {
|
||||
const store = useStore();
|
||||
const sseClient = useAtomStateValue(sseClientState);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
const writerRef = useRef<WritableStreamDefaultWriter<UIMessageChunk> | null>(
|
||||
null,
|
||||
);
|
||||
const isStreamingRef = useRef(false);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (writerRef.current) {
|
||||
writerRef.current.close().catch(() => {});
|
||||
writerRef.current = null;
|
||||
}
|
||||
if (disposeRef.current) {
|
||||
disposeRef.current();
|
||||
disposeRef.current = null;
|
||||
}
|
||||
if (isStreamingRef.current) {
|
||||
isStreamingRef.current = false;
|
||||
store.set(agentChatIsStreamingState.atom, false);
|
||||
}
|
||||
}, [store]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(threadId)) {
|
||||
cleanup();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDefined(sseClient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let bridge: TransformStream<UIMessageChunk> | null = null;
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let latestMessage: ExtendedUIMessage | null = null;
|
||||
|
||||
const flushToAtom = () => {
|
||||
const messageToFlush = latestMessage;
|
||||
|
||||
if (!isDefined(messageToFlush) || !isDefined(threadId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const atomKey = {
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
};
|
||||
|
||||
const currentMessages = store.get(
|
||||
agentChatMessagesComponentFamilyState.atomFamily(atomKey),
|
||||
);
|
||||
|
||||
// Find and replace the streaming assistant message by ID, or append it.
|
||||
// This preserves optimistic user messages that aren't yet in fetched data.
|
||||
const streamingMsgIndex = currentMessages.findIndex(
|
||||
(message) => message.id === messageToFlush.id,
|
||||
);
|
||||
|
||||
if (streamingMsgIndex >= 0) {
|
||||
const updatedMessages = [...currentMessages];
|
||||
|
||||
updatedMessages[streamingMsgIndex] = messageToFlush;
|
||||
store.set(
|
||||
agentChatMessagesComponentFamilyState.atomFamily(atomKey),
|
||||
updatedMessages,
|
||||
);
|
||||
} else {
|
||||
store.set(
|
||||
agentChatMessagesComponentFamilyState.atomFamily(atomKey),
|
||||
[...currentMessages, messageToFlush],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleAtomUpdate = (message: ExtendedUIMessage) => {
|
||||
latestMessage = message;
|
||||
|
||||
if (!isDefined(throttleTimer)) {
|
||||
// Leading edge: flush immediately so the first chunk renders instantly
|
||||
flushToAtom();
|
||||
|
||||
// Then suppress further flushes for THROTTLE_MS
|
||||
throttleTimer = setTimeout(() => {
|
||||
throttleTimer = null;
|
||||
// Trailing edge: flush whatever accumulated during the window
|
||||
flushToAtom();
|
||||
}, THROTTLE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const startReadLoop = async (
|
||||
readable: ReadableStream<UIMessageChunk>,
|
||||
) => {
|
||||
const messageStream = readUIMessageStream({ stream: readable });
|
||||
|
||||
for await (const message of messageStream) {
|
||||
const extendedMessage = message as ExtendedUIMessage;
|
||||
|
||||
// Extract usage and title from data parts
|
||||
const titlePart = extendedMessage.parts.find(
|
||||
(part) => part.type === 'data-thread-title',
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(titlePart) &&
|
||||
titlePart.type === 'data-thread-title'
|
||||
) {
|
||||
store.set(
|
||||
currentAIChatThreadTitleState.atom,
|
||||
titlePart.data.title,
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = extendedMessage.metadata as
|
||||
| {
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
inputCredits: number;
|
||||
outputCredits: number;
|
||||
conversationSize: number;
|
||||
};
|
||||
model?: {
|
||||
contextWindowTokens: number;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (isDefined(metadata?.usage) && isDefined(metadata?.model)) {
|
||||
const usage = metadata.usage;
|
||||
const model = metadata.model;
|
||||
|
||||
store.set(agentChatUsageState.atom, (prev) => ({
|
||||
lastMessage: {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedInputTokens: usage.cachedInputTokens,
|
||||
inputCredits: usage.inputCredits,
|
||||
outputCredits: usage.outputCredits,
|
||||
},
|
||||
conversationSize: usage.conversationSize,
|
||||
contextWindowTokens: model.contextWindowTokens,
|
||||
inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens,
|
||||
outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens,
|
||||
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
|
||||
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
|
||||
}));
|
||||
}
|
||||
|
||||
scheduleAtomUpdate(extendedMessage);
|
||||
}
|
||||
|
||||
// Stream finished -- flush last message immediately
|
||||
if (isDefined(throttleTimer)) {
|
||||
clearTimeout(throttleTimer);
|
||||
throttleTimer = null;
|
||||
}
|
||||
flushToAtom();
|
||||
|
||||
isStreamingRef.current = false;
|
||||
store.set(agentChatIsStreamingState.atom, false);
|
||||
};
|
||||
|
||||
const handleEvent = (event: AgentChatSubscriptionEvent) => {
|
||||
switch (event.type) {
|
||||
case 'stream-chunk': {
|
||||
if (!isStreamingRef.current) {
|
||||
isStreamingRef.current = true;
|
||||
store.set(agentChatIsStreamingState.atom, true);
|
||||
|
||||
bridge = new TransformStream<UIMessageChunk>();
|
||||
writerRef.current = bridge.writable.getWriter();
|
||||
|
||||
startReadLoop(bridge.readable).catch(() => {
|
||||
isStreamingRef.current = false;
|
||||
store.set(agentChatIsStreamingState.atom, false);
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(writerRef.current)) {
|
||||
writerRef.current
|
||||
.write(event.chunk as UIMessageChunk)
|
||||
.catch(() => {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message-persisted': {
|
||||
// Close the current stream writer so readUIMessageStream finishes
|
||||
if (isDefined(writerRef.current)) {
|
||||
writerRef.current.close().catch(() => {});
|
||||
writerRef.current = null;
|
||||
}
|
||||
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'queue-updated': {
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream-error': {
|
||||
const streamError = new Error(event.message) as Error & {
|
||||
code?: string;
|
||||
};
|
||||
|
||||
streamError.code = event.code;
|
||||
store.set(agentChatErrorState.atom, streamError);
|
||||
|
||||
if (isDefined(writerRef.current)) {
|
||||
writerRef.current.close().catch(() => {});
|
||||
writerRef.current = null;
|
||||
}
|
||||
|
||||
isStreamingRef.current = false;
|
||||
store.set(agentChatIsStreamingState.atom, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispose = sseClient.subscribe<AgentChatEventPayload>(
|
||||
{
|
||||
query: print(ON_AGENT_CHAT_EVENT),
|
||||
variables: { threadId },
|
||||
},
|
||||
{
|
||||
next: (
|
||||
value: ExecutionResult<AgentChatEventPayload>,
|
||||
) => {
|
||||
if (isDefined(value.data?.onAgentChatEvent?.event)) {
|
||||
handleEvent(
|
||||
value.data.onAgentChatEvent
|
||||
.event as AgentChatSubscriptionEvent,
|
||||
);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// graphql-sse handles reconnection automatically
|
||||
},
|
||||
complete: () => {
|
||||
cleanup();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
disposeRef.current = dispose;
|
||||
|
||||
return () => {
|
||||
if (isDefined(throttleTimer)) {
|
||||
clearTimeout(throttleTimer);
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
}, [threadId, sseClient, store, cleanup]);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||
import { renewToken } from '@/auth/services/AuthService';
|
||||
import { tokenPairState } from '@/auth/states/tokenPairState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
export const useAuthenticatedFetch = () => {
|
||||
const setTokenPair = useSetAtomState(tokenPairState);
|
||||
|
||||
const retryFetchWithRenewedToken = useCallback(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const tokenPair = getTokenPair();
|
||||
|
||||
if (!isDefined(tokenPair)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const renewedTokens = await renewToken(
|
||||
`${REACT_APP_SERVER_BASE_URL}/metadata`,
|
||||
tokenPair,
|
||||
);
|
||||
|
||||
if (!isDefined(renewedTokens)) {
|
||||
setTokenPair(null);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const renewedAccessToken =
|
||||
renewedTokens.accessOrWorkspaceAgnosticToken?.token;
|
||||
|
||||
if (!isDefined(renewedAccessToken)) {
|
||||
setTokenPair(null);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
setTokenPair(renewedTokens);
|
||||
|
||||
const updatedHeaders = new Headers(init?.headers ?? {});
|
||||
|
||||
updatedHeaders.set('Authorization', `Bearer ${renewedAccessToken}`);
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: updatedHeaders,
|
||||
});
|
||||
} catch {
|
||||
setTokenPair(null);
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[setTokenPair],
|
||||
);
|
||||
|
||||
const authenticatedFetch = useCallback(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await fetch(input, init);
|
||||
|
||||
if (response.status === 401) {
|
||||
const retriedResponse = await retryFetchWithRenewedToken(input, init);
|
||||
|
||||
return retriedResponse ?? response;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[retryFetchWithRenewedToken],
|
||||
);
|
||||
|
||||
return { authenticatedFetch };
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { normalizeAiSdkError } from '@/ai/utils/normalizeAiSdkError';
|
||||
|
||||
describe('normalizeAiSdkError', () => {
|
||||
it('should return undefined for undefined input', () => {
|
||||
expect(normalizeAiSdkError(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the error unchanged if it already has a code', () => {
|
||||
const error = new Error('test') as Error & { code: string };
|
||||
error.code = 'EXISTING_CODE';
|
||||
|
||||
const result = normalizeAiSdkError(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
expect((result as Error & { code: string }).code).toBe('EXISTING_CODE');
|
||||
});
|
||||
|
||||
it('should parse JSON message and attach code from response body', () => {
|
||||
const error = new Error(
|
||||
'{"statusCode":402,"error":"Error","messages":["Credits exhausted"],"code":"BILLING_CREDITS_EXHAUSTED"}',
|
||||
);
|
||||
|
||||
const result = normalizeAiSdkError(error);
|
||||
|
||||
expect(result).not.toBe(error);
|
||||
expect((result as Error & { code: string }).code).toBe(
|
||||
'BILLING_CREDITS_EXHAUSTED',
|
||||
);
|
||||
expect(result?.message).toBe(error.message);
|
||||
});
|
||||
|
||||
it('should return original error for non-JSON message', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
|
||||
const result = normalizeAiSdkError(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
});
|
||||
|
||||
it('should return original error for JSON message without code', () => {
|
||||
const error = new Error(
|
||||
'{"statusCode":500,"error":"Internal Server Error"}',
|
||||
);
|
||||
|
||||
const result = normalizeAiSdkError(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
});
|
||||
|
||||
it('should preserve the original stack trace', () => {
|
||||
const error = new Error(
|
||||
'{"statusCode":402,"code":"BILLING_CREDITS_EXHAUSTED"}',
|
||||
);
|
||||
const originalStack = error.stack;
|
||||
|
||||
const result = normalizeAiSdkError(error);
|
||||
|
||||
expect(result?.stack).toBe(originalStack);
|
||||
});
|
||||
});
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
// The SDK's messages array is always [...seedMessages, ...newMessages]
|
||||
// where seedMessages are the fetchedMessages we passed to useChat.
|
||||
// SDK-generated IDs differ from server-persisted IDs, so comparing
|
||||
// by ID doesn't work. Instead, slice by position: everything beyond
|
||||
// the fetched count is a new streaming message.
|
||||
export const mergeAgentChatFetchedAndStreamingMessages = (
|
||||
fetchedMessages: ExtendedUIMessage[],
|
||||
streamingMessages: ExtendedUIMessage[],
|
||||
): ExtendedUIMessage[] => {
|
||||
const newStreamingMessages = streamingMessages.slice(fetchedMessages.length);
|
||||
|
||||
return [...fetchedMessages, ...newStreamingMessages];
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
// The Vercel AI SDK wraps non-200 responses into an Error whose message
|
||||
// is the raw JSON response body, losing any custom properties (like `code`).
|
||||
// This function parses that JSON and re-attaches the code so downstream
|
||||
// consumers (extractErrorCode) can find it without knowing about the SDK.
|
||||
export const normalizeAiSdkError = (
|
||||
error: Error | undefined,
|
||||
): Error | undefined => {
|
||||
if (!isDefined(error) || !(error instanceof Error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (
|
||||
'code' in error &&
|
||||
typeof (error as Error & { code: string }).code === 'string'
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(error.message);
|
||||
|
||||
if (
|
||||
isDefined(parsed) &&
|
||||
typeof parsed === 'object' &&
|
||||
'code' in parsed &&
|
||||
typeof (parsed as { code: unknown }).code === 'string'
|
||||
) {
|
||||
const normalizedError = new Error(error.message) as Error & {
|
||||
code: string;
|
||||
};
|
||||
normalizedError.code = (parsed as { code: string }).code;
|
||||
normalizedError.stack = error.stack;
|
||||
|
||||
return normalizedError;
|
||||
}
|
||||
} catch {
|
||||
// message is not JSON — nothing to normalize
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
+4
-2
@@ -11,7 +11,7 @@ import { type RecordGqlGroupByConnection } from '@/object-record/graphql/types/R
|
||||
import { type RecordGqlOperationGroupByVariables } from '@/object-record/graphql/types/RecordGqlOperationGroupByVariables';
|
||||
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
|
||||
import { isArray } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getGroupByConnectionTypename, isDefined } from 'twenty-shared/utils';
|
||||
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
|
||||
|
||||
type TriggerUpdateGroupByQueriesOptimisticEffectArgs = {
|
||||
@@ -199,7 +199,9 @@ export const triggerUpdateGroupByQueriesOptimisticEffect = ({
|
||||
for (const [_, groupData] of recordsToAddToNewGroups) {
|
||||
if (groupData.edges.length > 0) {
|
||||
const newGroupConnection = {
|
||||
__typename: `${objectMetadataItem.nameSingular}Connection`,
|
||||
__typename: getGroupByConnectionTypename(
|
||||
objectMetadataItem.nameSingular,
|
||||
),
|
||||
edges: groupData.edges,
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
|
||||
+5
-7
@@ -4,19 +4,17 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
|
||||
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
|
||||
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
|
||||
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
|
||||
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
|
||||
|
||||
import { type ObjectPermissions } from 'twenty-shared/types';
|
||||
import { getEdgeTypename, isDefined } from 'twenty-shared/utils';
|
||||
import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect';
|
||||
import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
|
||||
import { encodeCursor } from '@/apollo/utils/encodeCursor';
|
||||
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
|
||||
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
|
||||
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
|
||||
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { type ObjectPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
|
||||
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
|
||||
|
||||
/*
|
||||
|
||||
+1
-2
@@ -6,13 +6,12 @@ import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effe
|
||||
import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
|
||||
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
|
||||
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
|
||||
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
|
||||
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { type ObjectPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getEdgeTypename, isDefined } from 'twenty-shared/utils';
|
||||
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
|
||||
// TODO: add extensive unit tests for this function
|
||||
// That will also serve as documentation
|
||||
|
||||
+1
-2
@@ -6,13 +6,12 @@ import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effe
|
||||
import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
|
||||
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
|
||||
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
|
||||
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
|
||||
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { type ObjectPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getEdgeTypename, isDefined } from 'twenty-shared/utils';
|
||||
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
|
||||
|
||||
// TODO: add extensive unit tests for this function
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user