feat(ai): add code interpreter for AI data analysis (#16559)
## Summary - Add code interpreter tool that enables AI to execute Python code for data analysis, CSV processing, and chart generation - Support for both local (development) and E2B (sandboxed production) execution drivers - Real-time streaming of stdout/stderr and generated files - Frontend components for displaying code execution results with expandable sections ## Code Quality Improvements - Extract `getMimeType` to shared utility to reduce code duplication between drivers - Fix security issue: escape single quotes/backslashes in E2B driver env variable injection - Add `buildExecutionState` helper to reduce duplicated state object construction - Add `DEFAULT_CODE_INTERPRETER_TIMEOUT_MS` constant for consistency - Fix lingui linting warning and TypeScript theme errors in frontend ## Test Plan - [ ] Test code interpreter with local driver in development - [ ] Test code interpreter with E2B driver in production environment - [ ] Verify streaming output displays correctly in chat UI - [ ] Verify generated files (charts, CSVs) are uploaded and downloadable - [ ] Test file upload flow (CSV, Excel) triggers code interpreter <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Updates generated i18n catalogs for Polish and pseudo-English, adding strings for code execution/output (code interpreter) and various UI messages, with minor text adjustments. > > - **Localization**: > - **Generated catalogs**: Refresh `locales/generated/pl-PL.ts` and `locales/generated/pseudo-en.ts`. > - Add strings for code execution/output (e.g., code, copy code/output, running/waiting states, download files, generated files, Python code execution). > - Include new UI texts (errors, prompts, menus) and minor text corrections. > - No changes to `pt-BR`; other files unchanged functionally. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit befc13d02c21e5a6647bc1aa6daa2a89f60b7ef8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
import { TerminalOutput } from '@/ai/components/TerminalOutput';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconFile,
|
||||
IconPlayerPlay,
|
||||
IconSquareRoundedCheck,
|
||||
IconSquareRoundedX,
|
||||
} from 'twenty-ui/display';
|
||||
import { CodeEditor, LightIconButton } from 'twenty-ui/input';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: ${({ theme }) => theme.spacing(2)} 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.div<{ status: 'success' | 'error' | 'running' }>`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: space-between;
|
||||
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledHeaderLeft = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledHeaderRight = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledStatusBadge = styled.div<{
|
||||
status: 'success' | 'error' | 'running';
|
||||
}>`
|
||||
align-items: center;
|
||||
background: ${({ status, theme }) =>
|
||||
status === 'success'
|
||||
? theme.background.transparent.success
|
||||
: status === 'error'
|
||||
? theme.background.transparent.danger
|
||||
: theme.background.transparent.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.pill};
|
||||
color: ${({ status, theme }) =>
|
||||
status === 'success'
|
||||
? theme.color.turquoise
|
||||
: status === 'error'
|
||||
? theme.color.red
|
||||
: theme.font.color.secondary};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: ${({ theme }) => theme.spacing(0.5)}
|
||||
${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledSection = styled.div`
|
||||
border-top: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
`;
|
||||
|
||||
const StyledSectionHeader = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: space-between;
|
||||
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
|
||||
transition: background ${({ theme }) => theme.animation.duration.fast}s;
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.tertiary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSectionHeaderLeft = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledCodeEditorContainer = styled.div`
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledFilesGrid = styled.div`
|
||||
display: grid;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledFileCard = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledFilePreview = styled.div`
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: ${({ theme }) => theme.background.tertiary};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledPreviewImage = styled.img`
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledFileInfo = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: space-between;
|
||||
padding: ${({ theme }) => theme.spacing(1.5)}
|
||||
${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledFileName = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledDownloadLink = styled.a`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
type CodeExecutionDisplayProps = {
|
||||
code: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode?: number;
|
||||
files?: Array<{
|
||||
filename: string;
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
isRunning?: boolean;
|
||||
};
|
||||
|
||||
const isPreviewableMimeType = (mimeType?: string): boolean => {
|
||||
if (!mimeType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(
|
||||
mimeType,
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeExecutionDisplay = ({
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode,
|
||||
files = [],
|
||||
isRunning = false,
|
||||
}: CodeExecutionDisplayProps) => {
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const [isCodeExpanded, setIsCodeExpanded] = useState(false);
|
||||
const [isOutputExpanded, setIsOutputExpanded] = useState(true);
|
||||
const [isFilesExpanded, setIsFilesExpanded] = useState(true);
|
||||
|
||||
const status: 'success' | 'error' | 'running' = isRunning
|
||||
? 'running'
|
||||
: exitCode === 0
|
||||
? 'success'
|
||||
: 'error';
|
||||
|
||||
const StatusIcon =
|
||||
status === 'success'
|
||||
? IconSquareRoundedCheck
|
||||
: status === 'error'
|
||||
? IconSquareRoundedX
|
||||
: IconPlayerPlay;
|
||||
|
||||
const statusText = isRunning
|
||||
? t`Running...`
|
||||
: exitCode === 0
|
||||
? t`Completed`
|
||||
: t`Failed`;
|
||||
|
||||
const hasOutput = stdout || stderr;
|
||||
const hasFiles = files.length > 0;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledHeader status={status}>
|
||||
<StyledHeaderLeft>
|
||||
<IconCode size={theme.icon.size.md} />
|
||||
<StyledTitle>{t`Python Code Execution`}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledHeaderRight>
|
||||
<StyledStatusBadge status={status}>
|
||||
<StatusIcon size={theme.icon.size.sm} />
|
||||
{statusText}
|
||||
</StyledStatusBadge>
|
||||
</StyledHeaderRight>
|
||||
</StyledHeader>
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionHeader onClick={() => setIsCodeExpanded(!isCodeExpanded)}>
|
||||
<StyledSectionHeaderLeft>
|
||||
<IconCode size={theme.icon.size.sm} />
|
||||
{t`Code`}
|
||||
</StyledSectionHeaderLeft>
|
||||
<StyledHeaderRight>
|
||||
<LightIconButton
|
||||
Icon={IconCopy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(code);
|
||||
}}
|
||||
title={t`Copy code`}
|
||||
size="small"
|
||||
accent="tertiary"
|
||||
/>
|
||||
{isCodeExpanded ? (
|
||||
<IconChevronUp size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
)}
|
||||
</StyledHeaderRight>
|
||||
</StyledSectionHeader>
|
||||
<AnimatedExpandableContainer
|
||||
isExpanded={isCodeExpanded}
|
||||
mode="fit-content"
|
||||
>
|
||||
<StyledCodeEditorContainer>
|
||||
<CodeEditor
|
||||
value={code}
|
||||
language="python"
|
||||
height="300px"
|
||||
options={{
|
||||
readOnly: true,
|
||||
domReadOnly: true,
|
||||
minimap: { enabled: false },
|
||||
}}
|
||||
/>
|
||||
</StyledCodeEditorContainer>
|
||||
</AnimatedExpandableContainer>
|
||||
</StyledSection>
|
||||
|
||||
{(hasOutput || isRunning) && (
|
||||
<StyledSection>
|
||||
<StyledSectionHeader
|
||||
onClick={() => setIsOutputExpanded(!isOutputExpanded)}
|
||||
>
|
||||
<StyledSectionHeaderLeft>{t`Output`}</StyledSectionHeaderLeft>
|
||||
{isOutputExpanded ? (
|
||||
<IconChevronUp size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
)}
|
||||
</StyledSectionHeader>
|
||||
<AnimatedExpandableContainer
|
||||
isExpanded={isOutputExpanded}
|
||||
mode="fit-content"
|
||||
>
|
||||
<TerminalOutput
|
||||
stdout={stdout}
|
||||
stderr={stderr}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
</AnimatedExpandableContainer>
|
||||
</StyledSection>
|
||||
)}
|
||||
|
||||
{hasFiles && (
|
||||
<StyledSection>
|
||||
<StyledSectionHeader
|
||||
onClick={() => setIsFilesExpanded(!isFilesExpanded)}
|
||||
>
|
||||
<StyledSectionHeaderLeft>
|
||||
<IconFile size={theme.icon.size.sm} />
|
||||
{t`Generated Files`} ({files.length})
|
||||
</StyledSectionHeaderLeft>
|
||||
{isFilesExpanded ? (
|
||||
<IconChevronUp size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
)}
|
||||
</StyledSectionHeader>
|
||||
<AnimatedExpandableContainer
|
||||
isExpanded={isFilesExpanded}
|
||||
mode="fit-content"
|
||||
>
|
||||
<StyledFilesGrid>
|
||||
{files.map((file) => {
|
||||
const filename = file.filename;
|
||||
|
||||
return (
|
||||
<StyledFileCard key={file.url}>
|
||||
<StyledFilePreview>
|
||||
{isPreviewableMimeType(file.mimeType) ? (
|
||||
<StyledPreviewImage
|
||||
src={file.url}
|
||||
alt={filename}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<IconFile size={48} color={theme.font.color.tertiary} />
|
||||
)}
|
||||
</StyledFilePreview>
|
||||
<StyledFileInfo>
|
||||
<StyledFileName title={filename}>
|
||||
{filename}
|
||||
</StyledFileName>
|
||||
<StyledDownloadLink
|
||||
href={file.url}
|
||||
download={filename}
|
||||
title={t`Download ${filename}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconDownload size={theme.icon.size.sm} />
|
||||
</StyledDownloadLink>
|
||||
</StyledFileInfo>
|
||||
</StyledFileCard>
|
||||
);
|
||||
})}
|
||||
</StyledFilesGrid>
|
||||
</AnimatedExpandableContainer>
|
||||
</StyledSection>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user