feat: server implement

This commit is contained in:
arvinxx
2025-12-20 22:48:19 +08:00
parent 34d059ffae
commit 685a6cd5a5
309 changed files with 23845 additions and 1021 deletions
@@ -0,0 +1,38 @@
import { ActionIcon } from '@lobehub/ui';
import { Bot } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentId } from '@/features/ChatInput/hooks/useAgentId';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
const AgentModeToggle = memo(() => {
const { t } = useTranslation('chat');
const agentId = useAgentId();
const [enableAgentMode, updateAgentConfigById] = useAgentStore((s) => [
agentByIdSelectors.getAgentEnableModeById(agentId)(s),
s.updateAgentConfigById,
]);
const handleToggle = (checked: boolean) => {
updateAgentConfigById(agentId, { enableAgentMode: checked });
};
return (
<ActionIcon
icon={Bot}
onClick={() => {
handleToggle(!enableAgentMode);
}}
style={{
color: enableAgentMode ? 'var(--colorPrimary)' : undefined,
}}
title={t('agentMode.title', { defaultValue: 'Agent Mode' })}
/>
);
});
AgentModeToggle.displayName = 'AgentModeToggle';
export default AgentModeToggle;
@@ -1,11 +1,14 @@
import { Form, type FormItemProps, SliderWithInput } from '@lobehub/ui';
import { Switch, Form as AntdForm } from 'antd';
import { debounce } from 'lodash-es';
import { Form as AntdForm, Switch } from 'antd';
import { debounce } from 'es-toolkit/compat';
import { memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
interface ControlsProps {
setUpdating: (updating: boolean) => void;
@@ -14,14 +17,13 @@ interface ControlsProps {
const Controls = memo<ControlsProps>(({ updating, setUpdating }) => {
const { t } = useTranslation('setting');
const [form] = AntdForm.useForm();
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [historyCount, enableHistoryCount, updateAgentConfig] = useAgentStore((s) => {
return [
agentChatConfigSelectors.historyCount(s),
agentChatConfigSelectors.enableHistoryCount(s),
s.updateAgentChatConfig,
];
});
const [historyCount, enableHistoryCount] = useAgentStore((s) => [
chatConfigByIdSelectors.getHistoryCountById(agentId)(s),
chatConfigByIdSelectors.getEnableHistoryCountById(agentId)(s),
]);
// Sync external store updates to the form without remounting to keep Switch animation
useEffect(() => {
@@ -73,7 +75,7 @@ const Controls = memo<ControlsProps>(({ updating, setUpdating }) => {
itemsType={'flat'}
onValuesChange={debounce(async (values) => {
setUpdating(true);
await updateAgentConfig(values);
await updateAgentChatConfig(values);
setUpdating(false);
}, 500)}
styles={{
@@ -2,18 +2,21 @@ import { Timer, TimerOff } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
import Action from '../components/Action';
import Controls from './Controls';
const History = memo(() => {
const [isLoading, chatConfig, updateAgentChatConfig] = useAgentStore((s) => [
agentSelectors.isAgentConfigLoading(s),
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [isLoading, chatConfig] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getChatConfigById(agentId)(s),
]);
const [updating, setUpdating] = useState(false);
const { t } = useTranslation('setting');
@@ -21,8 +24,8 @@ const History = memo(() => {
const [historyCount, enableHistoryCount] = useAgentStore((s) => {
return [
agentChatConfigSelectors.historyCount(s),
agentChatConfigSelectors.enableHistoryCount(s),
chatConfigByIdSelectors.getHistoryCountById(agentId)(s),
chatConfigByIdSelectors.getEnableHistoryCountById(agentId)(s),
];
});
@@ -43,11 +46,11 @@ const History = memo(() => {
isMobile
? undefined
: async (e) => {
e?.preventDefault?.();
e?.stopPropagation?.();
const next = !Boolean(chatConfig.enableHistoryCount);
await updateAgentChatConfig({ enableHistoryCount: next });
}
e?.preventDefault?.();
e?.stopPropagation?.();
const next = !Boolean(chatConfig.enableHistoryCount);
await updateAgentChatConfig({ enableHistoryCount: next });
}
}
popover={{
content: <Controls setUpdating={setUpdating} updating={updating} />,
@@ -4,7 +4,7 @@ import { Suspense, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import TipGuide from '@/components/TipGuide';
import { AttachKnowledgeModal } from '@/features/KnowledgeBaseModal';
import { AttachKnowledgeModal } from '@/features/LibraryModal';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
@@ -4,10 +4,11 @@ import { ArrowRight, LibraryBig } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import FileIcon from '@/components/FileIcon';
import RepoIcon from '@/components/RepoIcon';
import RepoIcon from '@/components/LibIcon';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import CheckboxItem from '../components/CheckbokWithLoading';
export const useControls = ({
@@ -18,9 +19,13 @@ export const useControls = ({
setUpdating: (updating: boolean) => void;
}) => {
const { t } = useTranslation('chat');
const agentId = useAgentId();
const files = useAgentStore(agentSelectors.currentAgentFiles, isEqual);
const knowledgeBases = useAgentStore(agentSelectors.currentAgentKnowledgeBases, isEqual);
const files = useAgentStore((s) => agentByIdSelectors.getAgentFilesById(agentId)(s), isEqual);
const knowledgeBases = useAgentStore(
(s) => agentByIdSelectors.getAgentKnowledgeBasesById(agentId)(s),
isEqual,
);
const [toggleFile, toggleKnowledgeBase] = useAgentStore((s) => [
s.toggleFile,
@@ -38,7 +43,7 @@ export const useControls = ({
// {
// icon: <RepoIcon />,
// key: 'allRepos',
// label: <KnowledgeBaseItem id={'all'} label={t('knowledgeBase.allKnowledgeBases')} />,
// label: <KnowledgeBaseItem id={'all'} label={t('knowledgeBase.allLibraries')} />,
// },
// ],
// key: 'all',
@@ -88,8 +93,8 @@ export const useControls = ({
),
})),
],
key: 'relativeFilesOrKnowledgeBases',
label: t('knowledgeBase.relativeFilesOrKnowledgeBases'),
key: 'relativeFilesOrLibraries',
label: t('knowledgeBase.relativeFilesOrLibraries'),
type: 'group',
},
{
@@ -30,7 +30,7 @@ const Mention = memo(() => {
<Avatar
avatar={agent.avatar}
background={agent.backgroundColor ?? undefined}
shape="circle"
shape={'square'}
size={24}
/>
),
@@ -7,9 +7,11 @@ import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
import ContextCachingSwitch from './ContextCachingSwitch';
import GPT5ReasoningEffortSlider from './GPT5ReasoningEffortSlider';
import GPT51ReasoningEffortSlider from './GPT51ReasoningEffortSlider';
@@ -24,15 +26,19 @@ import ThinkingSlider from './ThinkingSlider';
const ControlsForm = memo(() => {
const { t } = useTranslation('chat');
const [model, provider, updateAgentChatConfig] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.currentAgentModelProvider(s),
s.updateAgentChatConfig,
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [model, provider] = useAgentStore((s) => [
agentByIdSelectors.getAgentModelById(agentId)(s),
agentByIdSelectors.getAgentModelProviderById(agentId)(s),
]);
const [form] = Form.useForm();
const enableReasoning = AntdForm.useWatch(['enableReasoning'], form);
const config = useAgentStore(agentChatConfigSelectors.currentChatConfig, isEqual);
const config = useAgentStore(
(s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s),
isEqual,
);
const modelExtendParams = useAiInfraStore(aiModelSelectors.modelExtendParams(model, provider));
@@ -3,13 +3,15 @@ import { memo, useCallback } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
const GPT51ReasoningEffortSlider = memo(() => {
const [config, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const config = useAgentStore((s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s));
const gpt5_1ReasoningEffort = config.gpt5_1ReasoningEffort || 'none'; // Default to 'none' if not set
@@ -3,13 +3,15 @@ import { memo, useCallback } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
const GPT5ReasoningEffortSlider = memo(() => {
const [config, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const config = useAgentStore((s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s));
const gpt5ReasoningEffort = config.gpt5ReasoningEffort || 'medium'; // Default to 'medium' if not set
@@ -2,7 +2,10 @@ import { Select } from 'antd';
import { memo, useCallback, useMemo } from 'react';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
const NANO_BANANA_ASPECT_RATIOS = [
'1:1', // 1024x1024 / 2048x2048 / 4096x4096
@@ -18,10 +21,9 @@ const NANO_BANANA_ASPECT_RATIOS = [
];
const ImageAspectRatioSelect = memo(() => {
const [config, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const config = useAgentStore((s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s));
const imageAspectRatio = config.imageAspectRatio || '1:1';
@@ -3,16 +3,18 @@ import { memo, useCallback } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
const IMAGE_RESOLUTIONS = ['1K', '2K', '4K'] as const;
type ImageResolution = (typeof IMAGE_RESOLUTIONS)[number];
const ImageResolutionSlider = memo(() => {
const [config, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const config = useAgentStore((s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s));
const imageResolution = (config.imageResolution as ImageResolution) || '1K';
@@ -3,13 +3,15 @@ import { memo, useCallback } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
const ReasoningEffortSlider = memo(() => {
const [config, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const config = useAgentStore((s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s));
const reasoningEffort = config.reasoningEffort || 'medium'; // Default to 'medium' if not set
@@ -3,13 +3,15 @@ import { memo, useCallback } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
const TextVerbositySlider = memo(() => {
const [config, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const config = useAgentStore((s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s));
const textVerbosity = config.textVerbosity || 'medium'; // Default to 'medium' if not set
@@ -3,13 +3,15 @@ import { memo, useCallback } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
const ThinkingLevelSlider = memo(() => {
const [config, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const config = useAgentStore((s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s));
const thinkingLevel = config.thinkingLevel || 'high'; // Default to 'high' if not set
@@ -3,13 +3,15 @@ import { memo, useCallback } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/selectors';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
const ThinkingSlider = memo(() => {
const [config, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.currentChatConfig(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const config = useAgentStore((s) => chatConfigByIdSelectors.getChatConfigById(agentId)(s));
const thinking = config.thinking || 'auto'; // Default to 'auto' if not set
@@ -1,16 +1,18 @@
import { ModelIcon } from '@lobehub/icons';
import { createStyles } from 'antd-style';
import { Settings2Icon } from 'lucide-react';
import { memo } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import ModelSwitchPanel from '@/features/ModelSwitchPanel';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
import { useActionBarContext } from '../context';
import ControlsForm from './ControlsForm';
const useStyles = createStyles(({ css, token, cx }) => ({
@@ -55,19 +57,34 @@ const useStyles = createStyles(({ css, token, cx }) => ({
const ModelSwitch = memo(() => {
const { t } = useTranslation('chat');
const { styles, cx } = useStyles();
const { dropdownPlacement } = useActionBarContext();
const [model, provider] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.currentAgentModelProvider(s),
const agentId = useAgentId();
const [model, provider, updateAgentConfigById] = useAgentStore((s) => [
agentByIdSelectors.getAgentModelById(agentId)(s),
agentByIdSelectors.getAgentModelProviderById(agentId)(s),
s.updateAgentConfigById,
]);
const isModelHasExtendParams = useAiInfraStore(
aiModelSelectors.isModelHasExtendParams(model, provider),
);
const handleModelChange = useCallback(
async (params: { model: string; provider: string }) => {
await updateAgentConfigById(agentId, params);
},
[agentId, updateAgentConfigById],
);
return (
<Flexbox align={'center'} className={isModelHasExtendParams ? styles.container : ''} horizontal>
<ModelSwitchPanel>
<ModelSwitchPanel
model={model}
onModelChange={handleModelChange}
placement={dropdownPlacement}
provider={provider}
>
<Center
className={cx(styles.model, isModelHasExtendParams && styles.modelWithControl)}
height={36}
@@ -1,8 +1,8 @@
import { Form, type FormItemProps, Tag } from '@lobehub/ui';
import { Form as AntdForm, Checkbox } from 'antd';
import { createStyles } from 'antd-style';
import { debounce } from 'es-toolkit/compat';
import isEqual from 'fast-deep-equal';
import { debounce } from 'lodash-es';
import { memo, useCallback, useEffect, useRef } from 'react';
import type { ComponentType } from 'react';
import { useTranslation } from 'react-i18next';
@@ -16,9 +16,12 @@ import {
TopP,
} from '@/features/ModelParamsControl';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useServerConfigStore } from '@/store/serverConfig';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
interface ControlsProps {
setUpdating: (updating: boolean) => void;
updating: boolean;
@@ -142,10 +145,11 @@ const PARAM_CONFIG = {
const Controls = memo<ControlsProps>(({ setUpdating }) => {
const { t } = useTranslation('setting');
const mobile = useServerConfigStore((s) => s.isMobile);
const updateAgentConfig = useAgentStore((s) => s.updateAgentConfig);
const agentId = useAgentId();
const { updateAgentConfig } = useUpdateAgentConfig();
const { styles } = useStyles();
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
const config = useAgentStore((s) => agentByIdSelectors.getAgentConfigById(agentId)(s), isEqual);
const [form] = Form.useForm();
const { frequency_penalty, presence_penalty, temperature, top_p } = config.params ?? {};
@@ -3,13 +3,17 @@ import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/slices/chat';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
import Controls from './Controls';
const Params = memo(() => {
const [isLoading] = useAgentStore((s) => [agentSelectors.isAgentConfigLoading(s)]);
const agentId = useAgentId();
const [isLoading] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
]);
const [updating, setUpdating] = useState(false);
const { t } = useTranslation('setting');
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
import { SWRConfiguration } from 'swr';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/slices/chat';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
@@ -15,6 +15,7 @@ import { globalGeneralSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { settingsSelectors } from '@/store/user/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import CommonSTT from './common';
interface STTConfig extends SWRConfiguration {
@@ -23,7 +24,11 @@ interface STTConfig extends SWRConfiguration {
const useBrowserSTT = (config: STTConfig) => {
const ttsSettings = useUserStore(settingsSelectors.currentTTS, isEqual);
const ttsAgentSettings = useAgentStore(agentSelectors.currentAgentTTS, isEqual);
const agentId = useAgentId();
const ttsAgentSettings = useAgentStore(
(s) => agentByIdSelectors.getAgentTTSById(agentId)(s),
isEqual,
);
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const autoStop = ttsSettings.sttAutoStop;
@@ -10,7 +10,7 @@ import { SWRConfiguration } from 'swr';
import { createHeaderWithOpenAI } from '@/services/_header';
import { API_ENDPOINTS } from '@/services/_url';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
@@ -18,6 +18,7 @@ import { globalGeneralSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { settingsSelectors } from '@/store/user/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import CommonSTT from './common';
interface STTConfig extends SWRConfiguration {
@@ -26,7 +27,11 @@ interface STTConfig extends SWRConfiguration {
const useOpenaiSTT = (config: STTConfig) => {
const ttsSettings = useUserStore(settingsSelectors.currentTTS, isEqual);
const ttsAgentSettings = useAgentStore(agentSelectors.currentAgentTTS, isEqual);
const agentId = useAgentId();
const ttsAgentSettings = useAgentStore(
(s) => agentByIdSelectors.getAgentTTSById(agentId)(s),
isEqual,
);
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const autoStop = ttsSettings.sttAutoStop;
@@ -8,10 +8,12 @@ import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/slices/chat';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { aiModelSelectors, aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra';
import { SearchMode } from '@/types/search';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
import FCSearchModel from './FCSearchModel';
import ModelBuiltinSearch from './ModelBuiltinSearch';
@@ -64,10 +66,9 @@ interface NetworkOption {
const Item = memo<NetworkOption>(({ value, description, icon, label }) => {
const { cx, styles } = useStyles();
const [mode, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.agentSearchMode(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const mode = useAgentStore((s) => chatConfigByIdSelectors.getSearchModeById(agentId)(s));
return (
<Flexbox
@@ -93,12 +94,14 @@ const Item = memo<NetworkOption>(({ value, description, icon, label }) => {
const Controls = memo(() => {
const { t } = useTranslation('chat');
const [model, provider, useModelBuiltinSearch, searchMode, updateAgentChatConfig] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.currentAgentModelProvider(s),
agentChatConfigSelectors.useModelBuiltinSearch(s),
agentChatConfigSelectors.currentChatConfig(s).searchMode,
s.updateAgentChatConfig,
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [model, provider, useModelBuiltinSearch, searchMode] = useAgentStore((s) => [
agentByIdSelectors.getAgentModelById(agentId)(s),
agentByIdSelectors.getAgentModelProviderById(agentId)(s),
chatConfigByIdSelectors.getUseModelBuiltinSearchById(agentId)(s),
chatConfigByIdSelectors.getChatConfigById(agentId)(s).searchMode,
]);
const supportFC = useAiInfraStore(aiModelSelectors.isModelSupportToolUse(model, provider));
@@ -111,7 +114,9 @@ const Controls = memo(() => {
const isModelBuiltinSearchInternal = useAiInfraStore(
aiModelSelectors.isModelBuiltinSearchInternal(model, provider),
);
const modelBuiltinSearchImpl = useAiInfraStore(aiModelSelectors.modelBuiltinSearchImpl(model, provider));
const modelBuiltinSearchImpl = useAiInfraStore(
aiModelSelectors.modelBuiltinSearchImpl(model, provider),
);
useEffect(() => {
if (isModelBuiltinSearchInternal && (searchMode ?? 'off') === 'off') {
@@ -121,33 +126,35 @@ const Controls = memo(() => {
const options: NetworkOption[] = isModelBuiltinSearchInternal
? [
{
description: t('search.mode.auto.desc'),
icon: SparkleIcon,
label: t('search.mode.auto.title'),
value: 'auto',
},
]
{
description: t('search.mode.auto.desc'),
icon: SparkleIcon,
label: t('search.mode.auto.title'),
value: 'auto',
},
]
: [
{
description: t('search.mode.off.desc'),
icon: GlobeOffIcon,
label: t('search.mode.off.title'),
value: 'off',
},
{
description: t('search.mode.auto.desc'),
icon: SparkleIcon,
label: t('search.mode.auto.title'),
value: 'auto',
},
];
{
description: t('search.mode.off.desc'),
icon: GlobeOffIcon,
label: t('search.mode.off.title'),
value: 'off',
},
{
description: t('search.mode.auto.desc'),
icon: SparkleIcon,
label: t('search.mode.auto.title'),
value: 'auto',
},
];
const showModelBuiltinSearch = !isModelBuiltinSearchInternal &&
const showModelBuiltinSearch =
!isModelBuiltinSearchInternal &&
(isModelHasBuiltinSearchConfig || isProviderHasBuiltinSearchConfig);
const showFCSearchModel =
!supportFC && (!modelBuiltinSearchImpl || (!isModelBuiltinSearchInternal && !useModelBuiltinSearch));
!supportFC &&
(!modelBuiltinSearchImpl || (!isModelBuiltinSearchInternal && !useModelBuiltinSearch));
const showDivider = showModelBuiltinSearch || showFCSearchModel;
@@ -5,8 +5,10 @@ import { Flexbox } from 'react-layout-kit';
import InfoTooltip from '@/components/InfoTooltip';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors } from '@/store/agent/slices/chat';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
import FunctionCallingModelSelect from './FunctionCallingModelSelect';
const useStyles = createStyles(({ css, token }) => ({
@@ -34,10 +36,11 @@ const useStyles = createStyles(({ css, token }) => ({
const FCSearchModel = memo(() => {
const { t } = useTranslation('chat');
const { styles } = useStyles();
const [searchFCModel, updateAgentChatConfig] = useAgentStore((s) => [
agentChatConfigSelectors.searchFCModel(s),
s.updateAgentChatConfig,
]);
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const searchFCModel = useAgentStore((s) =>
chatConfigByIdSelectors.getSearchFCModelById(agentId)(s),
);
return (
<Flexbox distribution={'space-between'} gap={16} horizontal padding={8}>
<Flexbox align={'center'} gap={4} horizontal>
@@ -7,9 +7,12 @@ import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
interface SearchEngineIconProps {
icon?: string;
}
@@ -32,11 +35,12 @@ const SearchEngineIcon = ({ icon }: SearchEngineIconProps) => {
const ModelBuiltinSearch = memo(() => {
const { t } = useTranslation('chat');
const [model, provider, checked, updateAgentChatConfig] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.currentAgentModelProvider(s),
agentChatConfigSelectors.useModelBuiltinSearch(s),
s.updateAgentChatConfig,
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [model, provider, checked] = useAgentStore((s) => [
agentByIdSelectors.getAgentModelById(agentId)(s),
agentByIdSelectors.getAgentModelProviderById(agentId)(s),
chatConfigByIdSelectors.getUseModelBuiltinSearchById(agentId)(s),
]);
const [isLoading, setLoading] = useState(false);
@@ -4,21 +4,23 @@ import { Globe } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentEnableSearch } from '@/hooks/useAgentEnableSearch';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentChatConfigSelectors } from '@/store/agent/slices/chat';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useAgentEnableSearch } from '../../hooks/useAgentEnableSearch';
import { useAgentId } from '../../hooks/useAgentId';
import { useUpdateAgentConfig } from '../../hooks/useUpdateAgentConfig';
import Action from '../components/Action';
import Controls from './Controls';
const Search = memo(() => {
const { t } = useTranslation('chat');
const [isLoading, mode, updateAgentChatConfig] = useAgentStore((s) => [
agentSelectors.isAgentConfigLoading(s),
agentChatConfigSelectors.agentSearchMode(s),
s.updateAgentChatConfig,
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [isLoading, mode] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getSearchModeById(agentId)(s),
]);
const isAgentEnableSearch = useAgentEnableSearch();
const theme = useTheme();
@@ -11,12 +11,13 @@ import { useModelContextWindowTokens } from '@/hooks/useModelContextWindowTokens
import { useModelSupportToolUse } from '@/hooks/useModelSupportToolUse';
import { useTokenCount } from '@/hooks/useTokenCount';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { dbMessageSelectors, topicSelectors } from '@/store/chat/selectors';
import { useToolStore } from '@/store/tool';
import { toolSelectors } from '@/store/tool/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import ActionPopover from '../components/ActionPopover';
import TokenProgress from './TokenProgress';
@@ -32,29 +33,30 @@ const Token = memo<TokenTagProps>(({ total: messageString }) => {
topicSelectors.currentActiveTopicSummary(s)?.content || '',
]);
const agentId = useAgentId();
const [systemRole, model, provider] = useAgentStore((s) => {
return [
agentSelectors.currentAgentSystemRole(s),
agentSelectors.currentAgentModel(s) as string,
agentSelectors.currentAgentModelProvider(s) as string,
agentByIdSelectors.getAgentSystemRoleById(agentId)(s),
agentByIdSelectors.getAgentModelById(agentId)(s),
agentByIdSelectors.getAgentModelProviderById(agentId)(s),
// add these two params to enable the component to re-render
agentChatConfigSelectors.historyCount(s),
agentChatConfigSelectors.enableHistoryCount(s),
chatConfigByIdSelectors.getHistoryCountById(agentId)(s),
chatConfigByIdSelectors.getEnableHistoryCountById(agentId)(s),
];
});
const [historyCount, enableHistoryCount] = useAgentStore((s) => [
agentChatConfigSelectors.historyCount(s),
agentChatConfigSelectors.enableHistoryCount(s),
chatConfigByIdSelectors.getHistoryCountById(agentId)(s),
chatConfigByIdSelectors.getEnableHistoryCountById(agentId)(s),
// need to re-render by search mode
agentChatConfigSelectors.isAgentEnableSearch(s),
chatConfigByIdSelectors.isEnableSearchById(agentId)(s),
]);
const maxTokens = useModelContextWindowTokens(model, provider);
// Tool usage token
const canUseTool = useModelSupportToolUse(model, provider);
const pluginIds = useAgentStore(agentSelectors.currentAgentPlugins);
const pluginIds = useAgentStore((s) => agentByIdSelectors.getAgentPluginsById(agentId)(s));
const toolsString = useToolStore((s) => {
const toolsEngine = createAgentToolsEngine({ model, provider });
@@ -12,12 +12,12 @@ import { useModelContextWindowTokens } from '@/hooks/useModelContextWindowTokens
import { useModelSupportToolUse } from '@/hooks/useModelSupportToolUse';
import { useTokenCount } from '@/hooks/useTokenCount';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import { useAgentGroupStore } from '@/store/agentGroup/store';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { chatGroupSelectors } from '@/store/chatGroup/selectors';
import { useChatGroupStore } from '@/store/chatGroup/store';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import { useToolStore } from '@/store/tool';
@@ -25,6 +25,7 @@ import { toolSelectors } from '@/store/tool/selectors';
import { userProfileSelectors } from '@/store/user/selectors';
import { getUserStoreState } from '@/store/user/store';
import { useAgentId } from '../../hooks/useAgentId';
import ActionPopover from '../components/ActionPopover';
import TokenProgress from './TokenProgress';
@@ -39,19 +40,22 @@ const TokenTagForGroupChat = memo<TokenTagForGroupChatProps>(({ total: messageSt
const input = useChatStore((s) => s.inputMessage);
const activeTopicId = useChatStore((s) => s.activeTopicId);
const agentId = useAgentId();
const [model, provider] = useAgentStore((s) => {
return [
agentSelectors.currentAgentModel(s) as string,
agentSelectors.currentAgentModelProvider(s) as string,
agentByIdSelectors.getAgentModelById(agentId)(s),
agentByIdSelectors.getAgentModelProviderById(agentId)(s),
];
});
// Group chat specific data
const groupAgents = useSessionStore(sessionSelectors.currentGroupAgents);
const activeSessionId = useSessionStore((s) => s.activeId);
const groupConfig = useChatGroupStore(chatGroupSelectors.currentGroupConfig);
const groupConfig = useAgentGroupStore(agentGroupSelectors.currentGroupConfig);
const supervisorTodos = useChatStore((s) =>
activeSessionId ? s.supervisorTodos[messageMapKey(activeSessionId, activeTopicId)] || [] : [],
activeSessionId
? s.supervisorTodos[messageMapKey({ agentId: activeSessionId, topicId: activeTopicId })] || []
: [],
);
const maxTokens = useModelContextWindowTokens(model, provider);
@@ -327,6 +327,7 @@ const KlavisServerItem = memo<KlavisServerItemProps>(
return (
<Flexbox
align={'center'}
gap={24}
horizontal
justify={'space-between'}
@@ -1,4 +1,5 @@
import { Segmented } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Blocks } from 'lucide-react';
import { Suspense, memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -6,20 +7,45 @@ import { useTranslation } from 'react-i18next';
import PluginStore from '@/features/PluginStore';
import { useModelSupportToolUse } from '@/hooks/useModelSupportToolUse';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import {
featureFlagsSelectors,
serverConfigSelectors,
useServerConfigStore,
} from '@/store/serverConfig';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
import { useControls } from './useControls';
type TabType = 'all' | 'installed';
const useStyles = createStyles(({ css, prefixCls, token }) => ({
dropdown: css`
overflow: hidden;
width: 100%;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgElevated};
box-shadow: ${token.boxShadowSecondary};
.${prefixCls}-dropdown-menu {
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
`,
header: css`
padding: ${token.paddingXS}px;
border-block-end: 1px solid ${token.colorBorderSecondary};
background: transparent;
`,
scroller: css`
overflow: hidden auto;
`,
}));
const Tools = memo(() => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const [modalOpen, setModalOpen] = useState(false);
const [updating, setUpdating] = useState(false);
const [activeTab, setActiveTab] = useState<TabType | null>(null);
@@ -27,7 +53,7 @@ const Tools = memo(() => {
setModalOpen,
setUpdating,
});
const { enablePlugins } = useServerConfigStore(featureFlagsSelectors);
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
const isInitializedRef = useRef(false);
@@ -39,12 +65,12 @@ const Tools = memo(() => {
}
}, [installedPluginItems.length]);
const model = useAgentStore(agentSelectors.currentAgentModel);
const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
const agentId = useAgentId();
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
const enableFC = useModelSupportToolUse(model, provider);
if (!enablePlugins) return null;
if (!enableFC)
return <Action disabled icon={Blocks} showTooltip={true} title={t('tools.disabled')} />;
@@ -56,37 +82,48 @@ const Tools = memo(() => {
<Suspense fallback={<Action disabled icon={Blocks} title={t('tools.title')} />}>
<Action
dropdown={{
maxHeight: 500,
maxWidth: 480,
maxWidth: 320,
menu: {
items: [
{
key: 'tabs',
label: (
<Segmented
block
onChange={(v) => setActiveTab(v as TabType)}
options={[
{
label: t('tools.tabs.all', { defaultValue: 'all' }),
value: 'all',
},
{
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
value: 'installed',
},
]}
size="small"
value={effectiveTab}
/>
),
type: 'group',
},
...currentItems,
],
items: [...currentItems],
style: {
// let only the custom scroller scroll
maxHeight: 'unset',
overflowY: 'visible',
},
},
minHeight: enableKlavis ? 500 : undefined,
minWidth: 320,
popupRender: (menu) => (
<div className={styles.dropdown}>
<div className={styles.header}>
<Segmented
block
onChange={(v) => setActiveTab(v as TabType)}
options={[
{
label: t('tools.tabs.all', { defaultValue: 'all' }),
value: 'all',
},
{
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
value: 'installed',
},
]}
size="small"
value={effectiveTab}
/>
</div>
<div
className={styles.scroller}
style={{
maxHeight: 500,
minHeight: enableKlavis ? 500 : undefined,
}}
>
{menu}
</div>
</div>
),
}}
icon={Blocks}
loading={updating}
@@ -1,9 +1,8 @@
import { KLAVIS_SERVER_TYPES, KlavisServerType } from '@lobechat/const';
import { Avatar, Icon, ItemType } from '@lobehub/ui';
import { Avatar, Icon, Image, ItemType } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ArrowRight, Store, ToyBrick } from 'lucide-react';
import Image from 'next/image';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
@@ -12,7 +11,7 @@ import PluginAvatar from '@/components/Plugins/PluginAvatar';
import { useCheckPluginsIsInstalled } from '@/hooks/useCheckPluginsIsInstalled';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
import {
@@ -21,6 +20,7 @@ import {
pluginSelectors,
} from '@/store/tool/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import KlavisServerItem from './KlavisServerItem';
import ToolItem from './ToolItem';
@@ -33,9 +33,7 @@ const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label
const theme = useTheme();
if (typeof icon === 'string') {
return (
<Image alt={label} height={18} src={icon} style={{ flex: 'none' }} unoptimized width={18} />
);
return <Image alt={label} height={18} src={icon} style={{ flex: 'none' }} width={18} />;
}
// 使用主题色填充,在深色模式下自动适应
@@ -52,19 +50,20 @@ export const useControls = ({
setUpdating: (updating: boolean) => void;
}) => {
const { t } = useTranslation('setting');
const agentId = useAgentId();
const list = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
const [checked, togglePlugin] = useAgentStore((s) => [
agentSelectors.currentAgentPlugins(s),
agentByIdSelectors.getAgentPluginsById(agentId)(s),
s.togglePlugin,
]);
const builtinList = useToolStore(builtinToolSelectors.metaList, isEqual);
const enablePluginCount = useAgentStore(
(s) =>
agentSelectors
.currentAgentPlugins(s)
agentByIdSelectors
.getAgentPluginsById(agentId)(s)
.filter((i) => !builtinList.some((b) => b.identifier === i)).length,
);
const plugins = useAgentStore((s) => agentSelectors.currentAgentPlugins(s));
const plugins = useAgentStore((s) => agentByIdSelectors.getAgentPluginsById(agentId)(s));
// Klavis 相关状态
const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
@@ -127,7 +126,9 @@ export const useControls = ({
() => [
// 原有的 builtin 工具
...filteredBuiltinList.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
icon: (
<Avatar avatar={item.meta.avatar} shape={'square'} size={20} style={{ flex: 'none' }} />
),
key: item.identifier,
label: (
<ToolItem
@@ -212,7 +213,9 @@ export const useControls = ({
const enabledBuiltinItems = filteredBuiltinList
.filter((item) => checked.includes(item.identifier))
.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
icon: (
<Avatar avatar={item.meta.avatar} shape={'square'} size={20} style={{ flex: 'none' }} />
),
key: item.identifier,
label: (
<ToolItem
@@ -9,9 +9,10 @@ import { useTranslation } from 'react-i18next';
import { message } from '@/components/AntdStaticMethods';
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useFileStore } from '@/store/file';
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
const hotArea = css`
@@ -28,8 +29,9 @@ const FileUpload = memo(() => {
const upload = useFileStore((s) => s.uploadChatFiles);
const model = useAgentStore(agentSelectors.currentAgentModel);
const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
const agentId = useAgentId();
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
const canUploadImage = useModelSupportVision(model, provider);
@@ -1,12 +1,13 @@
'use client';
import { ActionIcon, type ActionIconProps } from '@lobehub/ui';
import { isUndefined } from 'lodash-es';
import { isUndefined } from 'es-toolkit/compat';
import { memo } from 'react';
import useMergeState from 'use-merge-value';
import { useServerConfigStore } from '@/store/serverConfig';
import { useActionBarContext } from '../context';
import ActionDropdown, { ActionDropdownProps } from './ActionDropdown';
import ActionPopover, { ActionPopoverProps } from './ActionPopover';
@@ -39,6 +40,7 @@ const Action = memo<ActionProps>(
value: open,
});
const mobile = useServerConfigStore((s) => s.isMobile);
const { dropdownPlacement } = useActionBarContext();
const iconNode = (
<ActionIcon
disabled={disabled}
@@ -72,7 +74,7 @@ const Action = memo<ActionProps>(
trigger={trigger}
{...dropdown}
minWidth={mobile ? '100%' : dropdown.minWidth}
placement={mobile ? 'top' : dropdown.placement}
placement={mobile ? 'top' : (dropdownPlacement ?? dropdown.placement)}
>
{iconNode}
</ActionDropdown>
@@ -85,7 +87,7 @@ const Action = memo<ActionProps>(
trigger={trigger}
{...popover}
minWidth={mobile ? '100%' : popover.minWidth}
placement={mobile ? 'top' : popover.placement}
placement={mobile ? 'top' : (dropdownPlacement ?? popover.placement)}
>
{iconNode}
</ActionPopover>
@@ -22,6 +22,7 @@ const CheckboxItem = memo<CheckboxItemProps>(({ id, onUpdate, label, checked })
return (
<Flexbox
align={'center'}
gap={24}
horizontal
justify={'space-between'}
@@ -1,3 +1,4 @@
import AgentMode from './AgentMode';
import Clear from './Clear';
import History from './History';
import Knowledge from './Knowledge';
@@ -13,6 +14,7 @@ import Typo from './Typo';
import Upload from './Upload';
export const actionMap = {
agentMode: AgentMode,
clear: Clear,
fileUpload: Upload,
groupChatToken: GroupChatToken,
@@ -0,0 +1,18 @@
'use client';
import { createContext, useContext } from 'react';
/**
* Common placement values supported by both Dropdown and Popover
*/
type DropdownPlacement = 'bottom' | 'bottomLeft' | 'bottomRight' | 'top' | 'topLeft' | 'topRight';
export interface ActionBarContextValue {
dropdownPlacement?: DropdownPlacement;
}
export const ActionBarContext = createContext<ActionBarContextValue>({});
export const useActionBarContext = () => useContext(ActionBarContext);
export type { DropdownPlacement };
+19 -10
View File
@@ -8,6 +8,7 @@ import { labPreferSelectors } from '@/store/user/slices/preference/selectors';
import { ActionKeys, actionMap } from '../ActionBar/config';
import { useChatInputStore } from '../store';
import { ActionBarContext, type DropdownPlacement } from './context';
const mapActionsToItems = (keys: ActionKeys[]): ChatInputActionsProps['items'] =>
keys.map((actionKey, index) => {
@@ -39,7 +40,11 @@ const mapActionsToItems = (keys: ActionKeys[]): ChatInputActionsProps['items'] =
}
});
const ActionToolbar = memo(() => {
export interface ActionToolbarProps {
dropdownPlacement?: DropdownPlacement;
}
const ActionToolbar = memo<ActionToolbarProps>(({ dropdownPlacement }) => {
const [expandInputActionbar, toggleExpandInputActionbar] = useGlobalStore((s) => [
systemStatusSelectors.expandInputActionbar(s),
s.toggleExpandInputActionbar,
@@ -54,16 +59,20 @@ const ActionToolbar = memo(() => {
const items = useMemo(() => mapActionsToItems(leftActions), [leftActions]);
const contextValue = useMemo(() => ({ dropdownPlacement }), [dropdownPlacement]);
return (
<ChatInputActions
collapseOffset={mobile ? 48 : 80}
defaultGroupCollapse={true}
groupCollapse={!expandInputActionbar}
items={items}
onGroupCollapseChange={(v) => {
toggleExpandInputActionbar(!v);
}}
/>
<ActionBarContext.Provider value={contextValue}>
<ChatInputActions
collapseOffset={mobile ? 48 : 80}
defaultGroupCollapse={true}
groupCollapse={!expandInputActionbar}
items={items}
onGroupCollapseChange={(v) => {
toggleExpandInputActionbar(!v);
}}
/>
</ActionBarContext.Provider>
);
});
@@ -10,6 +10,7 @@ interface ChatInputProviderProps extends StoreUpdaterProps {
export const ChatInputProvider = memo<ChatInputProviderProps>(
({
agentId,
children,
leftActions,
rightActions,
@@ -40,6 +41,7 @@ export const ChatInputProvider = memo<ChatInputProviderProps>(
}
>
<StoreUpdater
agentId={agentId}
chatInputEditorRef={chatInputEditorRef}
leftActions={leftActions}
mentionItems={mentionItems}
@@ -0,0 +1,44 @@
import { Image } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import FileIcon from '@/components/FileIcon';
import { UploadFileItem } from '@/types/files/upload';
const useStyles = createStyles(({ css }) => ({
image: css`
width: 100%;
height: 100%;
margin-block: 0 !important;
box-shadow: none;
img {
width: 100%;
height: 100%;
border-radius: 4px;
object-fit: cover;
}
`,
video: css`
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 4px;
`,
}));
const Content = memo<UploadFileItem>(({ file, previewUrl }) => {
const { styles } = useStyles();
if (file.type.startsWith('image')) {
return <Image alt={file.name} src={previewUrl} wrapperClassName={styles.image} />;
}
if (file.type.startsWith('video')) {
return <video className={styles.video} src={previewUrl} />;
}
return <FileIcon fileName={file.name} fileType={file.type} size={16} />;
});
export default Content;
@@ -0,0 +1,68 @@
import { Tag, Tooltip } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useFileStore } from '@/store/file';
import { UploadFileItem } from '@/types/files/upload';
import Content from './Content';
import { getFileBasename } from './utils';
const useStyles = createStyles(({ css, token }) => ({
closeBtn: css`
flex-shrink: 0;
color: ${token.colorTextTertiary};
&:hover {
color: ${token.colorError};
background: ${token.colorErrorBg};
}
`,
icon: css`
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 4px;
img,
video {
width: 100%;
height: 100%;
border-radius: 4px;
object-fit: cover;
}
`,
name: css`
overflow: hidden;
flex: 1;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
type FileItemProps = UploadFileItem;
const ContextItem = memo<FileItemProps>((props) => {
const { file, id } = props;
const { styles } = useStyles();
const [removeChatUploadFile] = useFileStore((s) => [s.removeChatUploadFile]);
const basename = getFileBasename(file.name);
return (
<Tag closable onClose={() => removeChatUploadFile(id)} size={'large'}>
<Flexbox className={styles.icon}>
<Content {...props} />
</Flexbox>
<Tooltip title={file.name}>
<span className={styles.name}>{basename}</span>
</Tooltip>
</Tag>
);
});
export default ContextItem;
@@ -0,0 +1,34 @@
export const getFileBasename = (filename: string): string => {
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex <= 0) return filename;
return filename.slice(0, lastDotIndex);
};
export const getHarmoniousSize = (
inputWidth: number,
inputHeight: number,
{
spacing = 24,
containerWidth,
containerHeight,
}: { containerHeight: number; containerWidth: number; spacing: number },
) => {
let width = String(inputWidth);
let height = String(inputHeight);
const maxWidth = containerWidth - spacing;
const maxHeight = containerHeight - spacing;
if (inputHeight >= inputWidth && inputHeight >= maxHeight) {
height = maxHeight + 'px';
width = 'auto';
} else if (inputWidth >= inputHeight && inputWidth >= maxWidth) {
height = 'auto';
width = maxWidth + 'px';
} else {
width = width + 'px';
height = height + 'px';
}
return { height, width };
};
@@ -0,0 +1,64 @@
import { ScrollShadow } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo, useEffect, useRef } from 'react';
import { Flexbox } from 'react-layout-kit';
import { fileChatSelectors, useFileStore } from '@/store/file';
import { useAgentId } from '../../hooks/useAgentId';
import ContextItem from './ContextItem';
import SelectionItem from './SelectionItem';
const useStyles = createStyles(({ css }) => ({
container: css`
overflow-x: scroll;
width: 100%;
`,
}));
const ContextList = memo(() => {
const agentId = useAgentId();
const prevAgentIdRef = useRef<string | undefined>(undefined);
const inputFilesList = useFileStore(fileChatSelectors.chatUploadFileList);
const showFileList = useFileStore(fileChatSelectors.chatUploadFileListHasItem);
const rawSelectionList = useFileStore(fileChatSelectors.chatContextSelections);
const showSelectionList = useFileStore(fileChatSelectors.chatContextSelectionHasItem);
const clearChatContextSelections = useFileStore((s) => s.clearChatContextSelections);
const { styles } = useStyles();
// Clear selections only when agentId changes (not on initial mount)
useEffect(() => {
if (prevAgentIdRef.current !== undefined && prevAgentIdRef.current !== agentId) {
clearChatContextSelections();
}
prevAgentIdRef.current = agentId;
}, [agentId, clearChatContextSelections]);
// Filter duplicates based on preview content
const selectionList = rawSelectionList.filter(
(item, index, self) => index === self.findIndex((t) => t.preview === item.preview),
);
if ((!inputFilesList.length || !showFileList) && !showSelectionList) return null;
return (
<ScrollShadow
className={styles.container}
hideScrollBar
horizontal
orientation={'horizontal'}
size={8}
>
<Flexbox gap={4} horizontal paddingInline={0} style={{ paddingBlockStart: 8 }} wrap={'wrap'}>
{selectionList.map((item) => (
<SelectionItem key={item.id} {...item} />
))}
{inputFilesList.map((item) => (
<ContextItem key={item.id} {...item} />
))}
</Flexbox>
</ScrollShadow>
);
});
export default ContextList;
@@ -0,0 +1,53 @@
import { ChatContextContent } from '@lobechat/types';
import { Tag, Tooltip } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { TextIcon } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useFileStore } from '@/store/file';
const useStyles = createStyles(({ css }) => ({
name: css`
overflow: hidden;
flex: 1;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
const MAX_PREVIEW_LENGTH = 8;
const getPreviewText = (content?: string, fallback?: string) => {
const source = content || fallback || '';
if (!source) return 'Text selection';
const plain = source
.replaceAll(/<[^>]*>/g, ' ')
.replaceAll(/\s+/g, ' ')
.trim();
if (!plain) return 'Text selection';
return plain.length > MAX_PREVIEW_LENGTH ? `${plain.slice(0, MAX_PREVIEW_LENGTH)}...` : plain;
};
const SelectionItem = memo<ChatContextContent>(({ preview, id }) => {
const { styles } = useStyles();
const [removeSelection] = useFileStore((s) => [s.removeChatContextSelection]);
const displayText = useMemo(() => getPreviewText(preview), [preview]);
return (
<Tag closable icon={<TextIcon size={16} />} onClose={() => removeSelection(id)} size={'large'}>
<Tooltip title={preview}>
<span className={styles.name}>{displayText}</span>
</Tooltip>
</Tag>
);
});
SelectionItem.displayName = 'SelectionItem';
export default SelectionItem;
@@ -0,0 +1,46 @@
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import DragUpload from '@/components/DragUpload';
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useFileStore } from '@/store/file';
import { useAgentId } from '../../hooks/useAgentId';
import ContextList from './ContextList';
/**
* Contains the context item to be attached, such as file, image, text, etc.
*/
const ContextContainer = memo(() => {
const agentId = useAgentId();
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
const canUploadImage = useModelSupportVision(model, provider);
const [uploadFiles] = useFileStore((s) => [s.uploadChatFiles]);
const upload = async (fileList: FileList | File[] | undefined) => {
if (!fileList || fileList.length === 0) return;
// Filter out files that are not images if the model does not support image uploads
const files = Array.from(fileList).filter((file) => {
if (canUploadImage) return true;
return !file.type.startsWith('image');
});
uploadFiles(files);
};
return (
<Flexbox paddingInline={8}>
<DragUpload onUploadFiles={upload} />
<ContextList />
</Flexbox>
);
});
export default ContextContainer;
@@ -0,0 +1,34 @@
export const getFileBasename = (filename: string): string => {
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex <= 0) return filename;
return filename.slice(0, lastDotIndex);
};
export const getHarmoniousSize = (
inputWidth: number,
inputHeight: number,
{
spacing = 24,
containerWidth,
containerHeight,
}: { containerHeight: number; containerWidth: number; spacing: number },
) => {
let width = String(inputWidth);
let height = String(inputHeight);
const maxWidth = containerWidth - spacing;
const maxHeight = containerHeight - spacing;
if (inputHeight >= inputWidth && inputHeight >= maxHeight) {
height = maxHeight + 'px';
width = 'auto';
} else if (inputWidth >= inputHeight && inputWidth >= maxWidth) {
height = 'auto';
width = maxWidth + 'px';
} else {
width = width + 'px';
height = height + 'px';
}
return { height, width };
};
@@ -3,14 +3,16 @@ import { memo } from 'react';
import DragUpload from '@/components/DragUpload';
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/slices/chat';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useFileStore } from '@/store/file';
import { useAgentId } from '../../hooks/useAgentId';
import FileItemList from './FileList';
const FilePreview = memo(() => {
const model = useAgentStore(agentSelectors.currentAgentModel);
const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
const agentId = useAgentId();
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
const canUploadImage = useModelSupportVision(model, provider);
@@ -63,7 +63,12 @@ const MentionedUserItem = memo<MentionedUserItemProps>(({ agent }) => {
return (
<Flexbox align={'center'} className={styles.container} horizontal>
<Center flex={1} height={64} padding={4} style={{ maxWidth: 64 }}>
<Avatar avatar={agent.avatar} background={agent.backgroundColor} shape="circle" size={48} />
<Avatar
avatar={agent.avatar}
background={agent.backgroundColor}
shape={'square'}
size={48}
/>
</Center>
<Flexbox flex={1} gap={4} style={{ paddingBottom: 4, paddingInline: 4 }}>
<Text ellipsis={{ tooltip: true }} style={{ fontSize: 12, maxWidth: 100 }}>
+56 -40
View File
@@ -1,28 +1,27 @@
'use client';
import { ChatInput, ChatInputActionBar } from '@lobehub/editor/react';
import { ChatInput, ChatInputActionBar, ChatInputProps } from '@lobehub/editor/react';
import { Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo, useEffect } from 'react';
import { ReactNode, memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { useChatInputStore } from '@/features/ChatInput/store';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { fileChatSelectors, useFileStore } from '@/store/file';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import ActionBar from '../ActionBar';
import ActionBar, { type ActionToolbarProps } from '../ActionBar';
import InputEditor from '../InputEditor';
import SendArea from '../SendArea';
import TypoBar from '../TypoBar';
import FilePreview from './FilePreview';
import ContextContainer from './ContextContainer';
const useStyles = createStyles(({ css, token }) => ({
const useStyles = createStyles(({ css }) => ({
container: css`
margin-block-start: -5px;
.show-on-hover {
opacity: 0;
}
@@ -43,50 +42,60 @@ const useStyles = createStyles(({ css, token }) => ({
width: 100%;
height: 100%;
padding: 12px;
background: ${token.colorBgContainerSecondary};
margin-block-start: 0;
`,
inputFullscreen: css`
border: none;
border-radius: 0 !important;
`,
}));
const DesktopChatInput = memo<{ showFootnote?: boolean }>(({ showFootnote }) => {
const { t } = useTranslation('chat');
const [chatInputHeight, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.chatInputHeight(s),
s.updateSystemStatus,
]);
const [slashMenuRef, expand, showTypoBar, editor, leftActions] = useChatInputStore((s) => [
s.slashMenuRef,
s.expand,
s.showTypoBar,
s.editor,
s.leftActions,
]);
interface DesktopChatInputProps extends ActionToolbarProps {
extenHeaderContent?: ReactNode;
inputContainerProps?: ChatInputProps;
showFootnote?: boolean;
}
const { styles, cx } = useStyles();
const DesktopChatInput = memo<DesktopChatInputProps>(
({ showFootnote, inputContainerProps, extenHeaderContent, dropdownPlacement }) => {
const { t } = useTranslation('chat');
const [chatInputHeight, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.chatInputHeight(s),
s.updateSystemStatus,
]);
const hasContextSelections = useFileStore(fileChatSelectors.chatContextSelectionHasItem);
const hasFiles = useFileStore(fileChatSelectors.chatUploadFileListHasItem);
const [slashMenuRef, expand, showTypoBar, editor, leftActions] = useChatInputStore((s) => [
s.slashMenuRef,
s.expand,
s.showTypoBar,
s.editor,
s.leftActions,
]);
const chatKey = useChatStore(chatSelectors.currentChatKey);
const { styles, cx } = useStyles();
useEffect(() => {
if (editor) editor.focus();
}, [chatKey, editor]);
const chatKey = useChatStore(chatSelectors.currentChatKey);
const fileNode = leftActions.flat().includes('fileUpload') && <FilePreview />;
useEffect(() => {
if (editor) editor.focus();
}, [chatKey, editor]);
return (
<>
{!expand && fileNode}
const shouldShowContextContainer =
leftActions.flat().includes('fileUpload') || hasContextSelections || hasFiles;
const contextContainerNode = shouldShowContextContainer && <ContextContainer />;
return (
<Flexbox
className={cx(styles.container, expand && styles.fullscreen)}
gap={8}
paddingBlock={showFootnote ? '0 8px' : '0 12px'}
paddingInline={12}
paddingBlock={expand ? 0 : showFootnote ? '0 12px' : '0 16px'}
>
<ChatInput
defaultHeight={chatInputHeight || 32}
footer={
<ChatInputActionBar
left={<ActionBar />}
left={<ActionBar dropdownPlacement={dropdownPlacement} />}
right={<SendArea />}
style={{
paddingRight: 8,
@@ -94,7 +103,13 @@ const DesktopChatInput = memo<{ showFootnote?: boolean }>(({ showFootnote }) =>
/>
}
fullscreen={expand}
header={showTypoBar && <TypoBar />}
header={
<Flexbox gap={0}>
{extenHeaderContent}
{showTypoBar && <TypoBar />}
{contextContainerNode}
</Flexbox>
}
maxHeight={320}
minHeight={36}
onSizeChange={(height) => {
@@ -102,8 +117,9 @@ const DesktopChatInput = memo<{ showFootnote?: boolean }>(({ showFootnote }) =>
}}
resize={true}
slashMenuRef={slashMenuRef}
{...inputContainerProps}
className={cx(expand && styles.inputFullscreen, inputContainerProps?.className)}
>
{expand && fileNode}
<InputEditor />
</ChatInput>
{showFootnote && !expand && (
@@ -114,9 +130,9 @@ const DesktopChatInput = memo<{ showFootnote?: boolean }>(({ showFootnote }) =>
</Center>
)}
</Flexbox>
</>
);
});
);
},
);
DesktopChatInput.displayName = 'DesktopChatInput';
@@ -1,37 +1,41 @@
import { KeyEnum } from '@lobechat/types';
import { Hotkey, combineKeys } from '@lobehub/ui';
import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Trans } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
const Placeholder = memo(() => {
const { t } = useTranslation(['editor', 'chat']);
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
const wrapperShortcut = useCmdEnterToSend
? KeyEnum.Enter
: combineKeys([KeyEnum.Mod, KeyEnum.Enter]);
return (
<Flexbox align={'center'} as={'span'} gap={4} horizontal>
{t('sendPlaceholder', { ns: 'chat' }).replace('...', ', ')}
<Flexbox align={'center'} as={'span'} gap={4} horizontal wrap={'wrap'}>
<Trans
as={'span'}
components={{
key: (
<Hotkey
as={'span'}
keys={wrapperShortcut}
style={{ color: 'inherit' }}
styles={{ kbdStyle: { color: 'inhert' } }}
variant={'borderless'}
hotkey: (
<Trans
components={{
key: (
<Hotkey
as={'span'}
keys={wrapperShortcut}
style={{ color: 'inherit' }}
styles={{ kbdStyle: { color: 'inhert' } }}
variant={'borderless'}
/>
),
}}
i18nKey={'input.warpWithKey'}
ns={'chat'}
/>
),
}}
i18nKey={'input.warpWithKey'}
i18nKey={'sendPlaceholder'}
ns={'chat'}
/>
{'...'}
+2
View File
@@ -12,6 +12,7 @@ export interface StoreUpdaterProps extends Partial<PublicState> {
const StoreUpdater = memo<StoreUpdaterProps>(
({
agentId,
chatInputEditorRef,
mobile,
sendButtonProps,
@@ -26,6 +27,7 @@ const StoreUpdater = memo<StoreUpdaterProps>(
const useStoreUpdater = createStoreUpdater(storeApi);
const editor = useChatInputEditor();
useStoreUpdater('agentId', agentId);
useStoreUpdater('mobile', mobile!);
useStoreUpdater('sendMenu', sendMenu!);
useStoreUpdater('mentionItems', mentionItems);
@@ -0,0 +1,28 @@
'use client';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useAgentId } from './useAgentId';
/**
* Hook to check if search is enabled for the current agent context.
* Uses agentId from ChatInput store if provided, otherwise falls back to activeAgentId.
*/
export const useAgentEnableSearch = () => {
const agentId = useAgentId();
const [model, provider, agentSearchMode] = useAgentStore((s) => [
agentByIdSelectors.getAgentModelById(agentId)(s),
agentByIdSelectors.getAgentModelProviderById(agentId)(s),
chatConfigByIdSelectors.getSearchModeById(agentId)(s),
]);
const searchImpl = useAiInfraStore(aiModelSelectors.modelBuiltinSearchImpl(model, provider));
// 只要是内置的搜索实现,一定可以联网搜索
if (searchImpl === 'internal') return true;
// 如果是关闭状态,一定不能联网搜索
return agentSearchMode !== 'off';
};
@@ -0,0 +1,22 @@
'use client';
import { useAgentStore } from '@/store/agent';
import { useChatInputStore } from '../store';
/**
* Hook to get the effective agentId for ChatInput components.
* Returns agentId from ChatInput store if provided (including empty string),
* otherwise falls back to activeAgentId.
*
* Note: Empty string is a valid value (e.g., when Group's supervisorAgentId is not loaded yet),
* so we only fallback when agentId is undefined (not provided).
*/
export const useAgentId = () => {
const agentIdFromChatInput = useChatInputStore((s) => s.agentId);
const activeAgentId = useAgentStore((s) => s.activeAgentId);
// Only fallback to activeAgentId when agentIdFromChatInput is undefined (not provided)
// Empty string is a valid value and should NOT trigger fallback
return agentIdFromChatInput !== undefined ? agentIdFromChatInput : activeAgentId || '';
};
@@ -0,0 +1,35 @@
'use client';
import { useCallback } from 'react';
import type { PartialDeep } from 'type-fest';
import { useAgentStore } from '@/store/agent';
import { LobeAgentChatConfig, LobeAgentConfig } from '@/types/agent';
import { useAgentId } from './useAgentId';
/**
* Hook to get update functions that work with the current agentId context.
* Uses agentId from ChatInput store if provided, otherwise falls back to activeAgentId.
*/
export const useUpdateAgentConfig = () => {
const agentId = useAgentId();
const updateAgentConfigById = useAgentStore((s) => s.updateAgentConfigById);
const updateAgentChatConfigById = useAgentStore((s) => s.updateAgentChatConfigById);
const updateAgentConfig = useCallback(
(config: PartialDeep<LobeAgentConfig>) => {
return updateAgentConfigById(agentId, config);
},
[agentId, updateAgentConfigById],
);
const updateAgentChatConfig = useCallback(
(config: Partial<LobeAgentChatConfig>) => {
return updateAgentChatConfigById(agentId, config);
},
[agentId, updateAgentChatConfigById],
);
return { updateAgentChatConfig, updateAgentConfig };
};
@@ -24,6 +24,7 @@ export const initialSendButtonState: SendButtonProps = {
};
export interface PublicState {
agentId?: string;
allowExpand?: boolean;
expand?: boolean;
leftActions: ActionKeys[];
@@ -0,0 +1,169 @@
'use client';
import type { SlashOptions } from '@lobehub/editor';
import { Alert } from '@lobehub/ui';
import type { MenuProps } from '@lobehub/ui/es/Menu';
import { type ReactNode, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { type ActionKeys, ChatInputProvider, DesktopChatInput } from '@/features/ChatInput';
import type { SendButtonHandler, SendButtonProps } from '@/features/ChatInput/store/initialState';
import { useChatStore } from '@/store/chat';
import { fileChatSelectors, useFileStore } from '@/store/file';
import WideScreenContainer from '../../WideScreenContainer';
import { messageStateSelectors, useConversationStore } from '../store';
export interface ChatInputProps {
/**
* Custom children to render instead of default Desktop component.
* Use this to add custom UI like error alerts, MessageFromUrl, etc.
*/
children?: ReactNode;
/**
* Left action buttons configuration
*/
leftActions?: ActionKeys[];
/**
* Mention items for @ mentions (for group chat)
*/
mentionItems?: SlashOptions['items'];
/**
* Callback when editor instance is ready
*/
onEditorReady?: (editor: any) => void;
/**
* Right action buttons configuration
*/
rightActions?: ActionKeys[];
/**
* Custom send button props override
*/
sendButtonProps?: Partial<SendButtonProps>;
/**
* Send menu configuration (for send options like Enter/Cmd+Enter, Add AI/User message)
*/
sendMenu?: MenuProps;
}
/**
* ChatInput component for Conversation
*
* Uses ConversationStore for state management instead of global ChatStore.
* Reuses the UI components from @/features/ChatInput.
*/
const ChatInput = memo<ChatInputProps>(
({
leftActions = [],
rightActions = [],
children,
mentionItems,
sendMenu,
sendButtonProps: customSendButtonProps,
onEditorReady,
}) => {
const { t } = useTranslation('chat');
// ConversationStore state
const [agentId, inputMessage, sendMessage, stopGenerating] = useConversationStore((s) => [
s.context.agentId,
s.inputMessage,
s.sendMessage,
s.stopGenerating,
]);
const updateInputMessage = useConversationStore((s) => s.updateInputMessage);
const setEditor = useConversationStore((s) => s.setEditor);
// Generation state from ConversationStore (bridged from ChatStore)
const isAIGenerating = useConversationStore(messageStateSelectors.isAIGenerating);
// Send message error from ConversationStore
const sendMessageErrorMsg = useConversationStore(messageStateSelectors.sendMessageError);
const clearSendMessageError = useChatStore((s) => s.clearSendMessageError);
// File store - for UI state only (disabled button, etc.)
const fileList = useFileStore(fileChatSelectors.chatUploadFileList);
const contextList = useFileStore(fileChatSelectors.chatContextSelections);
const isUploadingFiles = useFileStore(fileChatSelectors.isUploadingFiles);
// Computed state
const isInputEmpty = !inputMessage.trim() && fileList.length === 0 && contextList.length === 0;
const disabled = isInputEmpty || isUploadingFiles || isAIGenerating;
// Send handler - gets message, clears editor immediately, then sends
const handleSend: SendButtonHandler = useCallback(
async ({ clearContent, getMarkdownContent }) => {
// Get instant values from stores at trigger time
const fileStore = useFileStore.getState();
const currentFileList = fileChatSelectors.chatUploadFileList(fileStore);
const currentIsUploading = fileChatSelectors.isUploadingFiles(fileStore);
const currentContextList = fileChatSelectors.chatContextSelections(fileStore);
if (currentIsUploading || isAIGenerating) return;
// Get content before clearing
const message = getMarkdownContent();
if (!message.trim() && currentFileList.length === 0 && currentContextList.length === 0)
return;
// Clear content immediately for responsive UX
clearContent();
fileStore.clearChatUploadFileList();
fileStore.clearChatContextSelections();
// Fire and forget - send with captured message
await sendMessage({ contexts: currentContextList, files: currentFileList, message });
},
[isAIGenerating, sendMessage],
);
const sendButtonProps: SendButtonProps = {
disabled,
generating: isAIGenerating,
onStop: stopGenerating,
...customSendButtonProps,
};
const defaultContent = (
<WideScreenContainer>
{sendMessageErrorMsg && (
<Flexbox paddingBlock={'0 6px'} paddingInline={12}>
<Alert
closable
message={t('input.errorMsg', { errorMsg: sendMessageErrorMsg })}
onClose={clearSendMessageError}
type={'secondary'}
/>
</Flexbox>
)}
<DesktopChatInput />
</WideScreenContainer>
);
return (
<ChatInputProvider
agentId={agentId}
chatInputEditorRef={(instance) => {
if (instance) {
setEditor(instance);
onEditorReady?.(instance);
}
}}
leftActions={leftActions}
mentionItems={mentionItems}
onMarkdownContentChange={updateInputMessage}
onSend={handleSend}
rightActions={rightActions}
sendButtonProps={sendButtonProps}
sendMenu={sendMenu}
>
{children ?? defaultContent}
</ChatInputProvider>
);
},
);
ChatInput.displayName = 'ConversationChatInput';
export default ChatInput;
@@ -0,0 +1,129 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import Actions from './components/Actions';
import Avatar from './components/Avatar';
import ErrorContent from './components/ErrorContent';
import MessageContent from './components/MessageContent';
import Title from './components/Title';
import { useStyles } from './style';
import { ChatItemProps } from './type';
const ChatItem = memo<ChatItemProps>(
({
onAvatarClick,
avatarProps,
actions,
className,
loading,
message,
placeholderMessage = '...',
placement = 'left',
avatar,
error,
showTitle,
time,
editing,
messageExtra,
children,
customErrorRender,
onDoubleClick,
aboveMessage,
belowMessage,
showAvatar = true,
titleAddon,
disabled = false,
id,
style,
newScreen,
...rest
}) => {
const { cx, styles } = useStyles({
disabled,
placement,
});
const isUser = placement === 'right';
const isEmptyMessage =
!message || String(message).trim() === '' || message === placeholderMessage;
const errorContent = error && (
<ErrorContent customErrorRender={customErrorRender} error={error} id={id} />
);
return (
<Flexbox
align={isUser ? 'flex-end' : 'flex-start'}
className={cx(
'message-wrapper',
styles.container,
newScreen && styles.newScreen,
className,
)}
gap={8}
paddingBlock={8}
style={{
paddingInlineStart: isUser ? 36 : 0,
...style,
}}
{...rest}
>
<Flexbox
align={'center'}
className={'message-header'}
direction={isUser ? 'horizontal-reverse' : 'horizontal'}
gap={8}
>
{showAvatar && (
<Avatar
alt={avatarProps?.alt || avatar.title || 'avatar'}
loading={loading}
onClick={onAvatarClick}
shape={'square'}
{...avatarProps}
avatar={avatar}
/>
)}
<Title avatar={avatar} showTitle={showTitle} time={time} titleAddon={titleAddon} />
</Flexbox>
<Flexbox
className={'message-body'}
gap={8}
style={{
maxWidth: '100%',
overflow: 'hidden',
position: 'relative',
width: isUser ? undefined : '100%',
}}
>
{aboveMessage}
{error && isEmptyMessage ? (
errorContent
) : (
<MessageContent
disabled={disabled}
editing={editing}
id={id!}
message={message}
messageExtra={
<>
{errorContent}
{messageExtra}
</>
}
onDoubleClick={onDoubleClick}
variant={isUser ? 'bubble' : undefined}
>
{children}
</MessageContent>
)}
{belowMessage}
</Flexbox>
{actions && <Actions actions={actions} placement={placement} />}
</Flexbox>
);
},
);
export default ChatItem;
@@ -0,0 +1,28 @@
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { ChatItemProps } from '../type';
export interface ActionsProps {
actions: ChatItemProps['actions'];
placement?: ChatItemProps['placement'];
}
const Actions = memo<ActionsProps>(({ placement, actions }) => {
const isUser = placement === 'right';
return (
<Flexbox
align={'center'}
direction={'horizontal'}
gap={8}
role="menubar"
style={{
alignSelf: isUser ? 'flex-end' : 'flex-start',
}}
>
{actions}
</Flexbox>
);
});
export default Actions;
@@ -0,0 +1,35 @@
import { Avatar as A } from '@lobehub/ui';
import { type CSSProperties, memo } from 'react';
import type { ChatItemProps } from '../type';
export interface AvatarProps {
alt?: string;
avatar: ChatItemProps['avatar'];
loading?: boolean;
onClick?: ChatItemProps['onAvatarClick'];
size?: number;
style?: CSSProperties;
unoptimized?: boolean;
}
const Avatar = memo<AvatarProps>(
({ loading, avatar, unoptimized, onClick, size = 28, style, alt }) => {
return (
<A
alt={alt || avatar.title}
animation={loading}
avatar={avatar.avatar}
background={avatar.backgroundColor}
onClick={onClick}
shape={'square'}
size={size}
style={style}
title={avatar.title}
unoptimized={unoptimized}
/>
);
},
);
export default Avatar;
@@ -0,0 +1,45 @@
import { Alert , Skeleton } from '@lobehub/ui';
import { Suspense, memo } from 'react';
import { useConversationStore } from '@/features/Conversation';
import { ChatItemProps } from '../type';
export interface ErrorContentProps {
customErrorRender?: ChatItemProps['customErrorRender'];
error: ChatItemProps['error'];
id?: string;
}
const ErrorContent = memo<ErrorContentProps>(({ customErrorRender, error, id }) => {
const [deleteMessage] = useConversationStore((s) => [s.deleteMessage]);
if (!error) return;
if (customErrorRender) {
return (
<Suspense fallback={<Skeleton.Button active block />}>{customErrorRender(error)}</Suspense>
);
}
return (
<Alert
afterClose={() => {
if (id) {
deleteMessage(id);
}
}}
closable
showIcon
style={{
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
type={'secondary'}
{...error}
/>
);
});
export default ErrorContent;
@@ -4,7 +4,7 @@ import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useStyles } from '../style';
import { ChatItemProps } from '../type';
import type { ChatItemProps } from '../type';
export interface LoadingProps {
loading?: ChatItemProps['loading'];
@@ -0,0 +1,121 @@
import {
ReactCodePlugin,
ReactCodeblockPlugin,
ReactHRPlugin,
ReactLinkHighlightPlugin,
ReactListPlugin,
ReactMathPlugin,
ReactTablePlugin,
} from '@lobehub/editor';
import { Editor, useEditor } from '@lobehub/editor/react';
import { Modal } from '@lobehub/ui';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/slices/preference/selectors';
import TypoBar from './Typobar';
interface EditableMessageProps {
editing?: boolean;
onChange?: (value: string) => void;
onEditingChange?: (editing: boolean) => void;
value?: string;
}
const EditableMessage = memo<EditableMessageProps>(
({ editing, onEditingChange, onChange, value }) => {
const { t } = useTranslation('common');
const editor = useEditor();
const enableRichRender = useUserStore(labPreferSelectors.enableInputMarkdown);
const richRenderProps = useMemo(
() =>
!enableRichRender
? {
enablePasteMarkdown: false,
markdownOption: {
bold: false,
code: false,
header: false,
italic: false,
quote: false,
strikethrough: false,
underline: false,
underlineStrikethrough: false,
},
}
: {
plugins: [
ReactListPlugin,
ReactCodePlugin,
ReactCodeblockPlugin,
ReactHRPlugin,
ReactLinkHighlightPlugin,
ReactTablePlugin,
ReactMathPlugin,
],
},
[enableRichRender],
);
return (
<Modal
cancelText={t('cancel')}
closable={false}
destroyOnClose
destroyOnHidden
okText={t('ok')}
onCancel={() => onEditingChange?.(false)}
onOk={() => {
if (!editor) return;
const newValue = editor.getDocument('markdown') as unknown as string;
onChange?.(newValue);
onEditingChange?.(false);
}}
open={editing}
styles={{
body: {
overflow: 'hidden',
padding: 0,
},
}}
title={null}
width={'min(90vw, 960px)'}
>
<TypoBar editor={editor} />
<Flexbox
onClick={() => {
editor.focus();
}}
paddingBlock={16}
paddingInline={48}
style={{ cursor: 'text', maxHeight: '80vh', minHeight: '50vh', overflowY: 'auto' }}
>
<Editor
autoFocus
content={''}
editor={editor}
onInit={(editor) => {
if (!editor) return;
try {
editor?.setDocument('markdown', value);
} catch {}
}}
style={{
paddingBottom: 120,
}}
type={'text'}
variant={'chat'}
{...richRenderProps}
/>
</Flexbox>
</Modal>
);
},
);
export default EditableMessage;
@@ -0,0 +1,151 @@
import { HotkeyEnum, IEditor, getHotkeyById } from '@lobehub/editor';
import { useEditorState } from '@lobehub/editor/react';
import {
ChatInputActionBar,
ChatInputActions,
type ChatInputActionsProps,
CodeLanguageSelect,
} from '@lobehub/editor/react';
import { useTheme } from 'antd-style';
import {
BoldIcon,
CodeXmlIcon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
ListTodoIcon,
MessageSquareQuote,
SigmaIcon,
SquareDashedBottomCodeIcon,
StrikethroughIcon,
UnderlineIcon,
} from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const TypoBar = memo<{ editor?: IEditor }>(({ editor }) => {
const { t } = useTranslation('editor');
const editorState = useEditorState(editor);
const theme = useTheme();
const items: ChatInputActionsProps['items'] = useMemo(
() =>
[
{
active: editorState.isBold,
icon: BoldIcon,
key: 'bold',
label: t('typobar.bold'),
onClick: editorState.bold,
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Bold).keys },
},
{
active: editorState.isItalic,
icon: ItalicIcon,
key: 'italic',
label: t('typobar.italic'),
onClick: editorState.italic,
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Italic).keys },
},
{
active: editorState.isUnderline,
icon: UnderlineIcon,
key: 'underline',
label: t('typobar.underline'),
onClick: editorState.underline,
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Underline).keys },
},
{
active: editorState.isStrikethrough,
icon: StrikethroughIcon,
key: 'strikethrough',
label: t('typobar.strikethrough'),
onClick: editorState.strikethrough,
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.Strikethrough).keys },
},
{
type: 'divider',
},
{
icon: ListIcon,
key: 'bulletList',
label: t('typobar.bulletList'),
onClick: editorState.bulletList,
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.BulletList).keys },
},
{
icon: ListOrderedIcon,
key: 'numberlist',
label: t('typobar.numberList'),
onClick: editorState.numberList,
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.NumberList).keys },
},
{
icon: ListTodoIcon,
key: 'tasklist',
label: t('typobar.taskList'),
onClick: editorState.checkList,
},
{
type: 'divider',
},
{
active: editorState.isBlockquote,
icon: MessageSquareQuote,
key: 'blockquote',
label: t('typobar.blockquote'),
onClick: editorState.blockquote,
},
{
type: 'divider',
},
{
icon: SigmaIcon,
key: 'math',
label: t('typobar.tex'),
onClick: editorState.insertMath,
},
{
active: editorState.isCode,
icon: CodeXmlIcon,
key: 'code',
label: t('typobar.code'),
onClick: editorState.code,
tooltipProps: { hotkey: getHotkeyById(HotkeyEnum.CodeInline).keys },
},
{
icon: SquareDashedBottomCodeIcon,
key: 'codeblock',
label: t('typobar.codeblock'),
onClick: editorState.codeblock,
},
editorState.isCodeblock && {
children: (
<CodeLanguageSelect
onSelect={(value) => editorState.updateCodeblockLang(value)}
value={editorState.codeblockLang}
/>
),
disabled: !editorState.isCodeblock,
key: 'codeblockLang',
},
].filter(Boolean) as ChatInputActionsProps['items'],
[editorState],
);
return (
<ChatInputActionBar
left={<ChatInputActions items={items} />}
style={{
background: theme.colorFillQuaternary,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
}}
/>
);
});
TypoBar.displayName = 'TypoBar';
export default TypoBar;
@@ -0,0 +1,107 @@
import { createStyles } from 'antd-style';
import dynamic from 'next/dynamic';
import { type ReactNode, Suspense, memo, useCallback } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useConversationStore } from '@/features/Conversation/store';
import { ChatItemProps } from '../../type';
const EditableModal = dynamic(() => import('./EditableModal'), { ssr: false });
export const MSG_CONTENT_CLASSNAME = 'msg_content_flag';
export const useStyles = createStyles(({ css, token }) => {
return {
bubble: css`
padding-block: 8px;
padding-inline: 12px;
border-radius: ${token.borderRadiusLG}px;
background-color: ${token.colorFillTertiary};
`,
disabled: css`
user-select: ${'none'};
color: ${token.colorTextSecondary};
`,
message: css`
position: relative;
overflow: hidden;
max-width: 100%;
`,
};
});
export interface MessageContentProps {
children?: ReactNode;
className?: string;
disabled?: ChatItemProps['disabled'];
editing?: ChatItemProps['editing'];
id: string;
message?: ReactNode;
messageExtra?: ChatItemProps['messageExtra'];
onDoubleClick?: ChatItemProps['onDoubleClick'];
variant?: 'bubble' | 'default';
}
const MessageContent = memo<MessageContentProps>(
({
editing,
id,
message,
messageExtra,
children,
onDoubleClick,
disabled,
className,
variant,
}) => {
const { cx, styles } = useStyles();
const [toggleMessageEditing, updateMessageContent] = useConversationStore((s) => [
s.toggleMessageEditing,
s.updateMessageContent,
]);
const onChange = useCallback(
(value: string) => {
updateMessageContent(id, value);
},
[id, updateMessageContent],
);
const onEditingChange = useCallback(
(edit: boolean) => toggleMessageEditing(id, edit),
[id, toggleMessageEditing],
);
return (
<>
<Flexbox
className={cx(
MSG_CONTENT_CLASSNAME,
styles.message,
variant === 'bubble' && styles.bubble,
disabled && styles.disabled,
className,
)}
gap={16}
onDoubleClick={onDoubleClick}
>
{children || message}
{messageExtra}
</Flexbox>
<Suspense fallback={null}>
{editing && (
<EditableModal
editing={editing}
onChange={onChange}
onEditingChange={onEditingChange}
value={message ? String(message) : ''}
/>
)}
</Suspense>
</>
);
},
);
export default MessageContent;
@@ -0,0 +1,38 @@
import { Text } from '@lobehub/ui';
import dayjs from 'dayjs';
import { memo } from 'react';
import { ChatItemProps } from '../type';
export interface TitleProps {
avatar: ChatItemProps['avatar'];
showTitle?: ChatItemProps['showTitle'];
time?: ChatItemProps['time'];
titleAddon?: ChatItemProps['titleAddon'];
}
const Title = memo<TitleProps>(({ showTitle, time, avatar, titleAddon }) => {
return (
<>
{showTitle && avatar.title && (
<Text fontSize={14} weight={500}>
{avatar.title}
</Text>
)}
{showTitle ? titleAddon : undefined}
{time && (
<Text
aria-label="published-date"
as={'time'}
fontSize={12}
title={dayjs(time).format('YYYY-MM-DD HH:mm:ss')}
type={'secondary'}
>
{dayjs(time).fromNow()}
</Text>
)}
</>
);
});
export default Title;
@@ -1,2 +1,2 @@
export { default as ChatItem } from './ChatItem';
export type * from './type';
export type { ChatItemProps } from './type';
@@ -0,0 +1,51 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css, token }) => {
return {
container: css`
position: relative;
max-width: 100%;
time,
div[role='menubar'] {
pointer-events: none;
opacity: 0;
transition: opacity 200ms ${token.motionEaseOut};
}
time {
display: inline-block;
white-space: nowrap;
}
div[role='menubar'] {
display: flex;
}
&:hover {
time,
div[role='menubar'] {
pointer-events: unset;
opacity: 1;
}
}
`,
loading: css`
position: absolute;
inset-block-end: 0;
inset-inline-start: -4px;
inset-inline-end: unset;
width: 16px;
height: 16px;
border-radius: 50%;
color: ${token.colorBgLayout};
background: ${token.colorPrimary};
`,
newScreen: css`
min-height: calc(-300px + 100dvh);
`,
};
});
@@ -1,4 +1,4 @@
import { AlertProps, AvatarProps, DivProps, MarkdownProps } from '@lobehub/ui';
import { AlertProps, AvatarProps, DivProps } from '@lobehub/ui';
import { EditableMessageProps, MetaData } from '@lobehub/ui/chat';
import { ReactNode } from 'react';
import { FlexboxProps } from 'react-layout-kit';
@@ -8,9 +8,10 @@ export interface ChatItemProps extends Omit<FlexboxProps, 'children' | 'onChange
actions?: ReactNode;
actionsWrapWidth?: number;
avatar: MetaData;
avatarAddon?: ReactNode;
avatarProps?: AvatarProps;
belowMessage?: ReactNode;
children?: ReactNode;
customErrorRender?: (error: AlertProps) => ReactNode;
/**
* @description Whether the chat item is disabled
* @default false
@@ -24,18 +25,17 @@ export interface ChatItemProps extends Omit<FlexboxProps, 'children' | 'onChange
* @description Props for Error render
*/
error?: AlertProps;
errorMessage?: ReactNode;
fontSize?: number;
/**
* @description Whether the chat item is in loading state
*/
loading?: boolean;
markdownProps?: Omit<MarkdownProps, 'className' | 'style' | 'children'>;
/**
* @description The message content of the chat item
*/
message?: ReactNode;
messageExtra?: ReactNode;
newScreen?: boolean;
onAvatarClick?: () => void;
onDoubleClick?: DivProps['onDoubleClick'];
/**
@@ -47,11 +47,6 @@ export interface ChatItemProps extends Omit<FlexboxProps, 'children' | 'onChange
* @default 'left'
*/
placement?: 'left' | 'right';
/**
* @description Whether the chat item is primary
*/
primary?: boolean;
renderMessage?: (content: ReactNode) => ReactNode;
/**
* @description Whether to hide the avatar
* @default false
@@ -67,9 +62,4 @@ export interface ChatItemProps extends Omit<FlexboxProps, 'children' | 'onChange
*/
time?: number;
titleAddon?: ReactNode;
/**
* @description The type of the chat item
* @default 'bubble'
*/
variant?: 'bubble' | 'docs';
}
@@ -0,0 +1,31 @@
'use client';
import { memo, useEffect } from 'react';
import { useConversationStore, virtuaListSelectors } from '../../store';
import BackBottom from './BackBottom';
interface AutoScrollProps {
/**
* Whether AI is generating (for auto-scroll during generation)
*/
isGenerating?: boolean;
}
const AutoScroll = memo<AutoScrollProps>(({ isGenerating }) => {
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
const isScrolling = useConversationStore(virtuaListSelectors.isScrolling);
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
useEffect(() => {
if (atBottom && isGenerating && !isScrolling) {
scrollToBottom(false);
}
}, [atBottom, isGenerating, isScrolling, scrollToBottom]);
return <BackBottom onScrollToBottom={() => scrollToBottom(true)} visible={!atBottom} />;
});
AutoScroll.displayName = 'ConversationAutoScroll';
export default AutoScroll;
@@ -1,5 +1,5 @@
import { Button, Icon } from '@lobehub/ui';
import { ListEnd } from 'lucide-react';
import { ActionIcon } from '@lobehub/ui';
import { ArrowDownIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -16,14 +16,19 @@ const BackBottom = memo<BackBottomProps>(({ visible, onScrollToBottom }) => {
const { t } = useTranslation('chat');
return (
<Button
<ActionIcon
className={cx(styles.container, visible && styles.visible)}
icon={<Icon icon={ListEnd} />}
glass
icon={ArrowDownIcon}
onClick={onScrollToBottom}
size={'small'}
>
{t('backToBottom', { defaultValue: 'Back to bottom' })}
</Button>
size={{
blockSize: 36,
borderRadius: 36,
size: 18,
}}
title={t('backToBottom')}
variant={'outlined'}
/>
);
});
@@ -1,7 +1,7 @@
import { createStyles } from 'antd-style';
import { rgba } from 'polished';
export const useStyles = createStyles(({ token, css, stylish, cx, responsive }) => ({
export const useStyles = createStyles(({ token, css, stylish, cx }) => ({
container: cx(
stylish.blur,
css`
@@ -13,19 +13,8 @@ export const useStyles = createStyles(({ token, css, stylish, cx, responsive })
inset-inline-end: 16px;
transform: translateY(16px);
padding-inline: 12px !important;
border-color: ${token.colorFillTertiary} !important;
border-radius: 16px !important;
opacity: 0;
background: ${rgba(token.colorBgContainer, 0.5)};
${responsive.mobile} {
inset-inline-end: 0;
border-inline-end: none;
border-start-end-radius: 0 !important;
border-end-end-radius: 0 !important;
}
background: ${rgba(token.colorBgElevated, 0.5)} !important;
`,
),
visible: css`
@@ -0,0 +1,166 @@
'use client';
import isEqual from 'fast-deep-equal';
import { ReactElement, type ReactNode, memo, useCallback, useEffect, useRef } from 'react';
import { VList, VListHandle } from 'virtua';
import WideScreenContainer from '../../../WideScreenContainer';
import { useConversationStore, virtuaListSelectors } from '../../store';
import AutoScroll from './AutoScroll';
interface VirtualizedListProps {
dataSource: string[];
/**
* Whether AI is generating (for auto-scroll)
*/
isGenerating?: boolean;
itemContent: (index: number, data: string) => ReactNode;
}
/**
* VirtualizedList for Conversation
*
* Based on ConversationStore data flow, no dependency on global ChatStore.
*/
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent, isGenerating }) => {
const virtuaRef = useRef<VListHandle>(null);
const prevDataLengthRef = useRef(dataSource.length);
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const atBottomThreshold = 200;
// Store actions
const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods);
const setScrollState = useConversationStore((s) => s.setScrollState);
const resetVisibleItems = useConversationStore((s) => s.resetVisibleItems);
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
const atBottom = useConversationStore(virtuaListSelectors.atBottom);
// Check if at bottom based on scroll position
const checkAtBottom = useCallback(() => {
const ref = virtuaRef.current;
if (!ref) return false;
const scrollOffset = ref.scrollOffset;
const scrollSize = ref.scrollSize;
const viewportSize = ref.viewportSize;
return scrollSize - scrollOffset - viewportSize <= atBottomThreshold;
}, [atBottomThreshold]);
// Handle scroll events
const handleScroll = useCallback(() => {
setScrollState({ isScrolling: true });
// Check if at bottom
const isAtBottom = checkAtBottom();
setScrollState({ atBottom: isAtBottom });
// Clear existing timer
if (scrollEndTimerRef.current) {
clearTimeout(scrollEndTimerRef.current);
}
// Set new timer for scroll end
scrollEndTimerRef.current = setTimeout(() => {
setScrollState({ isScrolling: false });
}, 150);
}, [checkAtBottom, setScrollState]);
const handleScrollEnd = useCallback(() => {
setScrollState({ isScrolling: false });
}, [setScrollState]);
// Register scroll methods to store on mount
useEffect(() => {
const ref = virtuaRef.current;
if (ref) {
registerVirtuaScrollMethods({
getScrollOffset: () => ref.scrollOffset,
getScrollSize: () => ref.scrollSize,
getViewportSize: () => ref.viewportSize,
scrollToIndex: (index, options) => ref.scrollToIndex(index, options),
});
}
return () => {
registerVirtuaScrollMethods(null);
};
}, [registerVirtuaScrollMethods]);
// Cleanup on unmount
useEffect(() => {
return () => {
resetVisibleItems();
if (scrollEndTimerRef.current) {
clearTimeout(scrollEndTimerRef.current);
}
};
}, [resetVisibleItems]);
// Auto scroll to bottom when new messages arrive
useEffect(() => {
const shouldScroll = dataSource.length > prevDataLengthRef.current;
prevDataLengthRef.current = dataSource.length;
if (shouldScroll && virtuaRef.current) {
virtuaRef.current.scrollToIndex(dataSource.length - 2, { align: 'start', smooth: true });
}
}, [dataSource.length]);
// Scroll to bottom on initial render
useEffect(() => {
if (virtuaRef.current && dataSource.length > 0) {
virtuaRef.current.scrollToIndex(dataSource.length - 1, { align: 'end' });
}
}, []);
return (
<>
<VList
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
data={dataSource}
onScroll={handleScroll}
onScrollEnd={handleScrollEnd}
ref={virtuaRef}
reverse
style={{ height: '100%', paddingBottom: 24 }}
>
{(messageId, index): ReactElement => {
const isAgentCouncil = messageId.includes('agentCouncil');
const content = itemContent(index, messageId);
if (isAgentCouncil) {
// AgentCouncil needs full width for horizontal scroll
return (
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
{content}
</div>
);
}
return (
<WideScreenContainer key={messageId} style={{ position: 'relative' }}>
{content}
</WideScreenContainer>
);
}}
</VList>
<WideScreenContainer
onChange={() => {
if (!atBottom) return;
setTimeout(() => scrollToBottom(true), 100);
}}
style={{
position: 'relative',
}}
>
<AutoScroll isGenerating={isGenerating} />
</WideScreenContainer>
</>
);
}, isEqual);
VirtualizedList.displayName = 'ConversationVirtualizedList';
export default VirtualizedList;
@@ -0,0 +1,82 @@
'use client';
import { type ReactNode, memo, useCallback } from 'react';
import WideScreenContainer from '../../WideScreenContainer';
import MessageItem from '../Messages';
import { MessageActionProvider } from '../Messages/Contexts/MessageActionProvider';
import SkeletonList from '../components/SkeletonList';
import { dataSelectors, useConversationStore } from '../store';
import VirtualizedList from './components/VirtualizedList';
export interface ChatListProps {
/**
* Custom item renderer. If not provided, uses default ChatItem.
*/
itemContent?: (index: number, id: string) => ReactNode;
/**
* Welcome component to display when there are no messages
*/
welcome?: ReactNode;
}
/**
* ChatList component for Conversation
*
* Uses ConversationStore for message data and fetching.
*/
const ChatList = memo<ChatListProps>(({ welcome, itemContent }) => {
// Fetch messages (SWR key is null when skipFetch is true)
const context = useConversationStore((s) => s.context);
const [skipFetch, useFetchMessages] = useConversationStore((s) => [
dataSelectors.skipFetch(s),
s.useFetchMessages,
]);
useFetchMessages(context, skipFetch);
// Use selectors for data
const displayMessageIds = useConversationStore(dataSelectors.displayMessageIds);
const defaultItemContent = useCallback(
(index: number, id: string) => {
const isLatestItem = displayMessageIds.length === index + 1;
return <MessageItem id={id} index={index} isLatestItem={isLatestItem} />;
},
[displayMessageIds.length],
);
const messagesInit = useConversationStore(dataSelectors.messagesInit);
if (!messagesInit) {
return <SkeletonList />;
}
if (displayMessageIds.length === 0) {
return (
<WideScreenContainer
style={{
height: '100%',
}}
wrapperStyle={{
minHeight: '100%',
overflowY: 'auto',
}}
>
{welcome}
</WideScreenContainer>
);
}
return (
<MessageActionProvider withSingletonActionsBar>
<VirtualizedList
dataSource={displayMessageIds}
// isGenerating={isGenerating}
itemContent={itemContent ?? defaultItemContent}
/>
</MessageActionProvider>
);
});
ChatList.displayName = 'ConversationChatList';
export default ChatList;
@@ -0,0 +1,96 @@
'use client';
import type { UIChatMessage } from '@lobechat/types';
import isEqual from 'fast-deep-equal';
import { type ReactNode, memo } from 'react';
import StoreUpdater from './StoreUpdater';
import { Provider, createStore } from './store';
import type {
ActionsBarConfig,
ConversationContext,
ConversationHooks,
OperationState,
} from './types';
export interface ConversationProviderProps {
/**
* Actions bar configuration by message type
*/
actionsBar?: ActionsBarConfig;
children: ReactNode;
/**
* Conversation context (data coordinates)
*/
context: ConversationContext;
/**
* Whether external messages have been initialized
* When false, ChatList will show skeleton loading state
*/
hasInitMessages?: boolean;
/**
* Lifecycle hooks for external behavior injection
*/
hooks?: ConversationHooks;
/**
* External messages to sync into the store
* When provided, these messages will be used as the source of truth
*/
messages?: UIChatMessage[];
/**
* Callback when messages are fetched or changed internally
* Use this to sync messages back to external state (e.g., ChatStore)
*
* @param messages - The updated messages array
*/
onMessagesChange?: (messages: UIChatMessage[]) => void;
/**
* External operation state (from ChatStore)
*
* This state is managed by the global ChatStore and passed down for reactivity.
* Operations are kept global to support multiple agents/topics running in parallel.
*
* When provided, this will be synced into the store for reactive updates.
*/
operationState?: OperationState;
skipFetch?: boolean;
}
/**
* ConversationProvider
*
* Creates an isolated ConversationStore instance for a specific conversation context.
* This enables multiple independent conversations to run simultaneously.
*/
export const ConversationProvider = memo<ConversationProviderProps>(
({
actionsBar,
children,
context,
hooks = {},
hasInitMessages,
messages,
onMessagesChange,
operationState,
skipFetch,
}) => {
return (
<Provider createStore={() => createStore({ context, hooks, skipFetch })}>
<StoreUpdater
actionsBar={actionsBar}
context={context}
hasInitMessages={hasInitMessages}
hooks={hooks}
messages={messages}
onMessagesChange={onMessagesChange}
operationState={operationState}
skipFetch={skipFetch}
/>
{children}
</Provider>
);
},
isEqual,
);
ConversationProvider.displayName = 'ConversationProvider';
@@ -0,0 +1,40 @@
import { Block, Text } from '@lobehub/ui';
import { ReactNode, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
interface BaseErrorFormProps {
action?: ReactNode;
avatar?: ReactNode;
desc?: ReactNode;
title?: ReactNode;
}
const BaseErrorForm = memo<BaseErrorFormProps>(({ title, desc, action, avatar }) => {
return (
<Block
align={'center'}
gap={8}
horizontal
justify={'space-between'}
padding={16}
style={{
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
variant={'outlined'}
>
<Flexbox align="center" gap={12} horizontal>
{avatar}
<Flexbox gap={2}>
<Text weight={500}>{title}</Text>
<Text fontSize={12} type={'secondary'}>
{desc}
</Text>
</Flexbox>
</Flexbox>
{action}
</Block>
);
});
export default BaseErrorForm;
@@ -0,0 +1,52 @@
import { ProviderIcon } from '@lobehub/icons';
import { Button } from '@lobehub/ui';
import { ModelProvider } from 'model-bank';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { useProviderName } from '@/hooks/useProviderName';
import { GlobalLLMProviderKey } from '@/types/user/settings/modelProvider';
import { useConversationStore } from '../store';
import BaseErrorForm from './BaseErrorForm';
interface ChatInvalidAPIKeyProps {
id: string;
provider?: string;
}
const ChatInvalidAPIKey = memo<ChatInvalidAPIKeyProps>(({ id, provider }) => {
const { t } = useTranslation(['modelProvider', 'error']);
const navigate = useNavigate();
const [deleteMessage] = useConversationStore((s) => [s.deleteMessage]);
const providerName = useProviderName(provider as GlobalLLMProviderKey);
return (
<BaseErrorForm
action={
<Button
onClick={() => {
navigate(urlJoin('/settings/provider', provider || 'all'));
deleteMessage(id);
}}
type={'primary'}
>
{t('unlock.goToSettings', { ns: 'error' })}
</Button>
}
avatar={<ProviderIcon provider={provider} shape={'square'} size={40} />}
desc={
provider === ModelProvider.Bedrock
? t('bedrock.unlock.description')
: t(`unlock.apiKey.description`, {
name: providerName,
ns: 'error',
})
}
title={t(`unlock.apiKey.title`, { name: providerName, ns: 'error' })}
/>
);
});
export default ChatInvalidAPIKey;
@@ -3,8 +3,8 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import OllamaModelDownloader from '@/features/OllamaModelDownloader';
import { useChatStore } from '@/store/chat';
import { useConversationStore } from '../../store';
import { ErrorActionContainer } from '../style';
interface InvalidOllamaModelProps {
@@ -15,7 +15,7 @@ interface InvalidOllamaModelProps {
const InvalidOllamaModel = memo<InvalidOllamaModelProps>(({ id, model }) => {
const { t } = useTranslation('error');
const [delAndRegenerateMessage, deleteMessage] = useChatStore((s) => [
const [delAndRegenerateMessage, deleteMessage] = useConversationStore((s) => [
s.delAndRegenerateMessage,
s.deleteMessage,
]);
@@ -1,13 +1,13 @@
import { ChatMessageError } from '@lobechat/types';
import { Skeleton } from 'antd';
import { AlertProps , Skeleton } from '@lobehub/ui';
import dynamic from 'next/dynamic';
import { memo } from 'react';
import ErrorJsonViewer from '../ErrorJsonViewer';
import ErrorContent from '@/features/Conversation/ChatItem/components/ErrorContent';
const loading = () => <Skeleton active style={{ width: 300 }} />;
const SetupGuide = dynamic(() => import('@/features/OllamaSetupGuide'), { loading, ssr: false });
const SetupGuide = dynamic(() => import('../OllamaSetupGuide'), { loading, ssr: false });
const InvalidModel = dynamic(() => import('./InvalidOllamaModel'), { loading, ssr: false });
@@ -25,11 +25,12 @@ interface OllamaErrorResponse {
const UNRESOLVED_MODEL_REGEXP = /model "([\w+,-_]+)" not found/;
interface OllamaBizErrorProps {
alertError?: AlertProps;
error?: ChatMessageError | null;
id: string;
}
const OllamaBizError = memo<OllamaBizErrorProps>(({ error, id }) => {
const OllamaBizError = memo<OllamaBizErrorProps>(({ alertError, error, id }) => {
const errorBody: OllamaErrorResponse = (error as any)?.body;
const errorMessage = errorBody.error?.message;
@@ -45,7 +46,7 @@ const OllamaBizError = memo<OllamaBizErrorProps>(({ error, id }) => {
return <SetupGuide id={id} />;
}
return <ErrorJsonViewer error={error} id={id} />;
return <ErrorContent error={alertError} id={id} />;
});
export default OllamaBizError;
@@ -0,0 +1,41 @@
import { Ollama } from '@lobehub/icons';
import { Button } from '@lobehub/ui';
import Link from 'next/link';
import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useConversationStore } from '@/features/Conversation/store';
import BaseErrorForm from '../BaseErrorForm';
// TODO: 优化 Ollama setup 的流程,isDesktop 模式下可以直接做到端到端检测
const OllamaDesktopSetupGuide = memo<{ id?: string }>(({ id }) => {
const { t } = useTranslation('components');
const [delAndRegenerateMessage] = useConversationStore((s) => [s.delAndRegenerateMessage]);
return (
<BaseErrorForm
action={
<Button
onClick={() => {
if (id) delAndRegenerateMessage(id);
}}
type={'primary'}
>
{t('OllamaSetupGuide.action.start')}
</Button>
}
avatar={<Ollama.Avatar shape={'square'} size={40} />}
desc={
<Trans i18nKey={'OllamaSetupGuide.install.description'} ns={'components'}>
Ollama Ollama
<Link href={'https://ollama.com/download'}></Link>
</Trans>
}
title={t('OllamaSetupGuide.install.title')}
/>
);
});
export default OllamaDesktopSetupGuide;
@@ -0,0 +1,27 @@
import { Block } from '@lobehub/ui';
import { memo } from 'react';
import OllamaSetupGuide from '@/components/OllamaSetupGuide';
import { isDesktop } from '@/const/version';
import OllamaDesktopSetupGuide from './Desktop';
const SetupGuide = memo<{ id?: string }>(({ id }) => {
return (
<Block
align={'center'}
gap={8}
padding={16}
style={{
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
variant={'outlined'}
>
{isDesktop ? <OllamaDesktopSetupGuide id={id} /> : <OllamaSetupGuide />}
</Block>
);
});
export default SetupGuide;
@@ -1,29 +1,39 @@
import { AgentRuntimeErrorType, ILobeAgentRuntimeErrorType } from '@lobechat/model-runtime';
import { ChatErrorType, ChatMessageError, ErrorType } from '@lobechat/types';
import { IPluginErrorType } from '@lobehub/chat-plugin-sdk';
import type { AlertProps } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { AlertProps, Block , Skeleton } from '@lobehub/ui';
import dynamic from 'next/dynamic';
import { Suspense, memo, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ErrorContent from '@/features/Conversation/ChatItem/components/ErrorContent';
import { useProviderName } from '@/hooks/useProviderName';
import ChatInvalidAPIKey from './ChatInvalidApiKey';
import ClerkLogin from './ClerkLogin';
import ErrorJsonViewer from './ErrorJsonViewer';
import { ErrorActionContainer } from './style';
interface ErrorMessageData {
error?: ChatMessageError | null;
id: string;
}
const loading = () => <Skeleton active />;
const loading = () => (
<Block
align={'center'}
padding={16}
style={{
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
variant={'outlined'}
>
<Skeleton.Button active block />
</Block>
);
const OllamaBizError = dynamic(() => import('./OllamaBizError'), { loading, ssr: false });
const OllamaSetupGuide = dynamic(() => import('@/features/OllamaSetupGuide'), {
const OllamaSetupGuide = dynamic(() => import('./OllamaSetupGuide'), {
loading,
ssr: false,
});
@@ -37,7 +47,7 @@ const getErrorAlertConfig = (
return {
extraDefaultExpand: true,
extraIsolate: true,
type: 'warning',
type: 'secondary',
};
/* ↓ cloud slot ↓ */
@@ -53,7 +63,7 @@ const getErrorAlertConfig = (
case AgentRuntimeErrorType.ExceededContextWindow:
case AgentRuntimeErrorType.LocationNotSupportError: {
return {
type: 'warning',
type: 'secondary',
};
}
@@ -64,7 +74,7 @@ const getErrorAlertConfig = (
return {
extraDefaultExpand: true,
extraIsolate: true,
type: 'warning',
type: 'secondary',
};
}
@@ -92,11 +102,11 @@ export const useErrorContent = (error: any) => {
};
interface ErrorExtraProps {
block?: boolean;
data: ErrorMessageData;
error?: AlertProps;
}
const ErrorMessageExtra = memo<ErrorExtraProps>(({ data, block }) => {
const ErrorMessageExtra = memo<ErrorExtraProps>(({ error: alertError, data }) => {
const error = data.error as ChatMessageError;
if (!error?.type) return;
@@ -113,10 +123,6 @@ const ErrorMessageExtra = memo<ErrorExtraProps>(({ data, block }) => {
/* ↑ cloud slot ↑ */
case ChatErrorType.InvalidClerkUser: {
return <ClerkLogin id={data.id} />;
}
case AgentRuntimeErrorType.NoOpenAIAPIKey: {
{
return <ChatInvalidAPIKey id={data.id} provider={data.error?.body?.provider} />;
@@ -128,17 +134,7 @@ const ErrorMessageExtra = memo<ErrorExtraProps>(({ data, block }) => {
return <ChatInvalidAPIKey id={data.id} provider={data.error?.body?.provider} />;
}
return <ErrorJsonViewer block={block} error={data.error} id={data.id} />;
return <ErrorContent error={alertError} id={data.id} />;
});
export default memo<ErrorExtraProps>(({ data, block }) => (
<Suspense
fallback={
<ErrorActionContainer>
<Skeleton active style={{ width: '100%' }} />
</ErrorActionContainer>
}
>
<ErrorMessageExtra block={block} data={data} />
</Suspense>
));
export default ErrorMessageExtra;
@@ -65,6 +65,7 @@ export const FormAction = memo<
avatar={avatar}
background={background ?? theme.colorFillContent}
gap={12}
shape={'square'}
size={80}
/>
<Flexbox gap={8} width={'100%'}>
@@ -0,0 +1,32 @@
import { Markdown, MarkdownProps } from '@lobehub/ui';
import { memo } from 'react';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
const MarkdownMessage = memo<MarkdownProps>(({ children, componentProps, ...rest }) => {
const { highlighterTheme, mermaidTheme, fontSize } = useUserStore(
userGeneralSettingsSelectors.config,
);
return (
<Markdown
componentProps={{
...componentProps,
highlight: {
fullFeatured: true,
theme: highlighterTheme,
...componentProps?.highlight,
},
mermaid: { fullFeatured: false, theme: mermaidTheme, ...componentProps?.mermaid },
}}
fontSize={fontSize}
variant={'chat'}
{...rest}
>
{children}
</Markdown>
);
});
export default MarkdownMessage;
@@ -1,8 +1,7 @@
import { Icon } from '@lobehub/ui';
import { App } from 'antd';
import { createStyles } from 'antd-style';
import { Loader2 } from 'lucide-react';
import { memo, useContext, useEffect } from 'react';
import { memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
@@ -10,7 +9,6 @@ import { useChatStore } from '@/store/chat';
import { chatPortalSelectors, messageStateSelectors } from '@/store/chat/selectors';
import { dotLoading } from '@/styles/loading';
import { InPortalThreadContext } from '../../../context/InPortalThreadContext';
import { MarkdownElementProps } from '../../type';
import ArtifactIcon from './Icon';
@@ -62,8 +60,6 @@ const Render = memo<ArtifactProps>(({ identifier, title, type, language, childre
const hasChildren = !!children;
const str = ((children as string) || '').toString?.();
const inThread = useContext(InPortalThreadContext);
const { message } = App.useApp();
const [isGenerating, isArtifactTagClosed, openArtifact, closeArtifact] = useChatStore((s) => {
return [
messageStateSelectors.isMessageGenerating(id)(s),
@@ -94,10 +90,6 @@ const Render = memo<ArtifactProps>(({ identifier, title, type, language, childre
if (currentArtifactMessageId === id) {
closeArtifact();
} else {
if (inThread) {
message.info(t('artifact.inThread'));
return;
}
openArtifactUI();
}
}}
@@ -1,18 +1,17 @@
import { ARTIFACT_THINKING_TAG } from '@lobechat/const';
import { memo } from 'react';
import Thinking from '@/components/Thinking';
import { useChatStore } from '@/store/chat';
import { dbMessageSelectors } from '@/store/chat/selectors';
import { ARTIFACT_THINKING_TAG } from '@/const/index';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { dataSelectors, useConversationStore } from '../../../store';
import { MarkdownElementProps } from '../type';
import { isTagClosed } from '../utils';
const Render = memo<MarkdownElementProps>(({ children, id }) => {
const [isGenerating] = useChatStore((s) => {
const message = dbMessageSelectors.getDbMessageById(id)(s);
const [isGenerating] = useConversationStore((s) => {
const message = dataSelectors.getDbMessageById(id)(s);
return [!isTagClosed(ARTIFACT_THINKING_TAG, message?.content)];
});
const transitionMode = useUserStore(userGeneralSettingsSelectors.transitionMode);
@@ -1,6 +1,5 @@
'use client';
import { DEFAULT_AVATAR } from '@lobechat/const';
import { Avatar, Text } from '@lobehub/ui';
import { Popover } from 'antd';
import { createStyles } from 'antd-style';
@@ -9,6 +8,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { DEFAULT_AVATAR } from '@/const/index';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
@@ -84,6 +84,7 @@ const Render = memo<MarkdownElementProps<MentionProps>>(({ children, node }) =>
<Avatar
avatar={member.avatar || DEFAULT_AVATAR}
background={member.backgroundColor ?? undefined}
shape={'square'}
style={{ flex: 'none' }}
/>
<Flexbox style={{ overflow: 'hidden' }}>
@@ -1,11 +1,10 @@
import { memo } from 'react';
import Thinking from '@/components/Thinking';
import { useChatStore } from '@/store/chat';
import { dbMessageSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { dataSelectors, useConversationStore } from '../../../store';
import { MarkdownElementProps } from '../type';
const isThinkingClosed = (input: string = '') => {
@@ -16,12 +15,12 @@ const isThinkingClosed = (input: string = '') => {
};
const Render = memo<MarkdownElementProps>(({ children, id }) => {
const [isGenerating] = useChatStore((s) => {
const message = dbMessageSelectors.getDbMessageById(id)(s);
const [isGenerating] = useConversationStore((s) => {
const message = dataSelectors.getDbMessageById(id)(s);
return [!isThinkingClosed(message?.content)];
});
const citations = useChatStore((s) => {
const message = dbMessageSelectors.getDbMessageById(id)(s);
const citations = useConversationStore((s) => {
const message = dataSelectors.getDbMessageById(id)(s);
return message?.search?.citations;
});
@@ -5,6 +5,8 @@ import Mention from './Mention';
import Thinking from './Thinking';
import { MarkdownElement } from './type';
export type { MarkdownElement } from './type';
export const markdownElements: MarkdownElement[] = [
Thinking,
LobeArtifact,
@@ -15,6 +15,18 @@ const processMarkdown = (markdown: string, tagName: string) => {
describe('createRemarkSelfClosingTagPlugin', () => {
const tagName = 'localFile';
const collectNodesByType = (tree: any, type: string) => {
const nodes: any[] = [];
const walk = (node: any) => {
if (!node || typeof node !== 'object') return;
if (node.type === type) nodes.push(node);
const children = (node as any).children;
if (Array.isArray(children)) for (const child of children) walk(child);
};
walk(tree);
return nodes;
};
it('should replace a single self-closing tag (parsed as HTML) with a custom node', () => {
const markdown = `<${tagName} name="test.txt" path="/path/to/test.txt" />`;
const tree = processMarkdown(markdown, tagName);
@@ -202,6 +214,64 @@ describe('createRemarkSelfClosingTagPlugin', () => {
expect(tree).toMatchSnapshot();
});
it('should parse attributes containing spaces and @ (real-world screenshot file name)', () => {
const markdown = `<${tagName} name="CleanShot 2025-12-17 at 17.56.33@2x.jpg" path="/Users/innei/Desktop/CleanShot 2025-12-17 at 17.56.33@2x.jpg" />`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
const node = tree.children[0];
expect(node.type).toBe(tagName);
expect(node.data?.hName).toBe(tagName);
expect(node.data?.hProperties).toEqual({
name: 'CleanShot 2025-12-17 at 17.56.33@2x.jpg',
path: '/Users/innei/Desktop/CleanShot 2025-12-17 at 17.56.33@2x.jpg',
});
});
it('should handle multiple consecutive tags parsed as a single html block node', () => {
const markdown = [
'以下是桌面(`<localFile name="Desktop" path="/Users/innei/Desktop" isDirectory />`)下的文件列表:',
'',
'<localFile name="CleanShot 2025-12-17 at 17.56.33@2x.jpg" path="/Users/innei/Desktop/CleanShot 2025-12-17 at 17.56.33@2x.jpg" />',
'<localFile name="CleanShot 2025-12-17 at 17.56.39@2x.jpg" path="/Users/innei/Desktop/CleanShot 2025-12-17 at 17.56.39@2x.jpg" />',
'<localFile name="CleanShot 2025-12-17 at 21.49.12@2x.jpg" path="/Users/innei/Desktop/CleanShot 2025-12-17 at 21.49.12@2x.jpg" />',
'<localFile name="CleanShot 2025-12-17 at 22.04.01@2x.jpg" path="/Users/innei/Desktop/CleanShot 2025-12-17 at 22.04.01@2x.jpg" />',
'<localFile name="CleanShot 2025-12-17 at 22.07.37@2x.jpg" path="/Users/innei/Desktop/CleanShot 2025-12-17 at 22.07.37@2x.jpg" />',
'<localFile name="CleanShot 2025-12-17 at 22.39.37.mp4" path="/Users/innei/Desktop/CleanShot 2025-12-17 at 22.39.37.mp4" />',
'<localFile name="CleanShot 2025-12-18 at 10.36.19@2x.jpg" path="/Users/innei/Desktop/CleanShot 2025-12-18 at 10.36.19@2x.jpg" />',
'',
'如果你想查看其他目录或进一步筛选文件,请告诉我!',
].join('\n');
const tree = processMarkdown(markdown, tagName);
const localFiles = collectNodesByType(tree, tagName);
expect(localFiles).toHaveLength(8);
expect(localFiles.map((n) => n.data?.hProperties?.name)).toEqual([
'Desktop',
'CleanShot 2025-12-17 at 17.56.33@2x.jpg',
'CleanShot 2025-12-17 at 17.56.39@2x.jpg',
'CleanShot 2025-12-17 at 21.49.12@2x.jpg',
'CleanShot 2025-12-17 at 22.04.01@2x.jpg',
'CleanShot 2025-12-17 at 22.07.37@2x.jpg',
'CleanShot 2025-12-17 at 22.39.37.mp4',
'CleanShot 2025-12-18 at 10.36.19@2x.jpg',
]);
});
it('should match tag name in a case-insensitive way (HTML tag names are case-insensitive)', () => {
const markdown = `<localfile name="a.txt" path="/tmp/a.txt" />`;
const tree = processMarkdown(markdown, tagName);
expect(tree.children).toHaveLength(1);
const node = tree.children[0];
expect(node.type).toBe(tagName);
expect(node.data?.hProperties).toEqual({
name: 'a.txt',
path: '/tmp/a.txt',
});
});
it('should handle multiple tags in unordered list with directories and files', () => {
const markdown = [
'我已查看了你桌面上 test 文件夹的目录,里面包含以下项目:',
@@ -5,6 +5,8 @@ import { SKIP, visit } from 'unist-util-visit';
// 创建 debugger 实例
const log = debug('lobe-markdown:remark-plugin:self-closing');
const escapeRegExp = (str: string) => str.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&');
// Regex to parse attributes from a string
// Handles keys, keys with quoted values (double or single), and boolean keys
const attributeRegex = /([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
@@ -26,9 +28,11 @@ export const createRemarkSelfClosingTagPlugin =
() => {
// Regex for the specific tag, ensure it matches the entire string for HTML check
// Allow trailing whitespace after /> to handle cases where markdown parsers include it
const exactTagRegex = new RegExp(`^<${tagName}(\\s+[^>]*?)?\\s*\\/>\\s*$`);
// Tag names are case-insensitive in HTML, and some upstream processors may normalize casing.
const escapedTagName = escapeRegExp(tagName);
const exactTagRegex = new RegExp(`^<${escapedTagName}(\\s+[^>]*?)?\\s*\\/>\\s*$`, 'i');
// Regex for finding tags within text
const textTagRegex = new RegExp(`<${tagName}(\\s+[^>]*?)?\\s*\\/>`, 'g');
const textTagRegex = new RegExp(`<${escapedTagName}(\\s+[^>]*?)?\\s*\\/>`, 'gi');
return (tree) => {
// --- DEBUG LOG START (Before Visit) ---
@@ -73,6 +77,55 @@ export const createRemarkSelfClosingTagPlugin =
parent.children.splice(index, 1, newNode);
return [SKIP, index + 1]; // Skip the node we just inserted
}
// Handle the common case where remark parses multiple consecutive HTML tags as a single `html` node
// e.g.
// <localFile ... />
// <localFile ... />
if (
parent &&
typeof index === 'number' &&
typeof node.value === 'string' &&
node.value.toLowerCase().includes(`<${tagName.toLowerCase()}`)
) {
const html = node.value;
const newChildren: any[] = [];
let lastIndex = 0;
let textMatch: RegExpExecArray | null;
textTagRegex.lastIndex = 0;
while ((textMatch = textTagRegex.exec(html)) !== null) {
const [fullMatch, attributesString] = textMatch;
const matchIndex = textMatch.index;
// Preserve any non-tag fragments as `html` nodes (flow content), so we don't create invalid root-level `text`.
if (matchIndex > lastIndex) {
const fragment = html.slice(lastIndex, matchIndex);
if (fragment) newChildren.push({ type: 'html', value: fragment });
}
const properties = attributesString ? parseAttributes(attributesString.trim()) : {};
newChildren.push({
data: { hName: tagName, hProperties: properties },
type: tagName,
});
lastIndex = matchIndex + fullMatch.length;
}
if (newChildren.length > 0) {
if (lastIndex < html.length) {
const fragment = html.slice(lastIndex);
if (fragment) newChildren.push({ type: 'html', value: fragment });
}
// Only replace when we actually produced at least one tag node
if (newChildren.some((n) => n?.type === tagName)) {
parent.children.splice(index, 1, ...newChildren);
return [SKIP, index + newChildren.length];
}
}
}
});
// 2. Visit Text nodes for inline matches
@@ -80,7 +133,12 @@ export const createRemarkSelfClosingTagPlugin =
visit(tree, 'text', (node: any, index: number, parent) => {
log('>>> Visiting Text node: "%s"', node.value);
if (!parent || typeof index !== 'number' || !node.value?.includes(`<${tagName}`)) {
if (
!parent ||
typeof index !== 'number' ||
typeof node.value !== 'string' ||
!node.value.toLowerCase().includes(`<${tagName.toLowerCase()}`)
) {
return; // Quick exit if tag isn't possibly present
}
@@ -136,7 +194,12 @@ export const createRemarkSelfClosingTagPlugin =
visit(tree, 'inlineCode', (node: any, index: number, parent) => {
log('>>> Visiting inlineCode node: "%s"', node.value);
if (!parent || typeof index !== 'number' || !node.value?.includes(`<${tagName}`)) {
if (
!parent ||
typeof index !== 'number' ||
typeof node.value !== 'string' ||
!node.value.toLowerCase().includes(`<${tagName.toLowerCase()}`)
) {
return;
}

Some files were not shown because too many files have changed in this diff Show More