Compare commits

...
Author SHA1 Message Date
Claude 571f79978b fix: address PR review comments on agent chat queue and SSE refactor
- Remove .claude/launch.json (avoid new root-level folder)
- Extract useAuthenticatedFetch hook from useAgentChat for reuse
- Use authenticatedFetch in AIChatQueuedMessages for proper token renewal
- Replace timestamp-based optimistic IDs with uuid v4
- Remove redundant comments, keep only useful ones
- Split mapDBPartsToUIMessageParts into one export per file
- Split STREAM_AGENT_CHAT_JOB_NAME constant into its own file
- Simplify delete queue endpoint to use only messageId (derive threadId)
- Add thread ownership check in subscription resolver

https://claude.ai/code/session_01NGv3c62sYGMww7iuVsWzcJ
2026-04-01 18:27:45 +00:00
Félix MalfaitandGitHub 6aedeb1cb2 Merge branch 'main' into feat/agent-chat-queue-and-sse-refactor 2026-04-01 19:28:30 +02:00
4cc3deb937 i18n - docs translations (#19217)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-01 18:40:26 +02:00
e15feda3c3 i18n - translations (#19216)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-01 18:15:16 +02:00
Raphaël BosiandGitHub 9f95c4763c [COMMAND MENU ITEMS] Remove deprecated code (#19199)
This PR is the first one of a cleanup after upgrading command menu items
to V2.
2026-04-01 15:56:52 +00:00
e6fe48b66d i18n - translations (#19215)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-01 18:00:09 +02:00
20ac5b7e84 Field widget edition (#19209)
https://github.com/user-attachments/assets/2f1a5847-3375-414f-a2b8-ce4b533b5512

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 15:44:49 +00:00
136f362b24 i18n - translations (#19214)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-01 17:40:00 +02:00
Baptiste DevessierandGitHub 291792f864 Repair Edit Layout command menu item and add a button in settings to start edition too (#19208)
https://github.com/user-attachments/assets/f23f0777-abf3-4ff4-8fab-ec2004df60bc
2026-04-01 15:24:05 +00:00
Raphaël BosiandGitHub 9054d3aef6 [COMMAND MENU ITEMS] Resolve object metadata label in dynamic command menu item label (#19211)
- Adds a resolveObjectMetadataLabel utility that returns the singular or
plural label from object metadata based on the number of selected
records (e.g., "person" vs "people").
- Exposes a pre-computed objectMetadataLabel string on
CommandMenuContextApi, making it available for label interpolation
(e.g., Delete ${capitalize(objectMetadataLabel)}).
2026-04-01 15:02:24 +00:00
Thomas TrompetteandGitHub 19dd4d6c1b Fix workflow date fields (#19210)
Before: broken on forms, missing border right, no fullWidth, not
properly saved
<img width="220" height="160" alt="Capture d’écran 2026-03-31 à 17 10
47"
src="https://github.com/user-attachments/assets/4143fcb7-909f-42a3-b05e-39185395f657"
/> <img width="231" height="102" alt="Capture d’écran 2026-03-31 à 17
11 05"
src="https://github.com/user-attachments/assets/3989f6b8-ef8a-42a3-9ccc-35a9be1fb67f"
/>
<img width="230" height="75" alt="Capture d’écran 2026-03-31 à 17 12
15"
src="https://github.com/user-attachments/assets/67f5f93f-887f-4e3b-95c2-f5076f41fb21"
/>

After: save and validate on edition, fix design
<img width="231" height="132" alt="Capture d’écran 2026-03-31 à 17 15
08"
src="https://github.com/user-attachments/assets/d1aa0a64-499d-479d-8d5c-e5e104ad6464"
/>
<img width="231" height="75" alt="Capture d’écran 2026-03-31 à 17 15
33"
src="https://github.com/user-attachments/assets/8d668713-8466-4e81-8901-4b12b9271244"
/>
<img width="461" height="156" alt="Capture d’écran 2026-03-31 à 17 14
54"
src="https://github.com/user-attachments/assets/f9d33242-f1cc-48f7-9f63-322799e1f9b8"
/>

Simplifying FormDateInput using the existing DatePicker
2026-04-01 14:44:24 +00:00
a0c6727a61 i18n - docs translations (#19212)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-01 16:48:34 +02:00
b002930554 Update documentation on how to upload a file (#19197)
As per title, update a documentation on how to upload a file given
increasing amount of questions for this problem

CC: @StephanieJoly4

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-04-01 14:15:43 +00:00
MarieandGitHub aa0ea96582 Improve app errors logs at sync (#19174)
1. Fix scrollbar
Before

https://github.com/user-attachments/assets/29792a71-b2dd-49f6-bb90-9d15feeb95aa

After

https://github.com/user-attachments/assets/939a000a-b787-4ea5-a9f0-61fbac886025

2. Introduce verbose vs non-verbose
verbose = what we have today (very detailed)
non-verbose = summarized (with a log to say add --verbose for full
logs!)

without --verbose
<img width="1256" height="876" alt="updated_non_verbose"
src="https://github.com/user-attachments/assets/d6194c41-2366-4297-a7ac-b3f3b27e08dd"
/>


with --verbose
<img width="422" height="819" alt="verbose_logs"
src="https://github.com/user-attachments/assets/409e2e88-ec3d-4bab-957c-ef319895f8c5"
/>
2026-04-01 13:46:37 +00:00
Félix Malfait eefc5c1914 feat: replace AI SDK client with GraphQL SSE subscription for agent chat
Drop @ai-sdk/react and resumable-stream in favor of a unified GraphQL SSE
subscription for all agent chat events (token streaming, message persistence,
queue updates). This makes the architecture server-driven and eliminates
the class of bugs caused by dual-state (AI SDK internal state vs Jotai atoms).

Key changes:
- Backend: publish UIMessageChunks via Redis PubSub instead of HTTP SSE +
  resumable-stream. New AgentChatEventPublisherService, subscription resolver,
  and unified POST /:threadId/message endpoint (server decides queue vs stream).
- Frontend: new useAgentChatSubscription hook bridges graphql-sse events into
  readUIMessageStream (from ai package) for chunk accumulation. Optimistic
  rendering preserves user messages during streaming. SSE client recycling
  triggers automatic resubscription.
- Queue: messages sent during active streaming are placed directly in queue
  atoms (no flash). Server auto-flushes next queued message after stream ends.

Made-with: Cursor
2026-04-01 13:18:58 +02:00
36dece43c7 Fix: Upgrade Nodemailer to address SMTP command injection vulnerability (#19151)
📄 Summary

This PR upgrades the nodemailer dependency to a secure version (≥ 8.0.4)
to fix a known SMTP command injection vulnerability
(GHSA-c7w3-x93f-qmm8).

🚨 Issue

The current version used in twenty-server (^7.0.11, resolved to 7.0.11 /
7.0.13) is vulnerable to SMTP command injection due to improper
sanitization of the envelope.size parameter.
This could allow CRLF injection, potentially enabling attackers to add
unauthorized recipients to outgoing emails.

🔍 Root Cause

The vulnerability originates from insufficient validation of
user-controlled input in the SMTP envelope, specifically the size field,
which can be exploited via crafted input containing CRLF sequences.

 Changes
Upgraded nodemailer to version ^8.0.4
Ensured compatibility with existing email sending logic
Verified that no breaking changes affect current usage

🔐 Security Impact

This update mitigates the risk of:

SMTP command injection
Unauthorized email recipient manipulation
Potential data leakage via crafted email payloads
📎 References
GHSA: GHSA-c7w3-x93f-qmm8
CVE: (see linked report in issue)

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-31 19:55:50 +00:00
Charles BochetandGitHub ac8e0d4217 Replace twentycrm/twenty-postgres-spilo with official postgres:16 in CI (#19182)
## Summary
- Replaces `twentycrm/twenty-postgres-spilo` with the official
`postgres:16` image across all 7 CI workflow files
- Removes Docker Hub `credentials` blocks from all service containers
(postgres, redis, clickhouse)
- Removes the `Login to Docker Hub` step from the breaking changes
workflow

## Context
Fork PRs cannot access repository secrets/variables, causing `${{
vars.DOCKERHUB_USERNAME }}` and `${{ secrets.DOCKERHUB_PASSWORD }}` to
resolve to empty strings. GitHub Actions rejects empty credential values
at template validation time, failing the job before any step runs.

The custom spilo image was the original reason credentials were needed
(to avoid Docker Hub rate limits on non-official images). The only
Postgres extensions required in CI (`uuid-ossp`, `unaccent`) are built
into the official `postgres:16` image. Official Docker Hub images have
significantly higher pull rate limits and don't require authentication.
2026-03-31 21:41:42 +02:00
Charles BochetandGitHub ee3ebd0ca0 Add "search" to reserved metadata name keywords (#19181)
## Summary
- Adds `search` and `searches` to the `RESERVED_METADATA_NAME_KEYWORDS`
list in `twenty-shared`
- Prevents users from creating custom objects named "search", which
collides with the core `search` GraphQL resolver
2026-03-31 21:16:46 +02:00
Baptiste DevessierandGitHub c11e4ece39 Fallback to field metadata (#19131)
Rely on the field metadata items to always display all object's fields
in the fields widget configuration editor. If fields are missing in the
returned view fields, we add the missing fields through object metadata.


https://github.com/user-attachments/assets/3c4d45e8-05d0-4943-be4b-bcf1e310155c
2026-03-31 19:00:16 +00:00
Charles BochetandGitHub 5bbfce7789 Add 1-21 upgrade command to backfill datasource to workspace table (#19180)
## Summary
- Adds a new `upgrade:1-21:backfill-datasource-to-workspace` command
that copies `dataSource.schema` into `workspace.databaseSchema` for all
active/suspended workspaces that haven't been migrated yet
- Registers the command in the 1-21 upgrade module and wires it into the
upgrade runner (runs before other 1-21 commands)
- Part of the ongoing deprecation of the `dataSource` table in favor of
storing `databaseSchema` directly on `WorkspaceEntity`
2026-03-31 20:34:46 +02:00
EtienneandGitHub 887e0283c5 Direct execution - Follow up (#19177)
Feedbacks from https://github.com/twentyhq/twenty/pull/18972
2026-03-31 17:38:24 +00:00
Abdullah.andGitHub 94e019f012 Complete the structure for homepage in new website. (#19162)
This PR completes the sections we need for the Homepage. Assets, such as
images, are still placeholder, and will be replaced as they become
available. We're still waiting on Lottie and 3d asset files from the
design team.
2026-03-31 17:19:50 +00:00
Abdul RahmanandGitHub c23961fa81 Fix navbar object color not updating immediately when changed in edit mode (#19075)
https://github.com/user-attachments/assets/f2a8f2af-b92f-4d2e-9570-fe66f0a3eca0
2026-03-31 16:51:01 +00:00
424 changed files with 9653 additions and 25726 deletions
-23
View File
@@ -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
}
]
}
+3 -20
View File
@@ -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
+3 -11
View File
@@ -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:
+3 -11
View File
@@ -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:
+3 -11
View File
@@ -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:
+6 -25
View File
@@ -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"
+3 -8
View File
@@ -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: >-
+3 -11
View File
@@ -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:
+1
View File
@@ -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
],
@@ -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 الخاصة بالمرفق.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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` в мутации вложения.
@@ -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.
@@ -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` 对象。
@@ -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.
-1
View File
@@ -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 />
@@ -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);
});
});
@@ -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;
};
@@ -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,
@@ -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';
/*
@@ -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
@@ -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