mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat: 实现话题模块 (#16)
* ✨ feat: 初步实现话题功能 * ✨ feat: 实现话题的删除与二次对话 * ✅ test: 补充 topic 相关测试 * ✅ test: 补充 chat List 相关测试
This commit is contained in:
@@ -27,8 +27,8 @@ export default {
|
||||
share: '分享',
|
||||
tokenDetail: '系统设定: {{systemRoleToken}} 历史消息: {{chatsToken}}',
|
||||
topic: {
|
||||
saveCurrentMessages: '保存当前对话为话题',
|
||||
searchPlaceholder: '搜索归档对话...',
|
||||
saveCurrentMessages: '将当前会话保存为话题',
|
||||
searchPlaceholder: '搜索话题...',
|
||||
},
|
||||
updateAgent: '更新助理信息',
|
||||
updatePrompt: '更新提示词',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
),
|
||||
[],
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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),
|
||||
});
|
||||
+33
-23
@@ -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,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,7 +1,6 @@
|
||||
import { BaseDataModel } from '@/types/meta';
|
||||
|
||||
export interface ChatTopic extends Omit<BaseDataModel, 'meta'> {
|
||||
chats: string[];
|
||||
favorite?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user