mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
Add dev history view for onboarding
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"agent.history.current": "当前",
|
||||
"agent.history.title": "历史话题",
|
||||
"agent.modeSwitch.agent": "对话式",
|
||||
"agent.modeSwitch.classic": "经典版",
|
||||
"agent.modeSwitch.label": "选择引导模式",
|
||||
|
||||
@@ -196,4 +196,26 @@ describe('AgentOnboardingConversation', () => {
|
||||
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a read-only transcript when viewing a historical topic', () => {
|
||||
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
|
||||
|
||||
render(
|
||||
<AgentOnboardingConversation
|
||||
readOnly
|
||||
currentQuestion={
|
||||
{
|
||||
id: 'agent-identity-question',
|
||||
mode: 'form',
|
||||
node: 'agentIdentity',
|
||||
prompt: '先把我定下来吧。',
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('chat-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-list')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,12 +32,13 @@ const useStyles = createStyles(({ css, token }) => ({
|
||||
|
||||
interface AgentOnboardingConversationProps {
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const chatInputLeftActions: ActionKeys[] = isDev ? ['model'] : [];
|
||||
|
||||
const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
({ currentQuestion }) => {
|
||||
({ currentQuestion, readOnly }) => {
|
||||
const { styles } = useStyles();
|
||||
const [dismissedNodes, setDismissedNodes] = useState<string[]>([]);
|
||||
const displayMessages = useConversationStore(conversationSelectors.displayMessages);
|
||||
@@ -55,12 +56,12 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
}, [questionSignature]);
|
||||
|
||||
const visibleQuestion = useMemo(() => {
|
||||
if (!currentQuestion) return undefined;
|
||||
if (readOnly || !currentQuestion) return undefined;
|
||||
|
||||
const dismissedNodeSet = new Set(dismissedNodes);
|
||||
|
||||
return dismissedNodeSet.has(currentQuestion.node) ? undefined : currentQuestion;
|
||||
}, [currentQuestion, dismissedNodes]);
|
||||
}, [currentQuestion, dismissedNodes, readOnly]);
|
||||
|
||||
const lastAssistantMessageId = useMemo(() => {
|
||||
for (const message of [...displayMessages].reverse()) {
|
||||
@@ -115,13 +116,15 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
<ChatList itemContent={itemContent} />
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox className={styles.composerZone} paddingInline={8}>
|
||||
<ChatInput
|
||||
allowExpand={false}
|
||||
leftActions={chatInputLeftActions}
|
||||
showRuntimeConfig={false}
|
||||
/>
|
||||
</Flexbox>
|
||||
{!readOnly && (
|
||||
<Flexbox className={styles.composerZone} paddingInline={8}>
|
||||
<ChatInput
|
||||
allowExpand={false}
|
||||
leftActions={chatInputLeftActions}
|
||||
showRuntimeConfig={false}
|
||||
/>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
|
||||
69
src/features/Onboarding/Agent/HistoryPanel.test.tsx
Normal file
69
src/features/Onboarding/Agent/HistoryPanel.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ChatTopic } from '@/types/topic';
|
||||
|
||||
import HistoryPanel from './HistoryPanel';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
(
|
||||
({
|
||||
'agent.history.current': 'Current',
|
||||
'agent.history.title': 'History Topics',
|
||||
}) as Record<string, string>
|
||||
)[key] || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const createTopic = (id: string, title: string, updatedAt: number): ChatTopic => ({
|
||||
createdAt: updatedAt,
|
||||
id,
|
||||
title,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
describe('HistoryPanel', () => {
|
||||
it('renders the current topic marker and notifies topic selection', () => {
|
||||
const onSelectTopic = vi.fn();
|
||||
|
||||
render(
|
||||
<HistoryPanel
|
||||
activeTopicId="topic-2"
|
||||
selectedTopicId="topic-1"
|
||||
topics={[
|
||||
createTopic('topic-1', 'Earlier Topic', 100),
|
||||
createTopic('topic-2', 'Latest Topic', 200),
|
||||
]}
|
||||
onSelectTopic={onSelectTopic}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('History Topics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Current')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Latest Topic/i }));
|
||||
|
||||
expect(onSelectTopic).toHaveBeenCalledWith('topic-2');
|
||||
});
|
||||
|
||||
it('renders topics in updatedAt descending order', () => {
|
||||
render(
|
||||
<HistoryPanel
|
||||
activeTopicId="topic-2"
|
||||
selectedTopicId="topic-2"
|
||||
topics={[
|
||||
createTopic('topic-1', 'Earlier Topic', 100),
|
||||
createTopic('topic-2', 'Latest Topic', 200),
|
||||
]}
|
||||
onSelectTopic={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
expect(buttons[0]).toHaveTextContent('Latest Topic');
|
||||
expect(buttons[1]).toHaveTextContent('Earlier Topic');
|
||||
});
|
||||
});
|
||||
73
src/features/Onboarding/Agent/HistoryPanel.tsx
Normal file
73
src/features/Onboarding/Agent/HistoryPanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Flexbox, Tag, Text } from '@lobehub/ui';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { ChatTopic } from '@/types/topic';
|
||||
|
||||
import { getOnboardingHistoryTopics } from './history';
|
||||
|
||||
interface HistoryPanelProps {
|
||||
activeTopicId: string;
|
||||
onSelectTopic: (topicId: string) => void;
|
||||
selectedTopicId: string;
|
||||
topics: ChatTopic[];
|
||||
}
|
||||
|
||||
const formatUpdatedAt = (updatedAt: ChatTopic['updatedAt']) => new Date(updatedAt).toLocaleString();
|
||||
|
||||
const HistoryPanel = memo<HistoryPanelProps>(
|
||||
({ activeTopicId, onSelectTopic, selectedTopicId, topics }) => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const historyTopics = useMemo(() => getOnboardingHistoryTopics(topics), [topics]);
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox gap={8}>
|
||||
{historyTopics.map((topic) => {
|
||||
const isCurrentTopic = topic.id === activeTopicId;
|
||||
const isSelectedTopic = topic.id === selectedTopicId;
|
||||
|
||||
return (
|
||||
<Button
|
||||
block
|
||||
key={topic.id}
|
||||
size={'small'}
|
||||
style={{ height: 'auto', paddingBlock: 10 }}
|
||||
type={isSelectedTopic ? 'primary' : 'default'}
|
||||
onClick={() => onSelectTopic(topic.id)}
|
||||
>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
gap={8}
|
||||
justify={'space-between'}
|
||||
width={'100%'}
|
||||
>
|
||||
<Flexbox align={'flex-start'} gap={2} style={{ overflow: 'hidden' }}>
|
||||
<Text
|
||||
ellipsis={{ rows: 1, tooltip: topic.title }}
|
||||
style={{ maxWidth: '100%' }}
|
||||
weight={500}
|
||||
>
|
||||
{topic.title}
|
||||
</Text>
|
||||
<Text as={'time'} fontSize={12} type={'secondary'}>
|
||||
{formatUpdatedAt(topic.updatedAt)}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
{isCurrentTopic && <Tag variant={'borderless'}>{t('agent.history.current')}</Tag>}
|
||||
</Flexbox>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HistoryPanel.displayName = 'HistoryPanel';
|
||||
|
||||
export default HistoryPanel;
|
||||
34
src/features/Onboarding/Agent/history.test.ts
Normal file
34
src/features/Onboarding/Agent/history.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ChatTopic } from '@/types/topic';
|
||||
|
||||
import { getOnboardingHistoryTopics } from './history';
|
||||
|
||||
const createTopic = (id: string, title: string, updatedAt: number): ChatTopic => ({
|
||||
createdAt: updatedAt,
|
||||
id,
|
||||
title,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
describe('getOnboardingHistoryTopics', () => {
|
||||
it('sorts topics by updatedAt in descending order', () => {
|
||||
const topics = [
|
||||
createTopic('topic-1', 'First', 100),
|
||||
createTopic('topic-3', 'Third', 300),
|
||||
createTopic('topic-2', 'Second', 200),
|
||||
];
|
||||
|
||||
const result = getOnboardingHistoryTopics(topics);
|
||||
|
||||
expect(result.map((topic) => topic.id)).toEqual(['topic-3', 'topic-2', 'topic-1']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const topics = [createTopic('topic-1', 'First', 100), createTopic('topic-2', 'Second', 200)];
|
||||
|
||||
void getOnboardingHistoryTopics(topics);
|
||||
|
||||
expect(topics.map((topic) => topic.id)).toEqual(['topic-1', 'topic-2']);
|
||||
});
|
||||
});
|
||||
4
src/features/Onboarding/Agent/history.ts
Normal file
4
src/features/Onboarding/Agent/history.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ChatTopic } from '@/types/topic';
|
||||
|
||||
export const getOnboardingHistoryTopics = (topics: ChatTopic[]) =>
|
||||
[...topics].sort((left, right) => +new Date(right.updatedAt) - +new Date(left.updatedAt));
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
|
||||
import { Button, ErrorBoundary, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Drawer } from 'antd';
|
||||
import { History } from 'lucide-react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import ModeSwitch from '@/features/Onboarding/components/ModeSwitch';
|
||||
import { useOnlyFetchOnceSWR } from '@/libs/swr';
|
||||
import { useClientDataSWR, useOnlyFetchOnceSWR } from '@/libs/swr';
|
||||
import OnboardingContainer from '@/routes/onboarding/_layout';
|
||||
import { topicService } from '@/services/topic';
|
||||
import { userService } from '@/services/user';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { builtinAgentSelectors } from '@/store/agent/selectors';
|
||||
@@ -17,6 +20,7 @@ import { isDev } from '@/utils/env';
|
||||
|
||||
import { resolveAgentOnboardingContext } from './context';
|
||||
import AgentOnboardingConversation from './Conversation';
|
||||
import HistoryPanel from './HistoryPanel';
|
||||
import OnboardingConversationProvider from './OnboardingConversationProvider';
|
||||
|
||||
const AgentOnboardingPage = memo(() => {
|
||||
@@ -31,15 +35,27 @@ const AgentOnboardingPage = memo(() => {
|
||||
s.resetAgentOnboarding,
|
||||
]);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [selectedTopicId, setSelectedTopicId] = useState<string>();
|
||||
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
|
||||
|
||||
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.webOnboarding);
|
||||
|
||||
const { data: historyData, mutate: mutateHistoryTopics } = useClientDataSWR(
|
||||
isDev && onboardingAgentId ? ['agent-onboarding-history-topics', onboardingAgentId] : null,
|
||||
() =>
|
||||
topicService.getTopics({
|
||||
agentId: onboardingAgentId,
|
||||
pageSize: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
const { data, error, isLoading, mutate } = useOnlyFetchOnceSWR(
|
||||
'agent-onboarding-bootstrap',
|
||||
() => userService.getOrCreateOnboardingState(),
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await refreshUserState();
|
||||
if (isDev && onboardingAgentId) await mutateHistoryTopics();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -52,6 +68,11 @@ const AgentOnboardingPage = memo(() => {
|
||||
}),
|
||||
[agentOnboarding, data],
|
||||
);
|
||||
const activeTopicId = currentContext.topicId || data?.topicId;
|
||||
const historyTopics = historyData?.items || [];
|
||||
const effectiveTopicId = selectedTopicId || activeTopicId;
|
||||
const viewingHistoricalTopic =
|
||||
!!activeTopicId && !!effectiveTopicId && effectiveTopicId !== activeTopicId;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -67,13 +88,14 @@ const AgentOnboardingPage = memo(() => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data?.topicId || !onboardingAgentId) {
|
||||
if (isLoading || !activeTopicId || !onboardingAgentId || !effectiveTopicId) {
|
||||
return <Loading debugId="AgentOnboarding" />;
|
||||
}
|
||||
|
||||
const syncOnboardingContext = async () => {
|
||||
const nextContext = await userService.getOrCreateOnboardingState();
|
||||
await mutate(nextContext, { revalidate: false });
|
||||
if (isDev && onboardingAgentId) await mutateHistoryTopics();
|
||||
|
||||
return nextContext;
|
||||
};
|
||||
@@ -83,7 +105,8 @@ const AgentOnboardingPage = memo(() => {
|
||||
|
||||
try {
|
||||
await resetAgentOnboarding();
|
||||
await syncOnboardingContext();
|
||||
const nextContext = await syncOnboardingContext();
|
||||
setSelectedTopicId(nextContext.topicId);
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
@@ -98,7 +121,7 @@ const AgentOnboardingPage = memo(() => {
|
||||
<Flexbox flex={1} gap={16} style={{ minHeight: 0 }}>
|
||||
<OnboardingConversationProvider
|
||||
agentId={onboardingAgentId}
|
||||
topicId={currentContext.topicId || data.topicId}
|
||||
topicId={effectiveTopicId}
|
||||
hooks={{
|
||||
onAfterSendMessage: async () => {
|
||||
await syncOnboardingContext();
|
||||
@@ -107,16 +130,49 @@ const AgentOnboardingPage = memo(() => {
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={() => null}>
|
||||
<AgentOnboardingConversation currentQuestion={currentContext.currentQuestion} />
|
||||
<AgentOnboardingConversation
|
||||
currentQuestion={
|
||||
viewingHistoricalTopic ? undefined : currentContext.currentQuestion
|
||||
}
|
||||
readOnly={viewingHistoricalTopic}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</OnboardingConversationProvider>
|
||||
{isDev && historyTopics.length > 0 && (
|
||||
<Drawer
|
||||
open={historyDrawerOpen}
|
||||
title={t('agent.history.title')}
|
||||
onClose={() => setHistoryDrawerOpen(false)}
|
||||
>
|
||||
<HistoryPanel
|
||||
activeTopicId={activeTopicId}
|
||||
selectedTopicId={effectiveTopicId}
|
||||
topics={historyTopics}
|
||||
onSelectTopic={(id) => {
|
||||
setSelectedTopicId(id);
|
||||
setHistoryDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</Flexbox>
|
||||
<ModeSwitch
|
||||
actions={
|
||||
isDev ? (
|
||||
<Button danger loading={isResetting} size={'small'} onClick={handleReset}>
|
||||
{t('agent.modeSwitch.reset')}
|
||||
</Button>
|
||||
<>
|
||||
{historyTopics.length > 0 && (
|
||||
<Button
|
||||
icon={<History size={14} />}
|
||||
size={'small'}
|
||||
onClick={() => setHistoryDrawerOpen(true)}
|
||||
>
|
||||
{t('agent.history.title')}
|
||||
</Button>
|
||||
)}
|
||||
<Button danger loading={isResetting} size={'small'} onClick={handleReset}>
|
||||
{t('agent.modeSwitch.reset')}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default {
|
||||
'agent.banner.label': 'Agent Onboarding',
|
||||
'agent.history.current': 'Current',
|
||||
'agent.history.title': 'History Topics',
|
||||
'agent.modeSwitch.agent': 'Conversational',
|
||||
'agent.modeSwitch.classic': 'Classic',
|
||||
'agent.modeSwitch.label': 'Choose your onboarding mode',
|
||||
|
||||
@@ -456,7 +456,7 @@ describe('OnboardingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reset deletes the previous onboarding topic before clearing state', async () => {
|
||||
it('reset preserves the previous onboarding topic while clearing state', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
activeTopicId: 'topic-old',
|
||||
completedNodes: ['agentIdentity'],
|
||||
@@ -472,7 +472,25 @@ describe('OnboardingService', () => {
|
||||
draft: {},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
});
|
||||
expect(mockTopicModel.delete).toHaveBeenCalledWith('topic-old');
|
||||
expect(mockTopicModel.delete).not.toHaveBeenCalled();
|
||||
expect(persistedUserState.agentOnboarding).toEqual(result);
|
||||
});
|
||||
|
||||
it('creates a new onboarding topic after reset clears the active topic pointer', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
activeTopicId: 'topic-old',
|
||||
completedNodes: ['agentIdentity'],
|
||||
draft: {},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
mockTopicModel.create.mockResolvedValueOnce({ id: 'topic-2' });
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
|
||||
await service.reset();
|
||||
const result = await service.getOrCreateState();
|
||||
|
||||
expect(result.topicId).toBe('topic-2');
|
||||
expect(persistedUserState.agentOnboarding.activeTopicId).toBe('topic-2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1392,13 +1392,8 @@ export class OnboardingService {
|
||||
};
|
||||
|
||||
reset = async () => {
|
||||
const previousState = await this.ensurePersistedState();
|
||||
const state = defaultAgentOnboardingState();
|
||||
|
||||
if (previousState.activeTopicId) {
|
||||
await this.topicModel.delete(previousState.activeTopicId);
|
||||
}
|
||||
|
||||
await this.userModel.updateUser({ agentOnboarding: state });
|
||||
|
||||
return state;
|
||||
|
||||
Reference in New Issue
Block a user