5ca41d55fb
# Humanize tool-call labels cc: https://github.com/twentyhq/twenty/pull/21462 ## Preview <img width="459" height="156" alt="Screenshot 2026-06-22 at 19 13 11" src="https://github.com/user-attachments/assets/e7a2f5f5-cd09-4ec6-920b-5eb16b98285c" /> <img width="461" height="156" alt="Screenshot 2026-06-22 at 19 14 54" src="https://github.com/user-attachments/assets/c2114d2e-2aa8-499a-9801-68e3bb7c45f8" /> <img width="461" height="505" alt="Screenshot 2026-06-22 at 19 15 01" src="https://github.com/user-attachments/assets/ee9ca5d0-8e79-4c63-a2ff-ed5e359a9a9c" /> ## Why In the AI chat, tool steps were displayed using raw tool identifiers (`find_many_companies`, `create_one_task`, `send_email`...) and labels were partially reconstructed/humanized on the frontend. This was hard to localize and inconsistent across tool categories. This PR makes the **backend the single source of truth for human-readable, localized tool labels**, exposes them through `getToolIndex`, and reduces the frontend to a thin resolver that picks the right label for the current status (in-progress / completed). ## What changed ### Backend - `ToolIndexEntry` (and the `getToolIndex` GraphQL DTO) now carry `label`, `inProgressLabel?`, `completedLabel?`. - New `getCrudToolLabels(operation, objectLabel, i18nService, locale)` builds CRUD labels from a verb table (Search / Find / Group / Create / Update / Upsert / Delete × imperative / in-progress / completed) + the (translated, lowercased) object label. - New `translate-tool-label.util.ts` translates a source label via `I18nService` (`generateMessageId` → fallback to source when no translation exists). - Action tools: labels extracted to the `ACTION_TOOL_LABELS` constant (`msg` + `i18nLabel`) and translated in `ActionToolProvider.buildDescriptor`. - Logic-function tools use the function name as label; `toolSetToDescriptors` (workflow / view / metadata / dashboard) accepts an optional `labels` map and falls back to a humanized tool name. - Labels are localized server-side using the request locale (`@RequestLocale` → `buildToolIndex` → `context.locale`, threaded through `ToolContext` / `ToolProviderContext`). - `code_interpreter` schema now asks the model for `loadingMessage` (present tense) and `completedMessage` (past tense), so its status text is model-generated. - Removed the old generic `loadingMessage` injection mechanism (`wrap-tool-for-execution.util.ts` deleted; `wrapJsonSchemaForExecution` / `stripLoadingMessage` no longer wrap every tool). ### Frontend - New `useToolLabelMap()` hook builds a `Map<name, { label, inProgressLabel, completedLabel }>` from `getToolIndex`. - `getToolDisplayMessage` → `resolveToolDisplayMessage({ input, toolName, isFinished, labelMap, output })`: a small resolver registry keyed by tool name (`execute_tool`, `web_search`, `learn_tools`, `load_skills`, `code_interpreter`, default). - Default resolver prefers backend `completedLabel` / `inProgressLabel`, falling back to `Ran X` / `Running X`. - `learn_tools` / `load_skills` resolve their inner tool/skill names to labels (label map → tool output labels via `getToolOutputLabelEntries` → raw name). - `code_interpreter` step is now expandable to show the code even while running. ## How tool labelling flows (BE → FE) ```text BACKEND ┌───────────────────────────────────────────────────────────────────────────┐ │ Tool providers (per category) → ToolIndexEntry │ │ │ │ DatabaseToolProvider │ │ getCrudToolLabels(operation, object.labelPlural/Singular, i18n, locale) │ │ verb table (Search/Create/Update/Delete…) + translateToolLabel(object) │ │ → { label, inProgressLabel, completedLabel } │ │ │ │ ActionToolProvider │ │ ACTION_TOOL_LABELS[toolId] (msg) → translateToolLabel(…, locale) │ │ → { label, inProgressLabel?, completedLabel? } │ │ │ │ LogicFunctionToolProvider → label = logicFunction.name │ │ toolSetToDescriptors → label = labels[name] ?? humanize(name) │ │ (workflow / view / metadata / dashboard) │ └───────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────────────────────────┐ │ GraphQL Query getToolIndex : [ToolIndexEntry] │ │ { name, label, inProgressLabel, completedLabel, description, │ │ category, objectName, icon } │ └───────────────────────────────────────────────────────────────────────────┘ │ ▼ FRONTEND ─ resolve the right label for the current status ┌───────────────────────────────────────────────────────────────────────────┐ │ useGetToolIndex() → useToolLabelMap() │ │ Map<name, { label, inProgressLabel?, completedLabel? }> │ └───────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────────────────────────┐ │ resolveToolDisplayMessage({ input, toolName, isFinished, labelMap, output })│ │ │ │ TOOL_LABEL_RESOLVERS[toolName] ?? defaultResolver │ │ ├─ execute_tool → unwrap { toolName, arguments } then re-resolve │ │ ├─ web_search → "Searching/Searched the web for <query>" │ │ ├─ learn_tools → "Learning/Learned <labels>" │ │ ├─ load_skills → "Loading/Loaded <labels>" │ │ │ inner names resolved via: labelMap → output labels → raw name │ │ ├─ code_interpreter → model's loadingMessage / completedMessage │ │ └─ default → isFinished │ │ ? completedLabel ?? "Ran <label>" │ │ : inProgressLabel ?? "Running <label>" │ └───────────────────────────────────────────────────────────────────────────┘ │ ▼ Rendered by ThinkingStepsDisplay / ToolStepRenderer ``` ## Localization notes - Standard object labels and action/CRUD verbs are translated server-side via `I18nService` using the requester's locale. - Custom object labels are not translated unless a workspace custom translation exists (matched by `generateMessageId`); otherwise the source label is used as-is. ## Tests - **FE:** `resolveToolDisplayMessage` / `getToolOutputLabelEntries` (status selection, inner-name resolution, `code_interpreter` model labels, fallbacks). - **BE:** `toolSetToDescriptors` (label map + humanized fallback) and `database-tool.provider` label generation. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21976?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
import { styled } from '@linaria/react';
|
|
import { useContext, useState } from 'react';
|
|
|
|
import { IconChevronDown, IconChevronUp } from 'twenty-ui/icon';
|
|
import { JsonTree } from 'twenty-ui/json-visualizer';
|
|
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
|
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
|
|
|
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
|
|
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
|
import { useToolDisplayContext } from '@/ai/hooks/useToolDisplayContext';
|
|
import { getToolDisplayMessage } from '@/ai/utils/tool-display/get-tool-display-message';
|
|
import { unwrapToolInput } from '@/ai/utils/tool-display/unwrap-tool-input.util';
|
|
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
|
import { useLingui } from '@lingui/react/macro';
|
|
import { type DynamicToolUIPart, getToolName, type ToolUIPart } from 'ai';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { type JsonValue } from 'type-fest';
|
|
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
|
|
|
const StyledContainer = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-family: ${themeCssVariables.font.family};
|
|
gap: ${themeCssVariables.spacing[2]};
|
|
`;
|
|
|
|
const StyledContentContainer = styled.div`
|
|
background: ${themeCssVariables.background.transparent.lighter};
|
|
border: 1px solid ${themeCssVariables.border.color.light};
|
|
border-radius: ${themeCssVariables.border.radius.sm};
|
|
min-width: 0;
|
|
padding: ${themeCssVariables.spacing[3]};
|
|
`;
|
|
|
|
const StyledJsonTreeContainer = styled.div`
|
|
overflow-x: auto;
|
|
|
|
ul {
|
|
min-width: 0;
|
|
}
|
|
`;
|
|
|
|
const StyledToggleButton = styled.div<{ isExpandable: boolean }>`
|
|
align-items: center;
|
|
background: none;
|
|
border: none;
|
|
color: ${themeCssVariables.font.color.tertiary};
|
|
cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'auto')};
|
|
display: flex;
|
|
gap: ${themeCssVariables.spacing[1]};
|
|
justify-content: space-between;
|
|
padding: ${themeCssVariables.spacing[1]} 0;
|
|
transition: color calc(${themeCssVariables.animation.duration.fast} * 1s)
|
|
ease-in-out;
|
|
width: 100%;
|
|
|
|
&:hover {
|
|
color: ${themeCssVariables.font.color.primary};
|
|
}
|
|
`;
|
|
|
|
const StyledToolName = styled.span`
|
|
background: ${themeCssVariables.background.transparent.light};
|
|
border-radius: ${themeCssVariables.border.radius.xs};
|
|
color: ${themeCssVariables.font.color.light};
|
|
font-family: ${themeCssVariables.font.family};
|
|
font-size: ${themeCssVariables.font.size.xs};
|
|
padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]};
|
|
`;
|
|
|
|
const StyledLeftContent = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
gap: ${themeCssVariables.spacing[1]};
|
|
`;
|
|
|
|
const StyledRightContent = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
gap: ${themeCssVariables.spacing[2]};
|
|
`;
|
|
|
|
const StyledDisplayMessage = styled.span`
|
|
color: ${themeCssVariables.font.color.tertiary};
|
|
font-size: ${themeCssVariables.font.size.md};
|
|
font-weight: ${themeCssVariables.font.weight.medium};
|
|
`;
|
|
|
|
const StyledIconTextContainer = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
gap: ${themeCssVariables.spacing[1]};
|
|
|
|
svg {
|
|
min-width: calc(${themeCssVariables.icon.size.sm} * 1px);
|
|
}
|
|
`;
|
|
|
|
const StyledTabContainer = styled.div`
|
|
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
|
display: flex;
|
|
gap: ${themeCssVariables.spacing[3]};
|
|
margin-bottom: ${themeCssVariables.spacing[3]};
|
|
`;
|
|
|
|
const StyledTab = styled.div<{ isActive: boolean }>`
|
|
color: ${({ isActive }) =>
|
|
isActive
|
|
? themeCssVariables.font.color.primary
|
|
: themeCssVariables.font.color.tertiary};
|
|
cursor: pointer;
|
|
font-size: ${themeCssVariables.font.size.sm};
|
|
font-weight: ${({ isActive }) =>
|
|
isActive
|
|
? themeCssVariables.font.weight.medium
|
|
: themeCssVariables.font.weight.regular};
|
|
padding-bottom: ${themeCssVariables.spacing[2]};
|
|
transition: color calc(${themeCssVariables.animation.duration.fast} * 1s)
|
|
ease-in-out;
|
|
|
|
&:hover {
|
|
color: ${themeCssVariables.font.color.primary};
|
|
}
|
|
`;
|
|
|
|
type TabType = 'output' | 'input';
|
|
|
|
export const ToolStepRenderer = ({
|
|
toolPart,
|
|
isStreaming,
|
|
}: {
|
|
toolPart: ToolUIPart | DynamicToolUIPart;
|
|
isStreaming: boolean;
|
|
}) => {
|
|
const { theme } = useContext(ThemeContext);
|
|
const { t } = useLingui();
|
|
const { copyToClipboard } = useCopyToClipboard();
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<TabType>('output');
|
|
|
|
const { input, output, errorText } = toolPart;
|
|
const rawToolName = getToolName(toolPart);
|
|
|
|
const { toolInput, toolName } = unwrapToolInput({
|
|
input,
|
|
toolName: rawToolName,
|
|
});
|
|
|
|
const displayContext = useToolDisplayContext();
|
|
const hasError = isDefined(errorText);
|
|
const isCodeInterpreter = toolName === 'code_interpreter';
|
|
const isExpandable = isDefined(output) || hasError || isCodeInterpreter;
|
|
const ToolIcon = getToolIcon(toolName);
|
|
|
|
const outputObj =
|
|
typeof output === 'object' && output !== null
|
|
? (output as Record<string, unknown>)
|
|
: null;
|
|
const toolMessage =
|
|
typeof outputObj?.message === 'string' ? outputObj.message : null;
|
|
const toolError =
|
|
typeof outputObj?.error === 'string' ? outputObj.error : null;
|
|
|
|
const codeInput = isCodeInterpreter
|
|
? (toolInput as { code?: string } | undefined)
|
|
: null;
|
|
const codeOutput = isCodeInterpreter
|
|
? (outputObj as {
|
|
stdout?: string;
|
|
stderr?: string;
|
|
exitCode?: number;
|
|
files?: Array<{
|
|
fileId: string;
|
|
filename: string;
|
|
url: string;
|
|
mimeType?: string;
|
|
}>;
|
|
} | null)
|
|
: null;
|
|
|
|
if (!output && !hasError) {
|
|
const displayText = getToolDisplayMessage({
|
|
input,
|
|
toolName: rawToolName,
|
|
isFinished: !isStreaming,
|
|
displayContext,
|
|
output,
|
|
});
|
|
|
|
return (
|
|
<StyledContainer>
|
|
<StyledToggleButton
|
|
isExpandable={isCodeInterpreter}
|
|
onClick={
|
|
isCodeInterpreter ? () => setIsExpanded(!isExpanded) : undefined
|
|
}
|
|
>
|
|
<StyledLeftContent>
|
|
<StyledIconTextContainer>
|
|
<ToolIcon size={theme.icon.size.sm} />
|
|
{isStreaming ? (
|
|
<ShimmeringText>
|
|
<StyledDisplayMessage>{displayText}</StyledDisplayMessage>
|
|
</ShimmeringText>
|
|
) : (
|
|
<StyledDisplayMessage>{displayText}</StyledDisplayMessage>
|
|
)}
|
|
</StyledIconTextContainer>
|
|
</StyledLeftContent>
|
|
<StyledRightContent>
|
|
<StyledToolName>{toolName}</StyledToolName>
|
|
{isCodeInterpreter &&
|
|
(isExpanded ? (
|
|
<IconChevronUp size={theme.icon.size.sm} />
|
|
) : (
|
|
<IconChevronDown size={theme.icon.size.sm} />
|
|
))}
|
|
</StyledRightContent>
|
|
</StyledToggleButton>
|
|
{isCodeInterpreter && (
|
|
<AnimatedExpandableContainer
|
|
isExpanded={isExpanded}
|
|
mode="fit-content"
|
|
>
|
|
<CodeExecutionDisplay
|
|
code={codeInput?.code ?? ''}
|
|
stdout=""
|
|
stderr=""
|
|
isRunning={isStreaming}
|
|
/>
|
|
</AnimatedExpandableContainer>
|
|
)}
|
|
</StyledContainer>
|
|
);
|
|
}
|
|
|
|
const displayMessage = hasError
|
|
? t`Tool execution failed`
|
|
: rawToolName === 'learn_tools' ||
|
|
rawToolName === 'execute_tool' ||
|
|
rawToolName === 'load_skills'
|
|
? getToolDisplayMessage({
|
|
input,
|
|
toolName: rawToolName,
|
|
isFinished: true,
|
|
displayContext,
|
|
output,
|
|
})
|
|
: (toolMessage ??
|
|
getToolDisplayMessage({
|
|
input,
|
|
toolName: rawToolName,
|
|
isFinished: true,
|
|
displayContext,
|
|
output,
|
|
}));
|
|
|
|
const result = toolError ? { error: toolError } : outputObj;
|
|
|
|
const renderExpandedContent = () => {
|
|
if (isCodeInterpreter) {
|
|
return (
|
|
<CodeExecutionDisplay
|
|
code={codeInput?.code ?? ''}
|
|
stdout={codeOutput?.stdout ?? ''}
|
|
stderr={codeOutput?.stderr || errorText || ''}
|
|
exitCode={codeOutput?.exitCode}
|
|
files={codeOutput?.files}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (hasError) {
|
|
return errorText;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<StyledTabContainer>
|
|
<StyledTab
|
|
isActive={activeTab === 'output'}
|
|
onClick={() => setActiveTab('output')}
|
|
>
|
|
{t`Output`}
|
|
</StyledTab>
|
|
<StyledTab
|
|
isActive={activeTab === 'input'}
|
|
onClick={() => setActiveTab('input')}
|
|
>
|
|
{t`Input`}
|
|
</StyledTab>
|
|
</StyledTabContainer>
|
|
|
|
<StyledJsonTreeContainer>
|
|
<JsonTree
|
|
value={(activeTab === 'output' ? result : toolInput) as JsonValue}
|
|
shouldExpandNodeInitially={() => false}
|
|
emptyArrayLabel={t`Empty Array`}
|
|
emptyObjectLabel={t`Empty Object`}
|
|
emptyStringLabel={t`[empty string]`}
|
|
arrowButtonCollapsedLabel={t`Expand`}
|
|
arrowButtonExpandedLabel={t`Collapse`}
|
|
onNodeValueClick={copyToClipboard}
|
|
/>
|
|
</StyledJsonTreeContainer>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<StyledContainer>
|
|
<StyledToggleButton
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
isExpandable={isExpandable}
|
|
>
|
|
<StyledLeftContent>
|
|
<StyledIconTextContainer>
|
|
<ToolIcon size={theme.icon.size.sm} />
|
|
<StyledDisplayMessage>{displayMessage}</StyledDisplayMessage>
|
|
</StyledIconTextContainer>
|
|
</StyledLeftContent>
|
|
<StyledRightContent>
|
|
<StyledToolName>{toolName}</StyledToolName>
|
|
{isExpandable &&
|
|
(isExpanded ? (
|
|
<IconChevronUp size={theme.icon.size.sm} />
|
|
) : (
|
|
<IconChevronDown size={theme.icon.size.sm} />
|
|
))}
|
|
</StyledRightContent>
|
|
</StyledToggleButton>
|
|
|
|
{isExpandable && (
|
|
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
|
{isCodeInterpreter ? (
|
|
renderExpandedContent()
|
|
) : (
|
|
<StyledContentContainer>
|
|
{renderExpandedContent()}
|
|
</StyledContentContainer>
|
|
)}
|
|
</AnimatedExpandableContainer>
|
|
)}
|
|
</StyledContainer>
|
|
);
|
|
};
|