♻️ refactor(onboarding): switch UI to generic interaction tool

Enable UserInteraction and AgentDocuments tools in web-onboarding and
inbox agent configs. Remove obsolete inline question renderers
(QuestionRenderer, QuestionRendererView, questionRendererRuntime,
questionRendererSchema, ResponseLanguageInlineStep) and simplify
Conversation component to only render summary CTA.
This commit is contained in:
Innei
2026-03-25 20:17:58 +08:00
parent 5ba70771ad
commit 5614e2df8c
13 changed files with 49 additions and 863 deletions

View File

@@ -6,10 +6,12 @@
"dependencies": {
"@lobechat/builtin-agent-onboarding": "workspace:*",
"@lobechat/builtin-tool-agent-builder": "workspace:*",
"@lobechat/builtin-tool-agent-documents": "workspace:*",
"@lobechat/builtin-tool-group-agent-builder": "workspace:*",
"@lobechat/builtin-tool-group-management": "workspace:*",
"@lobechat/builtin-tool-gtd": "workspace:*",
"@lobechat/builtin-tool-notebook": "workspace:*",
"@lobechat/builtin-tool-user-interaction": "workspace:*",
"@lobechat/builtin-tool-web-onboarding": "workspace:*",
"@lobechat/business-const": "workspace:*",
"@lobechat/const": "workspace:*"

View File

@@ -1,3 +1,5 @@
import { AgentDocumentsIdentifier } from '@lobechat/builtin-tool-agent-documents';
import type { BuiltinAgentDefinition } from '../../types';
import { BUILTIN_AGENT_SLUGS } from '../../types';
import { createSystemRole } from './systemRole';
@@ -10,7 +12,7 @@ import { createSystemRole } from './systemRole';
export const INBOX: BuiltinAgentDefinition = {
avatar: '/avatars/lobe-ai.png',
runtime: (ctx) => ({
plugins: ctx.plugins || [],
plugins: [AgentDocumentsIdentifier, ...(ctx.plugins || [])],
systemRole: createSystemRole(ctx.userLocale),
}),

View File

@@ -1,4 +1,6 @@
import { createSystemRole } from '@lobechat/builtin-agent-onboarding';
import { AgentDocumentsIdentifier } from '@lobechat/builtin-tool-agent-documents';
import { UserInteractionIdentifier } from '@lobechat/builtin-tool-user-interaction';
import { WebOnboardingIdentifier } from '@lobechat/builtin-tool-web-onboarding';
import { DEFAULT_PROVIDER } from '@lobechat/business-const';
import { DEFAULT_MODEL } from '@lobechat/const';
@@ -26,7 +28,12 @@ export const WEB_ONBOARDING: BuiltinAgentDefinition = {
searchMode: 'off',
skillActivateMode: 'manual',
},
plugins: [WebOnboardingIdentifier, ...(ctx.plugins || [])],
plugins: [
WebOnboardingIdentifier,
UserInteractionIdentifier,
AgentDocumentsIdentifier,
...(ctx.plugins || []),
],
systemRole: createSystemRole(ctx.userLocale),
}),
slug: BUILTIN_AGENT_SLUGS.webOnboarding,

View File

