diff --git a/.claude-pr/.mcp.json b/.claude-pr/.mcp.json new file mode 100644 index 00000000000..0320cab0d44 --- /dev/null +++ b/.claude-pr/.mcp.json @@ -0,0 +1,22 @@ +{ + "mcpServers": { + "postgres": { + "type": "stdio", + "command": "bash", + "args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""], + "env": {} + }, + "playwright": { + "type": "stdio", + "command": "npx", + "args": ["@playwright/mcp@latest", "--no-sandbox", "--headless"], + "env": {} + }, + "context7": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"], + "env": {} + } + } +} diff --git a/.claude-pr/CLAUDE.md b/.claude-pr/CLAUDE.md new file mode 100644 index 00000000000..229477b7aaf --- /dev/null +++ b/.claude-pr/CLAUDE.md @@ -0,0 +1,220 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Twenty is an open-source CRM built with modern technologies in a monorepo structure. The codebase is organized as an Nx workspace with multiple packages. + +## Key Commands + +### Development +```bash +# Start development environment (frontend + backend + worker) +yarn start + +# Individual package development +npx nx start twenty-front # Start frontend dev server +npx nx start twenty-server # Start backend server +npx nx run twenty-server:worker # Start background worker +``` + +### Testing +```bash +# Preferred: run a single test file (fast) +npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs + +# Run all tests for a package +npx nx test twenty-front # Frontend unit tests +npx nx test twenty-server # Backend unit tests +npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset +# To run an indivual test or a pattern of tests, use the following command: +cd packages/{workspace} && npx jest "pattern or filename" + +# Storybook +npx nx storybook:build twenty-front +npx nx storybook:test twenty-front + +# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials. +``` + +### Code Quality +```bash +# Linting (diff with main - fastest, always prefer this) +npx nx lint:diff-with-main twenty-front +npx nx lint:diff-with-main twenty-server +npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix + +# Linting (full project - slower, use only when needed) +npx nx lint twenty-front +npx nx lint twenty-server + +# Type checking +npx nx typecheck twenty-front +npx nx typecheck twenty-server + +# Format code +npx nx fmt twenty-front +npx nx fmt twenty-server +``` + +### Build +```bash +# Build packages (twenty-shared must be built first) +npx nx build twenty-shared +npx nx build twenty-front +npx nx build twenty-server +``` + +### Database Operations +```bash +# Database management +npx nx database:reset twenty-server # Reset database +npx nx run twenty-server:database:init:prod # Initialize database +npx nx run twenty-server:database:migrate:prod # Run migrations + +# Generate migration +npx nx run twenty-server:database:migrate:generate +``` + +### Database Inspection (Postgres MCP) + +A read-only Postgres MCP server is configured in `.mcp.json`. Use it to: +- Inspect workspace data, metadata, and object definitions while developing +- Verify migration results (columns, types, constraints) after running migrations +- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas) +- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level +- Inspect metadata tables to debug GraphQL schema generation issues + +This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above. + +### GraphQL +```bash +# Generate GraphQL types (run after schema changes) +npx nx run twenty-front:graphql:generate +npx nx run twenty-front:graphql:generate --configuration=metadata +``` + +## Architecture Overview + +### Tech Stack +- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite +- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga) +- **Monorepo**: Nx workspace managed with Yarn 4 + +### Package Structure +``` +packages/ +├── twenty-front/ # React frontend application +├── twenty-server/ # NestJS backend API +├── twenty-ui/ # Shared UI components library +├── twenty-shared/ # Common types and utilities +├── twenty-emails/ # Email templates with React Email +├── twenty-website/ # Next.js documentation website +├── twenty-zapier/ # Zapier integration +└── twenty-e2e-testing/ # Playwright E2E tests +``` + +### Key Development Principles +- **Functional components only** (no class components) +- **Named exports only** (no default exports) +- **Types over interfaces** (except when extending third-party interfaces) +- **String literals over enums** (except for GraphQL enums) +- **No 'any' type allowed** — strict TypeScript enforced +- **Event handlers preferred over useEffect** for state updates +- **Props down, events up** — unidirectional data flow +- **Composition over inheritance** +- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`) + +### Naming Conventions +- **Variables/functions**: camelCase +- **Constants**: SCREAMING_SNAKE_CASE +- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`) +- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`) +- **TypeScript generics**: descriptive names (`TData` not `T`) + +### File Structure +- Components under 300 lines, services under 500 lines +- Components in their own directories with tests and stories +- Use `index.ts` barrel exports for clean imports +- Import order: external libraries first, then internal (`@/`), then relative + +### Comments +- Use short-form comments (`//`), not JSDoc blocks +- Explain WHY (business logic), not WHAT +- Do not comment obvious code +- Multi-line comments use multiple `//` lines, not `/** */` + +### State Management +- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections +- Component-specific state with React hooks (`useState`, `useReducer` for complex logic) +- GraphQL cache managed by Apollo Client +- Use functional state updates: `setState(prev => prev + 1)` + +### Backend Architecture +- **NestJS modules** for feature organization +- **TypeORM** for database ORM with PostgreSQL +- **GraphQL** API with code-first approach +- **Redis** for caching and session management +- **BullMQ** for background job processing + +### Database & Migrations +- **PostgreSQL** as primary database +- **Redis** for caching and sessions +- **ClickHouse** for analytics (when enabled) +- Always generate migrations when changing entity files +- Migration names must be kebab-case (e.g. `add-agent-turn-evaluation`) +- Include both `up` and `down` logic in migrations +- Never delete or rewrite committed migrations + +### Utility Helpers +Use existing helpers from `twenty-shared` instead of manual type guards: +- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()` + +## Development Workflow + +IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests. + +### Before Making Changes +1. Always run linting (`lint:diff-with-main`) and type checking after code changes +2. Test changes with relevant test suites (prefer single-file test runs) +3. Ensure database migrations are generated for entity changes +4. Check that GraphQL schema changes are backward compatible +5. Run `graphql:generate` after any GraphQL schema changes + +### Code Style Notes +- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern) +- Follow **Nx** workspace conventions for imports +- Use **Lingui** for internationalization +- Apply security first, then formatting (sanitize before format) + +### Testing Strategy +- **Test behavior, not implementation** — focus on user perspective +- **Test pyramid**: 70% unit, 20% integration, 10% E2E +- Query by user-visible elements (text, roles, labels) over test IDs +- Use `@testing-library/user-event` for realistic interactions +- Descriptive test names: "should [behavior] when [condition]" +- Clear mocks between tests with `jest.clearAllMocks()` + +## Dev Environment Setup + +All dev environments (Claude Code web, Cursor, local) use one script: + +```bash +bash packages/twenty-utils/setup-dev-env.sh +``` + +This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times. + +- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`) +- `--down` — stop services +- `--reset` — wipe data and restart fresh +- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc. + +**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script. + +## Important Files +- `nx.json` - Nx workspace configuration with task definitions +- `tsconfig.base.json` - Base TypeScript configuration +- `package.json` - Root package with workspace definitions +- `.cursor/rules/` - Detailed development guidelines and best practices diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index c84ef3d8c8f..f7f44f7ea70 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3554,7 +3554,7 @@ type Mutation { updateWebhook(input: UpdateWebhookInput!): Webhook! deleteWebhook(id: UUID!): Webhook! createChatThread: AgentChatThread! - sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String): SendChatMessageResult! + sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileIds: [UUID!]): SendChatMessageResult! stopAgentChatStream(threadId: UUID!): Boolean! deleteQueuedChatMessage(messageId: UUID!): Boolean! createSkill(input: CreateSkillInput!): Skill! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 55f945d6553..447cfcba565 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -6321,7 +6321,7 @@ export interface MutationGenqlSelection{ updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} }) deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} }) createChatThread?: AgentChatThreadGenqlSelection - sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null)} }) + sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileIds?: (Scalars['UUID'][] | null)} }) stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} } deleteQueuedChatMessage?: { __args: {messageId: Scalars['UUID']} } createSkill?: (SkillGenqlSelection & { __args: {input: CreateSkillInput} }) diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index c5ec805b9a9..84699fcb8ee 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -8363,6 +8363,10 @@ export default { ], "modelId": [ 1 + ], + "fileIds": [ + 3, + "[UUID!]" ] } ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index e46e79ac87e..3a84d4a932d 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -3389,6 +3389,7 @@ export type MutationSaveImapSmtpCaldavAccountArgs = { export type MutationSendChatMessageArgs = { browsingContext?: InputMaybe; + fileIds?: InputMaybe>; messageId: Scalars['UUID']; modelId?: InputMaybe; text: Scalars['String']; @@ -6450,6 +6451,7 @@ export type SendChatMessageMutationVariables = Exact<{ messageId: Scalars['UUID']; browsingContext?: InputMaybe; modelId?: InputMaybe; + fileIds?: InputMaybe | Scalars['UUID']>; }>; @@ -8410,7 +8412,7 @@ export const DeleteSkillDocument = {"kind":"Document","definitions":[{"kind":"Op export const EvaluateAgentTurnDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EvaluateAgentTurn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"turnId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"evaluateAgentTurn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"turnId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"turnId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; export const RemoveRoleFromAgentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveRoleFromAgent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeRoleFromAgent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}}]}]}}]} as unknown as DocumentNode; export const RunEvaluationInputDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RunEvaluationInput"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"runEvaluationInput"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"evaluations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; -export const SendChatMessageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SendChatMessage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"text"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendChatMessage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}},{"kind":"Argument","name":{"kind":"Name","value":"text"},"value":{"kind":"Variable","name":{"kind":"Name","value":"text"}}},{"kind":"Argument","name":{"kind":"Name","value":"messageId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}}},{"kind":"Argument","name":{"kind":"Name","value":"browsingContext"},"value":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"queued"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}}]} as unknown as DocumentNode; +export const SendChatMessageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SendChatMessage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"text"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fileIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendChatMessage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}},{"kind":"Argument","name":{"kind":"Name","value":"text"},"value":{"kind":"Variable","name":{"kind":"Name","value":"text"}}},{"kind":"Argument","name":{"kind":"Name","value":"messageId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageId"}}},{"kind":"Argument","name":{"kind":"Name","value":"browsingContext"},"value":{"kind":"Variable","name":{"kind":"Name","value":"browsingContext"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"fileIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fileIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"queued"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}}]}}]}}]} as unknown as DocumentNode; export const StopAgentChatStreamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StopAgentChatStream"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stopAgentChatStream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}]}]}}]} as unknown as DocumentNode; export const UpdateOneAgentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOneAgent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateAgentInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOneAgent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AgentFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Agent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"prompt"}},{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"responseFormat"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"modelConfiguration"}},{"kind":"Field","name":{"kind":"Name","value":"evaluationInputs"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const UpdateSkillDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSkill"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSkillInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSkill"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SkillFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SkillFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Skill"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/ai/graphql/mutations/sendChatMessage.ts b/packages/twenty-front/src/modules/ai/graphql/mutations/sendChatMessage.ts index 53ac67498bb..cd6ca8d8352 100644 --- a/packages/twenty-front/src/modules/ai/graphql/mutations/sendChatMessage.ts +++ b/packages/twenty-front/src/modules/ai/graphql/mutations/sendChatMessage.ts @@ -7,6 +7,7 @@ export const SEND_CHAT_MESSAGE = gql` $messageId: UUID! $browsingContext: JSON $modelId: String + $fileIds: [UUID!] ) { sendChatMessage( threadId: $threadId @@ -14,6 +15,7 @@ export const SEND_CHAT_MESSAGE = gql` messageId: $messageId browsingContext: $browsingContext modelId: $modelId + fileIds: $fileIds ) { messageId queued diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index 86493bddfa7..ad7ddd29ab3 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -114,6 +114,8 @@ export const useAgentChat = ( store.set(messagesAtom, [...currentMessages, optimisticUserMessage]); + const fileIds = agentChatUploadedFiles.map((file) => file.fileId); + setAgentChatUploadedFiles([]); try { @@ -131,6 +133,7 @@ export const useAgentChat = ( messageId, browsingContext: browsingContext ?? null, modelId: modelIdForRequest ?? undefined, + fileIds: fileIds.length > 0 ? fileIds : undefined, }, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart.ts index 44b48379108..b8437e86674 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart.ts @@ -1,4 +1,7 @@ -import { type ExtendedUIMessagePart } from 'twenty-shared/ai'; +import { + type ExtendedFileUIPart, + type ExtendedUIMessagePart, +} from 'twenty-shared/ai'; import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity'; @@ -24,12 +27,11 @@ export const mapDBPartToUIMessagePart = ( case 'file': return { type: 'file', - mediaType: part.fileFilename?.endsWith('.png') - ? 'image/png' - : 'application/octet-stream', + mediaType: part.file?.mimeType ?? 'application/octet-stream', filename: part.fileFilename ?? '', url: '', - }; + fileId: part.fileId ?? '', + } as ExtendedFileUIPart; case 'source-url': return { type: 'source-url', diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts index 97378837ce7..355910db5dd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts @@ -127,6 +127,8 @@ export class AgentChatResolver { browsingContext: BrowsingContextType | null, @Args('modelId', { type: () => String, nullable: true }) modelId: string | undefined, + @Args('fileIds', { type: () => [UUIDScalarType], nullable: true }) + fileIds: string[] | null, @AuthUserWorkspaceId() userWorkspaceId: string, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { @@ -174,6 +176,7 @@ export class AgentChatResolver { threadId, text, id: messageId, + fileIds: fileIds ?? undefined, workspaceId: workspace.id, }); @@ -194,6 +197,7 @@ export class AgentChatResolver { workspace, text, messageId, + fileIds: fileIds ?? undefined, }); return { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index ff196a460e9..5c34a13a17b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -2,8 +2,16 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { generateId } from 'ai'; -import { type Repository } from 'typeorm'; +import { + type ExtendedFileUIPart, + type ExtendedUIMessagePart, + isExtendedFileUIPart, +} from 'twenty-shared/ai'; +import { FileFolder } from 'twenty-shared/types'; +import { In, Like, type Repository } from 'typeorm'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; @@ -32,6 +40,7 @@ export type StreamAgentChatOptions = { browsingContext: BrowsingContextType | null; modelId?: string; messageId?: string; + fileIds?: string[]; }; @Injectable() @@ -41,10 +50,13 @@ export class AgentChatStreamingService { constructor( @InjectRepository(AgentChatThreadEntity) private readonly threadRepository: Repository, + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, @InjectMessageQueue(MessageQueue.aiStreamQueue) private readonly messageQueueService: MessageQueueService, private readonly agentChatService: AgentChatService, private readonly eventPublisherService: AgentChatEventPublisherService, + private readonly fileUrlService: FileUrlService, ) {} async streamAgentChat({ @@ -55,6 +67,7 @@ export class AgentChatStreamingService { browsingContext, modelId, messageId, + fileIds, }: StreamAgentChatOptions): Promise<{ streamId: string; messageId: string }> { const thread = await this.threadRepository.findOne({ where: { @@ -70,12 +83,19 @@ export class AgentChatStreamingService { ); } + const fileParts = await this.buildFilePartsFromIds(fileIds, workspace.id); + + const userMessageParts: ExtendedUIMessagePart[] = [ + { type: 'text' as const, text }, + ...fileParts, + ]; + const savedUserMessage = await this.agentChatService.addMessage({ threadId, id: messageId, uiMessage: { role: AgentMessageRole.USER, - parts: [{ type: 'text' as const, text }], + parts: userMessageParts, }, workspaceId: workspace.id, }); @@ -83,6 +103,7 @@ export class AgentChatStreamingService { const previousMessages = await this.loadMessagesFromDB( threadId, userWorkspaceId, + workspace.id, ); const streamId = generateId(); @@ -98,7 +119,7 @@ export class AgentChatStreamingService { browsingContext, modelId, lastUserMessageText: text, - lastUserMessageParts: [{ type: 'text', text }], + lastUserMessageParts: userMessageParts, hasTitle: !!thread.title, conversationSizeTokens: thread.conversationSize, existingTurnId: savedUserMessage.turnId ?? undefined, @@ -129,8 +150,19 @@ export class AgentChatStreamingService { const textPart = nextQueued.parts?.find((part) => part.type === 'text'); const messageText = textPart?.textContent ?? ''; + const fileParts = (nextQueued.parts ?? []) + .filter((part) => part.type === 'file') + .map( + (part): ExtendedFileUIPart => ({ + type: 'file', + mediaType: part.file?.mimeType ?? 'application/octet-stream', + filename: part.fileFilename ?? '', + url: '', + fileId: part.fileId ?? '', + }), + ); - if (messageText === '') { + if (messageText === '' && fileParts.length === 0) { await this.agentChatService.deleteQueuedMessage(nextQueued.id); return; @@ -159,12 +191,19 @@ export class AgentChatStreamingService { }); const [uiMessages, thread] = await Promise.all([ - this.loadMessagesFromDB(threadId, userWorkspaceId), + this.loadMessagesFromDB(threadId, userWorkspaceId, workspaceId), this.threadRepository.findOneByOrFail({ id: threadId }), ]); const streamId = generateId(); + const lastUserMessageParts: ExtendedUIMessagePart[] = [ + ...(messageText !== '' + ? [{ type: 'text' as const, text: messageText }] + : []), + ...fileParts, + ]; + await this.messageQueueService.add( STREAM_AGENT_CHAT_JOB_NAME, { @@ -175,7 +214,7 @@ export class AgentChatStreamingService { messages: uiMessages, browsingContext: null, lastUserMessageText: messageText, - lastUserMessageParts: [{ type: 'text', text: messageText }], + lastUserMessageParts, hasTitle, conversationSizeTokens: thread.conversationSize, existingTurnId: turnId, @@ -187,7 +226,11 @@ export class AgentChatStreamingService { }); } - private async loadMessagesFromDB(threadId: string, userWorkspaceId: string) { + private async loadMessagesFromDB( + threadId: string, + userWorkspaceId: string, + workspaceId: string, + ) { const allMessages = await this.agentChatService.getMessagesForThread( threadId, userWorkspaceId, @@ -198,8 +241,50 @@ export class AgentChatStreamingService { .map((message) => ({ id: message.id, role: message.role as 'user' | 'assistant' | 'system', - parts: mapDBPartsToUIMessageParts(message.parts ?? []), + parts: mapDBPartsToUIMessageParts(message.parts ?? []).map((part) => { + if (isExtendedFileUIPart(part as Record)) { + const filePart = part as ExtendedFileUIPart; + + return { + ...filePart, + url: this.fileUrlService.signFileByIdUrl({ + fileId: filePart.fileId, + workspaceId, + fileFolder: FileFolder.AgentChat, + }), + } as ExtendedFileUIPart; + } + + return part; + }), createdAt: message.createdAt, })); } + + private async buildFilePartsFromIds( + fileIds: string[] | undefined, + workspaceId: string, + ): Promise { + if (!fileIds || fileIds.length === 0) { + return []; + } + + const files = await this.fileRepository.find({ + where: { + id: In(fileIds), + workspaceId, + path: Like(`%/${FileFolder.AgentChat}/%`), + }, + }); + + return files.map( + (file): ExtendedFileUIPart => ({ + type: 'file' as const, + mediaType: file.mimeType, + filename: file.path.split('/').pop() ?? file.path, + url: '', + fileId: file.id, + }), + ); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts index d36e1165373..d45f665ad9c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts @@ -2,10 +2,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ExtendedUIMessage } from 'twenty-shared/ai'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import type { UIDataTypes, UIMessagePart, UITools } from 'ai'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity'; import { AgentMessageEntity, @@ -47,6 +48,8 @@ export class AgentChatService { private readonly messageRepository: Repository, @InjectRepository(AgentMessagePartEntity) private readonly messagePartRepository: Repository, + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, private readonly titleGenerationService: AgentTitleGenerationService, private readonly workspaceEventBroadcaster: WorkspaceEventBroadcaster, ) {} @@ -181,11 +184,13 @@ export class AgentChatService { threadId, text, id, + fileIds, workspaceId, }: { threadId: string; text: string; id?: string; + fileIds?: string[]; workspaceId: string; }): Promise { const message = this.messageRepository.create({ @@ -200,15 +205,34 @@ export class AgentChatService { const savedMessage = await this.messageRepository.save(message); - const part = this.messagePartRepository.create({ - messageId: savedMessage.id, - orderIndex: 0, - type: 'text', - textContent: text, - workspaceId, - }); + const files = + fileIds && fileIds.length > 0 + ? await this.fileRepository.find({ + where: { id: In(fileIds), workspaceId }, + }) + : []; - await this.messagePartRepository.save(part); + const parts = [ + this.messagePartRepository.create({ + messageId: savedMessage.id, + orderIndex: 0, + type: 'text', + textContent: text, + workspaceId, + }), + ...files.map((file, index) => + this.messagePartRepository.create({ + messageId: savedMessage.id, + orderIndex: index + 1, + type: 'file', + fileId: file.id, + fileFilename: file.path.split('/').pop() ?? null, + workspaceId, + }), + ), + ]; + + await this.messagePartRepository.save(parts); return savedMessage; } @@ -220,7 +244,7 @@ export class AgentChatService { status: AgentMessageStatus.QUEUED, }, order: { createdAt: 'ASC' }, - relations: ['parts'], + relations: ['parts', 'parts.file'], }); }