Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5717752be | ||
|
|
24796e9355 | ||
|
|
15fc850212 | ||
|
|
e6d5df751b | ||
|
|
5b4fed1afe | ||
|
|
e757a1418d | ||
|
|
2f298307f9 | ||
|
|
c15536f9f8 | ||
|
|
b2f7c745f8 | ||
|
|
0ee63a0525 | ||
|
|
18880f0385 | ||
|
|
0902579fbe | ||
|
|
52e57e70fd | ||
|
|
1e01f15182 | ||
|
|
d0c1841f0f | ||
|
|
5be64bf4be | ||
|
|
d9dab75052 | ||
|
|
2237273869 | ||
|
|
9e21e55db4 | ||
|
|
859241d237 | ||
|
|
41e413d7b1 | ||
|
|
b0cb29c11c | ||
|
|
b4d957306e | ||
|
|
9c984b137f | ||
|
|
05a6a96b13 | ||
|
|
16d414590b | ||
|
|
bc72879c70 | ||
|
|
bb037e13dd | ||
|
|
17786a3298 | ||
|
|
8f91529153 | ||
|
|
e995e84621 | ||
|
|
148584c730 | ||
|
|
b7ff587b5e | ||
|
|
2e77a68daf | ||
|
|
382746dd52 | ||
|
|
202a130ac8 | ||
|
|
24c6d3fef3 | ||
|
|
52fe21c04b | ||
|
|
ee0474e287 | ||
|
|
3f202c5c6a | ||
|
|
a63b31931f | ||
|
|
3c2aec1894 | ||
|
|
b250de216e | ||
|
|
2d42b0a726 | ||
|
|
7e5e800f63 | ||
|
|
8c70a875f8 | ||
|
|
a40612ef9e | ||
|
|
1979e013e9 | ||
|
|
3747005fd5 | ||
|
|
268cc40d80 | ||
|
|
11fd1a41f3 | ||
|
|
05eca08ad2 | ||
|
|
ef1464db73 | ||
|
|
5ccf18fdf5 | ||
|
|
9e6f19d16e | ||
|
|
987ed845ac | ||
|
|
f51291704d | ||
|
|
37b9a55382 | ||
|
|
aa7973e5b8 | ||
|
|
8c951d3623 | ||
|
|
2a76e1791e | ||
|
|
4ae375308f | ||
|
|
c01853c349 | ||
|
|
21b2b65dbe | ||
|
|
c6d04ccced | ||
|
|
c18726d712 | ||
|
|
96bc3594a3 | ||
|
|
617f634b6d | ||
|
|
8e977912e2 | ||
|
|
4cdb7d61b2 | ||
|
|
7fbd50e5a0 | ||
|
|
0b4cebfe0e | ||
|
|
d7c28d6455 | ||
|
|
2b29918bf8 | ||
|
|
2106f46e9e | ||
|
|
1edce5088c | ||
|
|
3216b634a3 | ||
|
|
6c7c389785 | ||
|
|
9162685b2e | ||
|
|
bf4c348c8b | ||
|
|
ece265c6e4 | ||
|
|
15f09736b2 | ||
|
|
bade5289b6 | ||
|
|
497230a052 | ||
|
|
9aa63f7ddc | ||
|
|
40d7e740ef | ||
|
|
7402edb887 | ||
|
|
d7132b35d3 | ||
|
|
bc6268bb29 | ||
|
|
c44d61f324 | ||
|
|
cc86b9acae | ||
|
|
30cbf0e40e | ||
|
|
4acca5b10d | ||
|
|
3dc5b162c7 | ||
|
|
a494a7a902 | ||
|
|
f6beb06364 | ||
|
|
7074d9ce1b | ||
|
|
b3c95744ef | ||
|
|
10de51fcbd |
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test:unit, storybook:build, storybook:test]
|
||||
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/[email protected]
|
||||
@@ -47,67 +47,54 @@ jobs:
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: ${{ matrix.task }}
|
||||
# TODO uncomment when syncApplication resolver is fixed
|
||||
# sdk-e2e-integration-test:
|
||||
# timeout-minutes: 30
|
||||
# runs-on: ubuntu-latest-8-cores
|
||||
# needs: [changed-files-check, sdk-test]
|
||||
# strategy:
|
||||
# matrix:
|
||||
# task: [test:integration, test:e2e]
|
||||
# if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
# services:
|
||||
# postgres:
|
||||
# image: twentycrm/twenty-postgres-spilo
|
||||
# env:
|
||||
# PGUSER_SUPERUSER: postgres
|
||||
# PGPASSWORD_SUPERUSER: postgres
|
||||
# ALLOW_NOSSL: 'true'
|
||||
# SPILO_PROVIDER: 'local'
|
||||
# ports:
|
||||
# - 5432:5432
|
||||
# options: >-
|
||||
# --health-cmd pg_isready
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
# redis:
|
||||
# image: redis
|
||||
# ports:
|
||||
# - 6379:6379
|
||||
# env:
|
||||
# NODE_ENV: test
|
||||
# steps:
|
||||
# - name: Fetch custom Github Actions and base branch history
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# fetch-depth: 0
|
||||
# - name: Install dependencies
|
||||
# uses: ./.github/actions/yarn-install
|
||||
# - name: Server / Append billing config to .env.test
|
||||
# working-directory: packages/twenty-server
|
||||
# run: |
|
||||
# echo "" >> .env.test
|
||||
# echo "IS_BILLING_ENABLED=true" >> .env.test
|
||||
# echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
|
||||
# echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
|
||||
# echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
# echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
# - name: Server / Create Test DB
|
||||
# run: |
|
||||
# PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
# - name: SDK / Run ${{ matrix.task }} Tests
|
||||
# uses: ./.github/actions/nx-affected
|
||||
# with:
|
||||
# tag: scope:sdk
|
||||
# tasks: ${{ matrix.task }}
|
||||
sdk-e2e-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
needs: [changed-files-check, sdk-test]
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
NODE_ENV: test
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build twenty-sdk
|
||||
- name: Server / Create Test DB
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
- name: SDK / Run e2e Tests
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: test:e2e
|
||||
ci-sdk-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, sdk-test]
|
||||
# TODO uncomment when syncApplication resolver is fixed
|
||||
# needs: [changed-files-check, sdk-test, sdk-e2e-integration-test]
|
||||
needs: [changed-files-check, sdk-test, sdk-e2e-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.4.8",
|
||||
"version": "0.5.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
expect(packageJson.name).toBe('my-test-app');
|
||||
expect(packageJson.version).toBe('0.1.0');
|
||||
expect(packageJson.dependencies['twenty-sdk']).toBe('0.4.8');
|
||||
expect(packageJson.dependencies['twenty-sdk']).toBe('0.5.0');
|
||||
expect(packageJson.scripts['app:dev']).toBe('twenty app:dev');
|
||||
});
|
||||
|
||||
|
||||
@@ -189,7 +189,6 @@ const createDefaultFunction = async ({
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
const triggerUniversalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
@@ -204,15 +203,11 @@ export default defineLogicFunction({
|
||||
description: 'A simple logic function',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: '${triggerUniversalIdentifier}',
|
||||
type: 'route',
|
||||
path: '/hello-world-logic-function',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
],
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/hello-world-logic-function',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
@@ -282,7 +277,7 @@ const createPackageJson = async ({
|
||||
'lint:fix': 'eslint --fix',
|
||||
},
|
||||
dependencies: {
|
||||
'twenty-sdk': '0.4.8',
|
||||
'twenty-sdk': '0.5.0',
|
||||
},
|
||||
devDependencies: {
|
||||
typescript: '^5.9.3',
|
||||
|
||||
@@ -17,7 +17,10 @@ setup_and_migrate_db() {
|
||||
yarn database:migrate:prod
|
||||
fi
|
||||
|
||||
yarn command:prod cache:flush
|
||||
yarn command:prod upgrade
|
||||
yarn command:prod cache:flush
|
||||
|
||||
echo "Successfully migrated DB!"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ test('Create workflow', async ({ page }) => {
|
||||
|
||||
await page.goto(process.env.LINK);
|
||||
|
||||
const workflowsFolder = page.getByRole('button', { name: 'Workflows' });
|
||||
await workflowsFolder.click();
|
||||
|
||||
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
|
||||
await workflowsLink.click();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
@@ -21,7 +21,6 @@ module.exports = {
|
||||
'./src/modules/logic-functions/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/databases/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/workflow/**/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/analytics/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/object-metadata/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/navigation-menu-item/graphql/**/*.{ts,tsx}',
|
||||
@@ -32,6 +31,9 @@ module.exports = {
|
||||
|
||||
'./src/modules/page-layout/widgets/**/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/dashboards/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/page-layout/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/marketplace/graphql/**/*.{ts,tsx}',
|
||||
'!./src/**/*.test.{ts,tsx}',
|
||||
'!./src/**/*.stories.{ts,tsx}',
|
||||
'!./src/**/__mocks__/*.ts',
|
||||
|
||||
@@ -5,23 +5,11 @@ module.exports = {
|
||||
(process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') +
|
||||
'/graphql',
|
||||
documents: [
|
||||
'./src/modules/activities/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/companies/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/people/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/opportunities/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/workflow/**/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/activities/emails/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/activities/calendar/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/search/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/views/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/favorites/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/spreadsheet-import/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/command-menu/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/marketplace/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/prefetch/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/subscription/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/dashboards/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/page-layout/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'!./src/**/*.test.{ts,tsx}',
|
||||
'!./src/**/*.stories.{ts,tsx}',
|
||||
|
||||
@@ -62,9 +62,9 @@ const jestConfig = {
|
||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 50,
|
||||
lines: 48.9,
|
||||
functions: 40.9,
|
||||
statements: 49.5,
|
||||
lines: 48,
|
||||
functions: 40,
|
||||
},
|
||||
},
|
||||
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { type AppPath } from 'twenty-shared/types';
|
||||
import { type AppPath, type NavigateOptions } from 'twenty-shared/types';
|
||||
import { getAppPath } from 'twenty-shared/utils';
|
||||
|
||||
export const useNavigateApp = () => {
|
||||
@@ -9,10 +9,7 @@ export const useNavigateApp = () => {
|
||||
to: T,
|
||||
params?: Parameters<typeof getAppPath<T>>[1],
|
||||
queryParams?: Record<string, any>,
|
||||
options?: {
|
||||
replace?: boolean;
|
||||
state?: any;
|
||||
},
|
||||
options?: NavigateOptions,
|
||||
) => {
|
||||
const path = getAppPath(to, params, queryParams);
|
||||
return navigate(path, options);
|
||||
|
||||
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,4 +1,4 @@
|
||||
import { type ConnectionParameters } from '~/generated/graphql';
|
||||
import { type ConnectionParameters } from '~/generated-metadata/graphql';
|
||||
|
||||
export type ImapSmtpCaldavAccount = {
|
||||
IMAP?: ConnectionParameters;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { PREVIEWABLE_EXTENSIONS } from '@/activities/files/const/previewable-extensions.const';
|
||||
import { downloadFile } from '@/activities/files/utils/downloadFile';
|
||||
import { fetchCsvPreview } from '@/activities/files/utils/fetchCsvPreview';
|
||||
import {
|
||||
type CsvPreviewData,
|
||||
fetchCsvPreview,
|
||||
} from '@/activities/files/utils/fetchCsvPreview';
|
||||
import { getFileType } from '@/activities/files/utils/getFileType';
|
||||
import DocViewer, { DocViewerRenderers } from '@cyntler/react-doc-viewer';
|
||||
import '@cyntler/react-doc-viewer/dist/index.css';
|
||||
@@ -79,6 +82,39 @@ const StyledTitle = styled.div`
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
const StyledCsvTable = styled.table`
|
||||
border-collapse: collapse;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
|
||||
td {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
type DocumentViewerProps = {
|
||||
documentName: string;
|
||||
documentUrl: string;
|
||||
@@ -165,7 +201,9 @@ export const DocumentViewer = ({
|
||||
}: DocumentViewerProps) => {
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const [csvPreview, setCsvPreview] = useState<string | undefined>(undefined);
|
||||
const [csvPreview, setCsvPreview] = useState<CsvPreviewData | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const { extension } = getFileNameAndExtension(documentName);
|
||||
const fileExtension = isDefined(documentExtension)
|
||||
@@ -175,15 +213,11 @@ export const DocumentViewer = ({
|
||||
const isPreviewable = PREVIEWABLE_EXTENSIONS.includes(fileExtension);
|
||||
const isMsOfficeFile = MS_OFFICE_EXTENSIONS.includes(fileExtension);
|
||||
|
||||
const mimeType = PREVIEWABLE_EXTENSIONS.includes(fileExtension)
|
||||
? MIME_TYPE_MAPPING[fileExtension]
|
||||
: undefined;
|
||||
const mimeType = isPreviewable ? MIME_TYPE_MAPPING[fileExtension] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (fileExtension === 'csv') {
|
||||
fetchCsvPreview(documentUrl).then((content) => {
|
||||
setCsvPreview(content);
|
||||
});
|
||||
fetchCsvPreview(documentUrl).then(setCsvPreview);
|
||||
}
|
||||
}, [documentUrl, fileExtension]);
|
||||
|
||||
@@ -218,12 +252,37 @@ export const DocumentViewer = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (fileExtension === 'csv' && !isDefined(csvPreview))
|
||||
if (fileExtension === 'csv') {
|
||||
if (!isDefined(csvPreview)) {
|
||||
return (
|
||||
<StyledDocumentViewerContainer>
|
||||
<Trans>Loading csv ... </Trans>
|
||||
</StyledDocumentViewerContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StyledDocumentViewerContainer>
|
||||
<Trans>Loading csv ... </Trans>
|
||||
<StyledDocumentViewerContainer style={{ background: 'transparent' }}>
|
||||
<StyledCsvTable>
|
||||
<thead>
|
||||
<tr>
|
||||
{csvPreview.headers.map((header, columnIndex) => (
|
||||
<th key={columnIndex}>{header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledCsvTable>
|
||||
</StyledDocumentViewerContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMsOfficeFile && isPrivateUrl(documentUrl)) {
|
||||
return (
|
||||
@@ -251,12 +310,7 @@ export const DocumentViewer = ({
|
||||
<DocViewer
|
||||
documents={[
|
||||
{
|
||||
uri:
|
||||
fileExtension === 'csv' && isDefined(csvPreview)
|
||||
? window.URL.createObjectURL(
|
||||
new Blob([csvPreview], { type: 'text/csv' }),
|
||||
)
|
||||
: documentUrl,
|
||||
uri: documentUrl,
|
||||
fileName: documentName,
|
||||
fileType: mimeType,
|
||||
},
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { fetchCsvPreview } from '@/activities/files/utils/fetchCsvPreview';
|
||||
|
||||
const mockFetch = (text: string) => {
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
text: () => Promise.resolve(text),
|
||||
} as unknown as Response),
|
||||
);
|
||||
};
|
||||
|
||||
describe('fetchCsvPreview', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should parse headers and rows from CSV', async () => {
|
||||
mockFetch('Name,Age,City\nAlice,30,Paris\nBob,25,London\n');
|
||||
|
||||
const result = await fetchCsvPreview('https://example.com/file.csv');
|
||||
|
||||
expect(result.headers).toEqual(['Name', 'Age', 'City']);
|
||||
expect(result.rows).toEqual([
|
||||
['Alice', '30', 'Paris'],
|
||||
['Bob', '25', 'London'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty headers and rows for empty CSV', async () => {
|
||||
mockFetch('');
|
||||
|
||||
const result = await fetchCsvPreview('https://example.com/empty.csv');
|
||||
|
||||
expect(result.headers).toEqual([]);
|
||||
expect(result.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return headers with no rows when CSV has only a header line', async () => {
|
||||
mockFetch('Name,Age,City\n');
|
||||
|
||||
const result = await fetchCsvPreview('https://example.com/header-only.csv');
|
||||
|
||||
expect(result.headers).toEqual(['Name', 'Age', 'City']);
|
||||
expect(result.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip empty lines', async () => {
|
||||
mockFetch('Name,Age\n\nAlice,30\n\nBob,25\n');
|
||||
|
||||
const result = await fetchCsvPreview('https://example.com/file.csv');
|
||||
|
||||
expect(result.rows).toEqual([
|
||||
['Alice', '30'],
|
||||
['Bob', '25'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle rows with inconsistent column counts', async () => {
|
||||
mockFetch('Name,Age,City\nAlice,30\nBob,25,London,Extra\n');
|
||||
|
||||
const result = await fetchCsvPreview('https://example.com/malformed.csv');
|
||||
|
||||
expect(result.headers).toEqual(['Name', 'Age', 'City']);
|
||||
expect(result.rows).toEqual([
|
||||
['Alice', '30'],
|
||||
['Bob', '25', 'London', 'Extra'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should limit rows to the preview amount', async () => {
|
||||
const lines = ['Name'];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
lines.push(`Person${i}`);
|
||||
}
|
||||
mockFetch(lines.join('\n'));
|
||||
|
||||
const result = await fetchCsvPreview('https://example.com/large.csv');
|
||||
|
||||
expect(result.headers).toEqual(['Name']);
|
||||
expect(result.rows).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
@@ -2,21 +2,22 @@ import Papa from 'papaparse';
|
||||
|
||||
const DEFAULT_PREVIEW_ROWS = 50;
|
||||
|
||||
export const fetchCsvPreview = async (url: string): Promise<string> => {
|
||||
export type CsvPreviewData = {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
};
|
||||
|
||||
export const fetchCsvPreview = async (url: string): Promise<CsvPreviewData> => {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
|
||||
const result = Papa.parse(text, {
|
||||
preview: DEFAULT_PREVIEW_ROWS,
|
||||
const result = Papa.parse<string[]>(text, {
|
||||
preview: DEFAULT_PREVIEW_ROWS + 1, // +1 for header row
|
||||
skipEmptyLines: true,
|
||||
header: true,
|
||||
header: false,
|
||||
});
|
||||
|
||||
const data = result.data as Record<string, string>[];
|
||||
const [headers = [], ...rows] = result.data;
|
||||
|
||||
const csvContent = Papa.unparse(data, {
|
||||
header: true,
|
||||
});
|
||||
|
||||
return csvContent;
|
||||
return { headers, rows };
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { useState } from 'react';
|
||||
|
||||
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
@@ -37,6 +38,7 @@ export const useCustomResolver = <
|
||||
fetchMoreRecords: () => Promise<void>;
|
||||
} => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const [page, setPage] = useState({
|
||||
pageNumber: 1,
|
||||
@@ -62,6 +64,7 @@ export const useCustomResolver = <
|
||||
loading: firstQueryLoading,
|
||||
fetchMore,
|
||||
} = useQuery<CustomResolverQueryResult<T>>(query, {
|
||||
client: apolloCoreClient,
|
||||
variables: queryVariables,
|
||||
onError: (error) => {
|
||||
enqueueErrorSnackBar({
|
||||
|
||||
+1
-12
@@ -1,28 +1,17 @@
|
||||
import { useLinkedObjectsTitle } from '@/activities/timeline-activities/hooks/useLinkedObjectsTitle';
|
||||
import { type TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
|
||||
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { FeatureFlagKey } from '~/generated/graphql';
|
||||
|
||||
// do we need to test this?
|
||||
export const useTimelineActivities = (
|
||||
targetableObject: ActivityTargetableObject,
|
||||
) => {
|
||||
const isTimelineActivityMigrated = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_TIMELINE_ACTIVITY_MIGRATED,
|
||||
);
|
||||
|
||||
const targetableObjectFieldIdName = isTimelineActivityMigrated
|
||||
? `target${capitalize(targetableObject.targetObjectNameSingular)}Id`
|
||||
: getActivityTargetObjectFieldIdName({
|
||||
nameSingular: targetableObject.targetObjectNameSingular,
|
||||
});
|
||||
const targetableObjectFieldIdName = `target${capitalize(targetableObject.targetObjectNameSingular)}Id`;
|
||||
|
||||
const { objectMetadataItem: timelineActivityMetadata } =
|
||||
useObjectMetadataItem({
|
||||
|
||||
+4
-3
@@ -30,6 +30,7 @@ const StyledEditorContainer = styled.div<{
|
||||
color: ${({ theme, readonly }) =>
|
||||
readonly ? theme.font.color.light : theme.font.color.primary};
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
border: none !important;
|
||||
|
||||
@@ -54,15 +55,15 @@ const StyledEditorContainer = styled.div<{
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
li {
|
||||
|
||||
@@ -1,52 +1,19 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconSparkles } from 'twenty-ui/display';
|
||||
|
||||
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
|
||||
|
||||
const StyledEmptyState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledSparkleIcon = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.blue};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: ${({ theme }) => theme.spacing(2.5)};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
text-align: center;
|
||||
max-width: 85%;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
`;
|
||||
|
||||
export const AIChatEmptyState = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledEmptyState>
|
||||
<StyledSparkleIcon>
|
||||
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
|
||||
</StyledSparkleIcon>
|
||||
<StyledTitle>{t`Chat`}</StyledTitle>
|
||||
<StyledDescription>
|
||||
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
|
||||
</StyledDescription>
|
||||
<AIChatSuggestedPrompts />
|
||||
</StyledEmptyState>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Avatar, IconSparkles } from 'twenty-ui/display';
|
||||
|
||||
import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePreview';
|
||||
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
|
||||
@@ -13,10 +11,11 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
|
||||
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||
align-items: ${({ isUser }) => (isUser ? 'flex-end' : 'flex-start')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -26,26 +25,17 @@ const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledMessageRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledMessageText = styled.div<{ isUser?: boolean }>`
|
||||
background: ${({ theme, isUser }) =>
|
||||
isUser ? theme.background.secondary : theme.background.transparent};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
|
||||
border: ${({ isUser, theme }) =>
|
||||
!isUser ? 'none' : `1px solid ${theme.border.color.light}`};
|
||||
isUser ? theme.background.tertiary : theme.background.transparent};
|
||||
border-radius: ${({ theme, isUser }) =>
|
||||
isUser ? theme.border.radius.sm : '0'};
|
||||
color: ${({ theme, isUser }) =>
|
||||
isUser ? theme.font.color.light : theme.font.color.primary};
|
||||
isUser ? theme.font.color.secondary : theme.font.color.primary};
|
||||
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
|
||||
width: fit-content;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
/* Pre-wrap within the whole container turns every newline between block
|
||||
@@ -114,23 +104,10 @@ const StyledMessageFooter = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledAvatarContainer = styled.div<{ isUser?: boolean }>`
|
||||
align-items: center;
|
||||
background: ${({ theme, isUser }) =>
|
||||
isUser
|
||||
? theme.background.transparent.light
|
||||
: theme.background.transparent.blue};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: 1px;
|
||||
`;
|
||||
|
||||
const StyledMessageContainer = styled.div`
|
||||
const StyledMessageContainer = styled.div<{ isUser?: boolean }>`
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')};
|
||||
`;
|
||||
|
||||
const StyledFilesContainer = styled.div`
|
||||
@@ -150,68 +127,48 @@ export const AIChatMessage = ({
|
||||
isLastMessageStreaming: boolean;
|
||||
error?: Error | null;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { localeCatalog } = useRecoilValue(dateLocaleState);
|
||||
|
||||
const isUser = message.role === AgentMessageRole.USER;
|
||||
const showError =
|
||||
isDefined(error) && message.role === AgentMessageRole.ASSISTANT;
|
||||
|
||||
const fileParts = message.parts.filter((part) => part.type === 'file');
|
||||
|
||||
return (
|
||||
<StyledMessageBubble
|
||||
key={message.id}
|
||||
isUser={message.role === AgentMessageRole.USER}
|
||||
>
|
||||
<StyledMessageRow>
|
||||
{message.role === AgentMessageRole.ASSISTANT && (
|
||||
<StyledAvatarContainer>
|
||||
<Avatar
|
||||
size="sm"
|
||||
placeholder="AI"
|
||||
Icon={IconSparkles}
|
||||
iconColor={theme.color.blue}
|
||||
/>
|
||||
</StyledAvatarContainer>
|
||||
<StyledMessageBubble key={message.id} isUser={isUser}>
|
||||
<StyledMessageContainer isUser={isUser}>
|
||||
<StyledMessageText isUser={isUser}>
|
||||
<AIChatAssistantMessageRenderer
|
||||
isLastMessageStreaming={isLastMessageStreaming}
|
||||
messageParts={message.parts}
|
||||
hasError={showError}
|
||||
/>
|
||||
</StyledMessageText>
|
||||
{fileParts.length > 0 && (
|
||||
<StyledFilesContainer>
|
||||
{fileParts.map((file) => (
|
||||
<AgentChatFilePreview key={file.filename} file={file} />
|
||||
))}
|
||||
</StyledFilesContainer>
|
||||
)}
|
||||
{message.role === AgentMessageRole.USER && (
|
||||
<StyledAvatarContainer isUser>
|
||||
<Avatar size="sm" placeholder="U" type="rounded" />
|
||||
</StyledAvatarContainer>
|
||||
)}
|
||||
<StyledMessageContainer>
|
||||
<StyledMessageText isUser={message.role === AgentMessageRole.USER}>
|
||||
<AIChatAssistantMessageRenderer
|
||||
isLastMessageStreaming={isLastMessageStreaming}
|
||||
messageParts={message.parts}
|
||||
hasError={showError}
|
||||
/>
|
||||
</StyledMessageText>
|
||||
{fileParts.length > 0 && (
|
||||
<StyledFilesContainer>
|
||||
{fileParts.map((file) => (
|
||||
<AgentChatFilePreview key={file.filename} file={file} />
|
||||
))}
|
||||
</StyledFilesContainer>
|
||||
)}
|
||||
{showError && <AIChatErrorRenderer error={error} />}
|
||||
{message.parts.length > 0 && message.metadata?.createdAt && (
|
||||
<StyledMessageFooter className="message-footer">
|
||||
<span>
|
||||
{beautifyPastDateRelativeToNow(
|
||||
message.metadata?.createdAt,
|
||||
localeCatalog,
|
||||
)}
|
||||
</span>
|
||||
<LightCopyIconButton
|
||||
copyText={
|
||||
message.parts.find((part) => part.type === 'text')?.text ?? ''
|
||||
}
|
||||
/>
|
||||
</StyledMessageFooter>
|
||||
)}
|
||||
</StyledMessageContainer>
|
||||
</StyledMessageRow>
|
||||
{showError && <AIChatErrorRenderer error={error} />}
|
||||
</StyledMessageContainer>
|
||||
{message.parts.length > 0 && message.metadata?.createdAt && (
|
||||
<StyledMessageFooter className="message-footer">
|
||||
<span>
|
||||
{beautifyPastDateRelativeToNow(
|
||||
message.metadata?.createdAt,
|
||||
localeCatalog,
|
||||
)}
|
||||
</span>
|
||||
<LightCopyIconButton
|
||||
copyText={
|
||||
message.parts.find((part) => part.type === 'text')?.text ?? ''
|
||||
}
|
||||
/>
|
||||
</StyledMessageFooter>
|
||||
)}
|
||||
</StyledMessageBubble>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Avatar, IconSparkles } from 'twenty-ui/display';
|
||||
|
||||
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
|
||||
|
||||
const StyledErrorContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledAvatarContainer = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.blue};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: 1px;
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
@@ -35,21 +16,9 @@ type AIChatStandaloneErrorProps = {
|
||||
export const AIChatStandaloneError = ({
|
||||
error,
|
||||
}: AIChatStandaloneErrorProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledErrorContainer>
|
||||
<StyledAvatarContainer>
|
||||
<Avatar
|
||||
size="sm"
|
||||
placeholder="AI"
|
||||
Icon={IconSparkles}
|
||||
iconColor={theme.color.blue}
|
||||
/>
|
||||
</StyledAvatarContainer>
|
||||
<StyledContent>
|
||||
<AIChatErrorRenderer error={error} />
|
||||
</StyledContent>
|
||||
<AIChatErrorRenderer error={error} />
|
||||
</StyledErrorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { TextArea } from '@/ui/input/components/TextArea';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconHistory, IconMessageCirclePlus } from 'twenty-ui/display';
|
||||
import { IconHistory } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
|
||||
import { DropZone } from '@/activities/files/components/DropZone';
|
||||
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
|
||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
|
||||
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
|
||||
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
|
||||
import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
|
||||
@@ -17,15 +16,16 @@ import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContext
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
|
||||
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
|
||||
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
|
||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
|
||||
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
|
||||
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
@@ -37,15 +37,56 @@ const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledInputArea = styled.div`
|
||||
const StyledInputArea = styled.div<{ isMobile: boolean }>`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
padding-inline: ${({ theme }) => theme.spacing(3)};
|
||||
padding-block: ${({ theme, isMobile }) => (isMobile ? 0 : theme.spacing(3))};
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
`;
|
||||
|
||||
const StyledInputBox = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
min-height: 140px;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus-within {
|
||||
border-color: ${({ theme }) => theme.color.blue};
|
||||
box-shadow: 0px 0px 0px 3px ${({ theme }) => theme.color.transparent.blue2};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTextAreaWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const StyledChatTextArea = styled(TextArea)`
|
||||
&& {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledScrollWrapper = styled(ScrollWrapper)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -57,14 +98,16 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
|
||||
`;
|
||||
|
||||
const StyledButtonsContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
gap: ${({ theme }) => theme.spacing(0.5)};
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export const AIChatTab = () => {
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const { isLoading, messages, isStreaming, error } =
|
||||
useAgentChatContextOrThrow();
|
||||
|
||||
@@ -72,7 +115,6 @@ export const AIChatTab = () => {
|
||||
useRecoilState(agentChatInputState);
|
||||
|
||||
const { uploadFiles } = useAIChatFileUpload();
|
||||
const { createChatThread } = useCreateNewAIChatThread();
|
||||
const { navigateCommandMenu } = useCommandMenu();
|
||||
|
||||
return (
|
||||
@@ -115,45 +157,46 @@ export const AIChatTab = () => {
|
||||
)}
|
||||
</StyledScrollWrapper>
|
||||
)}
|
||||
{messages.length === 0 && !error && <AIChatEmptyState />}
|
||||
{messages.length === 0 && !error && !isLoading && (
|
||||
<AIChatEmptyState />
|
||||
)}
|
||||
{messages.length === 0 && error && !isLoading && (
|
||||
<AIChatStandaloneError error={error} />
|
||||
)}
|
||||
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
|
||||
|
||||
<StyledInputArea>
|
||||
<StyledInputArea isMobile={isMobile}>
|
||||
<AgentChatContextPreview />
|
||||
<TextArea
|
||||
textAreaId={AI_CHAT_INPUT_ID}
|
||||
placeholder={t`Enter a question...`}
|
||||
value={agentChatInput}
|
||||
onChange={(value) => setAgentChatInput(value)}
|
||||
minRows={1}
|
||||
maxRows={20}
|
||||
/>
|
||||
<StyledButtonsContainer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
Icon={IconHistory}
|
||||
onClick={() =>
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.ViewPreviousAIChats,
|
||||
pageTitle: t`View Previous AI Chats`,
|
||||
pageIcon: IconHistory,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
Icon={IconMessageCirclePlus}
|
||||
onClick={() => createChatThread()}
|
||||
/>
|
||||
<AgentChatFileUploadButton />
|
||||
<AIChatContextUsageButton />
|
||||
<SendMessageButton />
|
||||
</StyledButtonsContainer>
|
||||
<StyledInputBox>
|
||||
<StyledTextAreaWrapper>
|
||||
<StyledChatTextArea
|
||||
textAreaId={AI_CHAT_INPUT_ID}
|
||||
placeholder={t`Ask, search or make anything...`}
|
||||
value={agentChatInput}
|
||||
onChange={(value) => setAgentChatInput(value)}
|
||||
minRows={3}
|
||||
maxRows={20}
|
||||
/>
|
||||
</StyledTextAreaWrapper>
|
||||
<StyledButtonsContainer>
|
||||
<AIChatContextUsageButton />
|
||||
<IconButton
|
||||
Icon={IconHistory}
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.ViewPreviousAIChats,
|
||||
pageTitle: t`View Previous AI Chats`,
|
||||
pageIcon: IconHistory,
|
||||
})
|
||||
}
|
||||
ariaLabel={t`View Previous AI Chats`}
|
||||
/>
|
||||
<AgentChatFileUploadButton />
|
||||
<SendMessageButton />
|
||||
</StyledButtonsContainer>
|
||||
</StyledInputBox>
|
||||
</StyledInputArea>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -84,17 +84,18 @@ export const AIChatThreadGroup = ({
|
||||
const handleThreadClick = (thread: AgentChatThread) => {
|
||||
setCurrentAIChatThread(thread.id);
|
||||
|
||||
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
|
||||
const hasUsageData =
|
||||
totalTokens > 0 && isDefined(thread.contextWindowTokens);
|
||||
(thread.conversationSize ?? 0) > 0 &&
|
||||
isDefined(thread.contextWindowTokens);
|
||||
|
||||
setAgentChatUsage(
|
||||
hasUsageData
|
||||
? {
|
||||
lastMessage: null,
|
||||
conversationSize: thread.conversationSize ?? 0,
|
||||
contextWindowTokens: thread.contextWindowTokens ?? 0,
|
||||
inputTokens: thread.totalInputTokens,
|
||||
outputTokens: thread.totalOutputTokens,
|
||||
totalTokens,
|
||||
contextWindowTokens: thread.contextWindowTokens ?? 0,
|
||||
inputCredits: thread.totalInputCredits,
|
||||
outputCredits: thread.totalOutputCredits,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
|
||||
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
||||
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
||||
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
|
||||
import {
|
||||
getToolDisplayMessage,
|
||||
resolveToolInput,
|
||||
} from '@/ai/utils/getToolDisplayMessage';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type ToolUIPart } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -132,12 +135,10 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('output');
|
||||
|
||||
const { input, output, type, errorText } = toolPart;
|
||||
const toolName = type.split('-')[1];
|
||||
const rawToolName = type.split('-')[1];
|
||||
|
||||
const toolInput =
|
||||
isDefined(input) && typeof input === 'object' && 'input' in input
|
||||
? input.input
|
||||
: input;
|
||||
const { resolvedInput: toolInput, resolvedToolName: toolName } =
|
||||
resolveToolInput(input, rawToolName);
|
||||
|
||||
const hasError = isDefined(errorText);
|
||||
const isExpandable = isDefined(output) || hasError;
|
||||
@@ -175,7 +176,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
||||
<StyledLoadingContainer>
|
||||
<ShimmeringText>
|
||||
<StyledDisplayMessage>
|
||||
{getToolDisplayMessage(input, toolName, false)}
|
||||
{getToolDisplayMessage(input, rawToolName, false)}
|
||||
</StyledDisplayMessage>
|
||||
</ShimmeringText>
|
||||
</StyledLoadingContainer>
|
||||
@@ -188,19 +189,32 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// For execute_tool, the actual result is nested inside output.result
|
||||
const unwrappedOutput =
|
||||
rawToolName === 'execute_tool' &&
|
||||
isDefined(output) &&
|
||||
typeof output === 'object' &&
|
||||
'result' in output
|
||||
? (output as { result: unknown }).result
|
||||
: output;
|
||||
|
||||
const displayMessage = hasError
|
||||
? t`Tool execution failed`
|
||||
: output &&
|
||||
typeof output === 'object' &&
|
||||
'message' in output &&
|
||||
typeof output.message === 'string'
|
||||
? output.message
|
||||
: getToolDisplayMessage(input, toolName, true);
|
||||
: rawToolName === 'learn_tools' || rawToolName === 'execute_tool'
|
||||
? getToolDisplayMessage(input, rawToolName, true)
|
||||
: unwrappedOutput &&
|
||||
typeof unwrappedOutput === 'object' &&
|
||||
'message' in unwrappedOutput &&
|
||||
typeof unwrappedOutput.message === 'string'
|
||||
? unwrappedOutput.message
|
||||
: getToolDisplayMessage(input, rawToolName, true);
|
||||
|
||||
const result =
|
||||
output && typeof output === 'object' && 'result' in output
|
||||
? (output as { result: string }).result
|
||||
: output;
|
||||
unwrappedOutput &&
|
||||
typeof unwrappedOutput === 'object' &&
|
||||
'result' in unwrappedOutput
|
||||
? (unwrappedOutput as { result: string }).result
|
||||
: unwrappedOutput;
|
||||
|
||||
const ToolIcon = getToolIcon(toolName);
|
||||
|
||||
|
||||
@@ -82,8 +82,10 @@ print("Chart saved successfully!")`,
|
||||
usage: {
|
||||
inputTokens: 1250,
|
||||
outputTokens: 890,
|
||||
cachedInputTokens: 0,
|
||||
inputCredits: 12,
|
||||
outputCredits: 8,
|
||||
conversationSize: 1250,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+97
-46
@@ -1,12 +1,17 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ProgressBar } from 'twenty-ui/feedback';
|
||||
|
||||
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import {
|
||||
agentChatUsageState,
|
||||
type AgentChatLastMessageUsage,
|
||||
} from '@/ai/states/agentChatUsageState';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -14,14 +19,11 @@ const StyledContainer = styled.div`
|
||||
|
||||
const StyledTrigger = styled.div<{ hasUsage: boolean }>`
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${({ theme }) => theme.background.transparent.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
cursor: ${({ hasUsage }) => (hasUsage ? 'pointer' : 'default')};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: 24px;
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
@@ -41,14 +43,14 @@ const StyledHoverCard = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
||||
min-width: 240px;
|
||||
min-width: 280px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.div`
|
||||
const StyledSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
@@ -61,14 +63,6 @@ const StyledRow = styled.div`
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
padding-top: 0;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
@@ -79,15 +73,16 @@ const StyledValue = styled.span`
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledFooter = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
const StyledSectionTitle = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
const StyledDivider = styled.div`
|
||||
border-top: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-radius: 0 0 ${({ theme }) => theme.border.radius.md}
|
||||
${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const formatTokenCount = (count: number): string => {
|
||||
@@ -103,6 +98,31 @@ const formatTokenCount = (count: number): string => {
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
const formatCredits = (credits: number): string => {
|
||||
// Credits are already in display units from the API (internal / 1000)
|
||||
// Show up to 1 decimal for fractional values, none for whole numbers
|
||||
if (Number.isInteger(credits)) {
|
||||
return credits.toLocaleString();
|
||||
}
|
||||
|
||||
return credits.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const getCachedLabel = (lastMessage: AgentChatLastMessageUsage): string => {
|
||||
if (lastMessage.cachedInputTokens <= 0 || lastMessage.inputTokens <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const cachedPercent = Math.round(
|
||||
(lastMessage.cachedInputTokens / lastMessage.inputTokens) * 100,
|
||||
);
|
||||
|
||||
return ` (${t`${cachedPercent}% cached`})`;
|
||||
};
|
||||
|
||||
export const AIChatContextUsageButton = () => {
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
@@ -114,21 +134,20 @@ export const AIChatContextUsageButton = () => {
|
||||
<StyledContainer>
|
||||
<StyledTrigger hasUsage={false}>
|
||||
<ContextUsageProgressRing percentage={0} />
|
||||
<StyledPercentage>0%</StyledPercentage>
|
||||
</StyledTrigger>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const percentage = Math.min(
|
||||
(agentChatUsage.totalTokens / agentChatUsage.contextWindowTokens) * 100,
|
||||
(agentChatUsage.conversationSize / agentChatUsage.contextWindowTokens) *
|
||||
100,
|
||||
100,
|
||||
);
|
||||
const formattedPercentage = percentage.toFixed(1);
|
||||
const totalCredits =
|
||||
agentChatUsage.inputCredits + agentChatUsage.outputCredits;
|
||||
const inputCredits = agentChatUsage.inputCredits.toLocaleString();
|
||||
const outputCredits = agentChatUsage.outputCredits.toLocaleString();
|
||||
const lastMessage = agentChatUsage.lastMessage;
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
@@ -137,17 +156,17 @@ export const AIChatContextUsageButton = () => {
|
||||
>
|
||||
<StyledTrigger hasUsage={true}>
|
||||
<ContextUsageProgressRing percentage={percentage} />
|
||||
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
|
||||
</StyledTrigger>
|
||||
|
||||
{isHovered && (
|
||||
<StyledHoverCard>
|
||||
<StyledHeader>
|
||||
<StyledSection>
|
||||
<StyledRow>
|
||||
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
|
||||
<StyledValue>
|
||||
{formatTokenCount(agentChatUsage.totalTokens)} /{' '}
|
||||
{formatTokenCount(agentChatUsage.contextWindowTokens)}
|
||||
{formatTokenCount(agentChatUsage.conversationSize)} /{' '}
|
||||
{formatTokenCount(agentChatUsage.contextWindowTokens)}{' '}
|
||||
{t`tokens`}
|
||||
</StyledValue>
|
||||
</StyledRow>
|
||||
<ProgressBar
|
||||
@@ -162,29 +181,61 @@ export const AIChatContextUsageButton = () => {
|
||||
backgroundColor={theme.background.quaternary}
|
||||
withBorderRadius
|
||||
/>
|
||||
</StyledHeader>
|
||||
</StyledSection>
|
||||
|
||||
<StyledBody>
|
||||
{isDefined(lastMessage) && (
|
||||
<>
|
||||
<StyledDivider />
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Last message`}</StyledSectionTitle>
|
||||
<StyledRow>
|
||||
<StyledLabel>{t`Input tokens`}</StyledLabel>
|
||||
<StyledValue>
|
||||
{formatTokenCount(lastMessage.inputTokens)}
|
||||
{getCachedLabel(lastMessage)}
|
||||
</StyledValue>
|
||||
</StyledRow>
|
||||
<StyledRow>
|
||||
<StyledLabel>{t`Output tokens`}</StyledLabel>
|
||||
<StyledValue>
|
||||
{formatTokenCount(lastMessage.outputTokens)}
|
||||
</StyledValue>
|
||||
</StyledRow>
|
||||
<StyledRow>
|
||||
<StyledLabel>{t`Cost`}</StyledLabel>
|
||||
<StyledValue>
|
||||
{formatCredits(
|
||||
lastMessage.inputCredits + lastMessage.outputCredits,
|
||||
)}{' '}
|
||||
{t`credits`}
|
||||
</StyledValue>
|
||||
</StyledRow>
|
||||
</StyledSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<StyledDivider />
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Conversation`}</StyledSectionTitle>
|
||||
<StyledRow>
|
||||
<StyledLabel>{t`Input`}</StyledLabel>
|
||||
<StyledLabel>{t`Input tokens`}</StyledLabel>
|
||||
<StyledValue>
|
||||
{formatTokenCount(agentChatUsage.inputTokens)} •{' '}
|
||||
{t`${inputCredits} credits`}
|
||||
{formatTokenCount(agentChatUsage.inputTokens)}
|
||||
</StyledValue>
|
||||
</StyledRow>
|
||||
<StyledRow>
|
||||
<StyledLabel>{t`Output`}</StyledLabel>
|
||||
<StyledLabel>{t`Output tokens`}</StyledLabel>
|
||||
<StyledValue>
|
||||
{formatTokenCount(agentChatUsage.outputTokens)} •{' '}
|
||||
{t`${outputCredits} credits`}
|
||||
{formatTokenCount(agentChatUsage.outputTokens)}
|
||||
</StyledValue>
|
||||
</StyledRow>
|
||||
</StyledBody>
|
||||
|
||||
<StyledFooter>
|
||||
<StyledLabel>{t`Total credits`}</StyledLabel>
|
||||
<StyledPercentage>{totalCredits.toLocaleString()}</StyledPercentage>
|
||||
</StyledFooter>
|
||||
<StyledRow>
|
||||
<StyledLabel>{t`Total cost`}</StyledLabel>
|
||||
<StyledValue>
|
||||
{formatCredits(totalCredits)} {t`credits`}
|
||||
</StyledValue>
|
||||
</StyledRow>
|
||||
</StyledSection>
|
||||
</StyledHoverCard>
|
||||
)}
|
||||
</StyledContainer>
|
||||
|
||||
+7
-5
@@ -1,10 +1,11 @@
|
||||
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import React, { useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { IconPaperclip } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { IconPlus } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
|
||||
const StyledFileUploadContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -44,13 +45,14 @@ export const AgentChatFileUploadButton = () => {
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
<IconButton
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
Icon={IconPaperclip}
|
||||
Icon={IconPlus}
|
||||
ariaLabel={t`Attach files`}
|
||||
/>
|
||||
</StyledFileUploadContainer>
|
||||
);
|
||||
|
||||
+5
-1
@@ -13,7 +13,11 @@ const StyledSvg = styled.svg`
|
||||
|
||||
const StyledBackgroundCircle = styled.circle`
|
||||
fill: none;
|
||||
stroke: ${({ theme }) => theme.background.quaternary};
|
||||
stroke: color-mix(
|
||||
in srgb,
|
||||
${({ theme }) => theme.border.color.strong} 50%,
|
||||
${({ theme }) => theme.background.quaternary} 50%
|
||||
);
|
||||
`;
|
||||
|
||||
const StyledProgressCircle = styled.circle`
|
||||
|
||||
@@ -2,14 +2,15 @@ import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { IconArrowUp, IconPlayerStop } from 'twenty-ui/display';
|
||||
import { RoundedIconButton } from 'twenty-ui/input';
|
||||
|
||||
export const SendMessageButton = () => {
|
||||
const agentChatInput = useRecoilValue(agentChatInputState);
|
||||
const { handleSendMessage, isLoading } = useAgentChatContextOrThrow();
|
||||
const { handleSendMessage, handleStop, isLoading, isStreaming } =
|
||||
useAgentChatContextOrThrow();
|
||||
|
||||
useHotkeysOnFocusedElement({
|
||||
keys: [Key.Enter],
|
||||
@@ -26,15 +27,22 @@ export const SendMessageButton = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<RoundedIconButton
|
||||
Icon={IconPlayerStop}
|
||||
size="medium"
|
||||
onClick={() => handleStop()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
hotkeys={agentChatInput && !isLoading ? ['⏎'] : undefined}
|
||||
<RoundedIconButton
|
||||
Icon={IconArrowUp}
|
||||
size="medium"
|
||||
onClick={() => handleSendMessage()}
|
||||
disabled={!agentChatInput || isLoading}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
size="small"
|
||||
title={t`Send`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { LightButton } from 'twenty-ui/input';
|
||||
|
||||
import {
|
||||
DEFAULT_SUGGESTED_PROMPTS,
|
||||
type SuggestedPrompt,
|
||||
} from '@/ai/components/suggested-prompts/default-suggested-prompts';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
padding: ${({ theme }) => `0 ${theme.spacing(2)}`};
|
||||
`;
|
||||
|
||||
const StyledSuggestedPromptButton = styled(LightButton)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const pickRandom = <T,>(items: T[]): T =>
|
||||
items[Math.floor(Math.random() * items.length)];
|
||||
|
||||
export const AIChatSuggestedPrompts = () => {
|
||||
const { t: resolveMessage } = useLingui();
|
||||
const setAgentChatInput = useSetRecoilState(agentChatInputState);
|
||||
|
||||
const handleClick = (prompt: SuggestedPrompt) => {
|
||||
const picked = pickRandom(prompt.prefillPrompts);
|
||||
|
||||
setAgentChatInput(resolveMessage(picked));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledTitle>{t`What can I help you with?`}</StyledTitle>
|
||||
{DEFAULT_SUGGESTED_PROMPTS.map((prompt) => (
|
||||
<StyledSuggestedPromptButton
|
||||
key={prompt.id}
|
||||
Icon={prompt.Icon}
|
||||
title={resolveMessage(prompt.label)}
|
||||
accent="secondary"
|
||||
onClick={() => handleClick(prompt)}
|
||||
/>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
type IconComponent,
|
||||
IconLayoutDashboard,
|
||||
IconPlus,
|
||||
IconSettingsAutomation,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
export type SuggestedPrompt = {
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
Icon: IconComponent;
|
||||
prefillPrompts: MessageDescriptor[];
|
||||
};
|
||||
|
||||
export const DEFAULT_SUGGESTED_PROMPTS: SuggestedPrompt[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: msg`Create a dashboard`,
|
||||
Icon: IconLayoutDashboard,
|
||||
prefillPrompts: [
|
||||
msg`Create a dashboard with a chart of deal value by pipeline stage (New, Meeting, Proposal, Negotiation, Closed Won/Lost) for the current quarter, and a table of my top 10 open opportunities with amount, stage and expected close date.`,
|
||||
msg`Build a dashboard that shows: (1) total pipeline value by stage for the last 3 months, (2) count of deals won vs lost per month, (3) average deal size. Use our standard pipeline stages.`,
|
||||
msg`I need a dashboard for lead conversion: number of new leads by source this month, how many moved to opportunity, and conversion rate by source. Include a simple table and a bar chart.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
label: msg`Create a workflow`,
|
||||
Icon: IconSettingsAutomation,
|
||||
prefillPrompts: [
|
||||
msg`When a deal's stage changes to Closed Won, create a task assigned to the deal owner, due 7 days after the close date, with title "Post-sale check-in" and the company name in the description.`,
|
||||
msg`When a new lead is created with source "Website", assign it to the sales rep whose territory (by region/country) matches the lead's address; if no match, assign to the team lead.`,
|
||||
msg`When any deal with amount over $100,000 has its stage or amount updated, send a notification to the sales channel with the deal name, company, new stage, amount and owner.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'record',
|
||||
label: msg`Create a record`,
|
||||
Icon: IconPlus,
|
||||
prefillPrompts: [
|
||||
msg`Add a new company we're in touch with (e.g. name, website, industry). Details: `,
|
||||
msg`Create a new contact and link them to a company. Details: `,
|
||||
msg`Log a new deal (company, amount, stage, expected close). Details: `,
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -8,7 +8,7 @@ export type AgentChatContextValue = {
|
||||
error?: Error;
|
||||
|
||||
handleSendMessage: () => Promise<void>;
|
||||
|
||||
handleStop: () => void;
|
||||
handleRetry: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const GET_CHAT_THREADS = gql`
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
contextWindowTokens
|
||||
conversationSize
|
||||
totalInputCredits
|
||||
totalOutputCredits
|
||||
createdAt
|
||||
|
||||
@@ -80,7 +80,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { sendMessage, messages, status, error, regenerate } = useChat({
|
||||
const { sendMessage, messages, status, error, regenerate, stop } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${REST_API_BASE_URL}/agent-chat/stream`,
|
||||
headers: () => ({
|
||||
@@ -119,8 +119,10 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
|
||||
type UsageMetadata = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
inputCredits: number;
|
||||
outputCredits: number;
|
||||
conversationSize: number;
|
||||
};
|
||||
type ModelMetadata = {
|
||||
contextWindowTokens: number;
|
||||
@@ -133,11 +135,17 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
|
||||
|
||||
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,
|
||||
totalTokens:
|
||||
(prev?.totalTokens ?? 0) + usage.inputTokens + usage.outputTokens,
|
||||
contextWindowTokens: model.contextWindowTokens,
|
||||
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
|
||||
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
|
||||
}));
|
||||
@@ -177,6 +185,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
|
||||
return {
|
||||
messages,
|
||||
handleSendMessage,
|
||||
handleStop: stop,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
error,
|
||||
|
||||
@@ -23,16 +23,17 @@ const setUsageFromThread = (
|
||||
thread: AgentChatThread,
|
||||
setAgentChatUsage: SetterOrUpdater<AgentChatUsageState | null>,
|
||||
) => {
|
||||
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
|
||||
const hasUsageData = totalTokens > 0 && isDefined(thread.contextWindowTokens);
|
||||
const hasUsageData =
|
||||
(thread.conversationSize ?? 0) > 0 && isDefined(thread.contextWindowTokens);
|
||||
|
||||
setAgentChatUsage(
|
||||
hasUsageData
|
||||
? {
|
||||
lastMessage: null,
|
||||
conversationSize: thread.conversationSize ?? 0,
|
||||
contextWindowTokens: thread.contextWindowTokens ?? 0,
|
||||
inputTokens: thread.totalInputTokens,
|
||||
outputTokens: thread.totalOutputTokens,
|
||||
totalTokens,
|
||||
contextWindowTokens: thread.contextWindowTokens ?? 0,
|
||||
inputCredits: thread.totalInputCredits,
|
||||
outputCredits: thread.totalOutputCredits,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type BrowsingContext } from '@/ai/types/BrowsingContext';
|
||||
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
@@ -7,9 +11,10 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex
|
||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
export const useGetBrowsingContext = () => {
|
||||
const getBrowsingContext = useRecoilCallback(
|
||||
@@ -61,11 +66,40 @@ export const useGetBrowsingContext = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
const recordContext: BrowsingContext = {
|
||||
type: 'recordPage',
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
recordId: targetedRecordsRule.selectedRecordIds[0],
|
||||
};
|
||||
|
||||
const pageLayoutId = snapshot
|
||||
.getLoadable(
|
||||
recordStoreFamilySelector<string | null | undefined>({
|
||||
recordId: targetedRecordsRule.selectedRecordIds[0],
|
||||
fieldName: 'pageLayoutId',
|
||||
}),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
if (isDefined(pageLayoutId)) {
|
||||
const tabListInstanceId =
|
||||
getTabListInstanceIdFromPageLayoutId(pageLayoutId);
|
||||
const activeTabId = snapshot
|
||||
.getLoadable(
|
||||
activeTabIdComponentState.atomFamily({
|
||||
instanceId: tabListInstanceId,
|
||||
}),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
return {
|
||||
...recordContext,
|
||||
pageLayoutId,
|
||||
activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
return recordContext;
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user