mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat: server implement
This commit is contained in:
@@ -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 };
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
{'...'}
|
||||
|
||||
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+13
-8
@@ -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'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
+2
-13
@@ -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;
|
||||
+2
-2
@@ -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,
|
||||
]);
|
||||
+6
-5
@@ -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
-9
@@ -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();
|
||||
}
|
||||
}}
|
||||
+4
-5
@@ -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);
|
||||
+2
-1
@@ -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' }}>
|
||||
+5
-6
@@ -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;
|
||||
});
|
||||
|
||||
+2
@@ -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,
|
||||
+70
@@ -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 文件夹的目录,里面包含以下项目:',
|
||||
+67
-4
@@ -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
Reference in New Issue
Block a user