Files
twenty/packages/twenty-front/src/modules/ai/components/TerminalOutput.tsx
T
Félix Malfait 2e104c8e76 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 -->
2025-12-15 16:11:24 +01:00

175 lines
4.9 KiB
TypeScript

import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { IconCopy, IconTerminal } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
border-radius: ${({ theme }) => theme.border.radius.sm};
overflow: hidden;
`;
const StyledHeader = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.tertiary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderLeft = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledTabContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledTab = styled.button<{ isActive: boolean; hasError?: boolean }>`
background: ${({ isActive, theme }) =>
isActive ? theme.background.secondary : 'transparent'};
border: none;
border-radius: ${({ theme }) => theme.border.radius.xs};
color: ${({ isActive, hasError, theme }) =>
hasError
? theme.color.red
: isActive
? theme.font.color.primary
: theme.font.color.tertiary};
cursor: pointer;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ isActive, theme }) =>
isActive ? theme.font.weight.medium : theme.font.weight.regular};
padding: ${({ theme }) => theme.spacing(0.5)}
${({ theme }) => theme.spacing(1)};
&:hover {
background: ${({ theme }) => theme.background.secondary};
color: ${({ hasError, theme }) =>
hasError ? theme.color.red : theme.font.color.primary};
}
`;
const StyledOutputArea = styled.div<{ isError?: boolean }>`
background: ${({ theme }) => theme.background.tertiary};
color: ${({ isError, theme }) =>
isError ? theme.color.red : theme.font.color.primary};
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-size: ${({ theme }) => theme.font.size.sm};
line-height: 1.5;
max-height: 300px;
min-height: 100px;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(2)};
white-space: pre-wrap;
word-break: break-word;
`;
const StyledEmptyMessage = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-style: italic;
`;
const StyledCursor = styled.span`
animation: blink 1s step-end infinite;
background: ${({ theme }) => theme.font.color.primary};
display: inline-block;
height: 1em;
margin-left: 2px;
vertical-align: text-bottom;
width: 8px;
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
`;
type TabType = 'stdout' | 'stderr';
type TerminalOutputProps = {
stdout: string;
stderr: string;
isRunning?: boolean;
};
export const TerminalOutput = ({
stdout,
stderr,
isRunning = false,
}: TerminalOutputProps) => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const hasStderr = stderr.length > 0;
const hasStdout = stdout.length > 0;
const defaultTab: TabType = hasStderr && !hasStdout ? 'stderr' : 'stdout';
const [userSelectedTab, setUserSelectedTab] = useState<TabType | null>(null);
const activeTab = userSelectedTab ?? defaultTab;
const currentOutput = activeTab === 'stdout' ? stdout : stderr;
return (
<StyledContainer>
<StyledHeader>
<StyledHeaderLeft>
<IconTerminal size={14} />
{t`Output`}
</StyledHeaderLeft>
<StyledTabContainer>
<StyledTab
isActive={activeTab === 'stdout'}
onClick={() => setUserSelectedTab('stdout')}
>
stdout
</StyledTab>
{hasStderr && (
<StyledTab
isActive={activeTab === 'stderr'}
hasError
onClick={() => setUserSelectedTab('stderr')}
>
stderr
</StyledTab>
)}
<LightIconButton
Icon={IconCopy}
onClick={() => copyToClipboard(currentOutput)}
title={t`Copy output`}
size="small"
accent="tertiary"
/>
</StyledTabContainer>
</StyledHeader>
<StyledOutputArea isError={activeTab === 'stderr'}>
{currentOutput ? (
<>
{currentOutput}
{isRunning && <StyledCursor />}
</>
) : (
<StyledEmptyMessage>
{isRunning ? t`Waiting for output...` : t`No output`}
</StyledEmptyMessage>
)}
</StyledOutputArea>
</StyledContainer>
);
};