mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
Add file attachment support to agent chat messaging (#19517)
## Summary This PR adds support for attaching files to agent chat messages. Users can now upload files when sending messages to the AI agent, and these files are properly processed, stored, and made available to the agent with signed URLs. ## Key Changes - **File attachment input**: Added `fileIds` parameter to the `sendChatMessage` GraphQL mutation to accept file IDs from the client - **File processing**: Implemented `buildFilePartsFromIds()` method to convert file IDs into file UI parts with signed URLs - **Message composition**: Updated user messages to include both text and file parts when files are attached - **File URL signing**: Integrated `FileUrlService` to generate signed URLs for files in the AgentChat folder, ensuring secure access - **Message persistence**: Files are now included in the message parts stored in the database and retrieved when loading conversation history - **File metadata mapping**: Enhanced `mapDBPartToUIMessagePart()` to properly extract MIME types from file entities and include file IDs ## Implementation Details - Files are fetched from the database using the provided file IDs and workspace context - Each file is converted to an `ExtendedFileUIPart` with proper metadata (filename, MIME type, signed URL, and file ID) - When loading messages from the database, file parts are enhanced with signed URLs to ensure they remain accessible - The `loadMessagesFromDB()` method now requires the workspace ID to properly sign file URLs - File attachments are seamlessly integrated into the existing message part system alongside text content https://claude.ai/code/session_01TAdN1gBzeiYELX4XDrrYY1 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
parent
d2f51cc939
commit
2bb939b4b5
12 changed files with 394 additions and 26 deletions
22
.claude-pr/.mcp.json
Normal file
22
.claude-pr/.mcp.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
.claude-pr/CLAUDE.md
Normal file
220
.claude-pr/CLAUDE.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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} })
|
||||
|
|
|
|||
|
|
@ -8363,6 +8363,10 @@ export default {
|
|||
],
|
||||
"modelId": [
|
||||
1
|
||||
],
|
||||
"fileIds": [
|
||||
3,
|
||||
"[UUID!]"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3389,6 +3389,7 @@ export type MutationSaveImapSmtpCaldavAccountArgs = {
|
|||
|
||||
export type MutationSendChatMessageArgs = {
|
||||
browsingContext?: InputMaybe<Scalars['JSON']>;
|
||||
fileIds?: InputMaybe<Array<Scalars['UUID']>>;
|
||||
messageId: Scalars['UUID'];
|
||||
modelId?: InputMaybe<Scalars['String']>;
|
||||
text: Scalars['String'];
|
||||
|
|
@ -6450,6 +6451,7 @@ export type SendChatMessageMutationVariables = Exact<{
|
|||
messageId: Scalars['UUID'];
|
||||
browsingContext?: InputMaybe<Scalars['JSON']>;
|
||||
modelId?: InputMaybe<Scalars['String']>;
|
||||
fileIds?: InputMaybe<Array<Scalars['UUID']> | 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<EvaluateAgentTurnMutation, EvaluateAgentTurnMutationVariables>;
|
||||
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<RemoveRoleFromAgentMutation, RemoveRoleFromAgentMutationVariables>;
|
||||
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<RunEvaluationInputMutation, RunEvaluationInputMutationVariables>;
|
||||
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<SendChatMessageMutation, SendChatMessageMutationVariables>;
|
||||
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<SendChatMessageMutation, SendChatMessageMutationVariables>;
|
||||
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<StopAgentChatStreamMutation, StopAgentChatStreamMutationVariables>;
|
||||
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<UpdateOneAgentMutation, UpdateOneAgentMutationVariables>;
|
||||
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<UpdateSkillMutation, UpdateSkillMutationVariables>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<SendChatMessageResultDTO> {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<AgentChatThreadEntity>,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
@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<StreamAgentChatJobData>(
|
||||
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<string, unknown>)) {
|
||||
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<ExtendedUIMessagePart[]> {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AgentMessageEntity>,
|
||||
@InjectRepository(AgentMessagePartEntity)
|
||||
private readonly messagePartRepository: Repository<AgentMessagePartEntity>,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
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<AgentMessageEntity> {
|
||||
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'],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue