Add dev history view for onboarding

This commit is contained in:
Innei
2026-03-24 21:18:42 +08:00
parent 31f327e70f
commit fe0b824754
11 changed files with 303 additions and 25 deletions

View File

@@ -1,4 +1,6 @@
{
"agent.history.current": "当前",
"agent.history.title": "历史话题",
"agent.modeSwitch.agent": "对话式",
"agent.modeSwitch.classic": "经典版",
"agent.modeSwitch.label": "选择引导模式",

View File

@@ -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();
});
});

View File

@@ -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>
);
},

View 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');
});
});

View 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;

View 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']);
});
});

View 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));

View File

@@ -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
}
/>

View File

@@ -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',

View File

@@ -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');
});
});

View File

@@ -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;