feat: 实现话题模块 (#16)

*  feat: 初步实现话题功能

*  feat: 实现话题的删除与二次对话

*  test: 补充 topic 相关测试

*  test: 补充 chat List 相关测试
This commit is contained in:
Arvin Xu
2023-07-25 23:56:25 +08:00
committed by GitHub
parent fcab3cd149
commit 64fd6ee9c0
27 changed files with 815 additions and 131 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ export default {
share: '分享',
tokenDetail: '系统设定: {{systemRoleToken}} 历史消息: {{chatsToken}}',
topic: {
saveCurrentMessages: '保存当前对话为话题',
searchPlaceholder: '搜索归档对话...',
saveCurrentMessages: '将当前会话保存为话题',
searchPlaceholder: '搜索话...',
},
updateAgent: '更新助理信息',
updatePrompt: '更新提示词',
-68
View File
@@ -1,68 +0,0 @@
import { Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { LucideChevronRight, LucideIcon } from 'lucide-react';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, token }) => ({
container: css`
background: ${token.colorFillQuaternary};
border-radius: 6px;
`,
split: css`
border-bottom: 1px solid ${token.colorSplit};
`,
}));
export interface ConfigItem {
icon: LucideIcon;
label: string;
value?: string | number;
}
export type ConfigCellProps = ConfigItem;
export const ConfigCell = memo<ConfigCellProps>(({ icon, label, value }) => {
const { styles } = useStyles();
return (
<Flexbox
className={styles.container}
distribution={'space-between'}
horizontal
padding={'10px 12px'}
>
<Flexbox gap={8} horizontal>
<Icon icon={icon} />
<Flexbox>{label}</Flexbox>
</Flexbox>
{value ?? <Icon icon={LucideChevronRight} />}
</Flexbox>
);
});
export interface CellGroupProps {
items: ConfigItem[];
}
export const ConfigCellGroup = memo<CellGroupProps>(({ items }) => {
const { styles } = useStyles();
return (
<Flexbox className={styles.container}>
{items.map(({ label, icon, value }, index) => (
<Flexbox
className={items.length === index + 1 ? undefined : styles.split}
distribution={'space-between'}
horizontal
key={label}
padding={'10px 12px'}
>
<Flexbox gap={8} horizontal>
<Icon icon={icon} />
<Flexbox>{label}</Flexbox>
</Flexbox>
{value ?? <Icon icon={LucideChevronRight} />}
</Flexbox>
))}
</Flexbox>
);
});
+9 -5
View File
@@ -1,11 +1,12 @@
import { ActionIcon, DraggablePanelBody, EditableMessage, SearchBar } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ChevronRight } from 'lucide-react';
import { Maximize2Icon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { shallow } from 'zustand/shallow';
import { Topic } from '@/pages/chat/[id]/Config/Topic';
import { agentSelectors, useSessionStore } from '@/store/session';
import Header from './Header';
@@ -42,8 +43,10 @@ const useStyles = createStyles(({ css, token }) => ({
const SideBar = memo(() => {
const [openModal, setOpenModal] = useState(false);
const { styles } = useStyles();
const [updateAgentConfig] = useSessionStore((s) => [s.updateAgentConfig], shallow);
const systemRole = useSessionStore(agentSelectors.currentAgentSystemRole, shallow);
const [systemRole, updateAgentConfig] = useSessionStore(
(s) => [agentSelectors.currentAgentSystemRole(s), s.updateAgentConfig],
shallow,
);
const { t } = useTranslation('common');
return (
@@ -51,7 +54,7 @@ const SideBar = memo(() => {
<Header
actions={
<ActionIcon
icon={ChevronRight}
icon={Maximize2Icon}
onClick={() => setOpenModal(true)}
size="small"
title={t('edit')}
@@ -76,8 +79,9 @@ const SideBar = memo(() => {
}}
value={systemRole}
/>
<Flexbox style={{ padding: 16 }}>
<Flexbox gap={12} style={{ padding: 16 }}>
<SearchBar placeholder={t('topic.searchPlaceholder')} type={'block'} />
<Topic />
</Flexbox>
</DraggablePanelBody>
);
+27
View File
@@ -0,0 +1,27 @@
import { Flexbox } from 'react-layout-kit';
import { shallow } from 'zustand/shallow';
import { topicSelectors, useSessionStore } from '@/store/session';
import TopicItem from './TopicItem';
export const Topic = () => {
const topics = useSessionStore(topicSelectors.currentTopics);
const [activeTopicId] = useSessionStore((s) => [s.activeTopicId], shallow);
return (
<Flexbox gap={8}>
<TopicItem active={!activeTopicId} fav={false} title={'默认话题'} />
{topics.map(({ id, favorite, title }) => (
<TopicItem
active={activeTopicId === id}
fav={favorite}
id={id}
key={id}
showFav
title={title}
/>
))}
</Flexbox>
);
};
+80
View File
@@ -0,0 +1,80 @@
import { StarFilled, StarOutlined } from '@ant-design/icons';
import { ActionIcon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { LucideIcon } from 'lucide-react';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { shallow } from 'zustand/shallow';
import { useSessionStore } from '@/store/session';
const useStyles = createStyles(({ css, token }) => ({
active: css`
background: ${token.colorFill};
&:hover {
background: ${token.colorFill};
}
`,
container: css`
cursor: pointer;
background: ${token.colorFillTertiary};
border-radius: 6px;
&:hover {
background: ${token.colorFillSecondary};
}
`,
split: css`
border-bottom: 1px solid ${token.colorSplit};
`,
}));
export interface ConfigCellProps {
active?: boolean;
fav?: boolean;
id?: string;
showFav?: boolean;
title: string;
}
const TopicItem = memo<ConfigCellProps>(({ title, active, id, showFav, fav }) => {
const { styles, theme, cx } = useStyles();
const [dispatchTopic, toggleTopic] = useSessionStore(
(s) => [s.dispatchTopic, s.toggleTopic],
shallow,
);
const starIcon = (fav ? StarFilled : StarOutlined) as LucideIcon;
return (
<Flexbox
align={'center'}
className={cx(styles.container, active && styles.active)}
distribution={'space-between'}
horizontal
onClick={() => {
toggleTopic(id);
}}
padding={'10px 12px'}
>
{title}
{!showFav ? undefined : (
<ActionIcon
icon={starIcon}
onClick={() => {
if (!id) return;
dispatchTopic({ id, key: 'favorite', type: 'updateChatTopic', value: !fav });
}}
size={'small'}
style={{
color: fav ? theme.yellow : undefined,
}}
/>
)}
</Flexbox>
);
});
export default TopicItem;
@@ -18,12 +18,15 @@ const ChatInput = () => {
const [text, setText] = useState('');
const [inputHeight] = useSettings((s) => [s.inputHeight], shallow);
const [sendMessage] = useSessionStore((s) => [s.createOrSendMsg], shallow);
const [sendMessage, saveToTopic] = useSessionStore(
(s) => [s.createOrSendMsg, s.saveToTopic],
shallow,
);
const footer = useMemo(
() => (
<Tooltip title={t('topic.saveCurrentMessages')}>
<Button icon={<Icon icon={LucideGalleryVerticalEnd} />} />
<Button icon={<Icon icon={LucideGalleryVerticalEnd} />} onClick={saveToTopic} />
</Tooltip>
),
[],
+14 -10
View File
@@ -12,10 +12,18 @@ import { agentSelectors, useSessionStore } from '@/store/session';
const Header = memo(() => {
const { t } = useTranslation('common');
const [meta, id, modle] = useSessionStore(
(s) => [agentSelectors.currentAgentMeta(s), s.activeId, agentSelectors.currentAgentModel(s)],
const [title, description, avatar, backgroundColor, id, model] = useSessionStore(
(s) => [
agentSelectors.currentAgentTitle(s),
agentSelectors.currentAgentDescription(s),
agentSelectors.currentAgentAvatar(s),
agentSelectors.currentAgentBackgroundColor(s),
s.activeId,
agentSelectors.currentAgentModel(s),
],
shallow,
);
const [showAgentSettings, toggleConfig] = useSessionStore(
(s) => [s.showAgentSettings, s.toggleConfig],
shallow,
@@ -26,20 +34,16 @@ const Header = memo(() => {
left={
<>
<Avatar
avatar={meta?.avatar}
background={meta?.backgroundColor}
avatar={avatar}
background={backgroundColor}
onClick={() => {
Router.push(`/chat/${id}/edit`);
}}
size={40}
style={{ cursor: 'pointer' }}
title={meta?.title}
/>
<HeaderTitle
desc={meta?.description || t('noDescription')}
tag={<Tag>{modle}</Tag>}
title={meta?.title || t('defaultAgent')}
title={title}
/>
<HeaderTitle desc={description} tag={<Tag>{model}</Tag>} title={title} />
</>
}
right={
+10 -4
View File
@@ -17,8 +17,8 @@ const ChatLayout = memo<PropsWithChildren>(({ children }) => {
initI18n.finally();
}, []);
const [activeSession] = useSessionStore((s) => {
return [s.activeSession];
const [activeSession, toggleTopic] = useSessionStore((s) => {
return [s.activeSession, s.toggleTopic];
}, shallow);
const router = useRouter();
@@ -26,10 +26,16 @@ const ChatLayout = memo<PropsWithChildren>(({ children }) => {
useEffect(() => {
const hasRehydrated = useSessionStore.persist.hasHydrated();
// 只有当水合完毕后,才能正常去激活会话
if (typeof id === 'string' && hasRehydrated) {
// 只有当水合完毕后再开始做操作
if (!hasRehydrated) return;
// 1. 正常激活会话
if (typeof id === 'string') {
activeSession(id);
}
// 将话题重置为默认值
toggleTopic();
}, [id]);
useEffect(() => {
+19
View File
@@ -0,0 +1,19 @@
import { OpenAIChatMessage, OpenAIStreamPayload } from '@/types/openai';
export const promptSummaryTitle = (
messages: OpenAIChatMessage[],
): Partial<OpenAIStreamPayload> => ({
messages: [
{
content:
'你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,不需要包含标点符号',
role: 'system',
},
{
content: `${messages.map((message) => `${message.role}: ${message.content}`).join('\n')}
请总结上述对话为10个字以内的标题,不需要包含标点符号`,
role: 'user',
},
],
});
+1 -1
View File
@@ -1,3 +1,3 @@
export { agentSelectors } from './slices/agentConfig';
export { chatSelectors } from './slices/chat';
export { chatSelectors, topicSelectors } from './slices/chat';
export { sessionSelectors } from './slices/session';
@@ -17,6 +17,9 @@ const currentAgentMeta = (s: SessionStore): MetaData => {
const currentAgentTitle = (s: SessionStore) => currentAgentMeta(s)?.title || t('defaultSession');
const currentAgentDescription = (s: SessionStore) =>
currentAgentMeta(s)?.description || t('noDescription');
const currentAgentBackgroundColor = (s: SessionStore) => {
const session = sessionSelectors.currentSession(s);
if (!session) return DEFAULT_BACKGROUND_COLOR;
@@ -62,6 +65,7 @@ export const agentSelectors = {
currentAgentBackgroundColor,
currentAgentConfig,
currentAgentConfigSafe,
currentAgentDescription,
currentAgentMeta,
currentAgentModel,
currentAgentSystemRole,
@@ -0,0 +1,21 @@
import { StateCreator } from 'zustand/vanilla';
import { SessionStore } from '@/store/session';
import { ChatMessageAction, chatMessage } from './message';
import { ChatTopicAction, chatTopic } from './topic';
/**
* 聊天操作
*/
export interface ChatAction extends ChatTopicAction, ChatMessageAction {}
export const createChatSlice: StateCreator<
SessionStore,
[['zustand/devtools', never]],
[],
ChatAction
> = (...params) => ({
...chatTopic(...params),
...chatMessage(...params),
});
@@ -7,14 +7,14 @@ import { fetchSSE } from '@/utils/fetch';
import { isFunctionMessage } from '@/utils/message';
import { nanoid } from '@/utils/uuid';
import { MessageDispatch, messagesReducer } from './reducers/message';
import { MessageDispatch, messagesReducer } from '../reducers/message';
const LOADING_FLAT = '...';
/**
*
*/
export interface ChatAction {
export interface ChatMessageAction {
/**
*
*/
@@ -57,6 +57,7 @@ export interface ChatAction {
* @param id - ID
*/
resendMessage: (id: string) => Promise<void>;
/**
*
* @param text -
@@ -64,14 +65,20 @@ export interface ChatAction {
sendMessage: (text: string) => Promise<void>;
}
export const createChatSlice: StateCreator<
export const chatMessage: StateCreator<
SessionStore,
[['zustand/devtools', never]],
[],
ChatAction
ChatMessageAction
> = (set, get) => ({
clearMessage: () => {
get().dispatchMessage({ type: 'resetMessages' });
const { dispatchMessage, activeTopicId, dispatchTopic } = get();
dispatchMessage({ topicId: activeTopicId, type: 'resetMessages' });
if (activeTopicId) {
dispatchTopic({ id: activeTopicId, type: 'deleteChatTopic' });
}
},
createOrSendMsg: async (message) => {
@@ -155,7 +162,7 @@ export const createChatSlice: StateCreator<
},
realFetchAIResponse: async (messages, userMessageId) => {
const { dispatchMessage, generateMessage } = get();
const { dispatchMessage, generateMessage, activeTopicId } = get();
// 添加 systemRole
const { systemRole, model } = agentSelectors.currentAgentConfigSafe(get());
@@ -165,49 +172,46 @@ export const createChatSlice: StateCreator<
// 再添加一个空的信息用于放置 ai 响应,注意顺序不能反
// 因为如果顺序反了,messages 中将包含新增的 ai message
const assistantId = nanoid();
const mid = nanoid();
dispatchMessage({
id: assistantId,
id: mid,
message: LOADING_FLAT,
parentId: userMessageId,
role: 'assistant',
type: 'addMessage',
});
// 如果有 activeTopicId,则添加 topicId
if (activeTopicId) {
dispatchMessage({ id: mid, key: 'topicId', type: 'updateMessage', value: activeTopicId });
}
// 为模型添加 fromModel 的额外信息
dispatchMessage({
id: assistantId,
key: 'fromModel',
type: 'updateMessageExtra',
value: model,
});
dispatchMessage({ id: mid, key: 'fromModel', type: 'updateMessageExtra', value: model });
// 生成 ai message
const { output, isFunctionCall } = await generateMessage(messages, assistantId);
const { output, isFunctionCall } = await generateMessage(messages, mid);
// 如果是 function,则发送函数调用方法
if (isFunctionCall) {
const { function_call } = JSON.parse(output);
dispatchMessage({
id: assistantId,
id: mid,
key: 'function_call',
type: 'updateMessage',
value: function_call,
});
await generateMessage(
[
...messages,
{ content: '', function_call, id: assistantId, role: 'assistant' } as ChatMessage,
],
assistantId,
[...messages, { content: '', function_call, id: mid, role: 'assistant' } as ChatMessage],
mid,
true,
);
dispatchMessage({
id: assistantId,
id: mid,
key: 'role',
type: 'updateMessage',
value: 'assistant',
@@ -243,13 +247,19 @@ export const createChatSlice: StateCreator<
},
sendMessage: async (message) => {
const { dispatchMessage, realFetchAIResponse, autocompleteSessionAgentMeta } = get();
const { dispatchMessage, realFetchAIResponse, autocompleteSessionAgentMeta, activeTopicId } =
get();
const session = sessionSelectors.currentSession(get());
if (!session || !message) return;
const userId = nanoid();
dispatchMessage({ id: userId, message, role: 'user', type: 'addMessage' });
// 如果有 activeTopicId,则添加 topicId
if (activeTopicId) {
dispatchMessage({ id: userId, key: 'topicId', type: 'updateMessage', value: activeTopicId });
}
// 先拿到当前的 messages
const messages = chatSelectors.currentChats(get());
@@ -0,0 +1,98 @@
import { StateCreator } from 'zustand/vanilla';
import { promptSummaryTitle } from '@/prompts/chat';
import { SessionStore, chatSelectors, sessionSelectors } from '@/store/session';
import { fetchPresetTaskResult } from '@/utils/fetch';
import { nanoid } from '@/utils/uuid';
import { ChatTopicDispatch, topicReducer } from '../reducers/topic';
export interface ChatTopicAction {
/**
* 分发主题
* @param payload - 要分发的主题
*/
dispatchTopic: (payload: ChatTopicDispatch) => void;
/**
* 将当前消息保存为主题
*/
saveToTopic: () => void;
/**
* 切换主题
* @param id - 要切换的主题的 ID
*/
toggleTopic: (id?: string) => void;
/**
* 更新主题加载状态
* @param id - 要更新的主题的 ID
*/
updateTopicLoading: (id?: string) => void;
}
export const chatTopic: StateCreator<
SessionStore,
[['zustand/devtools', never]],
[],
ChatTopicAction
> = (set, get) => ({
dispatchTopic: (payload) => {
const { activeId } = get();
const session = sessionSelectors.currentSession(get());
if (!activeId || !session) return;
const topics = topicReducer(session.topics || {}, payload);
get().dispatchSession({ id: activeId, topics, type: 'updateSessionTopic' });
},
saveToTopic: () => {
const session = sessionSelectors.currentSession(get());
if (!session) return;
const { dispatchTopic, dispatchMessage, updateTopicLoading } = get();
// 获取当前的 messages
const messages = chatSelectors.currentChats(get());
const topicId = nanoid();
const defaultTitle = '默认话题';
const newTopic = {
createAt: Date.now(),
id: topicId,
title: defaultTitle,
updateAt: Date.now(),
};
dispatchTopic({
topic: newTopic,
type: 'addChatTopic',
});
// 为所有 message 添加 topicId
for (const m of messages) {
dispatchMessage({ id: m.id, key: 'topicId', type: 'updateMessage', value: topicId });
}
let output = '';
// 自动总结话题标题
fetchPresetTaskResult({
onError: () => {
dispatchTopic({ id: topicId, key: 'title', type: 'updateChatTopic', value: defaultTitle });
},
onLoadingChange: (loading) => {
updateTopicLoading(loading ? topicId : undefined);
},
onMessageHandle: (x) => {
output += x;
dispatchTopic({ id: topicId, key: 'title', type: 'updateChatTopic', value: output });
},
params: promptSummaryTitle(messages),
});
},
toggleTopic: (id) => {
set({ activeTopicId: id });
},
updateTopicLoading: (id) => {
set({ topicLoadingId: id });
},
});
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './action';
export * from './actions';
export * from './initialState';
export * from './selectors';
@@ -1,12 +1,9 @@
export interface ChatState {
activeTopicId?: string;
chatLoading: boolean;
editingMessageId?: string;
topicLoadingId?: string;
}
export const initialChatState: ChatState = {
chatLoading: false,
// activeId: null,
// searchKeywords: '',
//
};
@@ -116,6 +116,30 @@ describe('messagesReducer', () => {
quotaId: 'message2',
});
});
it('should use the provided parentId and quotaId when adding a new message', () => {
const payload: MessageDispatch = {
type: 'addMessage',
message: 'New Message',
id: 'message3',
role: 'user',
parentId: 'message1',
quotaId: 'message2',
};
const newState = messagesReducer(initialState, payload);
expect(newState.message3).toEqual({
id: 'message3',
content: 'New Message',
meta: {},
createAt: expect.any(Number),
updateAt: expect.any(Number),
role: 'user',
parentId: 'message1',
quotaId: 'message2',
});
});
});
describe('deleteMessage', () => {
@@ -142,6 +166,17 @@ describe('messagesReducer', () => {
expect(newState).toEqual(initialState);
});
it('should not modify the state if the specified message does not exist', () => {
const payload: MessageDispatch = {
type: 'deleteMessage',
id: 'nonexistentMessage',
};
const newState = messagesReducer(initialState, payload);
expect(newState).toEqual(initialState);
});
});
describe('updateMessage', () => {
@@ -171,6 +206,19 @@ describe('messagesReducer', () => {
expect(newState).toEqual(initialState);
});
it('should not modify the state if the specified message does not exist', () => {
const payload: MessageDispatch = {
type: 'updateMessage',
id: 'nonexistentMessage',
key: 'content',
value: 'Updated Message',
};
const newState = messagesReducer(initialState, payload);
expect(newState).toEqual(initialState);
});
});
describe('updateMessageExtra', () => {
@@ -200,6 +248,19 @@ describe('messagesReducer', () => {
expect(newState).toEqual(initialState);
});
it('should not modify the state if the specified message does not exist', () => {
const payload: MessageDispatch = {
type: 'updateMessageExtra',
id: 'nonexistentMessage',
key: 'translate',
value: { target: 'en', to: 'zh' },
};
const newState = messagesReducer(initialState, payload);
expect(newState).toEqual(initialState);
});
});
describe('resetMessages', () => {
@@ -212,6 +273,37 @@ describe('messagesReducer', () => {
expect(newState).toEqual({});
});
it('should delete messages with the specified topicId', () => {
const initialState = {
message1: {
id: 'message1',
content: 'Hello World',
createAt: 1629264000000,
updateAt: 1629264000000,
role: 'user',
topicId: 'topic1',
},
message2: {
id: 'message2',
content: 'How are you?',
createAt: 1629264000000,
updateAt: 1629264000000,
role: 'system',
},
} as unknown as ChatMessageMap;
const payload: MessageDispatch = {
type: 'resetMessages',
topicId: 'topic1',
};
const newState = messagesReducer(initialState, payload);
expect(Object.keys(newState)).toHaveLength(1);
expect(newState).not.toHaveProperty('message1');
expect(newState).toHaveProperty('message2');
});
});
describe('unimplemented type', () => {
@@ -21,6 +21,7 @@ interface DeleteMessage {
}
interface ResetMessages {
topicId?: string;
type: 'resetMessages';
}
@@ -101,7 +102,21 @@ export const messagesReducer = (
}
case 'resetMessages': {
return {};
return produce(state, (draftState) => {
const { topicId } = payload;
const messages = Object.values(draftState).filter((message) => {
// 如果没有 topicId,说明是清空默认对话里的消息
if (!topicId) return !message.topicId;
return message.topicId === topicId;
});
// 删除上述找到的消息
for (const message of messages) {
delete draftState[message.id];
}
});
}
default: {
@@ -0,0 +1,179 @@
import { produce } from 'immer';
import { ChatTopic, ChatTopicMap } from '@/types/topic';
import { ChatTopicDispatch, topicReducer } from './topic';
describe('topicReducer', () => {
let state: ChatTopicMap;
beforeEach(() => {
state = {};
});
describe('addChatTopic', () => {
it('should add a new ChatTopic object to state', () => {
const payload: ChatTopicDispatch = {
type: 'addChatTopic',
topic: {
id: '1',
title: 'Test Topic',
createAt: Date.now(),
updateAt: Date.now(),
},
};
const newState = topicReducer(state, payload);
expect(newState).toMatchObject({
'1': payload.topic,
});
});
it('should add a ChatTopic object with correct id', () => {
const payload: ChatTopicDispatch = {
type: 'addChatTopic',
topic: {
id: '1',
title: 'Test Topic',
createAt: Date.now(),
updateAt: Date.now(),
},
};
const newState = topicReducer(state, payload);
expect(newState['1']).toBeDefined();
});
it('should add a ChatTopic object with correct properties', () => {
const payload: ChatTopicDispatch = {
type: 'addChatTopic',
topic: {
id: '1',
title: 'Test Topic',
createAt: Date.now(),
updateAt: Date.now(),
},
};
const newState = topicReducer(state, payload);
expect(newState['1']).toMatchObject(payload.topic);
});
});
describe('updateChatTopic', () => {
it('should update the ChatTopic object in state', () => {
const topic: ChatTopic = {
id: '1',
title: 'Test Topic',
createAt: Date.now(),
updateAt: Date.now(),
};
state['1'] = topic;
const payload: ChatTopicDispatch = {
type: 'updateChatTopic',
id: '1',
key: 'title',
value: 'Updated Topic',
};
const newState = topicReducer(state, payload);
expect(newState['1'].title).toBe('Updated Topic');
});
it('should update the ChatTopic object with correct properties', () => {
const topic: ChatTopic = {
id: '1',
title: 'Test Topic',
createAt: Date.now() - 1,
updateAt: Date.now() - 1, // 设定比当前时间前面一点
};
state['1'] = topic;
const payload: ChatTopicDispatch = {
type: 'updateChatTopic',
id: '1',
key: 'title',
value: 'Updated Topic',
};
const newState = topicReducer(state, payload);
expect(newState['1'].updateAt).toBeGreaterThan(topic.updateAt);
});
});
describe('deleteChatTopic', () => {
it('should delete the specified ChatTopic object from state', () => {
const topic: ChatTopic = {
id: '1',
title: 'Test Topic',
createAt: Date.now(),
updateAt: Date.now(),
};
state['1'] = topic;
const payload: ChatTopicDispatch = {
type: 'deleteChatTopic',
id: '1',
};
const newState = topicReducer(state, payload);
expect(newState['1']).toBeUndefined();
});
});
describe('default', () => {
it('should return the original state object', () => {
const payload = {
type: 'unknown',
} as unknown as ChatTopicDispatch;
const newState = topicReducer(state, payload);
expect(newState).toBe(state);
});
});
describe('produce', () => {
it('should generate immutable state object', () => {
const payload: ChatTopicDispatch = {
type: 'addChatTopic',
topic: {
id: '1',
title: 'Test Topic',
createAt: Date.now(),
updateAt: Date.now(),
},
};
const newState = topicReducer(state, payload);
expect(newState).not.toBe(state);
});
it('should not modify the original state object', () => {
const payload: ChatTopicDispatch = {
type: 'addChatTopic',
topic: {
id: '1',
title: 'Test Topic',
createAt: Date.now(),
updateAt: Date.now(),
},
};
const newState = topicReducer(state, payload);
expect(state).toMatchObject({});
});
});
});
@@ -0,0 +1,59 @@
import { produce } from 'immer';
import { ChatTopic, ChatTopicMap } from '@/types/topic';
interface AddChatTopicAction {
topic: ChatTopic;
type: 'addChatTopic';
}
interface UpdateChatTopicAction {
id: string;
key: keyof ChatTopic;
type: 'updateChatTopic';
value: any;
}
interface DeleteChatTopicAction {
id: string;
type: 'deleteChatTopic';
}
export type ChatTopicDispatch = AddChatTopicAction | UpdateChatTopicAction | DeleteChatTopicAction;
export const topicReducer = (state: ChatTopicMap, payload: ChatTopicDispatch): ChatTopicMap => {
switch (payload.type) {
case 'addChatTopic': {
return produce(state, (draftState) => {
draftState[payload.topic.id] = payload.topic;
});
}
case 'updateChatTopic': {
return produce(state, (draftState) => {
const { key, value, id } = payload;
if (!draftState[id]) return;
const topic = draftState[id];
if (value !== undefined) {
// @ts-ignore
topic[key] = value;
topic.updateAt = Date.now();
}
});
}
case 'deleteChatTopic': {
return produce(state, (draftState) => {
delete draftState[payload.id];
});
}
default: {
return state;
}
}
};
@@ -12,10 +12,14 @@ export const currentChats = (s: SessionStore): ChatMessage[] => {
const session = sessionSelectors.currentSession(s);
if (!session) return [];
return organizeChats(session, {
assistant: agentSelectors.currentAgentAvatar(s),
user: useSettings.getState().settings.avatar || DEFAULT_USER_AVATAR,
});
return organizeChats(
session,
{
assistant: agentSelectors.currentAgentAvatar(s),
user: useSettings.getState().settings.avatar || DEFAULT_USER_AVATAR,
},
s.activeTopicId,
);
};
export const systemRoleSel = (s: SessionStore): string => {
@@ -1,5 +1,6 @@
import { currentChats } from './chat';
import { chatsTokenCount, systemRoleTokenCount, totalTokenCount } from './token';
import { currentTopics } from './topic';
export const chatSelectors = {
chatsTokenCount,
@@ -7,3 +8,7 @@ export const chatSelectors = {
systemRoleTokenCount,
totalTokenCount,
};
export const topicSelectors = {
currentTopics,
};
@@ -0,0 +1,16 @@
// 展示在聊天框中的消息
import { SessionStore, sessionSelectors } from '@/store/session';
import { ChatTopic } from '@/types/topic';
export const currentTopics = (s: SessionStore): ChatTopic[] => {
const session = sessionSelectors.currentSession(s);
if (!session) return [];
const topics = Object.values(session.topics || {});
// 按时间倒序
const favTopics = topics.filter((t) => t.favorite).sort((a, b) => b.updateAt - a.updateAt);
const defaultTopics = topics.filter((t) => !t.favorite).sort((a, b) => b.updateAt - a.updateAt);
return [...favTopics, ...defaultTopics];
};
@@ -1,5 +1,6 @@
import { beforeEach } from 'vitest';
import { beforeEach, describe } from 'vitest';
import { ChatMessage } from '@/types/chatMessage';
import { LobeAgentSession } from '@/types/session';
import { organizeChats } from './utils';
@@ -261,4 +262,83 @@ describe('organizeChats', () => {
},
]);
});
const avatar = {
assistant: 'assistant-avatar',
user: 'user-avatar',
};
it('should organize chats in chronological order when topicId is not provided', () => {
const result = organizeChats(session, avatar);
expect(result.length).toBe(3);
expect(result[0].id).toBe('1');
expect(result[1].id).toBe('2');
expect(result[2].id).toBe('3');
});
it('should only return chats with specified topicId when topicId is provided', () => {
const result = organizeChats(session, avatar, 'topic-id');
expect(result.length).toBe(0);
});
describe('user Meta', () => {
it('should return correct meta for user role', () => {
const result = organizeChats(session, avatar);
const meta = result[1].meta;
expect(meta.avatar).toBe(avatar.user);
});
it('should return correct meta for assistant role', () => {
const result = organizeChats(session, avatar);
const meta = result[0].meta;
expect(meta.avatar).toBe(avatar.assistant);
expect(meta.title).toBeUndefined();
});
describe('should return correct meta for function role', () => {
it('找不到插件', () => {
const message = {
id: '4',
createAt: 1927785600004,
updateAt: 1927785600004,
role: 'function',
function_call: {
name: 'plugin-name',
},
} as ChatMessage;
session.chats[message.id] = message;
const result = organizeChats(session, avatar);
const meta = result[3].meta;
expect(meta.avatar).toBe('🧩');
expect(meta.title).toBe('plugin-unknown');
});
it('找到的插件', () => {
const message = {
id: '4',
createAt: 1927785600004,
updateAt: 1927785600004,
role: 'function',
function_call: {
name: 'realtimeWeather',
},
} as ChatMessage;
session.chats[message.id] = message;
const result = organizeChats(session, avatar);
const meta = result[3].meta;
expect(meta.avatar).toBe('☂️');
expect(meta.title).toBe('realtimeWeather');
});
});
});
});
@@ -5,6 +5,7 @@ import { LobeAgentSession } from '@/types/session';
export const organizeChats = (
session: LobeAgentSession,
avatar: { assistant: string; user: string },
topicId?: string,
) => {
const getMeta = (message: ChatMessage) => {
switch (message.role) {
@@ -38,8 +39,13 @@ export const organizeChats = (
const basic = Object.values<ChatMessage>(session.chats)
// 首先按照时间顺序排序,越早的在越前面
.sort((pre, next) => pre.createAt - next.createAt)
// 过滤掉包含 topicId 的消息,有主题的消息不应该出现在聊天框中
.filter((m) => !m.topicId)
.filter((m) => {
// 过滤掉包含 topicId 的消息,有主题的消息不应该出现在聊天框中
if (!topicId) return !m.topicId;
// 或者当话题 id 一致时,再展示话题
return m.topicId === topicId;
})
// 映射头像关系
.map((m) => {
return {
@@ -3,6 +3,7 @@ import { produce } from 'immer';
import { ChatMessageMap } from '@/types/chatMessage';
import { MetaData } from '@/types/meta';
import { LobeAgentConfig, LobeAgentSession, LobeSessions } from '@/types/session';
import { ChatTopicMap } from '@/types/topic';
/**
* @title 添加会话
@@ -37,6 +38,19 @@ interface UpdateSessionChat {
type: 'updateSessionChat';
}
/**
* @title 更新会话聊天上下文
*/
interface UpdateSessionTopic {
/**
* 会话 ID
*/
id: string;
topics: ChatTopicMap;
type: 'updateSessionTopic';
}
interface UpdateSessionMeta {
id: string;
key: keyof MetaData;
@@ -55,7 +69,8 @@ export type SessionDispatch =
| UpdateSessionChat
| RemoveSession
| UpdateSessionMeta
| UpdateSessionAgentConfig;
| UpdateSessionAgentConfig
| UpdateSessionTopic;
export const sessionsReducer = (state: LobeSessions, payload: SessionDispatch): LobeSessions => {
switch (payload.type) {
@@ -96,6 +111,15 @@ export const sessionsReducer = (state: LobeSessions, payload: SessionDispatch):
});
}
case 'updateSessionTopic': {
return produce(state, (draft) => {
const chat = draft[payload.id];
if (!chat) return;
chat.topics = payload.topics;
});
}
case 'updateSessionConfig': {
return produce(state, (draft) => {
const { id, config } = payload;
-1
View File
@@ -1,7 +1,6 @@
import { BaseDataModel } from '@/types/meta';
export interface ChatTopic extends Omit<BaseDataModel, 'meta'> {
chats: string[];
favorite?: boolean;
title: string;
}