feat(ai): add ask_questions interactive clarifying-question tool (#22346)
## What & why Adds an `ask_questions` tool that lets the in-app **Ask AI** assistant **pause a turn to ask the user one or more multiple-choice questions** (per the [Figma design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=105959-117153)) and resume once answered — instead of guessing on ambiguous/consequential decisions. The tool is **harness-only**: an interactive question UI is meaningless without a user to answer it, so it must be absent from MCP and from head-less workflow agents. ## Design — true tool-result resume (not a synthetic user message) The user's answer is a **structured tool result bound to the `toolCallId`**, and the **same agent turn resumes** — exactly how Anthropic (`tool_result` by `tool_use_id`) and OpenAI (`function_call_output`) model human-in-the-loop. The naive form of this (leave the tool call in `input-available` to mean "pending") is **impossible** here: `finalizeDanglingToolParts` rewrites `input-available` → `output-error` ("Tool execution was interrupted") on both the persist path (`addMessage`) and the model-reload path (`chat-execution.service.ts`). That util is a load-bearing safety net, so weakening it is the wrong move. Instead: - `ask_questions` is an **inline, chat-only tool with an `execute` that returns a `status: 'pending'` result immediately**, so the tool part is always `output-available` and **immune to `finalizeDanglingToolParts`**. `stopWhen(hasToolCall('ask_questions'))` halts the turn right after the call (the model never sees the placeholder). - A nullable **`thread.pendingQuestionMessageId`** marker records that a turn is awaiting an answer. - The new **`answerAgentChatQuestion`** mutation atomically *claims* the question (clears the marker, marks the thread streaming), **writes the answer onto the same tool part** (`status: 'answered'`), and **re-enqueues the turn via the existing `existingTurnId` plumbing** (`isResume` bypasses the per-turn dedup guard). On resume `finalizeDanglingToolParts` leaves the `output-available` part untouched and `convertToModelMessages` emits `assistant(tool_use)` + `tool_result(answers)`, so the model continues. This achieves the platform-aligned semantics **without** weakening the finalize safety net or inventing a fragile new part state. ### Meets the two requirements - **Survives refresh, scoped per-thread** — the pending state is a normal persisted `output-available` part + the thread marker; the frontend card is derived per-thread from the loaded messages, so it re-appears on reload and only on its own thread. - **Takes priority over the queue** — a unified `isBlocked = activeStreamId || pendingQuestionMessageId` gate is applied in both `sendChatMessage` (new messages queue) and `flushNextQueuedMessage` (the drain). The queue cannot unpile until the question is answered and the resumed turn completes. ### Harness-only by construction `ask_questions` is added **only** to the chat's inline `activeTools` (like `learn_tools`/`execute_tool`/`load_skills`). It never enters the tool registry/catalog, so it is invisible to MCP and to workflow agents — no `MCP_EXCLUDED_TOOL_NAMES` entry needed. ## UX While a question is pending, the **composer is replaced by the question card** (matching the Figma): question title + pager (`1/2`), numbered option rows (`IconSquareNumber*`) with per-option info-icon descriptions and a "Recommended" badge, and the normal composer as the free-text fallback ("Type anything to do differently."). The transcript shows a compact "Asking questions…" status line that becomes an answered summary. ## Changes **twenty-shared** - `ai/types/AskQuestionsToolTypes.ts` — `AskQuestionItem/Option/Answer/Result`, `ASK_QUESTIONS_TOOL_NAME`. **twenty-server** - `ai-chat/tools/ask-questions.tool.ts` — inline tool factory (pending-result `execute`, zod schema, 1–4 questions × 2–4 options). - `chat-execution.service.ts` — add to `activeTools` + `preloadedToolNames`; `hasToolCall` in `stopWhen`. - `chat-system-prompts.const.ts` — when-to-use guidance. - `entities/agent-chat-thread.entity.ts` — `pendingQuestionMessageId` column. - `stream-agent-chat.job.ts` — set the marker on a question pause; bypass the dedup guard on resume; suppress the no-text warning for question pauses. - `agent-chat-streaming.service.ts` — gate `flushNextQueuedMessage`; `enqueueResumeStream`. - `agent-chat.resolver.ts` — gate `sendChatMessage`; `answerAgentChatQuestion` mutation. - `agent-chat.service.ts` — `resolvePendingQuestion` (atomic claim + write answer). - `dtos/agent-chat-question-answer.input.ts`, `ai.exception.ts` (`QUESTION_NOT_PENDING`), `utils/find-pending-question-part.util.ts`. **twenty-front** - `components/AiChatQuestionCard.tsx` — the interactive card (matches Figma tokens) + `__stories__/AiChatQuestionCard.stories.tsx`. - `components/AiChatEditorSection.tsx` — swap the composer for the card while pending. - `components/AiChatQuestionStatusRenderer.tsx` + branch in `AiChatAssistantMessageRenderer.tsx`. - `states/selectors/agentChatPendingQuestionComponentSelector.ts`, `types/AgentChatPendingQuestion.ts`. - `hooks/useSubmitQuestionAnswer.ts` + `utils/markQuestionAnswered.ts` (optimistic) + `graphql/mutations/answerAgentChatQuestion.ts`. A design doc lives at `packages/twenty-server/docs/ASK_USER_QUESTION_TOOL_PLAN.md`. ## Migration Adds a nullable `pendingQuestionMessageId` (uuid) column to `core.agentChatThread`. Needs a generated **fast instance command** (`database:migrate:generate --name addThreadPendingQuestion --type fast`) — see "Verification status". ## Tests - Server: `ask-questions.tool.spec.ts` (pending echo + schema bounds), `find-pending-question-part.util.spec.ts`. - Front: `markQuestionAnswered.test.ts`, plus the Storybook story. ## Verification status (please read) This branch was authored in an environment where the monorepo `yarn install` repeatedly failed on transient TLS resets from the package registry, so I could **not** locally run the mechanical gates. The logic was reviewed by hand and the `ai@6.0.97` exports used (`hasToolCall`, `stepCountIs`, `generateId`) were confirmed against the package's type defs. Still **TODO** (will rely on CI / a follow-up once deps install): - [ ] `nx run twenty-shared:generateBarrels` (the `ai/index.ts` export was added by hand; regen to reconcile) - [ ] `nx run twenty-front:graphql:generate` (new mutation + input type) - [ ] generate the fast instance command (migration) for the new column - [ ] `typecheck` + `lint:diff-with-main` (front + server) — expect minor import-ordering autofixes - [ ] run the unit tests **Screenshots:** reproducing the live flow needs an AI provider API key (to get the model to actually call `ask_questions`), which isn't available here. The card can be screenshotted from its **Storybook story** (`AiChatQuestionCard.stories.tsx`) with no API key — I'll add that image once deps install, or a reviewer can run `nx storybook twenty-front`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB --- _Generated by [Claude Code](https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22346?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. -->
This commit is contained in:
@@ -4,13 +4,17 @@ import { RoutingStatusDisplay } from '@/ai/components/RoutingStatusDisplay';
|
||||
import { ThinkingStepsDisplay } from '@/ai/components/ThinkingStepsDisplay';
|
||||
import { IconDotsVertical } from 'twenty-ui/icon';
|
||||
|
||||
import { AiChatQuestionStatusRenderer } from '@/ai/components/AiChatQuestionStatusRenderer';
|
||||
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
|
||||
import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
|
||||
import { groupContiguousThinkingStepParts } from '@/ai/utils/groupContiguousThinkingStepParts';
|
||||
import { isCodeInterpreterToolPart } from '@/ai/utils/isCodeInterpreterToolPart';
|
||||
import { styled } from '@linaria/react';
|
||||
import { isToolUIPart } from 'ai';
|
||||
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
|
||||
import { getToolName, isToolUIPart } from 'ai';
|
||||
import {
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
type ExtendedUIMessagePart,
|
||||
} from 'twenty-shared/ai';
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
@@ -76,6 +80,15 @@ const MessagePartRenderer = ({
|
||||
);
|
||||
default:
|
||||
if (isToolUIPart(part)) {
|
||||
if (getToolName(part) === ASK_QUESTIONS_TOOL_NAME) {
|
||||
return (
|
||||
<AiChatQuestionStatusRenderer
|
||||
toolPart={part}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ToolStepRenderer toolPart={part} isStreaming={isStreaming} />;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -3,8 +3,11 @@ import { EditorContent } from '@tiptap/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AiChatBanner } from '@/ai/components/AiChatBanner';
|
||||
import { AiChatEmptyState } from '@/ai/components/AiChatEmptyState';
|
||||
import { AiChatQuestionCard } from '@/ai/components/AiChatQuestionCard';
|
||||
import { AIChatNoMoreBillingCreditsBanner } from '@/ai/components/AIChatNoMoreBillingCreditsBanner';
|
||||
import { AiChatStandaloneError } from '@/ai/components/AiChatStandaloneError';
|
||||
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
|
||||
@@ -18,8 +21,10 @@ import { useAiChatEditor } from '@/ai/hooks/useAiChatEditor';
|
||||
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
|
||||
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
|
||||
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
|
||||
import { agentChatPendingQuestionComponentSelector } from '@/ai/states/selectors/agentChatPendingQuestionComponentSelector';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { hasReachedCurrentBillingPeriodCapSelector } from '@/workspace/states/hasReachedCurrentBillingPeriodCapSelector';
|
||||
@@ -137,6 +142,10 @@ export const AiChatEditorSection = () => {
|
||||
|
||||
const { editor, handleSendAndClear } = useAiChatEditor();
|
||||
|
||||
const pendingQuestion = useAtomComponentSelectorValue(
|
||||
agentChatPendingQuestionComponentSelector,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AiChatEditorFocusEffect editor={editor} />
|
||||
@@ -155,35 +164,39 @@ export const AiChatEditorSection = () => {
|
||||
{hasReachedCurrentBillingPeriodCap && (
|
||||
<AIChatNoMoreBillingCreditsBanner />
|
||||
)}
|
||||
<StyledInputBox>
|
||||
<StyledEditorWrapper>
|
||||
<EditorContent editor={editor} />
|
||||
</StyledEditorWrapper>
|
||||
<StyledButtonsContainer>
|
||||
<StyledLeftButtonsContainer>
|
||||
<AgentChatFileUploadButton />
|
||||
<AiChatContextUsageButton />
|
||||
</StyledLeftButtonsContainer>
|
||||
<StyledRightButtonsContainer>
|
||||
<Select
|
||||
dropdownId="ai-chat-smart-model-select"
|
||||
value={selectedModelId}
|
||||
onChange={setAgentChatUserSelectedModel}
|
||||
options={smartModelOptions}
|
||||
pinnedOption={defaultPinnedOption}
|
||||
disabled={hasNoEnabledModels}
|
||||
selectSizeVariant="small"
|
||||
showContextualTextInControl={false}
|
||||
withSearchInput
|
||||
dropdownOffset={{ x: 0, y: 8 }}
|
||||
/>
|
||||
<SendMessageButton
|
||||
onSend={handleSendAndClear}
|
||||
isDisabled={hasNoEnabledModels}
|
||||
/>
|
||||
</StyledRightButtonsContainer>
|
||||
</StyledButtonsContainer>
|
||||
</StyledInputBox>
|
||||
{isDefined(pendingQuestion) ? (
|
||||
<AiChatQuestionCard pendingQuestion={pendingQuestion} />
|
||||
) : (
|
||||
<StyledInputBox>
|
||||
<StyledEditorWrapper>
|
||||
<EditorContent editor={editor} />
|
||||
</StyledEditorWrapper>
|
||||
<StyledButtonsContainer>
|
||||
<StyledLeftButtonsContainer>
|
||||
<AgentChatFileUploadButton />
|
||||
<AiChatContextUsageButton />
|
||||
</StyledLeftButtonsContainer>
|
||||
<StyledRightButtonsContainer>
|
||||
<Select
|
||||
dropdownId="ai-chat-smart-model-select"
|
||||
value={selectedModelId}
|
||||
onChange={setAgentChatUserSelectedModel}
|
||||
options={smartModelOptions}
|
||||
pinnedOption={defaultPinnedOption}
|
||||
disabled={hasNoEnabledModels}
|
||||
selectSizeVariant="small"
|
||||
showContextualTextInControl={false}
|
||||
withSearchInput
|
||||
dropdownOffset={{ x: 0, y: 8 }}
|
||||
/>
|
||||
<SendMessageButton
|
||||
onSend={handleSendAndClear}
|
||||
isDisabled={hasNoEnabledModels}
|
||||
/>
|
||||
</StyledRightButtonsContainer>
|
||||
</StyledButtonsContainer>
|
||||
</StyledInputBox>
|
||||
)}
|
||||
</StyledInputArea>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type KeyboardEvent, useContext, useMemo, useState } from 'react';
|
||||
import { type AskQuestionAnswer, type AskQuestionItem } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { AppTooltip, TooltipDelay } from 'twenty-ui/surfaces';
|
||||
import {
|
||||
IconArrowUp,
|
||||
IconChevronLeft,
|
||||
IconChevronRightPipe,
|
||||
IconInfoCircle,
|
||||
type IconComponent,
|
||||
IconSquareNumber1,
|
||||
IconSquareNumber2,
|
||||
IconSquareNumber3,
|
||||
IconSquareNumber4,
|
||||
IconSquareNumber5,
|
||||
IconSquareNumber6,
|
||||
IconSquareNumber7,
|
||||
IconSquareNumber8,
|
||||
IconSquareNumber9,
|
||||
} from 'twenty-ui/icon';
|
||||
import {
|
||||
LightIconButton,
|
||||
RoundedIconButton,
|
||||
type SelectOption,
|
||||
} from 'twenty-ui/input';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
|
||||
import { AiChatContextUsageButton } from '@/ai/components/internal/AiChatContextUsageButton';
|
||||
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
|
||||
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
|
||||
import { useSubmitQuestionAnswer } from '@/ai/hooks/useSubmitQuestionAnswer';
|
||||
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
|
||||
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
|
||||
import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
const NUMBER_ICONS: IconComponent[] = [
|
||||
IconSquareNumber1,
|
||||
IconSquareNumber2,
|
||||
IconSquareNumber3,
|
||||
IconSquareNumber4,
|
||||
IconSquareNumber5,
|
||||
IconSquareNumber6,
|
||||
IconSquareNumber7,
|
||||
IconSquareNumber8,
|
||||
IconSquareNumber9,
|
||||
];
|
||||
|
||||
const StyledCard = styled.div`
|
||||
background-color: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledQuestionSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
padding: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledQuestionHeaderRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
justify-content: space-between;
|
||||
min-height: 24px;
|
||||
padding-left: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledQuestionText = styled.p`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
flex: 1 0 0;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
const StyledPager = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledPagerLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledOptionsList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing['0.5']};
|
||||
`;
|
||||
|
||||
const StyledOptionRow = styled.div<{ isHighlighted: boolean }>`
|
||||
align-items: center;
|
||||
background: ${({ isHighlighted }) =>
|
||||
isHighlighted
|
||||
? themeCssVariables.background.transparent.light
|
||||
: 'transparent'};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
height: 32px;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
padding: 0 ${themeCssVariables.spacing[1]};
|
||||
|
||||
&:hover {
|
||||
background: ${themeCssVariables.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledOptionLeft = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledOptionLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
flex-shrink: 0;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
line-height: 1.4;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledRecommended = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
flex-shrink: 0;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const StyledDivider = styled.div`
|
||||
background: ${themeCssVariables.border.color.light};
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledComposerSection = styled.div`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
min-height: 80px;
|
||||
padding: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledFreeTextArea = styled.textarea`
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
flex: 1 0 0;
|
||||
font-family: inherit;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
line-height: 1.4;
|
||||
min-height: 24px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
|
||||
&::placeholder {
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledActionsRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledLeftActions = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing['0.5']};
|
||||
`;
|
||||
|
||||
const StyledRightActions = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const areAllQuestionsAnswered = (
|
||||
questions: AskQuestionItem[],
|
||||
selectedByQuestion: Record<number, number[]>,
|
||||
freeTextByQuestion: Record<number, string>,
|
||||
) =>
|
||||
questions.every(
|
||||
(_, index) =>
|
||||
(selectedByQuestion[index]?.length ?? 0) > 0 ||
|
||||
(freeTextByQuestion[index] ?? '').trim().length > 0,
|
||||
);
|
||||
|
||||
type AiChatQuestionCardProps = {
|
||||
pendingQuestion: AgentChatPendingQuestion;
|
||||
};
|
||||
|
||||
export const AiChatQuestionCard = ({
|
||||
pendingQuestion,
|
||||
}: AiChatQuestionCardProps) => {
|
||||
const { t } = useLingui();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { messageId, toolCallId, questions } = pendingQuestion;
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [selectedByQuestion, setSelectedByQuestion] = useState<
|
||||
Record<number, number[]>
|
||||
>({});
|
||||
const [freeTextByQuestion, setFreeTextByQuestion] = useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { submitAnswer } = useSubmitQuestionAnswer();
|
||||
|
||||
const { options: modelOptions, pinnedOption } = useAiModelOptions({
|
||||
variant: 'pinned-default',
|
||||
});
|
||||
const { enabledModels } = useWorkspaceAiModelAvailability();
|
||||
const hasNoEnabledModels = enabledModels.length === 0;
|
||||
const { selectedModelId } = useAgentChatModelId();
|
||||
const setAgentChatUserSelectedModel = useSetAtomState(
|
||||
agentChatUserSelectedModelState,
|
||||
);
|
||||
const defaultPinnedOption: SelectOption<string | null> | undefined =
|
||||
pinnedOption ? { ...pinnedOption, value: null } : undefined;
|
||||
|
||||
const currentQuestion = questions[currentIndex];
|
||||
const hasMultipleQuestions = questions.length > 1;
|
||||
const isLastQuestion = currentIndex === questions.length - 1;
|
||||
|
||||
const buildAnswers = (
|
||||
selected: Record<number, number[]>,
|
||||
): AskQuestionAnswer[] =>
|
||||
questions.map((_, index) => {
|
||||
const trimmedFreeText = (freeTextByQuestion[index] ?? '').trim();
|
||||
|
||||
return {
|
||||
questionIndex: index,
|
||||
selectedOptionIndices: selected[index] ?? [],
|
||||
freeText: trimmedFreeText.length > 0 ? trimmedFreeText : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const submit = async (answers: AskQuestionAnswer[]) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
await submitAnswer({ messageId, toolCallId, answers });
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSelectOption = (optionIndex: number) => {
|
||||
if (currentQuestion.allowMultiSelect === true) {
|
||||
setSelectedByQuestion((previous) => {
|
||||
const current = previous[currentIndex] ?? [];
|
||||
const next = current.includes(optionIndex)
|
||||
? current.filter((value) => value !== optionIndex)
|
||||
: [...current, optionIndex];
|
||||
|
||||
return { ...previous, [currentIndex]: next };
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelected = {
|
||||
...selectedByQuestion,
|
||||
[currentIndex]: [optionIndex],
|
||||
};
|
||||
|
||||
setSelectedByQuestion(nextSelected);
|
||||
|
||||
if (!isLastQuestion) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (areAllQuestionsAnswered(questions, nextSelected, freeTextByQuestion)) {
|
||||
void submit(buildAnswers(nextSelected));
|
||||
}
|
||||
};
|
||||
|
||||
const allQuestionsAnswered = useMemo(
|
||||
() =>
|
||||
areAllQuestionsAnswered(
|
||||
questions,
|
||||
selectedByQuestion,
|
||||
freeTextByQuestion,
|
||||
),
|
||||
[questions, selectedByQuestion, freeTextByQuestion],
|
||||
);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!allQuestionsAnswered) {
|
||||
return;
|
||||
}
|
||||
|
||||
void submit(buildAnswers(selectedByQuestion));
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledCard>
|
||||
<StyledQuestionSection>
|
||||
<StyledQuestionHeaderRow>
|
||||
<StyledQuestionText>{currentQuestion.question}</StyledQuestionText>
|
||||
{hasMultipleQuestions && (
|
||||
<StyledPager>
|
||||
<LightIconButton
|
||||
Icon={IconChevronLeft}
|
||||
size="small"
|
||||
disabled={currentIndex === 0}
|
||||
onClick={() =>
|
||||
setCurrentIndex((index) => Math.max(0, index - 1))
|
||||
}
|
||||
/>
|
||||
<StyledPagerLabel>
|
||||
{currentIndex + 1}/{questions.length}
|
||||
</StyledPagerLabel>
|
||||
<LightIconButton
|
||||
Icon={IconChevronRightPipe}
|
||||
size="small"
|
||||
disabled={isLastQuestion}
|
||||
onClick={() =>
|
||||
setCurrentIndex((index) =>
|
||||
Math.min(questions.length - 1, index + 1),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</StyledPager>
|
||||
)}
|
||||
</StyledQuestionHeaderRow>
|
||||
|
||||
<StyledOptionsList>
|
||||
{currentQuestion.options.map((option, optionIndex) => {
|
||||
const NumberIcon =
|
||||
NUMBER_ICONS[optionIndex] ??
|
||||
NUMBER_ICONS[NUMBER_ICONS.length - 1];
|
||||
const isSelected = (
|
||||
selectedByQuestion[currentIndex] ?? []
|
||||
).includes(optionIndex);
|
||||
const hasSelection =
|
||||
(selectedByQuestion[currentIndex] ?? []).length > 0;
|
||||
const isHighlighted =
|
||||
isSelected || (!hasSelection && option.isRecommended === true);
|
||||
const tooltipId = `ask-question-option-${toolCallId}-${currentIndex}-${optionIndex}`;
|
||||
|
||||
return (
|
||||
<StyledOptionRow
|
||||
key={optionIndex}
|
||||
isHighlighted={isHighlighted}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectOption(optionIndex)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleSelectOption(optionIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StyledOptionLeft>
|
||||
<NumberIcon
|
||||
size={theme.icon.size.sm}
|
||||
color={themeCssVariables.font.color.tertiary}
|
||||
/>
|
||||
<StyledOptionLabel>{option.label}</StyledOptionLabel>
|
||||
{option.isRecommended === true && (
|
||||
<StyledRecommended>· {t`Recommended`}</StyledRecommended>
|
||||
)}
|
||||
</StyledOptionLeft>
|
||||
{isDefined(option.description) && (
|
||||
<>
|
||||
<span
|
||||
id={tooltipId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<LightIconButton
|
||||
Icon={IconInfoCircle}
|
||||
size="small"
|
||||
accent="tertiary"
|
||||
/>
|
||||
</span>
|
||||
<AppTooltip
|
||||
anchorSelect={`#${tooltipId}`}
|
||||
content={option.description}
|
||||
delay={TooltipDelay.shortDelay}
|
||||
place="left"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledOptionRow>
|
||||
);
|
||||
})}
|
||||
</StyledOptionsList>
|
||||
</StyledQuestionSection>
|
||||
|
||||
<StyledDivider />
|
||||
|
||||
<StyledComposerSection>
|
||||
<StyledFreeTextArea
|
||||
value={freeTextByQuestion[currentIndex] ?? ''}
|
||||
placeholder={t`Type anything to do differently.`}
|
||||
onChange={(event) =>
|
||||
setFreeTextByQuestion((previous) => ({
|
||||
...previous,
|
||||
[currentIndex]: event.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<StyledActionsRow>
|
||||
<StyledLeftActions>
|
||||
<AgentChatFileUploadButton />
|
||||
<AiChatContextUsageButton />
|
||||
</StyledLeftActions>
|
||||
<StyledRightActions>
|
||||
<Select
|
||||
dropdownId="ai-chat-question-model-select"
|
||||
value={selectedModelId}
|
||||
onChange={setAgentChatUserSelectedModel}
|
||||
options={modelOptions}
|
||||
pinnedOption={defaultPinnedOption}
|
||||
disabled={hasNoEnabledModels}
|
||||
selectSizeVariant="small"
|
||||
showContextualTextInControl={false}
|
||||
withSearchInput
|
||||
dropdownOffset={{ x: 0, y: 8 }}
|
||||
/>
|
||||
<RoundedIconButton
|
||||
Icon={IconArrowUp}
|
||||
size="medium"
|
||||
onClick={handleSend}
|
||||
disabled={!allQuestionsAnswered || isSubmitting}
|
||||
/>
|
||||
</StyledRightActions>
|
||||
</StyledActionsRow>
|
||||
</StyledComposerSection>
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type DynamicToolUIPart, type ToolUIPart } from 'ai';
|
||||
import { useContext } from 'react';
|
||||
import { type AskQuestionsToolResult } from 'twenty-shared/ai';
|
||||
import { IconHelpCircle } from 'twenty-ui/icon';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { ShimmeringText } from '@/ai/components/ShimmeringText';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
padding: ${themeCssVariables.spacing[1]} 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing['0.5']};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const StyledMessage = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledAnswerLine = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledAnswerValue = styled.span`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
`;
|
||||
|
||||
export const AiChatQuestionStatusRenderer = ({
|
||||
toolPart,
|
||||
isStreaming,
|
||||
}: {
|
||||
toolPart: ToolUIPart | DynamicToolUIPart;
|
||||
isStreaming: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const result = (toolPart.output as { result?: AskQuestionsToolResult } | null)
|
||||
?.result;
|
||||
const questions = result?.questions ?? [];
|
||||
const status = result?.status ?? 'pending';
|
||||
|
||||
if (status === 'pending') {
|
||||
const label = t`Asking questions...`;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<IconHelpCircle size={theme.icon.size.sm} />
|
||||
{isStreaming ? (
|
||||
<ShimmeringText>
|
||||
<StyledMessage>{label}</StyledMessage>
|
||||
</ShimmeringText>
|
||||
) : (
|
||||
<StyledMessage>{label}</StyledMessage>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const answers = result?.answers ?? [];
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<IconHelpCircle size={theme.icon.size.sm} />
|
||||
<StyledContent>
|
||||
<StyledMessage>{t`Questions answered`}</StyledMessage>
|
||||
{questions.map((question, index) => {
|
||||
const answer = answers.find(
|
||||
(candidate) => candidate.questionIndex === index,
|
||||
);
|
||||
const selectedLabels = (answer?.selectedOptionIndices ?? [])
|
||||
.map((optionIndex) => question.options[optionIndex]?.label)
|
||||
.filter(isNonEmptyString);
|
||||
const freeTextAnswer = answer?.freeText ?? '';
|
||||
const value =
|
||||
freeTextAnswer.length > 0
|
||||
? freeTextAnswer
|
||||
: selectedLabels.join(', ');
|
||||
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledAnswerLine key={index}>
|
||||
{question.header}: <StyledAnswerValue>{value}</StyledAnswerValue>
|
||||
</StyledAnswerLine>
|
||||
);
|
||||
})}
|
||||
</StyledContent>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { useStore } from 'jotai';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
|
||||
import { AiChatQuestionCard } from '@/ai/components/AiChatQuestionCard';
|
||||
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { RootDecorator } from '~/testing/decorators/RootDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
max-width: 400px;
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
const INSTANCE_ID = 'agentChatQuestionCardStory';
|
||||
|
||||
const singleQuestion: AgentChatPendingQuestion = {
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'call-1',
|
||||
questions: [
|
||||
{
|
||||
header: 'Email type',
|
||||
question: 'What type of emails would you like to send?',
|
||||
options: [
|
||||
{
|
||||
label: 'A welcome email',
|
||||
description: 'A short, friendly note to introduce yourself.',
|
||||
isRecommended: true,
|
||||
},
|
||||
{ label: 'A presentation of Twenty' },
|
||||
{ label: 'An offer for a potential partnership' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const multipleQuestions: AgentChatPendingQuestion = {
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'call-2',
|
||||
questions: [
|
||||
singleQuestion.questions[0],
|
||||
{
|
||||
header: 'Tone',
|
||||
question: 'Which tone should the email use?',
|
||||
options: [
|
||||
{ label: 'Friendly', isRecommended: true },
|
||||
{ label: 'Formal' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const longQuestion: AgentChatPendingQuestion = {
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'call-3',
|
||||
questions: [
|
||||
{
|
||||
header: 'Improvement',
|
||||
question:
|
||||
'What is the one improvement you would make to the "Send follow-up emails to stale opportunities" workflow before we roll it out to the whole team?',
|
||||
options: [
|
||||
{ label: 'Wording clarity' },
|
||||
{ label: 'Better layout' },
|
||||
{ label: 'More flexibility', isRecommended: true },
|
||||
{ label: 'Fewer steps' },
|
||||
],
|
||||
},
|
||||
multipleQuestions.questions[1],
|
||||
],
|
||||
};
|
||||
|
||||
const StoreSeeder = ({ children }: { children: ReactNode }) => {
|
||||
const store = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
store.set(currentAiChatThreadState.atom, 'thread-1');
|
||||
store.set(agentChatDisplayedThreadState.atom, 'thread-1');
|
||||
}, [store]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const meta: Meta<typeof AiChatQuestionCard> = {
|
||||
title: 'Modules/AiChat/AiChatQuestionCard',
|
||||
component: AiChatQuestionCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AgentChatComponentInstanceContext.Provider
|
||||
value={{ instanceId: INSTANCE_ID }}
|
||||
>
|
||||
<StoreSeeder>
|
||||
<StyledContainer>
|
||||
<Story />
|
||||
</StyledContainer>
|
||||
</StoreSeeder>
|
||||
</AgentChatComponentInstanceContext.Provider>
|
||||
),
|
||||
SnackBarDecorator,
|
||||
ComponentDecorator,
|
||||
RootDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AiChatQuestionCard>;
|
||||
|
||||
export const SingleQuestion: Story = {
|
||||
args: { pendingQuestion: singleQuestion },
|
||||
};
|
||||
|
||||
export const MultipleQuestions: Story = {
|
||||
args: { pendingQuestion: multipleQuestions },
|
||||
};
|
||||
|
||||
export const LongQuestion: Story = {
|
||||
args: { pendingQuestion: longQuestion },
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ANSWER_AGENT_CHAT_QUESTION = gql`
|
||||
mutation AnswerAgentChatQuestion(
|
||||
$threadId: UUID!
|
||||
$messageId: UUID!
|
||||
$answers: [AgentChatQuestionAnswerInput!]!
|
||||
$modelId: String
|
||||
) {
|
||||
answerAgentChatQuestion(
|
||||
threadId: $threadId
|
||||
messageId: $messageId
|
||||
answers: $answers
|
||||
modelId: $modelId
|
||||
) {
|
||||
messageId
|
||||
queued
|
||||
streamId
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { useApolloClient } from '@apollo/client/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { type AskQuestionAnswer } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { ANSWER_AGENT_CHAT_QUESTION } from '@/ai/graphql/mutations/answerAgentChatQuestion';
|
||||
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { markQuestionAnswered } from '@/ai/utils/markQuestionAnswered';
|
||||
import { markQuestionPending } from '@/ai/utils/markQuestionPending';
|
||||
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
export const useSubmitQuestionAnswer = () => {
|
||||
const apolloClient = useApolloClient();
|
||||
const store = useStore();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { modelIdForRequest } = useAgentChatModelId();
|
||||
|
||||
const submitAnswer = useCallback(
|
||||
async ({
|
||||
messageId,
|
||||
toolCallId,
|
||||
answers,
|
||||
}: {
|
||||
messageId: string;
|
||||
toolCallId: string;
|
||||
answers: AskQuestionAnswer[];
|
||||
}) => {
|
||||
const threadId = store.get(agentChatDisplayedThreadState.atom);
|
||||
|
||||
if (!isDefined(threadId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesAtom = agentChatMessagesComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
});
|
||||
const previousMessages = store.get(messagesAtom);
|
||||
|
||||
store.set(
|
||||
messagesAtom,
|
||||
markQuestionAnswered(previousMessages, messageId, toolCallId, answers),
|
||||
);
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: ANSWER_AGENT_CHAT_QUESTION,
|
||||
variables: {
|
||||
threadId,
|
||||
messageId,
|
||||
answers,
|
||||
modelId: modelIdForRequest,
|
||||
},
|
||||
});
|
||||
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
} catch (error) {
|
||||
const currentMessages = store.get(messagesAtom);
|
||||
|
||||
store.set(
|
||||
messagesAtom,
|
||||
markQuestionPending(currentMessages, messageId, toolCallId),
|
||||
);
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[apolloClient, store, enqueueErrorSnackBar, modelIdForRequest],
|
||||
);
|
||||
|
||||
return { submitAnswer };
|
||||
};
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { getToolName, isToolUIPart } from 'ai';
|
||||
import {
|
||||
ASK_QUESTIONS_TOOL_NAME,
|
||||
type AskQuestionsToolResult,
|
||||
} from 'twenty-shared/ai';
|
||||
|
||||
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { type AgentChatPendingQuestion } from '@/ai/types/AgentChatPendingQuestion';
|
||||
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
|
||||
|
||||
export const agentChatPendingQuestionComponentSelector =
|
||||
createAtomComponentSelector<AgentChatPendingQuestion | null>({
|
||||
key: 'agentChatPendingQuestionComponentSelector',
|
||||
componentInstanceContext: AgentChatComponentInstanceContext,
|
||||
get:
|
||||
({ instanceId }) =>
|
||||
({ get }) => {
|
||||
const currentThreadId = get(agentChatDisplayedThreadState);
|
||||
|
||||
const messages = get(agentChatMessagesComponentFamilyState, {
|
||||
instanceId,
|
||||
familyKey: { threadId: currentThreadId },
|
||||
});
|
||||
|
||||
const lastAssistantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant');
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const part of lastAssistantMessage.parts) {
|
||||
if (
|
||||
!isToolUIPart(part) ||
|
||||
getToolName(part) !== ASK_QUESTIONS_TOOL_NAME
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = (part.output as { result?: AskQuestionsToolResult })
|
||||
?.result;
|
||||
|
||||
if (result?.status === 'pending') {
|
||||
return {
|
||||
messageId: lastAssistantMessage.id,
|
||||
toolCallId: part.toolCallId,
|
||||
questions: result.questions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type AskQuestionItem } from 'twenty-shared/ai';
|
||||
|
||||
export type AgentChatPendingQuestion = {
|
||||
messageId: string;
|
||||
toolCallId: string;
|
||||
questions: AskQuestionItem[];
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
import { markQuestionAnswered } from '@/ai/utils/markQuestionAnswered';
|
||||
|
||||
const buildMessages = (status: 'pending' | 'answered'): ExtendedUIMessage[] => [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool-ask_questions',
|
||||
toolCallId: 'call-1',
|
||||
state: 'output-available',
|
||||
input: { questions: [] },
|
||||
output: {
|
||||
success: true,
|
||||
message: 'x',
|
||||
result: {
|
||||
questions: [
|
||||
{ header: 'Type', question: 'Which?', options: [{ label: 'A' }] },
|
||||
],
|
||||
status,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: { createdAt: '2024-01-01T00:00:00.000Z' },
|
||||
} as unknown as ExtendedUIMessage,
|
||||
];
|
||||
|
||||
describe('markQuestionAnswered', () => {
|
||||
it('flips the matching tool part to answered and stores the answers', () => {
|
||||
const answers = [
|
||||
{ questionIndex: 0, selectedOptionIndices: [0], freeText: undefined },
|
||||
];
|
||||
|
||||
const result = markQuestionAnswered(
|
||||
buildMessages('pending'),
|
||||
'assistant-1',
|
||||
'call-1',
|
||||
answers,
|
||||
);
|
||||
|
||||
const output = (result[0].parts[0] as { output?: { result?: unknown } })
|
||||
.output;
|
||||
|
||||
expect(output).toMatchObject({
|
||||
result: { status: 'answered', answers },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the original questions on the resolved part', () => {
|
||||
const result = markQuestionAnswered(
|
||||
buildMessages('pending'),
|
||||
'assistant-1',
|
||||
'call-1',
|
||||
[{ questionIndex: 0, selectedOptionIndices: [0] }],
|
||||
);
|
||||
|
||||
const output = (
|
||||
result[0].parts[0] as {
|
||||
output?: { result?: { questions?: unknown[] } };
|
||||
}
|
||||
).output;
|
||||
|
||||
expect(output?.result?.questions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves non-matching tool calls untouched', () => {
|
||||
const result = markQuestionAnswered(
|
||||
buildMessages('pending'),
|
||||
'assistant-1',
|
||||
'other-call',
|
||||
[{ questionIndex: 0, selectedOptionIndices: [0] }],
|
||||
);
|
||||
|
||||
const output = (
|
||||
result[0].parts[0] as { output?: { result?: { status?: string } } }
|
||||
).output;
|
||||
|
||||
expect(output?.result?.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { isToolUIPart } from 'ai';
|
||||
import {
|
||||
type AskQuestionAnswer,
|
||||
type AskQuestionsToolResult,
|
||||
type ExtendedUIMessage,
|
||||
type ExtendedUIMessagePart,
|
||||
} from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const markQuestionAnswered = (
|
||||
messages: ExtendedUIMessage[],
|
||||
messageId: string,
|
||||
toolCallId: string,
|
||||
answers: AskQuestionAnswer[],
|
||||
): ExtendedUIMessage[] =>
|
||||
messages.map((message) => {
|
||||
if (message.id !== messageId) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part) => {
|
||||
if (!isToolUIPart(part) || part.toolCallId !== toolCallId) {
|
||||
return part;
|
||||
}
|
||||
|
||||
const previousOutput = isDefined(part.output)
|
||||
? (part.output as Record<string, unknown>)
|
||||
: {};
|
||||
const previousResult = previousOutput.result as
|
||||
| AskQuestionsToolResult
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
...part,
|
||||
output: {
|
||||
...previousOutput,
|
||||
result: {
|
||||
questions: previousResult?.questions ?? [],
|
||||
status: 'answered',
|
||||
answers,
|
||||
} satisfies AskQuestionsToolResult,
|
||||
},
|
||||
} as ExtendedUIMessagePart;
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { isToolUIPart } from 'ai';
|
||||
import {
|
||||
type AskQuestionsToolResult,
|
||||
type ExtendedUIMessage,
|
||||
type ExtendedUIMessagePart,
|
||||
} from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const markQuestionPending = (
|
||||
messages: ExtendedUIMessage[],
|
||||
messageId: string,
|
||||
toolCallId: string,
|
||||
): ExtendedUIMessage[] =>
|
||||
messages.map((message) => {
|
||||
if (message.id !== messageId) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part) => {
|
||||
if (!isToolUIPart(part) || part.toolCallId !== toolCallId) {
|
||||
return part;
|
||||
}
|
||||
|
||||
const previousOutput = isDefined(part.output)
|
||||
? (part.output as Record<string, unknown>)
|
||||
: {};
|
||||
const previousResult = previousOutput.result as
|
||||
| AskQuestionsToolResult
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
...part,
|
||||
output: {
|
||||
...previousOutput,
|
||||
result: {
|
||||
questions: previousResult?.questions ?? [],
|
||||
status: 'pending',
|
||||
} satisfies AskQuestionsToolResult,
|
||||
},
|
||||
} as ExtendedUIMessagePart;
|
||||
}),
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user