@@ -90,55 +90,6 @@ vi.mock('@/store/user', () => ({
}),
}));
vi.mock('./QuestionRenderer', () => ({
default: ({
currentQuestion,
}: {
currentQuestion?: { id: string; node?: string; prompt?: string };
}) => (
<div data-testid="structured-actions">
<div>{currentQuestion?.id}</div>
<div>{currentQuestion?.node}</div>
<div>{currentQuestion?.prompt}</div>
</div>
),
}));
vi.mock('./questionRendererRuntime', () => ({
useQuestionRendererRuntime: () => ({
fallbackQuestionDescription: 'agent.summaryHint',
fallbackTextFieldLabel: 'agent.summaryHint',
fallbackTextFieldPlaceholder: 'agent.summaryHint',
loading: false,
nextLabel: 'next',
onChangeResponseLanguage: vi.fn(),
onSendMessage: vi.fn(),
renderEmojiPicker: vi.fn(),
responseLanguageOptions: [],
submitLabel: 'next',
}),
}));
vi.mock('./QuestionRendererView', () => ({
default: ({
currentQuestion,
onSendMessage,
}: {
currentQuestion?: { id: string; prompt?: string };
onSendMessage?: (message: string) => Promise<void> | void;
}) => (
<div data-testid="completion-actions">
<div>{currentQuestion?.id}</div>
<div>{currentQuestion?.prompt}</div>
<button onClick={() => onSendMessage?.('finish-onboarding')}>complete</button>
</div>
),
}));
vi.mock('./ResponseLanguageInlineStep', () => ({
default: () => <div data-testid="response-language-inline-step" />,
}));
describe('AgentOnboardingConversation', () => {
beforeEach(() => {
chatInputSpy.mockClear();
@@ -148,22 +99,6 @@ describe('AgentOnboardingConversation', () => {
refreshUserStateSpy.mockReset();
});
it('renders the response language step and disables expand + runtime config in chat input', () => {
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
render(<AgentOnboardingConversation activeNode="responseLanguage" />);
expect(screen.getByTestId('chat-list')).toBeInTheDocument();
expect(screen.getByTestId('response-language-inline-step')).toBeInTheDocument();
expect(chatInputSpy).toHaveBeenCalledWith(
expect.objectContaining({
allowExpand: false,
leftActions: [],
showRuntimeConfig: false,
}),
);
});
it('renders a read-only transcript when viewing a historical topic', () => {
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
@@ -173,22 +108,13 @@ describe('AgentOnboardingConversation', () => {
expect(screen.getByTestId('chat-list')).toBeInTheDocument();
});
it('renders the built-in response language step even without an AI question surface', () => {
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
render(<AgentOnboardingConversation activeNode="responseLanguage" />);
expect(screen.getByTestId('response-language-inline-step')).toBeInTheDocument();
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
});
it('renders the completion CTA on the summary step', () => {
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
render(<AgentOnboardingConversation activeNode="summary" />);
expect(screen.getByTestId('completion-actions')).toHaveTextContent('finish-onboarding');
expect(screen.getByTestId('completion-actions')).toHaveTextContent('finish');
expect(screen.getByText('finish')).toBeInTheDocument();
expect(screen.getByText('agent.summaryHint')).toBeInTheDocument();
});
it('finishes onboarding and navigates to inbox when the completion CTA is clicked', async () => {
@@ -198,7 +124,7 @@ describe('AgentOnboardingConversation', () => {
render(<AgentOnboardingConversation activeNode="summary" />);
fireEvent.click(screen.getByRole('button', { name: 'complete' }));
fireEvent.click(screen.getByText('finish'));
await waitFor(() => {
expect(finishOnboardingSpy).toHaveBeenCalledTimes(1);
@@ -206,4 +132,26 @@ describe('AgentOnboardingConversation', () => {
expect(navigateSpy).toHaveBeenCalledWith('/');
});
});
it('disables expand and runtime config in chat input', () => {
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
render(<AgentOnboardingConversation activeNode="agentIdentity" />);
expect(chatInputSpy).toHaveBeenCalledWith(
expect.objectContaining({
allowExpand: false,
leftActions: [],
showRuntimeConfig: false,
}),
);
});
it('does not show completion CTA when not on summary step', () => {
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
render(<AgentOnboardingConversation activeNode="agentIdentity" />);
expect(screen.queryByText('finish')).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,6 @@
'use client';
import type { UserAgentOnboardingNode, UserAgentOnboardingQuestion } from '@lobechat/types';
import { Avatar, Flexbox, Markdown, Text } from '@lobehub/ui';
import { Avatar, Button, Flexbox, Markdown, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
@@ -21,16 +20,12 @@ import { userService } from '@/services/user';
import { useUserStore } from '@/store/user';
import { isDev } from '@/utils/env';
import QuestionRenderer from './QuestionRenderer';
import { useQuestionRendererRuntime } from './questionRendererRuntime';
import QuestionRendererView from './QuestionRendererView';
import ResponseLanguageInlineStep from './ResponseLanguageInlineStep';
import { staticStyle } from './staticStyle';
const assistantLikeRoles = new Set(['assistant', 'assistantGroup', 'supervisor']);
interface AgentOnboardingConversationProps {
activeNode?: UserAgentOnboardingNode;
activeNode?: string;
readOnly?: boolean;
}
@@ -52,11 +47,9 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
const agentMeta = useAgentMeta();
const navigate = useNavigate();
const refreshUserState = useUserStore((s) => s.refreshUserState);
const questionRendererRuntime = useQuestionRendererRuntime();
const [isFinishing, setIsFinishing] = useState(false);
const isFinishingRef = useRef(false);
const displayMessages = useConversationStore(conversationSelectors.displayMessages);
const shouldRenderResponseLanguageStep = !readOnly && activeNode === 'responseLanguage';
const lastAssistantMessageId = useMemo(() => {
for (let i = displayMessages.length - 1; i >= 0; i--) {
@@ -72,59 +65,6 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
return assistantLikeRoles.has(first.role);
}, [displayMessages]);
const presetGreetingQuestion = useMemo<UserAgentOnboardingQuestion>(
() => ({
fields: [
{
key: 'name',
kind: 'text' as const,
label: t('agent.greeting.nameLabel'),
placeholder: t('agent.greeting.namePlaceholder'),
},
{
key: 'vibe',
kind: 'text' as const,
label: t('agent.greeting.vibeLabel'),
placeholder: t('agent.greeting.vibePlaceholder'),
},
{
key: 'emoji',
kind: 'emoji' as const,
label: t('agent.greeting.emojiLabel'),
},
],
id: 'greeting-agent-identity',
mode: 'form',
node: 'agentIdentity',
prompt: t('agent.greeting.prompt'),
submitMode: 'message',
}),
[t],
);
const completionQuestion = useMemo<UserAgentOnboardingQuestion>(
() => ({
choices: [
{
id: 'finish-onboarding',
label: t('finish'),
payload: {
kind: 'message',
message: 'finish-onboarding',
},
style: 'primary',
},
],
description: t('agent.summaryHint'),
id: 'finish-onboarding',
mode: 'button_group',
node: 'summary',
prompt: t('finish'),
submitMode: 'message',
}),
[t],
);
const handleFinishOnboarding = useCallback(async () => {
if (isFinishingRef.current) return;
@@ -146,35 +86,20 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
const itemContent = useCallback(
(index: number, id: string) => {
const isLatestItem = displayMessages.length === index + 1;
const effectiveQuestion = !readOnly && isGreetingState ? presetGreetingQuestion : undefined;
const effectiveStep = shouldRenderResponseLanguageStep ? 'responseLanguage' : undefined;
const showCompletionCTA = !readOnly && activeNode === 'summary';
const completionRender = !showCompletionCTA ? undefined : (
<div className={staticStyle.inlineQuestion}>
<QuestionRendererView
{...questionRendererRuntime}
currentQuestion={completionQuestion}
loading={questionRendererRuntime.loading || isFinishing}
onSendMessage={handleFinishOnboarding}
/>
<Flexbox gap={8}>
<Text type={'secondary'}>{t('agent.summaryHint')}</Text>
<Button loading={isFinishing} type={'primary'} onClick={handleFinishOnboarding}>
{t('finish')}
</Button>
</Flexbox>
</div>
);
const endRender =
id !== lastAssistantMessageId ? undefined : effectiveStep ? (
<div className={staticStyle.inlineQuestion}>
<ResponseLanguageInlineStep />
</div>
) : showCompletionCTA ? (
completionRender
) : effectiveQuestion ? (
<div className={staticStyle.inlineQuestion}>
<QuestionRenderer currentQuestion={effectiveQuestion} />
</div>
) : (
completionRender
);
const endRender = id !== lastAssistantMessageId ? undefined : completionRender;
if (isGreetingState && index === 0) {
const message = displayMessages[0];
@@ -218,14 +143,11 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
displayMessages,
isGreetingState,
lastAssistantMessageId,
presetGreetingQuestion,
readOnly,
activeNode,
completionQuestion,
handleFinishOnboarding,
isFinishing,
questionRendererRuntime,
shouldRenderResponseLanguageStep,
t,
],
);

View File

@@ -1,123 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import QuestionRenderer from './QuestionRenderer';
const sendMessage = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@/utils/env', () => ({ isDev: false }));
vi.mock('@/const/onboarding', () => ({
ONBOARDING_PRODUCTION_DEFAULT_MODEL: {
model: 'gpt-4.1-mini',
provider: 'openai',
},
}));
vi.mock('@/features/Conversation', () => ({
useConversationStore: (selector: (state: any) => unknown) =>
selector({
sendMessage,
}),
}));
vi.mock('@/features/Conversation/store', () => ({
messageStateSelectors: {
isInputLoading: () => false,
},
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: any) => unknown) =>
selector({
switchLocale: vi.fn(),
}),
}));
vi.mock('@/store/serverConfig', () => ({
serverConfigSelectors: {
enableKlavis: () => false,
},
useServerConfigStore: (selector: (state: any) => unknown) => selector({}),
}));
vi.mock('@/store/user', () => ({
useUserStore: (selector: (state: any) => unknown) =>
selector({
updateGeneralConfig: vi.fn(),
updateDefaultModel: vi.fn(),
}),
}));
vi.mock('@/store/user/selectors', () => ({
settingsSelectors: {
currentSettings: () => ({
defaultAgent: {},
general: {},
}),
},
userGeneralSettingsSelectors: {
currentResponseLanguage: () => 'en-US',
},
}));
vi.mock('@/components/EmojiPicker', () => ({
default: () => <div data-testid="emoji-picker" />,
}));
vi.mock('@/features/ModelSelect', () => ({
default: () => <div data-testid="model-select" />,
}));
vi.mock('@/routes/onboarding/components/KlavisServerList', () => ({
default: () => <div data-testid="klavis-list" />,
}));
describe('QuestionRenderer runtime', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('dispatches send message without waiting for the full send lifecycle', async () => {
sendMessage.mockImplementation(
() =>
new Promise(() => {
// Intentionally unresolved to simulate a long-running streaming lifecycle.
}),
);
render(
<QuestionRenderer
currentQuestion={{
choices: [
{
id: 'preset',
label: 'Warm + curious',
payload: {
kind: 'message',
message: 'hello from hint',
},
style: 'primary',
},
],
id: 'question-1',
mode: 'button_group',
node: 'agentIdentity',
prompt: 'Pick one',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Warm + curious' }));
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledWith({ message: 'hello from hint' });
});
});
});

View File

@@ -1,274 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import QuestionRendererView from './QuestionRendererView';
const sendMessage = vi.fn();
const baseProps = {
fallbackQuestionDescription: 'agent.telemetryHint',
fallbackTextFieldLabel: 'agent.telemetryHint',
fallbackTextFieldPlaceholder: 'agent.telemetryHint',
nextLabel: 'next',
onSendMessage: sendMessage,
submitLabel: 'next',
} as const;
describe('QuestionRendererView', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders button group questions and forwards message choices', async () => {
render(
<QuestionRendererView
{...baseProps}
currentQuestion={{
choices: [
{
id: 'preset',
label: 'Warm + curious',
payload: {
kind: 'message',
message: 'hello from hint',
},
style: 'primary',
},
],
id: 'question-1',
mode: 'button_group',
node: 'agentIdentity',
prompt: 'Pick one',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Warm + curious' }));
expect(sendMessage).toHaveBeenCalledWith('hello from hint');
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
});
it('falls back to sending the action label when a message button has no payload', async () => {
render(
<QuestionRendererView
{...baseProps}
currentQuestion={{
choices: [
{
id: 'identity-ai-builder',
label: 'AI 产品开发者',
style: 'default',
},
],
id: 'identity-quick-pick',
mode: 'button_group',
node: 'userIdentity',
prompt: '选一个最贴切的身份标签',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'AI 产品开发者' }));
expect(sendMessage).toHaveBeenCalledWith('AI 产品开发者');
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
});
it('falls back to the action label for patch-style actions', async () => {
render(
<QuestionRendererView
{...baseProps}
currentQuestion={{
choices: [
{
id: 'set-language',
label: 'Use Chinese',
payload: {
kind: 'patch',
patch: {
responseLanguage: 'zh-CN',
},
},
style: 'default',
},
],
id: 'question-2',
mode: 'button_group',
node: 'responseLanguage',
prompt: 'Language',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Use Chinese' }));
expect(sendMessage).toHaveBeenCalledWith('Use Chinese');
});
it('falls back to a text form when a button group has no choices', async () => {
render(
<QuestionRendererView
{...baseProps}
currentQuestion={{
id: 'agent-identity-missing-choices',
mode: 'button_group',
node: 'agentIdentity',
prompt: '帮我取个名字,再定个气质。',
}}
/>,
);
fireEvent.change(screen.getByPlaceholderText('agent.telemetryHint'), {
target: { value: '叫 shishi风格偏直接。' },
});
fireEvent.click(screen.getByRole('button', { name: 'next' }));
expect(sendMessage).toHaveBeenCalledWith(
['Q: agent.telemetryHint', 'A: 叫 shishi风格偏直接。'].join('\n'),
);
});
it('formats form submissions as question-answer text', async () => {
render(
<QuestionRendererView
{...baseProps}
currentQuestion={{
fields: [
{
key: 'professionalRole',
kind: 'text',
label: 'Role',
placeholder: 'Your role',
value: '',
},
{
key: 'name',
kind: 'text',
label: 'Name',
placeholder: 'Your name',
value: '',
},
],
id: 'user-identity-form',
mode: 'form',
node: 'userIdentity',
prompt: 'About you',
}}
/>,
);
fireEvent.change(screen.getByPlaceholderText('Your role'), {
target: { value: 'Independent developer' },
});
fireEvent.change(screen.getByPlaceholderText('Your name'), {
target: { value: 'Ada' },
});
fireEvent.click(screen.getByRole('button', { name: 'next' }));
expect(sendMessage).toHaveBeenCalledWith(
['Q: Role', 'A: Independent developer', '', 'Q: Name', 'A: Ada'].join('\n'),
);
});
it('submits the form when pressing Enter in a text input', async () => {
render(
<QuestionRendererView
{...baseProps}
currentQuestion={{
fields: [
{
key: 'professionalRole',
kind: 'text',
label: 'Role',
placeholder: 'Your role',
value: '',
},
],
id: 'user-identity-form',
mode: 'form',
node: 'userIdentity',
prompt: 'About you',
}}
/>,
);
fireEvent.change(screen.getByPlaceholderText('Your role'), {
target: { value: 'Independent developer' },
});
fireEvent.keyDown(screen.getByPlaceholderText('Your role'), {
code: 'Enter',
key: 'Enter',
});
expect(sendMessage).toHaveBeenCalledWith(['Q: Role', 'A: Independent developer'].join('\n'));
});
it('formats select submissions as question-answer text', async () => {
render(
<QuestionRendererView
{...baseProps}
currentQuestion={{
fields: [
{
key: 'responseLanguage',
kind: 'select',
label: 'Response language',
options: [
{ label: 'English', value: 'en-US' },
{ label: 'Chinese', value: 'zh-CN' },
],
value: 'en-US',
},
],
id: 'response-language-select',
mode: 'select',
node: 'responseLanguage',
prompt: 'Language',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'next' }));
expect(sendMessage).toHaveBeenCalledWith(['Q: Response language', 'A: English'].join('\n'));
});
it('normalizes select questions with choices into button groups', async () => {
render(
<QuestionRendererView
{...baseProps}
responseLanguageOptions={[{ label: '简体中文', value: 'zh-CN' }]}
currentQuestion={{
choices: [
{
id: 'emoji_lightning',
label: '⚡ 闪电 — 快速、高效',
payload: {
kind: 'patch',
patch: {
emoji: '⚡',
},
},
},
],
id: 'agent_emoji_select',
mode: 'select',
node: 'agentIdentity',
prompt: '最后一步——选个 emoji 作为我的标志。',
}}
/>,
);
expect(screen.getByRole('button', { name: '⚡ 闪电 — 快速、高效' })).toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '⚡ 闪电 — 快速、高效' }));
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledWith('⚡ 闪电 — 快速、高效');
});
});
});

View File

@@ -1,21 +0,0 @@
'use client';
import type { UserAgentOnboardingQuestion } from '@lobechat/types';
import { memo } from 'react';
import { useQuestionRendererRuntime } from './questionRendererRuntime';
import QuestionRendererView from './QuestionRendererView';
interface QuestionRendererProps {
currentQuestion: UserAgentOnboardingQuestion;
}
const QuestionRenderer = memo<QuestionRendererProps>(({ currentQuestion }) => {
const runtime = useQuestionRendererRuntime();
return <QuestionRendererView currentQuestion={currentQuestion} {...runtime} />;
});
QuestionRenderer.displayName = 'QuestionRenderer';
export default QuestionRenderer;

View File

@@ -1,46 +0,0 @@
'use client';
import {
QuestionRenderer as BuiltinAgentQuestionRenderer,
type QuestionRendererProps as BuiltinAgentQuestionRendererProps,
} from '@lobechat/builtin-agent-onboarding/client';
import { memo, useMemo } from 'react';
import { normalizeQuestionRendererQuestion } from './questionRendererSchema';
export interface QuestionRendererViewProps extends BuiltinAgentQuestionRendererProps {
fallbackQuestionDescription: string;
fallbackTextFieldLabel: string;
fallbackTextFieldPlaceholder: string;
}
const QuestionRendererView = memo<QuestionRendererViewProps>(
({
currentQuestion,
fallbackQuestionDescription,
fallbackTextFieldLabel,
fallbackTextFieldPlaceholder,
...builtinProps
}) => {
const resolvedQuestion = useMemo(
() =>
normalizeQuestionRendererQuestion(currentQuestion, {
description: fallbackQuestionDescription,
label: fallbackTextFieldLabel,
placeholder: fallbackTextFieldPlaceholder,
}),
[
currentQuestion,
fallbackQuestionDescription,
fallbackTextFieldLabel,
fallbackTextFieldPlaceholder,
],
);
return <BuiltinAgentQuestionRenderer currentQuestion={resolvedQuestion} {...builtinProps} />;
},
);
QuestionRendererView.displayName = 'QuestionRendererView';
export default QuestionRendererView;

View File

@@ -1,78 +0,0 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ResponseLanguageInlineStep from './ResponseLanguageInlineStep';
const questionRendererViewSpy = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('./questionRendererRuntime', () => ({
useQuestionRendererRuntime: () => ({
fallbackQuestionDescription: 'fallback-description',
fallbackTextFieldLabel: 'fallback-label',
fallbackTextFieldPlaceholder: 'fallback-placeholder',
loading: false,
nextLabel: 'next',
onChangeResponseLanguage: vi.fn(),
onSendMessage: vi.fn(),
responseLanguageOptions: [{ label: 'English', value: 'en-US' }],
submitLabel: 'submit',
}),
}));
vi.mock('@/store/user', () => ({
useUserStore: (selector: (state: Record<string, never>) => unknown) => selector({}),
}));
vi.mock('@/store/user/selectors', () => ({
userGeneralSettingsSelectors: {
currentResponseLanguage: () => 'en-US',
},
}));
vi.mock('./QuestionRendererView', () => ({
default: (props: Record<string, unknown>) => {
questionRendererViewSpy(props);
return <div data-testid="question-renderer-view" />;
},
}));
describe('ResponseLanguageInlineStep', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the built-in response-language node through the shared question UI', () => {
render(<ResponseLanguageInlineStep />);
expect(screen.getByTestId('question-renderer-view')).toBeInTheDocument();
const [props] = questionRendererViewSpy.mock.calls[0];
expect(props).toEqual(
expect.objectContaining({
currentQuestion: {
description: 'responseLanguage.desc',
fields: [
{
key: 'responseLanguage',
kind: 'select',
label: 'agent.stage.responseLanguage',
value: 'en-US',
},
],
id: 'builtin-response-language',
mode: 'select',
node: 'responseLanguage',
prompt: 'responseLanguage.title',
submitMode: 'message',
},
}),
);
});
});

View File

@@ -1,45 +0,0 @@
'use client';
import type { UserAgentOnboardingQuestion } from '@lobechat/types';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { useQuestionRendererRuntime } from './questionRendererRuntime';
import QuestionRendererView from './QuestionRendererView';
const ResponseLanguageInlineStep = memo(() => {
const { t } = useTranslation('onboarding');
const runtime = useQuestionRendererRuntime();
const currentResponseLanguage = useUserStore(
userGeneralSettingsSelectors.currentResponseLanguage,
);
const currentQuestion = useMemo<UserAgentOnboardingQuestion>(
() => ({
description: t('responseLanguage.desc'),
fields: [
{
key: 'responseLanguage',
kind: 'select',
label: t('agent.stage.responseLanguage'),
value: currentResponseLanguage,
},
],
id: 'builtin-response-language',
mode: 'select',
node: 'responseLanguage',
prompt: t('responseLanguage.title'),
submitMode: 'message',
}),
[currentResponseLanguage, t],
);
return <QuestionRendererView currentQuestion={currentQuestion} {...runtime} />;
});
ResponseLanguageInlineStep.displayName = 'ResponseLanguageInlineStep';
export default ResponseLanguageInlineStep;

View File

@@ -1,66 +0,0 @@
'use client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import EmojiPicker from '@/components/EmojiPicker';
import { useConversationStore } from '@/features/Conversation';
import { messageStateSelectors } from '@/features/Conversation/store';
import { localeOptions } from '@/locales/resources';
import { useGlobalStore } from '@/store/global';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { type LocaleMode } from '@/types/locale';
import type { QuestionRendererViewProps } from './QuestionRendererView';
export interface QuestionRendererRuntimeProps extends Omit<
QuestionRendererViewProps,
'currentQuestion'
> {}
export const useQuestionRendererRuntime = (): QuestionRendererRuntimeProps => {
const { t } = useTranslation('onboarding');
const loading = useConversationStore(messageStateSelectors.isInputLoading);
const sendMessage = useConversationStore((s) => s.sendMessage);
const switchLocale = useGlobalStore((s) => s.switchLocale);
const currentResponseLanguage = useUserStore(
userGeneralSettingsSelectors.currentResponseLanguage,
);
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const telemetryHint = t('agent.telemetryHint');
const nextLabel = t('next');
return useMemo(
() => ({
currentResponseLanguage,
fallbackQuestionDescription: telemetryHint,
fallbackTextFieldLabel: telemetryHint,
fallbackTextFieldPlaceholder: telemetryHint,
loading,
nextLabel,
onChangeResponseLanguage: (value: string) => {
switchLocale(value as LocaleMode);
void updateGeneralConfig({ responseLanguage: value });
},
onSendMessage: async (message: string) => {
// Dismiss the inline onboarding widget immediately after dispatch.
// The full chat send lifecycle also awaits runtime streaming, which is too late
// for this UI pattern because the question should disappear once submitted.
void sendMessage({ message }).catch(console.error);
},
renderEmojiPicker: ({ onChange, value }) => <EmojiPicker value={value} onChange={onChange} />,
responseLanguageOptions: localeOptions,
submitLabel: nextLabel,
}),
[
currentResponseLanguage,
loading,
nextLabel,
sendMessage,
switchLocale,
telemetryHint,
updateGeneralConfig,
],
);
};

View File

@@ -1,42 +0,0 @@
import type { UserAgentOnboardingQuestion } from '@lobechat/types';
interface QuestionRendererFallbackCopy {
description: string;
label: string;
placeholder: string;
}
export const normalizeQuestionRendererQuestion = (
question: UserAgentOnboardingQuestion,
fallbackCopy: QuestionRendererFallbackCopy,
): UserAgentOnboardingQuestion => {
if (
question.mode === 'select' &&
(!question.fields || question.fields.length === 0) &&
question.choices &&
question.choices.length > 0
) {
return {
...question,
mode: 'button_group',
};
}
if (question.mode !== 'button_group' || (question.choices && question.choices.length > 0)) {
return question;
}
return {
...question,
description: question.description ?? fallbackCopy.description,
fields: [
{
key: 'answer',
kind: 'text',
label: fallbackCopy.label,
placeholder: fallbackCopy.placeholder,
},
],
mode: 'form',
};
};