mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
♻️ 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:
@@ -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:*"
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('⚡ 闪电 — 快速、高效');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user