diff --git a/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/ChatHeader/HeaderAction.tsx b/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/ChatHeader/HeaderAction.tsx
index 9948cff529..ca466e119c 100644
--- a/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/ChatHeader/HeaderAction.tsx
+++ b/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/ChatHeader/HeaderAction.tsx
@@ -1,7 +1,12 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
-import { PanelRightClose, PanelRightOpen } from 'lucide-react';
+import {
+ PanelLeftRightDashedIcon,
+ PanelRightClose,
+ PanelRightOpen,
+ SquareChartGanttIcon,
+} from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
@@ -20,15 +25,26 @@ import ShareButton from '../../../features/ShareButton';
const HeaderAction = memo<{ className?: string }>(({ className }) => {
const { t } = useTranslation('chat');
const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.ToggleRightPanel));
- const [showAgentSettings, toggleConfig] = useGlobalStore((s) => [
+ const [showAgentSettings, wideScreen, toggleConfig, toggleWideScreen] = useGlobalStore((s) => [
systemStatusSelectors.showChatSideBar(s),
+ systemStatusSelectors.wideScreen(s),
s.toggleChatSideBar,
+ s.toggleWideScreen,
]);
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
return (
+ toggleWideScreen()}
+ size={DESKTOP_HEADER_ICON_SIZE}
+ title={t(wideScreen ? 'toggleWideScreen.off' : 'toggleWideScreen.on')}
+ tooltipProps={{
+ placement: 'bottom',
+ }}
+ />
({
recording: css`
width: 8px;
@@ -49,35 +51,36 @@ const CommonSTT = memo<{
};
return (
-
- {t('stt.action')}
-
- ),
- },
- {
- key: 'time',
- label: (
-
-
- {time > 0 ? formattedTime : t(isRecording ? 'stt.loading' : 'stt.prettifying')}
-
- ),
- },
- ],
- }}
- onOpenChange={handleDropdownVisibleChange}
- open={dropdownOpen || !!error || isRecording || isLoading}
- placement={mobile ? 'topRight' : 'top'}
- popupRender={
- error
+
+ {t('stt.action')}
+
+ ),
+ },
+ {
+ key: 'time',
+ label: (
+
+
+ {time > 0 ? formattedTime : t(isRecording ? 'stt.loading' : 'stt.prettifying')}
+
+ ),
+ },
+ ],
+ },
+ onOpenChange: handleDropdownVisibleChange,
+ open: dropdownOpen || !!error || isRecording || isLoading,
+ placement: mobile ? 'topRight' : 'top',
+ popupRender: error
? () => (
)
- : undefined
- }
- trigger={['click']}
- >
-
-
+ : undefined,
+ trigger: ['click'],
+ }}
+ icon={isLoading ? MicOff : Mic}
+ onClick={handleTriggerStartStop}
+ title={dropdownOpen ? undefined : desc}
+ variant={mobile ? 'outlined' : 'borderless'}
+ />
);
},
);
diff --git a/src/features/ChatInput/Topic/index.tsx b/src/features/ChatInput/ActionBar/SaveTopic/index.tsx
similarity index 83%
rename from src/features/ChatInput/Topic/index.tsx
rename to src/features/ChatInput/ActionBar/SaveTopic/index.tsx
index 3bf09d9e69..e50c40cdca 100644
--- a/src/features/ChatInput/Topic/index.tsx
+++ b/src/features/ChatInput/ActionBar/SaveTopic/index.tsx
@@ -1,4 +1,4 @@
-import { ActionIcon, Button, Hotkey, Tooltip } from '@lobehub/ui';
+import { ActionIcon, Hotkey } from '@lobehub/ui';
import { Popconfirm } from 'antd';
import { LucideGalleryVerticalEnd, LucideMessageSquarePlus } from 'lucide-react';
import { memo, useState } from 'react';
@@ -54,11 +54,22 @@ const SaveTopic = memo<{ mobile?: boolean }>(({ mobile }) => {
);
} else {
return (
-
-
+ mutate()}
+ size={{ blockSize: 32, size: 16, strokeWidth: 2.3 }}
+ title={desc}
+ tooltipProps={{
+ hotkey,
+ }}
+ variant={'outlined'}
+ />
);
}
});
+SaveTopic.displayName = 'SaveTopic';
+
export default SaveTopic;
diff --git a/src/features/ChatInput/ActionBar/Typo/index.tsx b/src/features/ChatInput/ActionBar/Typo/index.tsx
new file mode 100644
index 0000000000..9e9dfbd55e
--- /dev/null
+++ b/src/features/ChatInput/ActionBar/Typo/index.tsx
@@ -0,0 +1,22 @@
+import { TypeIcon } from 'lucide-react';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useChatInputStore } from '../../store';
+import Action from '../components/Action';
+
+const Typo = memo(() => {
+ const { t } = useTranslation('editor');
+ const [showTypoBar, setShowTypoBar] = useChatInputStore((s) => [s.showTypoBar, s.setShowTypoBar]);
+
+ return (
+ setShowTypoBar(!showTypoBar)}
+ title={t(showTypoBar ? 'actions.typobar.off' : 'actions.typobar.on')}
+ />
+ );
+});
+
+export default Typo;
diff --git a/src/features/ChatInput/ActionBar/components/Action.tsx b/src/features/ChatInput/ActionBar/components/Action.tsx
index d7981e05df..9250e4a538 100644
--- a/src/features/ChatInput/ActionBar/components/Action.tsx
+++ b/src/features/ChatInput/ActionBar/components/Action.tsx
@@ -51,6 +51,10 @@ const Action = memo(
placement: 'bottom',
}}
{...rest}
+ size={{
+ blockSize: 36,
+ size: 20,
+ }}
/>
);
diff --git a/src/features/ChatInput/ActionBar/config.ts b/src/features/ChatInput/ActionBar/config.ts
index ff40e1e9c7..ca6b0996ee 100644
--- a/src/features/ChatInput/ActionBar/config.ts
+++ b/src/features/ChatInput/ActionBar/config.ts
@@ -4,9 +4,11 @@ import Knowledge from './Knowledge';
import Model from './Model';
import Params from './Params';
import STT from './STT';
+import SaveTopic from './SaveTopic';
import Search from './Search';
import { MainToken, PortalToken } from './Token';
import Tools from './Tools';
+import Typo from './Typo';
import Upload from './Upload';
export const actionMap = {
@@ -18,10 +20,14 @@ export const actionMap = {
model: Model,
params: Params,
portalToken: PortalToken,
+ saveTopic: SaveTopic,
search: Search,
stt: STT,
temperature: Params,
tools: Tools,
+ typo: Typo,
} as const;
-export type ActionKeys = keyof typeof actionMap;
+export type ActionKey = keyof typeof actionMap;
+
+export type ActionKeys = ActionKey | ActionKey[] | '---';
diff --git a/src/features/ChatInput/ActionBar/index.tsx b/src/features/ChatInput/ActionBar/index.tsx
index 03a5dbe37a..10efcadd5e 100644
--- a/src/features/ChatInput/ActionBar/index.tsx
+++ b/src/features/ChatInput/ActionBar/index.tsx
@@ -1,55 +1,44 @@
-import { ChatInputActionBar } from '@lobehub/ui/chat';
-import { ReactNode, memo } from 'react';
+import { ChatInputActions, type ChatInputActionsProps } from '@lobehub/editor/react';
+import { memo, useMemo } from 'react';
-import { ActionKeys, actionMap } from './config';
+import { ActionKeys, actionMap } from '../ActionBar/config';
+import { useChatInputStore } from '../store';
-const RenderActionList = ({ dataSource }: { dataSource: ActionKeys[] }) => (
- <>
- {dataSource.map((key) => {
- const Render = actionMap[key];
- return ;
- })}
- >
-);
-
-export interface ActionBarProps {
- leftActions: ActionKeys[];
- leftAreaEndRender?: ReactNode;
- leftAreaStartRender?: ReactNode;
- padding?: number | string;
- rightActions: ActionKeys[];
- rightAreaEndRender?: ReactNode;
- rightAreaStartRender?: ReactNode;
-}
-
-const ActionBar = memo(
- ({
- padding = '0 8px',
- rightAreaStartRender,
- rightAreaEndRender,
- leftAreaStartRender,
- leftAreaEndRender,
- leftActions,
- rightActions,
- }) => (
-
- {leftAreaStartRender}
-
- {leftAreaEndRender}
- >
+const mapActionsToItems = (keys: ActionKeys[]): ChatInputActionsProps['items'] =>
+ keys.map((actionKey, index) => {
+ if (typeof actionKey === 'string') {
+ if (actionKey === '---') {
+ return {
+ key: `divider-${index}`,
+ type: 'divider',
+ };
}
- padding={padding}
- rightAddons={
- <>
- {rightAreaStartRender}
-
- {rightAreaEndRender}
- >
- }
- />
- ),
-);
+ const Render = actionMap[actionKey];
+ return {
+ alwaysDisplay: actionKey === 'mainToken',
+ children: ,
+ key: actionKey,
+ };
+ } else {
+ return {
+ children: actionKey.map((groupActionKey) => {
+ const Render = actionMap[groupActionKey];
+ return {
+ children: ,
+ key: groupActionKey,
+ };
+ }),
+ key: `group-${index}`,
+ type: 'collapse',
+ };
+ }
+ });
-export default ActionBar;
+const ActionToolbar = memo(() => {
+ const leftActions = useChatInputStore((s) => s.leftActions);
+ const mobile = useChatInputStore((s) => s.mobile);
+ const items = useMemo(() => mapActionsToItems(leftActions), [leftActions]);
+ return ;
+});
+
+export default ActionToolbar;
diff --git a/src/features/ChatInput/ChatInputProvider.tsx b/src/features/ChatInput/ChatInputProvider.tsx
new file mode 100644
index 0000000000..b4685037a1
--- /dev/null
+++ b/src/features/ChatInput/ChatInputProvider.tsx
@@ -0,0 +1,54 @@
+import { useEditor } from '@lobehub/editor/react';
+import { ReactNode, memo, useRef } from 'react';
+
+import StoreUpdater, { StoreUpdaterProps } from './StoreUpdater';
+import { Provider, createStore } from './store';
+
+interface ChatInputProviderProps extends StoreUpdaterProps {
+ children: ReactNode;
+}
+
+export const ChatInputProvider = memo(
+ ({
+ children,
+ leftActions,
+ rightActions,
+ mobile,
+ sendButtonProps,
+ onSend,
+ sendMenu,
+ chatInputEditorRef,
+ onMarkdownContentChange,
+ }) => {
+ const editor = useEditor();
+ const slashMenuRef = useRef(null);
+
+ return (
+
+ createStore({
+ editor,
+ leftActions,
+ mobile,
+ rightActions,
+ sendButtonProps,
+ sendMenu,
+ slashMenuRef,
+ })
+ }
+ >
+
+ {children}
+
+ );
+ },
+);
diff --git a/src/features/ChatInput/Desktop/FilePreview/FileItem/index.tsx b/src/features/ChatInput/Desktop/FilePreview/FileItem/index.tsx
index ea8a0b690a..8e9df2fc9d 100644
--- a/src/features/ChatInput/Desktop/FilePreview/FileItem/index.tsx
+++ b/src/features/ChatInput/Desktop/FilePreview/FileItem/index.tsx
@@ -1,4 +1,4 @@
-import { ActionIcon, Text } from '@lobehub/ui';
+import { ActionIcon, Block, Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Trash2Icon } from 'lucide-react';
import { memo } from 'react';
@@ -26,17 +26,13 @@ const useStyles = createStyles(({ css, token }) => ({
${token.boxShadowTertiary};
`,
container: css`
+ user-select: none;
+
position: relative;
width: 180px;
height: 64px;
border-radius: 8px;
-
- background: ${token.colorBgContainer};
-
- :hover {
- background: ${token.colorBgElevated};
- }
`,
image: css`
margin-block: 0 !important;
@@ -58,15 +54,28 @@ const FileItem = memo((props) => {
const [removeChatUploadFile] = useFileStore((s) => [s.removeChatUploadFile]);
return (
-
+
-
+
{file.name}
-
@@ -80,7 +89,7 @@ const FileItem = memo((props) => {
title={t('delete', { ns: 'common' })}
/>
-
+
);
});
diff --git a/src/features/ChatInput/Desktop/FilePreview/FileList.tsx b/src/features/ChatInput/Desktop/FilePreview/FileList.tsx
index 837088e994..a13cfe7ecf 100644
--- a/src/features/ChatInput/Desktop/FilePreview/FileList.tsx
+++ b/src/features/ChatInput/Desktop/FilePreview/FileList.tsx
@@ -1,42 +1,43 @@
+import { ScrollShadow } from '@lobehub/ui';
import { createStyles } from 'antd-style';
-import { lighten } from 'polished';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
+import { useChatInputStore } from '@/features/ChatInput/store';
import { fileChatSelectors, useFileStore } from '@/store/file';
import FileItem from './FileItem';
-const useStyles = createStyles(({ css, token }) => ({
+const useStyles = createStyles(({ css }) => ({
container: css`
overflow-x: scroll;
-
width: 100%;
- border-start-start-radius: 8px;
- border-start-end-radius: 8px;
-
- background: ${lighten(0.01, token.colorBgLayout)};
`,
}));
const FileList = memo(() => {
+ const expand = useChatInputStore((s) => s.expand);
+
const inputFilesList = useFileStore(fileChatSelectors.chatUploadFileList);
const showFileList = useFileStore(fileChatSelectors.chatUploadFileListHasItem);
const { styles } = useStyles();
- if (!inputFilesList.length) return null;
+ if (!inputFilesList.length || !showFileList) return null;
return (
-
- {inputFilesList.map((item) => (
-
- ))}
-
+
+ {inputFilesList.map((item) => (
+
+ ))}
+
+
);
});
diff --git a/src/features/ChatInput/Desktop/Header/index.tsx b/src/features/ChatInput/Desktop/Header/index.tsx
deleted file mode 100644
index 53dcce7f2d..0000000000
--- a/src/features/ChatInput/Desktop/Header/index.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { ActionIcon } from '@lobehub/ui';
-import { Maximize2, Minimize2 } from 'lucide-react';
-import { memo } from 'react';
-
-import ActionBar from '@/features/ChatInput/ActionBar';
-import { ActionKeys } from '@/features/ChatInput/types';
-
-interface HeaderProps {
- expand: boolean;
- leftActions: ActionKeys[];
- rightActions: ActionKeys[];
- setExpand: (expand: boolean) => void;
-}
-
-const Header = memo(({ expand, setExpand, leftActions, rightActions }) => (
- {
- setExpand(!expand);
- }}
- />
- }
- />
-));
-
-export default Header;
diff --git a/src/features/ChatInput/Desktop/InputArea/index.tsx b/src/features/ChatInput/Desktop/InputArea/index.tsx
deleted file mode 100644
index 265c3ceb51..0000000000
--- a/src/features/ChatInput/Desktop/InputArea/index.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { TextArea } from '@lobehub/ui';
-import { createStyles } from 'antd-style';
-import { TextAreaRef } from 'antd/es/input/TextArea';
-import { RefObject, memo, useEffect, useRef } from 'react';
-import { useHotkeysContext } from 'react-hotkeys-hook';
-import { useTranslation } from 'react-i18next';
-
-import { isDesktop } from '@/const/version';
-import { useUserStore } from '@/store/user';
-import { preferenceSelectors } from '@/store/user/selectors';
-import { HotkeyEnum } from '@/types/hotkey';
-import { isCommandPressed } from '@/utils/keyboard';
-
-import { useAutoFocus } from '../useAutoFocus';
-
-const useStyles = createStyles(({ css }) => {
- return {
- textarea: css`
- resize: none !important;
-
- height: 100% !important;
- padding-block: 0;
- padding-inline: 16px;
-
- line-height: 1.5;
-
- box-shadow: none !important;
- `,
- textareaContainer: css`
- position: relative;
- flex: 1;
- `,
- };
-});
-
-interface InputAreaProps {
- loading?: boolean;
- onChange: (string: string) => void;
- onSend: () => void;
- value: string;
-}
-
-const InputArea = memo(({ onSend, value, loading, onChange }) => {
- const { enableScope, disableScope } = useHotkeysContext();
- const { t } = useTranslation('chat');
- const { styles } = useStyles();
-
- const ref = useRef(null);
- const isChineseInput = useRef(false);
-
- const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
-
- useAutoFocus(ref as RefObject);
-
- const hasValue = !!value;
-
- useEffect(() => {
- const fn = (e: BeforeUnloadEvent) => {
- if (hasValue) {
- // set returnValue to trigger alert modal
- // Note: No matter what value is set, the browser will display the standard text
- e.returnValue = '你有正在输入中的内容,确定要离开吗?';
- }
- };
-
- window.addEventListener('beforeunload', fn);
- return () => {
- window.removeEventListener('beforeunload', fn);
- };
- }, [hasValue]);
-
- return (
-
-
- );
-});
-
-InputArea.displayName = 'DesktopInputArea';
-
-export default InputArea;
diff --git a/src/features/ChatInput/Desktop/__tests__/useAutoFocus.test.ts b/src/features/ChatInput/Desktop/__tests__/useAutoFocus.test.ts
deleted file mode 100644
index a1c96894d2..0000000000
--- a/src/features/ChatInput/Desktop/__tests__/useAutoFocus.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-
-import { useChatStore } from '@/store/chat';
-import { chatSelectors } from '@/store/chat/selectors';
-
-import { useAutoFocus } from '../useAutoFocus';
-
-vi.mock('zustand/traditional');
-
-describe('useAutoFocus', () => {
- it('should focus the input when chatKey changes', () => {
- const focusMock = vi.fn();
- const inputRef = { current: { focus: focusMock } };
-
- act(() => {
- useChatStore.setState({ activeId: '1', activeTopicId: '2' });
- });
-
- renderHook(() => useAutoFocus(inputRef as any));
-
- expect(focusMock).toHaveBeenCalledTimes(1);
-
- act(() => {
- useChatStore.setState({ activeId: '1', activeTopicId: '3' });
- });
-
- renderHook(() => useAutoFocus(inputRef as any));
-
- // I don't know why its 3, but is large than 2 is fine
- expect(focusMock).toHaveBeenCalledTimes(3);
- });
-
- it('should not focus the input if inputRef is not available', () => {
- const inputRef = { current: null };
-
- act(() => {
- useChatStore.setState({ activeId: '1', activeTopicId: '2' });
- });
-
- renderHook(() => useAutoFocus(inputRef as any));
-
- expect(inputRef.current).toBeNull();
- });
-});
diff --git a/src/features/ChatInput/Desktop/index.tsx b/src/features/ChatInput/Desktop/index.tsx
index 593ab89587..5c4fb35c76 100644
--- a/src/features/ChatInput/Desktop/index.tsx
+++ b/src/features/ChatInput/Desktop/index.tsx
@@ -1,82 +1,95 @@
'use client';
-import { DraggablePanel } from '@lobehub/ui';
-import { ReactNode, memo, useCallback, useState } from 'react';
+import { ChatInput, ChatInputActionBar } from '@lobehub/editor/react';
+import { createStyles } from 'antd-style';
+import { memo, useEffect } from 'react';
import { Flexbox } from 'react-layout-kit';
-import { CHAT_TEXTAREA_HEIGHT, CHAT_TEXTAREA_MAX_HEIGHT } from '@/const/layoutTokens';
+import { useChatInputStore } from '@/features/ChatInput/store';
+import { useChatStore } from '@/store/chat';
+import { chatSelectors } from '@/store/chat/selectors';
-import { ActionKeys } from '../ActionBar/config';
-import LocalFiles from './FilePreview';
-import Head from './Header';
+import ActionBar from '../ActionBar';
+import InputEditor from '../InputEditor';
+import SendArea from '../SendArea';
+import ShortcutHint from '../SendArea/ShortcutHint';
+import TypoBar from '../TypoBar';
+import FilePreview from './FilePreview';
-export type FooterRender = (params: {
- expand: boolean;
- onExpandChange: (expand: boolean) => void;
-}) => ReactNode;
+const useStyles = createStyles(({ css, token }) => ({
+ container: css`
+ .show-on-hover {
+ opacity: 0;
+ }
-interface DesktopChatInputProps {
- inputHeight: number;
- leftActions: ActionKeys[];
- onInputHeightChange?: (height: number) => void;
- renderFooter: FooterRender;
- renderTextArea: (onSend: () => void) => ReactNode;
- rightActions: ActionKeys[];
-}
+ &:hover {
+ .show-on-hover {
+ opacity: 1;
+ }
+ }
+ `,
+ fullscreen: css`
+ position: absolute;
+ z-index: 100;
+ inset: 0;
-const DesktopChatInput = memo(
- ({
- leftActions,
- rightActions,
- renderTextArea,
- inputHeight,
- onInputHeightChange,
- renderFooter,
- }) => {
- const [expand, setExpand] = useState(false);
- const onSend = useCallback(() => {
- setExpand(false);
- }, []);
+ width: 100%;
+ height: 100%;
+ padding: 12px;
- return (
- <>
- {!expand && leftActions.includes('fileUpload') && }
- {
- if (!size) return;
- const height =
- typeof size.height === 'string' ? Number.parseInt(size.height) : size.height;
- if (!height) return;
+ background: ${token.colorBgContainerSecondary};
+ `,
+}));
- onInputHeightChange?.(height);
- }}
- placement="bottom"
- size={{ height: inputHeight, width: '100%' }}
- style={{ zIndex: 10 }}
- >
-
- (({ showFootnote }) => {
+ const [slashMenuRef, expand, showTypoBar, editor, leftActions] = useChatInputStore((s) => [
+ s.slashMenuRef,
+ s.expand,
+ s.showTypoBar,
+ s.editor,
+ s.leftActions,
+ ]);
+
+ const { styles, cx } = useStyles();
+
+ const chatKey = useChatStore(chatSelectors.currentChatKey);
+
+ useEffect(() => {
+ if (editor) editor.focus();
+ }, [chatKey, editor]);
+
+ const fileNode = leftActions.flat().includes('fileUpload') && ;
+
+ return (
+ <>
+ {!expand && fileNode}
+
+ }
+ right={}
+ style={{
+ paddingRight: 8,
+ }}
/>
- {renderTextArea(onSend)}
- {renderFooter({ expand, onExpandChange: setExpand })}
-
-
- >
- );
- },
-);
+ }
+ fullscreen={expand}
+ header={showTypoBar && }
+ slashMenuRef={slashMenuRef}
+ >
+ {expand && fileNode}
+
+
+ {showFootnote && !expand && }
+
+ >
+ );
+});
DesktopChatInput.displayName = 'DesktopChatInput';
diff --git a/src/features/ChatInput/Desktop/useAutoFocus.ts b/src/features/ChatInput/Desktop/useAutoFocus.ts
deleted file mode 100644
index 78c5505b9f..0000000000
--- a/src/features/ChatInput/Desktop/useAutoFocus.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { TextAreaRef } from 'antd/es/input/TextArea';
-import { RefObject, useEffect } from 'react';
-
-import { useChatStore } from '@/store/chat';
-import { chatSelectors } from '@/store/chat/selectors';
-
-export const useAutoFocus = (inputRef: RefObject) => {
- const chatKey = useChatStore(chatSelectors.currentChatKey);
-
- useEffect(() => {
- inputRef.current?.focus();
- }, [chatKey]);
-};
diff --git a/src/features/ChatInput/InputEditor/index.tsx b/src/features/ChatInput/InputEditor/index.tsx
new file mode 100644
index 0000000000..1eab248636
--- /dev/null
+++ b/src/features/ChatInput/InputEditor/index.tsx
@@ -0,0 +1,134 @@
+import { isDesktop } from '@lobechat/const';
+import { HotkeyEnum } from '@lobechat/types';
+import { isCommandPressed } from '@lobechat/utils';
+import {
+ INSERT_TABLE_COMMAND,
+ ReactCodeblockPlugin,
+ ReactHRPlugin,
+ ReactLinkPlugin,
+ ReactListPlugin,
+ ReactTablePlugin,
+} from '@lobehub/editor';
+import { Editor, SlashMenu, useEditorState } from '@lobehub/editor/react';
+import { Table2Icon } from 'lucide-react';
+import { memo, useEffect, useRef } from 'react';
+import { useHotkeysContext } from 'react-hotkeys-hook';
+import { useTranslation } from 'react-i18next';
+
+import { useUserStore } from '@/store/user';
+import { preferenceSelectors } from '@/store/user/selectors';
+
+import { useChatInputStore, useStoreApi } from '../store';
+
+const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
+ const [editor, slashMenuRef, send, updateMarkdownContent] = useChatInputStore((s) => [
+ s.editor,
+ s.slashMenuRef,
+ s.handleSendButton,
+ s.updateMarkdownContent,
+ ]);
+
+ const storeApi = useStoreApi();
+
+ const state = useEditorState(editor);
+ const { enableScope, disableScope } = useHotkeysContext();
+ const { t } = useTranslation(['editor', 'chat']);
+
+ const isChineseInput = useRef(false);
+
+ const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
+
+ useEffect(() => {
+ const fn = (e: BeforeUnloadEvent) => {
+ if (!state.isEmpty) {
+ // set returnValue to trigger alert modal
+ // Note: No matter what value is set, the browser will display the standard text
+ e.returnValue = 'You are typing something, are you sure you want to leave?';
+ }
+ };
+ window.addEventListener('beforeunload', fn);
+ return () => {
+ window.removeEventListener('beforeunload', fn);
+ };
+ }, [state.isEmpty]);
+
+ return (
+ {
+ disableScope(HotkeyEnum.AddUserMessage);
+ }}
+ onChange={() => {
+ updateMarkdownContent();
+ }}
+ onCompositionEnd={() => {
+ isChineseInput.current = false;
+ }}
+ onCompositionStart={() => {
+ isChineseInput.current = true;
+ }}
+ onContextMenu={async ({ event: e, editor }) => {
+ if (isDesktop) {
+ e.preventDefault();
+ const { electronSystemService } = await import('@/services/electron/system');
+
+ const selectionValue = editor.getSelectionDocument('markdown') as unknown as string;
+ const hasSelection = !!selectionValue;
+
+ await electronSystemService.showContextMenu('editor', {
+ hasSelection,
+ value: selectionValue,
+ });
+ }
+ }}
+ onFocus={() => {
+ enableScope(HotkeyEnum.AddUserMessage);
+ }}
+ onInit={(editor) => storeApi.setState({ editor })}
+ onPressEnter={({ event: e }) => {
+ if (e.altKey || e.shiftKey || isChineseInput.current) return;
+ const commandKey = isCommandPressed(e);
+ // when user like cmd + enter to send message
+ if (useCmdEnterToSend) {
+ if (commandKey) send();
+ } else {
+ if (!commandKey) send();
+ }
+ }}
+ placeholder={t('sendPlaceholder', { ns: 'chat' })}
+ plugins={[
+ ReactListPlugin,
+ ReactLinkPlugin,
+ ReactCodeblockPlugin,
+ ReactHRPlugin,
+ ReactTablePlugin,
+ ]}
+ slashOption={{
+ items: [
+ {
+ icon: Table2Icon,
+ key: 'table',
+ label: t('typobar.table'),
+ onSelect: (editor) => {
+ editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: '3', rows: '3' });
+ },
+ },
+ ],
+ renderComp: (props) => {
+ return (slashMenuRef as any)?.current} />;
+ },
+ }}
+ style={{
+ minHeight: defaultRows > 1 ? defaultRows * 23 : undefined,
+ }}
+ type={'text'}
+ variant={'chat'}
+ />
+ );
+});
+
+InputEditor.displayName = 'InputEditor';
+
+export default InputEditor;
diff --git a/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/File.tsx b/src/features/ChatInput/Mobile/FilePreview/FileItem/File.tsx
similarity index 94%
rename from src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/File.tsx
rename to src/features/ChatInput/Mobile/FilePreview/FileItem/File.tsx
index d3a4006c1e..c8151ef2f9 100644
--- a/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/File.tsx
+++ b/src/features/ChatInput/Mobile/FilePreview/FileItem/File.tsx
@@ -5,10 +5,9 @@ import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import FileIcon from '@/components/FileIcon';
+import UploadDetail from '@/features/ChatInput/components/UploadDetail';
import { UploadFileItem } from '@/types/files';
-import UploadDetail from '../../../../../../../../../../../features/ChatInput/components/UploadDetail';
-
const useStyles = createStyles(({ css, token }) => ({
container: css`
cursor: pointer;
diff --git a/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/Image.tsx b/src/features/ChatInput/Mobile/FilePreview/FileItem/Image.tsx
similarity index 100%
rename from src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/Image.tsx
rename to src/features/ChatInput/Mobile/FilePreview/FileItem/Image.tsx
diff --git a/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/index.tsx b/src/features/ChatInput/Mobile/FilePreview/FileItem/index.tsx
similarity index 100%
rename from src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/index.tsx
rename to src/features/ChatInput/Mobile/FilePreview/FileItem/index.tsx
diff --git a/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/style.ts b/src/features/ChatInput/Mobile/FilePreview/FileItem/style.ts
similarity index 100%
rename from src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/Files/FileItem/style.ts
rename to src/features/ChatInput/Mobile/FilePreview/FileItem/style.ts
diff --git a/src/features/ChatInput/Mobile/FilePreview/index.tsx b/src/features/ChatInput/Mobile/FilePreview/index.tsx
new file mode 100644
index 0000000000..8f99d8d3e3
--- /dev/null
+++ b/src/features/ChatInput/Mobile/FilePreview/index.tsx
@@ -0,0 +1,44 @@
+import { PreviewGroup, ScrollShadow } from '@lobehub/ui';
+import { createStyles } from 'antd-style';
+import isEqual from 'fast-deep-equal';
+import { memo } from 'react';
+import { Flexbox } from 'react-layout-kit';
+
+import { useChatInputStore } from '@/features/ChatInput/store';
+import { filesSelectors, useFileStore } from '@/store/file';
+
+import FileItem from './FileItem';
+
+const useStyles = createStyles(({ css }) => ({
+ container: css`
+ overflow-x: scroll;
+ width: 100%;
+ `,
+}));
+
+const FilePreview = memo(() => {
+ const expand = useChatInputStore((s) => s.expand);
+ const list = useFileStore(filesSelectors.chatUploadFileList, isEqual);
+ const { styles } = useStyles();
+ if (!list || list?.length === 0) return null;
+
+ return (
+
+
+
+ {list.map((i) => (
+
+ ))}
+
+
+
+ );
+});
+
+export default FilePreview;
diff --git a/src/features/ChatInput/Mobile/index.tsx b/src/features/ChatInput/Mobile/index.tsx
new file mode 100644
index 0000000000..888cfcf085
--- /dev/null
+++ b/src/features/ChatInput/Mobile/index.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { ChatInput, ChatInputActionBar } from '@lobehub/editor/react';
+import { createStyles } from 'antd-style';
+import dynamic from 'next/dynamic';
+import { memo } from 'react';
+import { Flexbox } from 'react-layout-kit';
+
+import { useChatInputStore } from '@/features/ChatInput/store';
+
+import ActionBar from '../ActionBar';
+import InputEditor from '../InputEditor';
+import SendArea from '../SendArea';
+
+const FilePreview = dynamic(() => import('./FilePreview'), { ssr: false });
+
+const useStyles = createStyles(({ css, token }) => ({
+ container: css``,
+ fullscreen: css`
+ position: absolute;
+ z-index: 100;
+ inset: 0;
+
+ width: 100%;
+ height: 100%;
+ padding: 12px;
+
+ background: ${token.colorBgLayout};
+ `,
+}));
+
+const DesktopChatInput = memo(() => {
+ const [slashMenuRef, expand] = useChatInputStore((s) => [s.slashMenuRef, s.expand]);
+ const leftActions = useChatInputStore((s) => s.leftActions);
+
+ const { styles, cx } = useStyles();
+
+ const fileNode = leftActions.flat().includes('fileUpload') && ;
+
+ return (
+ <>
+ {!expand && fileNode}
+
+ }
+ right={}
+ style={{
+ paddingRight: 8,
+ }}
+ />
+ }
+ fullscreen={expand}
+ header={} />}
+ slashMenuRef={slashMenuRef}
+ >
+ {expand && fileNode}
+
+
+
+ >
+ );
+});
+
+DesktopChatInput.displayName = 'DesktopChatInput';
+
+export default DesktopChatInput;
diff --git a/src/features/ChatInput/SendArea/ExpandButton.tsx b/src/features/ChatInput/SendArea/ExpandButton.tsx
new file mode 100644
index 0000000000..5bb3387117
--- /dev/null
+++ b/src/features/ChatInput/SendArea/ExpandButton.tsx
@@ -0,0 +1,30 @@
+import { ActionIcon } from '@lobehub/ui';
+import { Maximize2Icon, Minimize2Icon } from 'lucide-react';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useChatInputStore } from '@/features/ChatInput/store';
+
+const ExpandButton = memo(() => {
+ const { t } = useTranslation('editor');
+ const [expand, setExpand, editor] = useChatInputStore((s) => [s.expand, s.setExpand, s.editor]);
+ return (
+ {
+ setExpand?.(!expand);
+ editor?.focus();
+ }}
+ size={{ blockSize: 32, size: 16, strokeWidth: 2.3 }}
+ style={{
+ zIndex: 10,
+ }}
+ title={t(expand ? 'actions.expand.off' : 'actions.expand.on')}
+ />
+ );
+});
+
+ExpandButton.displayName = 'ExpandButton';
+
+export default ExpandButton;
diff --git a/src/features/ChatInput/SendArea/SendButton.tsx b/src/features/ChatInput/SendArea/SendButton.tsx
new file mode 100644
index 0000000000..8e54fd0b2f
--- /dev/null
+++ b/src/features/ChatInput/SendArea/SendButton.tsx
@@ -0,0 +1,29 @@
+import { SendButton as Send } from '@lobehub/editor/react';
+import isEqual from 'fast-deep-equal';
+import { memo } from 'react';
+
+import { selectors, useChatInputStore } from '../store';
+
+const SendButton = memo(() => {
+ const sendMenu = useChatInputStore((s) => s.sendMenu);
+ const shape = useChatInputStore((s) => s.sendButtonProps?.shape);
+ const { generating, disabled } = useChatInputStore(selectors.sendButtonProps, isEqual);
+ const [send, handleStop] = useChatInputStore((s) => [s.handleSendButton, s.handleStop]);
+
+ return (
+ send()}
+ onStop={() => handleStop()}
+ placement={'topRight'}
+ shape={shape}
+ trigger={['hover']}
+ />
+ );
+});
+
+SendButton.displayName = 'SendButton';
+
+export default SendButton;
diff --git a/src/features/ChatInput/SendArea/ShortcutHint.tsx b/src/features/ChatInput/SendArea/ShortcutHint.tsx
new file mode 100644
index 0000000000..7dc8bbae20
--- /dev/null
+++ b/src/features/ChatInput/SendArea/ShortcutHint.tsx
@@ -0,0 +1,52 @@
+import { Hotkey, Text, combineKeys } from '@lobehub/ui';
+import { useTheme } from 'antd-style';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Flexbox } from 'react-layout-kit';
+
+import { useUserStore } from '@/store/user';
+import { preferenceSelectors } from '@/store/user/selectors';
+import { KeyEnum } from '@/types/hotkey';
+
+const ShortcutHint = memo(() => {
+ const { t } = useTranslation('chat');
+ const theme = useTheme();
+
+ const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
+
+ const sendShortcut = useCmdEnterToSend
+ ? combineKeys([KeyEnum.Mod, KeyEnum.Enter])
+ : KeyEnum.Enter;
+
+ const wrapperShortcut = useCmdEnterToSend
+ ? KeyEnum.Enter
+ : combineKeys([KeyEnum.Mod, KeyEnum.Enter]);
+
+ return (
+
+
+
+ {t('input.send')}
+ /
+
+ {t('input.warp')}
+
+
+ );
+});
+
+export default ShortcutHint;
diff --git a/src/features/ChatInput/SendArea/index.tsx b/src/features/ChatInput/SendArea/index.tsx
new file mode 100644
index 0000000000..9e357df04a
--- /dev/null
+++ b/src/features/ChatInput/SendArea/index.tsx
@@ -0,0 +1,36 @@
+import isEqual from 'fast-deep-equal';
+import { memo, useMemo } from 'react';
+import { Flexbox } from 'react-layout-kit';
+
+import { ActionKey, actionMap } from '../ActionBar/config';
+import { useChatInputStore } from '../store';
+import ExpandButton from './ExpandButton';
+import SendButton from './SendButton';
+
+const mapActionsToItems = (keys: ActionKey[]) =>
+ keys.map((actionKey) => {
+ const Render = actionMap[actionKey];
+ return ;
+ });
+
+const SendArea = memo(() => {
+ const allowExpand = useChatInputStore((s) => s.allowExpand);
+ const rightActions = useChatInputStore((s) => s.rightActions, isEqual);
+
+ const items = useMemo(
+ () => mapActionsToItems((rightActions as ActionKey[]) || []),
+ [rightActions],
+ );
+
+ return (
+
+ {allowExpand && }
+ {items}
+
+
+ );
+});
+
+SendArea.displayName = 'SendArea';
+
+export default SendArea;
diff --git a/src/features/ChatInput/StoreUpdater.tsx b/src/features/ChatInput/StoreUpdater.tsx
new file mode 100644
index 0000000000..4223e11a4e
--- /dev/null
+++ b/src/features/ChatInput/StoreUpdater.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { ForwardedRef, memo, useImperativeHandle } from 'react';
+import { createStoreUpdater } from 'zustand-utils';
+
+import { ChatInputEditor, useChatInputEditor } from './hooks/useChatInputEditor';
+import { PublicState, useStoreApi } from './store';
+
+export interface StoreUpdaterProps extends Partial {
+ chatInputEditorRef?: ForwardedRef;
+}
+
+const StoreUpdater = memo(
+ ({
+ chatInputEditorRef,
+ mobile,
+ sendButtonProps,
+ leftActions,
+ rightActions,
+ onSend,
+ onMarkdownContentChange,
+ }) => {
+ const storeApi = useStoreApi();
+ const useStoreUpdater = createStoreUpdater(storeApi);
+ const editor = useChatInputEditor();
+
+ useStoreUpdater('mobile', mobile);
+ useStoreUpdater('leftActions', leftActions);
+ useStoreUpdater('rightActions', rightActions);
+
+ useStoreUpdater('sendButtonProps', sendButtonProps);
+ useStoreUpdater('onSend', onSend);
+ useStoreUpdater('onMarkdownContentChange', onMarkdownContentChange);
+
+ useImperativeHandle(chatInputEditorRef, () => editor);
+
+ return null;
+ },
+);
+
+export default StoreUpdater;
diff --git a/src/features/ChatInput/TypoBar/index.tsx b/src/features/ChatInput/TypoBar/index.tsx
new file mode 100644
index 0000000000..e4b2c19cb2
--- /dev/null
+++ b/src/features/ChatInput/TypoBar/index.tsx
@@ -0,0 +1,139 @@
+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,
+ LinkIcon,
+ ListIcon,
+ ListOrderedIcon,
+ MessageSquareQuote,
+ SquareDashedBottomCodeIcon,
+ StrikethroughIcon,
+} from 'lucide-react';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useChatInputStore } from '@/features/ChatInput/store';
+
+const TypoBar = memo(() => {
+ const { t } = useTranslation('editor');
+ const editor = useChatInputStore((s) => s.editor);
+ const editorState = useEditorState(editor);
+ const theme = useTheme();
+
+ return (
+
+ // {
+ // active: editorState.isUnderline,
+ // icon: UnderlineIcon,
+ // key: 'underline',
+ // label: t('typobar.underline'),
+ // onClick: editorState.underline,
+ // },
+ {
+ active: editorState.isStrikethrough,
+ icon: StrikethroughIcon,
+ key: 'strikethrough',
+ label: t('typobar.strikethrough'),
+ onClick: editorState.strikethrough,
+ },
+ {
+ type: 'divider',
+ },
+
+ {
+ icon: ListIcon,
+ key: 'bulletList',
+ label: t('typobar.bulletList'),
+ onClick: editorState.bulletList,
+ },
+ {
+ icon: ListOrderedIcon,
+ key: 'numberlist',
+ label: t('typobar.numberList'),
+ onClick: editorState.numberList,
+ },
+ {
+ active: editorState.isBlockquote,
+ icon: MessageSquareQuote,
+ key: 'blockquote',
+ label: t('typobar.blockquote'),
+ onClick: editorState.blockquote,
+ },
+ {
+ icon: LinkIcon,
+ key: 'link',
+ label: t('typobar.link'),
+ onClick: editorState.insertLink,
+ },
+ {
+ type: 'divider',
+ },
+ {
+ active: editorState.isCode,
+ icon: CodeXmlIcon,
+ key: 'code',
+ label: t('typobar.code'),
+ onClick: editorState.code,
+ },
+ {
+ icon: SquareDashedBottomCodeIcon,
+ key: 'codeblock',
+ label: t('typobar.codeblock'),
+ onClick: editorState.codeblock,
+ },
+ editorState.isCodeblock && {
+ children: (
+ editorState.updateCodeblockLang(value)}
+ value={editorState.codeblockLang}
+ />
+ ),
+ disabled: !editorState.isCodeblock,
+ key: 'codeblockLang',
+ },
+ ].filter(Boolean) as ChatInputActionsProps['items']
+ }
+ onClick={() => {
+ editor?.focus();
+ }}
+ />
+ }
+ style={{
+ background: theme.colorFillQuaternary,
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
+ }}
+ />
+ );
+});
+
+TypoBar.displayName = 'TypoBar';
+
+export default TypoBar;
diff --git a/src/features/ChatInput/hooks/useChatInputEditor.ts b/src/features/ChatInput/hooks/useChatInputEditor.ts
new file mode 100644
index 0000000000..9ba25d7dea
--- /dev/null
+++ b/src/features/ChatInput/hooks/useChatInputEditor.ts
@@ -0,0 +1,36 @@
+import { IEditor } from '@lobehub/editor';
+import { useMemo } from 'react';
+
+import { useChatInputStore } from '@/features/ChatInput/store';
+
+export interface ChatInputEditor {
+ clearContent: () => void;
+ focus: () => void;
+ getJSONState: () => any;
+ getMarkdownContent: () => string;
+ instance: IEditor;
+ setExpand: (expand: boolean) => void;
+ setJSONState: (content: any) => void;
+}
+export const useChatInputEditor = () => {
+ const [editor, getMarkdownContent, getJSONState, setExpand, setJSONState] = useChatInputStore(
+ (s) => [s.editor, s.getMarkdownContent, s.getJSONState, s.setExpand, s.setJSONState],
+ );
+
+ return useMemo(
+ () => ({
+ clearContent: () => {
+ editor?.cleanDocument();
+ },
+ focus: () => {
+ editor?.focus();
+ },
+ getJSONState,
+ getMarkdownContent,
+ instance: editor!,
+ setExpand,
+ setJSONState,
+ }),
+ [editor],
+ );
+};
diff --git a/src/features/ChatInput/index.ts b/src/features/ChatInput/index.ts
new file mode 100644
index 0000000000..9b257f8870
--- /dev/null
+++ b/src/features/ChatInput/index.ts
@@ -0,0 +1,7 @@
+export type { ActionKey, ActionKeys } from './ActionBar/config';
+export { ChatInputProvider } from './ChatInputProvider';
+export { default as DesktopChatInput } from './Desktop';
+export type { ChatInputEditor } from './hooks/useChatInputEditor';
+export { useChatInputEditor } from './hooks/useChatInputEditor';
+export { default as MobileChatInput } from './Mobile';
+export type { SendButtonHandler } from './store/initialState';
diff --git a/src/features/ChatInput/store/action.ts b/src/features/ChatInput/store/action.ts
new file mode 100644
index 0000000000..7c86b5bf47
--- /dev/null
+++ b/src/features/ChatInput/store/action.ts
@@ -0,0 +1,75 @@
+import { StateCreator } from 'zustand/vanilla';
+
+import { PublicState, State, initialState } from './initialState';
+
+export interface Action {
+ getJSONState: () => any;
+ getMarkdownContent: () => string;
+ handleSendButton: () => void;
+ handleStop: () => void;
+ setExpand: (expend: boolean) => void;
+ setJSONState: (content: any) => void;
+ setShowTypoBar: (show: boolean) => void;
+ updateMarkdownContent: () => void;
+}
+
+export type Store = Action & State;
+
+// const t = setNamespace('ChatInput');
+
+type CreateStore = (
+ initState?: Partial,
+) => StateCreator;
+
+export const store: CreateStore = (publicState) => (set, get) => ({
+ ...initialState,
+ ...publicState,
+
+ getJSONState: () => {
+ return get().editor?.getDocument('json');
+ },
+ getMarkdownContent: () => {
+ return String(get().editor?.getDocument('markdown') || '').trimEnd();
+ },
+ handleSendButton: () => {
+ if (!get().editor) return;
+
+ const editor = get().editor;
+
+ get().onSend?.({
+ clearContent: () => editor?.cleanDocument(),
+ editor: editor!,
+ getMarkdownContent: get().getMarkdownContent,
+ });
+ },
+
+ handleStop: () => {
+ if (!get().editor) return;
+
+ get().sendButtonProps?.onStop?.({ editor: get().editor! });
+ },
+
+ setExpand: (expand) => {
+ set({ expand });
+ },
+
+ setJSONState: (content) => {
+ get().editor?.setDocument('json', content);
+ },
+
+ setShowTypoBar: (showTypoBar) => {
+ set({ showTypoBar });
+ },
+
+ updateMarkdownContent: () => {
+ if (!get().onMarkdownContentChange) return;
+
+ const content = get().getMarkdownContent();
+
+ if (content === get().markdownContent) return;
+
+ get().onMarkdownContentChange?.(content);
+
+ set({ markdownContent: content });
+ },
+});
diff --git a/src/features/ChatInput/store/index.ts b/src/features/ChatInput/store/index.ts
new file mode 100644
index 0000000000..bb36e75169
--- /dev/null
+++ b/src/features/ChatInput/store/index.ts
@@ -0,0 +1,23 @@
+'use client';
+
+import { StoreApiWithSelector } from '@lobechat/types';
+import { createContext } from 'zustand-utils';
+import { subscribeWithSelector } from 'zustand/middleware';
+import { shallow } from 'zustand/shallow';
+import { createWithEqualityFn } from 'zustand/traditional';
+
+import { Store, store } from './action';
+import { State } from './initialState';
+
+export type { PublicState, State } from './initialState';
+
+export const createStore = (initState?: Partial) =>
+ createWithEqualityFn(subscribeWithSelector(store(initState)), shallow);
+
+export const {
+ useStore: useChatInputStore,
+ useStoreApi,
+ Provider,
+} = createContext>();
+
+export { selectors } from './selectors';
diff --git a/src/features/ChatInput/store/initialState.ts b/src/features/ChatInput/store/initialState.ts
new file mode 100644
index 0000000000..2a57260797
--- /dev/null
+++ b/src/features/ChatInput/store/initialState.ts
@@ -0,0 +1,54 @@
+import type { IEditor } from '@lobehub/editor';
+import type { ChatInputProps } from '@lobehub/editor/react';
+import type { MenuProps } from '@lobehub/ui/es/Menu';
+
+import { ActionKeys } from '@/features/ChatInput';
+
+export type SendButtonHandler = (params: {
+ clearContent: () => void;
+ editor: IEditor;
+ getMarkdownContent: () => string;
+}) => Promise | void;
+
+export interface SendButtonProps {
+ disabled?: boolean;
+ generating: boolean;
+ onStop: (params: { editor: IEditor }) => void;
+ shape?: 'round' | 'default';
+}
+
+export const initialSendButtonState: SendButtonProps = {
+ disabled: false,
+ generating: false,
+ onStop: () => {},
+};
+
+export interface PublicState {
+ allowExpand?: boolean;
+ expand?: boolean;
+ leftActions: ActionKeys[];
+ mobile?: boolean;
+ onMarkdownContentChange?: (content: string) => void;
+ onSend?: SendButtonHandler;
+ rightActions: ActionKeys[];
+ sendButtonProps?: SendButtonProps;
+ sendMenu?: MenuProps;
+ showTypoBar?: boolean;
+}
+
+export interface State extends PublicState {
+ editor?: IEditor;
+ isContentEmpty: boolean;
+ markdownContent: string;
+ slashMenuRef: ChatInputProps['slashMenuRef'];
+}
+
+export const initialState: State = {
+ allowExpand: true,
+ expand: false,
+ isContentEmpty: false,
+ leftActions: [],
+ markdownContent: '',
+ rightActions: [],
+ slashMenuRef: { current: null },
+};
diff --git a/src/features/ChatInput/store/selectors.ts b/src/features/ChatInput/store/selectors.ts
new file mode 100644
index 0000000000..c7cd30c122
--- /dev/null
+++ b/src/features/ChatInput/store/selectors.ts
@@ -0,0 +1,5 @@
+import { SendButtonProps, State, initialSendButtonState } from './initialState';
+
+export const selectors = {
+ sendButtonProps: (s: State): SendButtonProps => s.sendButtonProps || initialSendButtonState,
+};
diff --git a/src/features/ChatInput/useSend.ts b/src/features/ChatInput/useSend.ts
deleted file mode 100644
index ba16697cb4..0000000000
--- a/src/features/ChatInput/useSend.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useAnalytics } from '@lobehub/analytics/react';
-import { useCallback, useMemo } from 'react';
-
-import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
-import { getAgentStoreState } from '@/store/agent';
-import { agentSelectors } from '@/store/agent/selectors';
-import { useChatStore } from '@/store/chat';
-import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
-import { fileChatSelectors, useFileStore } from '@/store/file';
-import { getUserStoreState } from '@/store/user';
-import { SendMessageParams } from '@/types/message';
-
-export type UseSendMessageParams = Pick<
- SendMessageParams,
- 'onlyAddUserMessage' | 'isWelcomeQuestion'
->;
-
-export const useSendMessage = () => {
- const [sendMessage, updateInputMessage] = useChatStore((s) => [
- s.sendMessage,
- s.updateInputMessage,
- ]);
- const { analytics } = useAnalytics();
- const checkGeminiChineseWarning = useGeminiChineseWarning();
-
- const clearChatUploadFileList = useFileStore((s) => s.clearChatUploadFileList);
-
- const isUploadingFiles = useFileStore(fileChatSelectors.isUploadingFiles);
- const isSendButtonDisabledByMessage = useChatStore(chatSelectors.isSendButtonDisabledByMessage);
-
- const canSend = !isUploadingFiles && !isSendButtonDisabledByMessage;
-
- const send = useCallback(async (params: UseSendMessageParams = {}) => {
- const store = useChatStore.getState();
- if (chatSelectors.isAIGenerating(store)) return;
-
- // if uploading file or send button is disabled by message, then we should not send the message
- const isUploadingFiles = fileChatSelectors.isUploadingFiles(useFileStore.getState());
- const isSendButtonDisabledByMessage = chatSelectors.isSendButtonDisabledByMessage(
- useChatStore.getState(),
- );
-
- const canSend = !isUploadingFiles && !isSendButtonDisabledByMessage;
- if (!canSend) return;
-
- const fileList = fileChatSelectors.chatUploadFileList(useFileStore.getState());
- // if there is no message and no image, then we should not send the message
- if (!store.inputMessage && fileList.length === 0) return;
-
- // Check for Chinese text warning with Gemini model
- const agentStore = getAgentStoreState();
- const currentModel = agentSelectors.currentAgentModel(agentStore);
- const shouldContinue = await checkGeminiChineseWarning({
- model: currentModel,
- prompt: store.inputMessage,
- scenario: 'chat',
- });
-
- if (!shouldContinue) return;
-
- sendMessage({
- files: fileList,
- message: store.inputMessage,
- ...params,
- });
-
- updateInputMessage('');
- clearChatUploadFileList();
-
- // 获取分析数据
- const userStore = getUserStoreState();
-
- // 直接使用现有数据结构判断消息类型
- const hasImages = fileList.some((file) => file.file?.type?.startsWith('image'));
- const messageType = fileList.length === 0 ? 'text' : hasImages ? 'image' : 'file';
-
- analytics?.track({
- name: 'send_message',
- properties: {
- chat_id: store.activeId || 'unknown',
- current_topic: topicSelectors.currentActiveTopic(store)?.title || null,
- has_attachments: fileList.length > 0,
- history_message_count: chatSelectors.activeBaseChats(store).length,
- message: store.inputMessage,
- message_length: store.inputMessage.length,
- message_type: messageType,
- selected_model: agentSelectors.currentAgentModel(agentStore),
- session_id: store.activeId || 'inbox', // 当前活跃的会话ID
- user_id: userStore.user?.id || 'anonymous',
- },
- });
- // const hasSystemRole = agentSelectors.hasSystemRole(useAgentStore.getState());
- // const agentSetting = useAgentStore.getState().agentSettingInstance;
-
- // // if there is a system role, then we need to use agent setting instance to autocomplete agent meta
- // if (hasSystemRole && !!agentSetting) {
- // agentSetting.autocompleteAllMeta();
- // }
- }, []);
-
- return useMemo(() => ({ canSend, send }), [canSend]);
-};
diff --git a/src/features/Conversation/components/BackBottom/style.ts b/src/features/Conversation/components/BackBottom/style.ts
index d94ea21611..ed4d5afc06 100644
--- a/src/features/Conversation/components/BackBottom/style.ts
+++ b/src/features/Conversation/components/BackBottom/style.ts
@@ -8,7 +8,7 @@ export const useStyles = createStyles(({ token, css, stylish, cx, responsive })
pointer-events: none;
position: absolute;
- z-index: 1000;
+ z-index: 50;
inset-block-end: 16px;
inset-inline-end: 16px;
transform: translateY(16px);
diff --git a/src/features/Conversation/components/SkeletonList.tsx b/src/features/Conversation/components/SkeletonList.tsx
index 2570e87bfa..18c1b1f969 100644
--- a/src/features/Conversation/components/SkeletonList.tsx
+++ b/src/features/Conversation/components/SkeletonList.tsx
@@ -3,7 +3,8 @@
import { Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
-import { Flexbox } from 'react-layout-kit';
+
+import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
const useStyles = createStyles(({ css, prefixCls }) => ({
message: css`
@@ -30,7 +31,13 @@ const SkeletonList = memo(({ mobile }) => {
const { cx, styles } = useStyles();
return (
-
+
(({ mobile }) => {
paragraph={{ width: mobile ? ['80%', '40%'] : ['50%', '30%'] }}
title={false}
/>
-
+
);
});
export default SkeletonList;
diff --git a/src/features/Conversation/components/VirtualizedList/index.tsx b/src/features/Conversation/components/VirtualizedList/index.tsx
index 262e9525d6..893e740137 100644
--- a/src/features/Conversation/components/VirtualizedList/index.tsx
+++ b/src/features/Conversation/components/VirtualizedList/index.tsx
@@ -1,13 +1,10 @@
'use client';
-import { Icon } from '@lobehub/ui';
-import { useTheme } from 'antd-style';
-import { Loader2Icon } from 'lucide-react';
-import React, { ReactNode, memo, useCallback, useEffect, useRef, useState } from 'react';
-import { Center, Flexbox } from 'react-layout-kit';
+import { ReactNode, forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react';
+import { Flexbox } from 'react-layout-kit';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
-import { isServerMode } from '@/const/version';
+import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
@@ -21,8 +18,17 @@ interface VirtualizedListProps {
mobile?: boolean;
}
+const List = forwardRef(({ ...props }, ref) => {
+ return (
+
+
+
+ );
+});
+
const VirtualizedList = memo(({ mobile, dataSource, itemContent }) => {
const virtuosoRef = useRef(null);
+ const prevDataLengthRef = useRef(dataSource.length);
const [atBottom, setAtBottom] = useState(true);
const [isScrolling, setIsScrolling] = useState(false);
@@ -32,71 +38,74 @@ const VirtualizedList = memo(({ mobile, dataSource, itemCo
chatSelectors.isCurrentChatLoaded(s),
]);
- useEffect(() => {
- if (virtuosoRef.current) {
- virtuosoRef.current.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
- }
- }, [id]);
-
- const prevDataLengthRef = useRef(dataSource.length);
-
const getFollowOutput = useCallback(() => {
const newFollowOutput = dataSource.length > prevDataLengthRef.current ? 'auto' : false;
prevDataLengthRef.current = dataSource.length;
return newFollowOutput;
}, [dataSource.length]);
- const theme = useTheme();
+ const scrollToBottom = useCallback(
+ (behavior: 'auto' | 'smooth' = 'smooth') => {
+ if (atBottom) return;
+ if (!virtuosoRef.current) return;
+ virtuosoRef.current.scrollToIndex({ align: 'end', behavior, index: 'LAST' });
+ },
+ [atBottom],
+ );
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [id]);
+
// overscan should be 3 times the height of the window
const overscan = typeof window !== 'undefined' ? window.innerHeight * 3 : 0;
// first time loading or not loaded
- if (isFirstLoading) return ;
-
- if (!isCurrentChatLoaded)
- // use skeleton list when not loaded in server mode due to the loading duration is much longer than client mode
- return isServerMode ? (
-
- ) : (
- // in client mode and switch page, using the center loading for smooth transition
-
-
-
- );
+ if (isFirstLoading || !isCurrentChatLoaded) return ;
return (
-
- item}
- data={dataSource}
- followOutput={getFollowOutput}
- increaseViewportBy={overscan}
- initialTopMostItemIndex={dataSource?.length - 1}
- isScrolling={setIsScrolling}
- itemContent={itemContent}
- ref={virtuosoRef}
- />
+ item}
+ data={dataSource}
+ followOutput={getFollowOutput}
+ increaseViewportBy={overscan}
+ initialTopMostItemIndex={dataSource?.length - 1}
+ isScrolling={setIsScrolling}
+ itemContent={itemContent}
+ ref={virtuosoRef}
+ />
+ {
+ if (!atBottom) return;
+ setTimeout(scrollToBottom, 100);
+ }}
+ style={{
+ position: 'relative',
+ }}
+ >
{
- const virtuoso = virtuosoRef.current;
switch (type) {
case 'auto': {
- virtuoso?.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
+ scrollToBottom();
break;
}
case 'click': {
- virtuoso?.scrollToIndex({ align: 'end', behavior: 'smooth', index: 'LAST' });
+ scrollToBottom('smooth');
break;
}
}
}}
/>
-
+
);
});
diff --git a/src/features/Conversation/components/WideScreenContainer/index.tsx b/src/features/Conversation/components/WideScreenContainer/index.tsx
new file mode 100644
index 0000000000..7aec160b2f
--- /dev/null
+++ b/src/features/Conversation/components/WideScreenContainer/index.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { createStyles } from 'antd-style';
+import { memo, useEffect } from 'react';
+import { Flexbox, FlexboxProps } from 'react-layout-kit';
+
+import { CONVERSATION_MIN_WIDTH } from '@/const/layoutTokens';
+import { useGlobalStore } from '@/store/global';
+import { systemStatusSelectors } from '@/store/global/selectors';
+
+const useStyles = createStyles(({ css, token }) => ({
+ container: css`
+ align-self: center;
+ transition: width 0.25s ${token.motionEaseInOut};
+ `,
+}));
+
+interface WideScreenContainerProps extends FlexboxProps {
+ onChange?: () => void;
+}
+
+const WideScreenContainer = memo(
+ ({ children, className, onChange, ...rest }) => {
+ const { cx, styles } = useStyles();
+ const wideScreen = useGlobalStore(systemStatusSelectors.wideScreen);
+
+ useEffect(() => {
+ onChange?.();
+ }, [wideScreen]);
+
+ return (
+
+ {children}
+
+ );
+ },
+);
+
+export default WideScreenContainer;
diff --git a/src/features/Portal/Thread/Chat/ChatInput/Footer.tsx b/src/features/Portal/Thread/Chat/ChatInput/Footer.tsx
deleted file mode 100644
index 3948845c9d..0000000000
--- a/src/features/Portal/Thread/Chat/ChatInput/Footer.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Button } from '@lobehub/ui';
-import { createStyles } from 'antd-style';
-import { rgba } from 'polished';
-import { memo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Flexbox } from 'react-layout-kit';
-
-import StopLoadingIcon from '@/components/StopLoading';
-import { useChatStore } from '@/store/chat';
-import { threadSelectors } from '@/store/chat/selectors';
-
-import { useSendThreadMessage } from './useSend';
-
-const useStyles = createStyles(({ css, prefixCls, token }) => {
- return {
- loadingButton: css`
- display: flex;
- align-items: center;
- `,
- overrideAntdIcon: css`
- .${prefixCls}-btn.${prefixCls}-btn-icon-only {
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .${prefixCls}-btn.${prefixCls}-dropdown-trigger {
- &::before {
- background-color: ${rgba(token.colorBgLayout, 0.1)} !important;
- }
- }
- `,
- };
-});
-
-interface FooterProps {
- onExpandChange: (expand: boolean) => void;
-}
-
-const Footer = memo(({ onExpandChange }) => {
- const { t } = useTranslation('chat');
-
- const { styles } = useStyles();
-
- const [isAIGenerating, stopGenerateMessage] = useChatStore((s) => [
- threadSelectors.isThreadAIGenerating(s),
- s.stopGenerateMessage,
- ]);
-
- const { send: sendMessage, canSend } = useSendThreadMessage();
-
- return (
-
-
- {isAIGenerating ? (
- }
- onClick={stopGenerateMessage}
- >
- {t('input.stop')}
-
- ) : (
-
- )}
-
- );
-});
-
-Footer.displayName = 'Footer';
-
-export default Footer;
diff --git a/src/features/Portal/Thread/Chat/ChatInput/TextArea.tsx b/src/features/Portal/Thread/Chat/ChatInput/TextArea.tsx
deleted file mode 100644
index 0e28ccc4f7..0000000000
--- a/src/features/Portal/Thread/Chat/ChatInput/TextArea.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { memo } from 'react';
-
-import InputArea from '@/features/ChatInput/Desktop/InputArea';
-import { useChatStore } from '@/store/chat';
-import { chatSelectors } from '@/store/chat/selectors';
-
-import { useSendThreadMessage } from './useSend';
-
-const TextArea = memo<{ onSend?: () => void }>(({ onSend }) => {
- const [loading, value, updateInputMessage] = useChatStore((s) => [
- chatSelectors.isAIGenerating(s),
- s.threadInputMessage,
- s.updateThreadInputMessage,
- ]);
- const { send: sendMessage } = useSendThreadMessage();
-
- return (
- {
- sendMessage();
- onSend?.();
- }}
- value={value}
- />
- );
-});
-
-export default TextArea;
diff --git a/src/features/Portal/Thread/Chat/ChatInput/index.tsx b/src/features/Portal/Thread/Chat/ChatInput/index.tsx
index c0b51b2341..00b9d821ca 100644
--- a/src/features/Portal/Thread/Chat/ChatInput/index.tsx
+++ b/src/features/Portal/Thread/Chat/ChatInput/index.tsx
@@ -4,63 +4,70 @@ import { Alert } from '@lobehub/ui';
import Link from 'next/link';
import { memo } from 'react';
import { Trans } from 'react-i18next';
+import { Flexbox } from 'react-layout-kit';
-import { ActionKeys } from '@/features/ChatInput/ActionBar/config';
-import DesktopChatInput, { FooterRender } from '@/features/ChatInput/Desktop';
+import { type ActionKeys, ChatInputProvider, DesktopChatInput } from '@/features/ChatInput';
+import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
+import { useChatStore } from '@/store/chat';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
-import Footer from './Footer';
-import TextArea from './TextArea';
+import { useSendThreadMessage } from './useSend';
-const leftActions = ['stt', 'portalToken'] as ActionKeys[];
-
-const rightActions = [] as ActionKeys[];
-
-const renderTextArea = (onSend: () => void) => ;
-const renderFooter: FooterRender = (props) => ;
+const threadActions: ActionKeys[] = ['typo', 'stt', 'portalToken'];
const Desktop = memo(() => {
- const [inputHeight, hideThreadLimitAlert, updateSystemStatus] = useGlobalStore((s) => [
- systemStatusSelectors.threadInputHeight(s),
+ const [hideThreadLimitAlert, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.systemStatus(s).hideThreadLimitAlert,
s.updateSystemStatus,
]);
+ const { send, disabled, generating, stop } = useSendThreadMessage();
+
return (
- <>
+
{!hideThreadLimitAlert && (
-
- 子话题暂不支持文件/图片上传,如有需求,欢迎留言:
-
- 💬 讨论
-
-
- }
- onClose={() => {
- updateSystemStatus({ hideThreadLimitAlert: true });
- }}
- type={'info'}
- />
+
+
+ 子话题暂不支持文件/图片上传,如有需求,欢迎留言:
+
+ 💬 讨论
+
+
+ }
+ onClose={() => {
+ updateSystemStatus({ hideThreadLimitAlert: true });
+ }}
+ type={'info'}
+ />
+
)}
- {
- updateSystemStatus({ threadInputHeight: height });
+
+ {
+ if (!instance) return;
+ useChatStore.setState({ threadInputEditor: instance });
}}
- renderFooter={renderFooter}
- renderTextArea={renderTextArea}
- rightActions={rightActions}
- />
- >
+ leftActions={threadActions}
+ onSend={() => {
+ send();
+ }}
+ sendButtonProps={{
+ disabled,
+ generating,
+ onStop: stop,
+ shape: 'round',
+ }}
+ >
+
+
+
);
});
diff --git a/src/features/Portal/Thread/Chat/ChatInput/useSend.ts b/src/features/Portal/Thread/Chat/ChatInput/useSend.ts
index 6513a28505..2740fe8097 100644
--- a/src/features/Portal/Thread/Chat/ChatInput/useSend.ts
+++ b/src/features/Portal/Thread/Chat/ChatInput/useSend.ts
@@ -1,5 +1,8 @@
-import { useCallback, useMemo } from 'react';
+import { useMemo, useState } from 'react';
+import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
+import { getAgentStoreState } from '@/store/agent';
+import { agentSelectors } from '@/store/agent/slices/chat';
import { useChatStore } from '@/store/chat';
import { threadSelectors } from '@/store/chat/selectors';
import { SendMessageParams } from '@/types/message';
@@ -10,41 +13,64 @@ export type UseSendMessageParams = Pick<
>;
export const useSendThreadMessage = () => {
+ const [loading, setLoading] = useState(false);
+ const canNotSend = useChatStore(threadSelectors.isSendButtonDisabledByMessage);
+ const generating = useChatStore((s) => threadSelectors.isThreadAIGenerating(s));
+ const stop = useChatStore((s) => s.stopGenerateMessage);
const [sendMessage, updateInputMessage] = useChatStore((s) => [
s.sendThreadMessage,
s.updateThreadInputMessage,
]);
+ const checkGeminiChineseWarning = useGeminiChineseWarning();
- const isSendButtonDisabledByMessage = useChatStore(threadSelectors.isSendButtonDisabledByMessage);
-
- const canSend = !isSendButtonDisabledByMessage;
-
- const send = useCallback((params: UseSendMessageParams = {}) => {
+ const handleSend = async (params: UseSendMessageParams = {}) => {
const store = useChatStore.getState();
+
if (threadSelectors.isThreadAIGenerating(store)) return;
+ const canNotSend = threadSelectors.isSendButtonDisabledByMessage(store);
- const isSendButtonDisabledByMessage = threadSelectors.isSendButtonDisabledByMessage(
- useChatStore.getState(),
- );
+ if (canNotSend) return;
- const canSend = !isSendButtonDisabledByMessage;
- if (!canSend) return;
+ const threadInputEditor = store.threadInputEditor;
+
+ if (!threadInputEditor) {
+ console.warn('not found threadInputEditor instance');
+ return;
+ }
+
+ const inputMessage = threadInputEditor.getMarkdownContent();
// if there is no message and no image, then we should not send the message
- if (!store.threadInputMessage) return;
+ if (!inputMessage) return;
- sendMessage({ message: store.threadInputMessage, ...params });
+ // Check for Chinese text warning with Gemini model
+ const agentStore = getAgentStoreState();
+ const currentModel = agentSelectors.currentAgentModel(agentStore);
+ const shouldContinue = await checkGeminiChineseWarning({
+ model: currentModel,
+ prompt: inputMessage,
+ scenario: 'chat',
+ });
+
+ if (!shouldContinue) return;
+
+ updateInputMessage(inputMessage);
+
+ sendMessage({ message: inputMessage, ...params });
updateInputMessage('');
+ threadInputEditor.clearContent();
+ threadInputEditor.focus();
+ };
- // const hasSystemRole = agentSelectors.hasSystemRole(useAgentStore.getState());
- // const agentSetting = useAgentStore.getState().agentSettingInstance;
+ const send = async (params: UseSendMessageParams = {}) => {
+ setLoading(true);
+ await handleSend(params);
+ setLoading(false);
+ };
- // // if there is a system role, then we need to use agent setting instance to autocomplete agent meta
- // if (hasSystemRole && !!agentSetting) {
- // agentSetting.autocompleteAllMeta();
- // }
- }, []);
-
- return useMemo(() => ({ canSend, send }), [canSend]);
+ return useMemo(
+ () => ({ disabled: canNotSend, generating, loading, send, stop }),
+ [canNotSend, send, generating, stop, loading],
+ );
};
diff --git a/src/features/Portal/Thread/Chat/index.tsx b/src/features/Portal/Thread/Chat/index.tsx
index ed4a878a7e..2dda5f52ce 100644
--- a/src/features/Portal/Thread/Chat/index.tsx
+++ b/src/features/Portal/Thread/Chat/index.tsx
@@ -11,7 +11,7 @@ interface ConversationProps {
}
const Conversation = memo(({ mobile }) => (
-
+ <>
@@ -22,7 +22,7 @@ const Conversation = memo(({ mobile }) => (
-
+ >
));
export default Conversation;
diff --git a/src/features/Portal/Thread/Header/index.tsx b/src/features/Portal/Thread/Header/index.tsx
index ee157b1034..e79bf71d60 100644
--- a/src/features/Portal/Thread/Header/index.tsx
+++ b/src/features/Portal/Thread/Header/index.tsx
@@ -40,7 +40,7 @@ const Header = memo(() => {
paddingBlock={6}
paddingInline={8}
style={{
- background: `linear-gradient(to bottom, ${theme.colorBgContainerSecondary}, ${theme.colorFillQuaternary})`,
+ borderBottom: `1px solid ${theme.colorBorderSecondary}`,
}}
title={}
/>
diff --git a/src/hooks/useHotkeys/chatScope.ts b/src/hooks/useHotkeys/chatScope.ts
index fcfd527fe9..e1a2bb345f 100644
--- a/src/hooks/useHotkeys/chatScope.ts
+++ b/src/hooks/useHotkeys/chatScope.ts
@@ -2,8 +2,8 @@ import isEqual from 'fast-deep-equal';
import { useEffect } from 'react';
import { useHotkeysContext } from 'react-hotkeys-hook';
+import { useSend } from '@/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/useSend';
import { useClearCurrentMessages } from '@/features/ChatInput/ActionBar/Clear';
-import { useSendMessage } from '@/features/ChatInput/useSend';
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
import { useActionSWR } from '@/libs/swr';
import { useChatStore } from '@/store/chat';
@@ -75,8 +75,10 @@ export const useToggleRightPanelHotkey = () => {
};
export const useAddUserMessageHotkey = () => {
- const { send } = useSendMessage();
- return useHotkeyById(HotkeyEnum.AddUserMessage, () => send({ onlyAddUserMessage: true }));
+ const { send } = useSend();
+ return useHotkeyById(HotkeyEnum.AddUserMessage, () => {
+ send({ onlyAddUserMessage: true });
+ });
};
export const useClearCurrentMessagesHotkey = () => {
diff --git a/src/layout/GlobalProvider/Editor.tsx b/src/layout/GlobalProvider/Editor.tsx
new file mode 100644
index 0000000000..266e1eb26f
--- /dev/null
+++ b/src/layout/GlobalProvider/Editor.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { EditorProvider } from '@lobehub/editor/react';
+import { PropsWithChildren, memo, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+const Editor = memo(({ children }) => {
+ const {
+ i18n: { language, getResourceBundle },
+ } = useTranslation('editor');
+
+ const localization = useMemo(() => getResourceBundle(language, 'editor'), [language]);
+
+ return (
+
+ {children}
+
+ );
+});
+
+Editor.displayName = 'Editor';
+
+export default Editor;
diff --git a/src/layout/GlobalProvider/Locale.tsx b/src/layout/GlobalProvider/Locale.tsx
index 3a67257dc2..01dd689631 100644
--- a/src/layout/GlobalProvider/Locale.tsx
+++ b/src/layout/GlobalProvider/Locale.tsx
@@ -1,7 +1,6 @@
'use client';
import { ConfigProvider } from 'antd';
-import { useTheme } from 'antd-style';
import dayjs from 'dayjs';
import { PropsWithChildren, memo, useEffect, useState } from 'react';
import { isRtlLang } from 'rtl-detect';
@@ -10,6 +9,8 @@ import { createI18nNext } from '@/locales/create';
import { isOnServerSide } from '@/utils/env';
import { getAntdLocale } from '@/utils/locale';
+import Editor from './Editor';
+
const updateDayjs = async (lang: string) => {
// load default lang
let dayJSLocale;
@@ -36,7 +37,6 @@ const Locale = memo(({ children, defaultLang, antdLocale }) =
const [i18n] = useState(createI18nNext(defaultLang));
const [lang, setLang] = useState(defaultLang);
const [locale, setLocale] = useState(antdLocale);
- const theme = useTheme();
// if run on server side, init i18n instance everytime
if (isOnServerSide) {
@@ -91,30 +91,10 @@ const Locale = memo(({ children, defaultLang, antdLocale }) =
Button: {
contentFontSizeSM: 12,
},
- DatePicker: {
- activeBorderColor: theme.colorBorder,
- hoverBorderColor: theme.colorBorder,
- },
- Input: {
- activeBorderColor: theme.colorBorder,
- hoverBorderColor: theme.colorBorder,
- },
- InputNumber: {
- activeBorderColor: theme.colorBorder,
- hoverBorderColor: theme.colorBorder,
- },
- Mentions: {
- activeBorderColor: theme.colorBorder,
- hoverBorderColor: theme.colorBorder,
- },
- Select: {
- activeBorderColor: theme.colorBorder,
- hoverBorderColor: theme.colorBorder,
- },
},
}}
>
- {children}
+ {children}
);
});
diff --git a/src/libs/trpc/client/types.ts b/src/libs/trpc/client/types.ts
deleted file mode 100644
index a3765729b4..0000000000
--- a/src/libs/trpc/client/types.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export type ErrorResponse = ErrorItem[];
-
-export interface ErrorItem {
- error: {
- json: {
- code: number;
- data: Data;
- message: string;
- };
- };
-}
-
-export interface Data {
- code: string;
- httpStatus: number;
- path: string;
- stack: string;
-}
diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts
index 0949924120..b77e4381ee 100644
--- a/src/locales/default/chat.ts
+++ b/src/locales/default/chat.ts
@@ -71,10 +71,11 @@ export default {
input: {
addAi: '添加一条 AI 消息',
addUser: '添加一条用户消息',
+ errorMsg: '消息发送失败,请检查网络后重试: {{errorMsg}}',
more: '更多',
send: '发送',
- sendWithCmdEnter: '按 {{meta}} + Enter 键发送',
- sendWithEnter: '按 Enter 键发送',
+ sendWithCmdEnter: '按 键发送',
+ sendWithEnter: '按 键发送',
stop: '停止',
warp: '换行',
},
@@ -236,6 +237,10 @@ export default {
threadMessageCount: '{{messageCount}} 条消息',
title: '子话题',
},
+ toggleWideScreen: {
+ off: '关闭宽屏模式',
+ on: '开启宽屏模式',
+ },
tokenDetails: {
chats: '会话消息',
historySummary: '历史总结',
diff --git a/src/locales/default/editor.ts b/src/locales/default/editor.ts
new file mode 100644
index 0000000000..9ee81bd501
--- /dev/null
+++ b/src/locales/default/editor.ts
@@ -0,0 +1,47 @@
+export default {
+ actions: {
+ expand: {
+ off: '收起',
+ on: '展开',
+ },
+ typobar: {
+ off: '隐藏格式工具栏',
+ on: '显示格式工具栏',
+ },
+ },
+ file: {
+ error: '错误:{{message}}',
+ uploading: '正在上传文件...',
+ },
+ image: {
+ broken: '图片损坏',
+ },
+ link: {
+ edit: '编辑链接',
+ open: '打开链接',
+ placeholder: '输入链接 URL',
+ unlink: '取消链接',
+ },
+ table: {
+ delete: '删除表格',
+ deleteColumn: '删除列',
+ deleteRow: '删除行',
+ insertColumnLeft: '在左侧插入 {{count}} 列',
+ insertColumnRight: '在右侧插入 {{count}} 列',
+ insertRowAbove: '在上方插入 {{count}} 行',
+ insertRowBelow: '在下方插入 {{count}} 行',
+ },
+ typobar: {
+ blockquote: '引用',
+ bold: '加粗',
+ bulletList: '无序列表',
+ code: '行内代码',
+ codeblock: '代码块',
+ italic: '斜体',
+ link: '链接',
+ numberList: '有序列表',
+ strikethrough: '删除线',
+ table: '插入表格',
+ underline: '下划线',
+ },
+};
diff --git a/src/locales/default/index.ts b/src/locales/default/index.ts
index 8affa365e6..203c0fa7cb 100644
--- a/src/locales/default/index.ts
+++ b/src/locales/default/index.ts
@@ -6,6 +6,7 @@ import color from './color';
import common from './common';
import components from './components';
import discover from './discover';
+import editor from './editor';
import electron from './electron';
import error from './error';
import file from './file';
@@ -37,6 +38,7 @@ const resources = {
common,
components,
discover,
+ editor,
electron,
error,
file,
diff --git a/src/services/aiChat.ts b/src/services/aiChat.ts
index c813b0021b..f2d7e9ade6 100644
--- a/src/services/aiChat.ts
+++ b/src/services/aiChat.ts
@@ -4,8 +4,14 @@ import { cleanObject } from '@lobechat/utils';
import { lambdaClient } from '@/libs/trpc/client';
class AiChatService {
- sendMessageInServer = async (params: SendMessageServerParams) => {
- return lambdaClient.aiChat.sendMessageInServer.mutate(cleanObject(params));
+ sendMessageInServer = async (
+ params: SendMessageServerParams,
+ abortController: AbortController,
+ ) => {
+ return lambdaClient.aiChat.sendMessageInServer.mutate(cleanObject(params), {
+ context: { showNotification: false },
+ signal: abortController?.signal,
+ });
};
}
diff --git a/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts
index e9e4e85263..91f1fba3fe 100644
--- a/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts
+++ b/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts
@@ -187,18 +187,21 @@ describe('generateAIChatV2 actions', () => {
await result.current.sendMessage({ message, files });
});
- expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith({
- newAssistantMessage: {
- model: DEFAULT_MODEL,
- provider: DEFAULT_PROVIDER,
+ expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith(
+ {
+ newAssistantMessage: {
+ model: DEFAULT_MODEL,
+ provider: DEFAULT_PROVIDER,
+ },
+ newUserMessage: {
+ content: message,
+ files: files.map((f) => f.id),
+ },
+ sessionId: mockState.activeId,
+ topicId: mockState.activeTopicId,
},
- newUserMessage: {
- content: message,
- files: files.map((f) => f.id),
- },
- sessionId: mockState.activeId,
- topicId: mockState.activeTopicId,
- });
+ expect.anything(),
+ );
expect(result.current.internal_execAgentRuntime).toHaveBeenCalled();
});
@@ -270,18 +273,21 @@ describe('generateAIChatV2 actions', () => {
await result.current.sendMessage({ message: '', files: [{ id: 'file-1' }] as any });
});
- expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith({
- newAssistantMessage: {
- model: DEFAULT_MODEL,
- provider: DEFAULT_PROVIDER,
+ expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith(
+ {
+ newAssistantMessage: {
+ model: DEFAULT_MODEL,
+ provider: DEFAULT_PROVIDER,
+ },
+ newUserMessage: {
+ content: '',
+ files: ['file-1'],
+ },
+ sessionId: 'session-id',
+ topicId: 'topic-id',
},
- newUserMessage: {
- content: '',
- files: ['file-1'],
- },
- sessionId: 'session-id',
- topicId: 'topic-id',
- });
+ expect.anything(),
+ );
});
it('当同时有文件和消息内容时,正确发送消息并关联文件', async () => {
@@ -291,18 +297,21 @@ describe('generateAIChatV2 actions', () => {
await result.current.sendMessage({ message: 'test', files: [{ id: 'file-1' }] as any });
});
- expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith({
- newAssistantMessage: {
- model: DEFAULT_MODEL,
- provider: DEFAULT_PROVIDER,
+ expect(aiChatService.sendMessageInServer).toHaveBeenCalledWith(
+ {
+ newAssistantMessage: {
+ model: DEFAULT_MODEL,
+ provider: DEFAULT_PROVIDER,
+ },
+ newUserMessage: {
+ content: 'test',
+ files: ['file-1'],
+ },
+ sessionId: 'session-id',
+ topicId: 'topic-id',
},
- newUserMessage: {
- content: 'test',
- files: ['file-1'],
- },
- sessionId: 'session-id',
- topicId: 'topic-id',
- });
+ expect.anything(),
+ );
});
it('当 createMessage 抛出错误时,正确处理错误而不影响整个应用', async () => {
diff --git a/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts b/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts
index 4b00f3c241..80310494c1 100644
--- a/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts
+++ b/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts
@@ -7,9 +7,12 @@ import {
ChatTopic,
MessageSemanticSearchChunk,
SendMessageParams,
+ SendMessageServerResponse,
TraceNameMap,
} from '@lobechat/types';
+import { TRPCClientError } from '@trpc/client';
import { t } from 'i18next';
+import { produce } from 'immer';
import { StateCreator } from 'zustand/vanilla';
import { aiChatService } from '@/services/aiChat';
@@ -18,6 +21,7 @@ import { messageService } from '@/services/message';
import { getAgentStoreState } from '@/store/agent';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/slices/chat';
import { aiModelSelectors, aiProviderSelectors, getAiInfraStoreState } from '@/store/aiInfra';
+import { MainSendMessageOperation } from '@/store/chat/slices/aiChat/initialState';
import type { ChatStore } from '@/store/chat/store';
import { getSessionStoreState } from '@/store/session';
import { WebBrowsingManifest } from '@/tools/web-browsing';
@@ -33,6 +37,11 @@ export interface AIGenerateV2Action {
* Sends a new message to the AI chat system
*/
sendMessageInServer: (params: SendMessageParams) => Promise;
+ /**
+ * Cancels sendMessageInServer operation for a specific topic/session
+ */
+ cancelSendMessageInServer: (topicId?: string) => void;
+ clearSendMessageError: () => void;
internal_refreshAiChat: (params: {
topics?: ChatTopic[];
messages: ChatMessage[];
@@ -57,6 +66,19 @@ export interface AIGenerateV2Action {
inPortalThread?: boolean;
traceId?: string;
}) => Promise;
+ /**
+ * Toggle sendMessageInServer operation state
+ */
+ internal_toggleSendMessageOperation: (
+ key: string | { sessionId: string; topicId?: string | null },
+ loading: boolean,
+ cancelReason?: string,
+ ) => AbortController | undefined;
+ internal_updateSendMessageOperation: (
+ key: string | { sessionId: string; topicId?: string | null },
+ value: Partial | null,
+ actionName?: any,
+ ) => void;
}
export const generateAIChatV2: StateCreator<
@@ -66,7 +88,8 @@ export const generateAIChatV2: StateCreator<
AIGenerateV2Action
> = (set, get) => ({
sendMessageInServer: async ({ message, files, onlyAddUserMessage, isWelcomeQuestion }) => {
- const { activeTopicId, activeId, activeThreadId, internal_execAgentRuntime } = get();
+ const { activeTopicId, activeId, activeThreadId, internal_execAgentRuntime, mainInputEditor } =
+ get();
if (!activeId) return;
const fileIdList = files?.map((f) => f.id);
@@ -95,44 +118,78 @@ export const generateAIChatV2: StateCreator<
topicId: activeTopicId,
threadId: activeThreadId,
});
-
get().internal_toggleMessageLoading(true, tempId);
- set({ isCreatingMessage: true }, false, 'creatingMessage/start');
- const { model, provider } = agentSelectors.currentAgentConfig(getAgentStoreState());
+ const operationKey = messageMapKey(activeId, activeTopicId);
- const data = await aiChatService.sendMessageInServer({
- newUserMessage: {
- content: message,
- files: fileIdList,
- },
- // if there is activeTopicId,then add topicId to message
- topicId: activeTopicId,
- threadId: activeThreadId,
- newTopic: !activeTopicId
- ? {
- topicMessageIds: messages.map((m) => m.id),
- title: t('defaultTitle', { ns: 'topic' }),
- }
- : undefined,
- sessionId: activeId === INBOX_SESSION_ID ? undefined : activeId,
- newAssistantMessage: { model, provider: provider! },
- });
+ // Start tracking sendMessageInServer operation with AbortController
+ const abortController = get().internal_toggleSendMessageOperation(operationKey, true)!;
- // refresh the total data
- get().internal_refreshAiChat({
- messages: data.messages,
- topics: data.topics,
- sessionId: activeId,
- topicId: data.topicId,
- });
- get().internal_dispatchMessage({ type: 'deleteMessage', id: tempId });
+ const jsonState = mainInputEditor?.getJSONState();
+ get().internal_updateSendMessageOperation(
+ operationKey,
+ { inputSendErrorMsg: undefined, inputEditorTempState: jsonState },
+ 'creatingMessage/start',
+ );
- if (!activeTopicId) {
- await get().switchTopic(data.topicId!, true);
+ let data: SendMessageServerResponse | undefined;
+ try {
+ const { model, provider } = agentSelectors.currentAgentConfig(getAgentStoreState());
+ data = await aiChatService.sendMessageInServer(
+ {
+ newUserMessage: {
+ content: message,
+ files: fileIdList,
+ },
+ // if there is activeTopicId,then add topicId to message
+ topicId: activeTopicId,
+ threadId: activeThreadId,
+ newTopic: !activeTopicId
+ ? {
+ topicMessageIds: messages.map((m) => m.id),
+ title: t('defaultTitle', { ns: 'topic' }),
+ }
+ : undefined,
+ sessionId: activeId === INBOX_SESSION_ID ? undefined : activeId,
+ newAssistantMessage: { model, provider: provider! },
+ },
+ abortController,
+ );
+ // refresh the total data
+ get().internal_refreshAiChat({
+ messages: data.messages,
+ topics: data.topics,
+ sessionId: activeId,
+ topicId: data.topicId,
+ });
+
+ if (!activeTopicId) {
+ await get().switchTopic(data.topicId!, true);
+ }
+ } catch (e) {
+ if (e instanceof TRPCClientError) {
+ const isAbort = e.message.includes('aborted') || e.name === 'AbortError';
+ // Check if error is due to cancellation
+ if (!isAbort) {
+ get().internal_updateSendMessageOperation(operationKey, { inputSendErrorMsg: e.message });
+ get().mainInputEditor?.setJSONState(jsonState);
+ }
+ }
+ } finally {
+ // Stop tracking sendMessageInServer operation
+ get().internal_toggleSendMessageOperation(operationKey, false);
}
+ // remove temporally message
+ get().internal_dispatchMessage({ type: 'deleteMessage', id: tempId });
get().internal_toggleMessageLoading(false, tempId);
+ get().internal_updateSendMessageOperation(
+ operationKey,
+ { inputEditorTempState: null },
+ 'creatingMessage/finished',
+ );
+
+ if (!data) return;
// update assistant update to make it rerank
getSessionStoreState().triggerSessionUpdate(get().activeId);
@@ -143,6 +200,7 @@ export const generateAIChatV2: StateCreator<
.activeBaseChats(get())
.filter((item) => item.id !== data.assistantMessageId);
+ if (data.topicId) get().internal_updateTopicLoading(data.topicId, true);
try {
await internal_execAgentRuntime({
messages: baseMessages,
@@ -152,7 +210,6 @@ export const generateAIChatV2: StateCreator<
ragQuery: get().internal_shouldUseRAG() ? message : undefined,
threadId: activeThreadId,
});
- set({ isCreatingMessage: false }, false, 'creatingMessage/stop');
const summaryTitle = async () => {
// check activeTopic and then auto update topic title
@@ -161,9 +218,9 @@ export const generateAIChatV2: StateCreator<
return;
}
- if (!activeTopicId) return;
+ if (!data.topicId) return;
- const topic = topicSelectors.getTopicById(activeTopicId)(get());
+ const topic = topicSelectors.getTopicById(data.topicId)(get());
if (topic && !topic.title) {
const chats = chatSelectors.getBaseChatsByKey(messageMapKey(activeId, topic.id))(get());
@@ -181,10 +238,38 @@ export const generateAIChatV2: StateCreator<
await Promise.all([summaryTitle(), addFilesToAgent()]);
} catch (e) {
console.error(e);
- set({ isCreatingMessage: false }, false, 'creatingMessage/stop');
+ } finally {
+ if (data.topicId) get().internal_updateTopicLoading(data.topicId, false);
}
},
+ cancelSendMessageInServer: (topicId?: string) => {
+ const { activeId, activeTopicId } = get();
+
+ // Determine which operation to cancel
+ const targetTopicId = topicId ?? activeTopicId;
+ const operationKey = messageMapKey(activeId, targetTopicId);
+
+ // Cancel the specific operation
+ get().internal_toggleSendMessageOperation(
+ operationKey,
+ false,
+ 'User cancelled sendMessageInServer operation',
+ );
+
+ // Only clear creating message state if it's the active session
+ if (operationKey === messageMapKey(activeId, activeTopicId)) {
+ const editorTempState = get().mainSendMessageOperations[operationKey]?.inputEditorTempState;
+ get().mainInputEditor?.setJSONState(editorTempState);
+ }
+ },
+ clearSendMessageError: () => {
+ get().internal_updateSendMessageOperation(
+ { sessionId: get().activeId, topicId: get().activeTopicId },
+ null,
+ 'clearSendMessageError',
+ );
+ },
internal_refreshAiChat: ({ topics, messages, sessionId, topicId }) => {
set(
{
@@ -407,4 +492,58 @@ export const generateAIChatV2: StateCreator<
await get().internal_summaryHistory(historyMessages);
}
},
+
+ internal_updateSendMessageOperation: (key, value, actionName) => {
+ const operationKey = typeof key === 'string' ? key : messageMapKey(key.sessionId, key.topicId);
+
+ set(
+ produce((draft) => {
+ if (!draft.mainSendMessageOperations[operationKey])
+ draft.mainSendMessageOperations[operationKey] = value;
+ else {
+ if (value === null) {
+ delete draft.mainSendMessageOperations[operationKey];
+ } else {
+ draft.mainSendMessageOperations[operationKey] = {
+ ...draft.mainSendMessageOperations[operationKey],
+ ...value,
+ };
+ }
+ }
+ }),
+ false,
+ actionName ?? n('updateSendMessageOperation', { operationKey, value }),
+ );
+ },
+ internal_toggleSendMessageOperation: (key, loading: boolean, cancelReason?: string) => {
+ if (loading) {
+ const abortController = new AbortController();
+
+ get().internal_updateSendMessageOperation(
+ key,
+ { isLoading: true, abortController },
+ n('toggleSendMessageOperation(start)', { key }),
+ );
+
+ return abortController;
+ } else {
+ const operationKey =
+ typeof key === 'string' ? key : messageMapKey(key.sessionId, key.topicId);
+
+ const operation = get().mainSendMessageOperations[operationKey];
+
+ // If cancelReason is provided, abort the operation first
+ if (cancelReason && operation?.isLoading) {
+ operation.abortController?.abort(cancelReason);
+ }
+
+ get().internal_updateSendMessageOperation(
+ key,
+ { isLoading: false, abortController: null },
+ n('toggleSendMessageOperation(stop)', { key, cancelReason }),
+ );
+
+ return undefined;
+ }
+ },
});
diff --git a/src/store/chat/slices/aiChat/initialState.ts b/src/store/chat/slices/aiChat/initialState.ts
index a1882e5d78..2e48548015 100644
--- a/src/store/chat/slices/aiChat/initialState.ts
+++ b/src/store/chat/slices/aiChat/initialState.ts
@@ -1,3 +1,12 @@
+import type { ChatInputEditor } from '@/features/ChatInput';
+
+export interface MainSendMessageOperation {
+ abortController?: AbortController | null;
+ inputEditorTempState?: any | null;
+ inputSendErrorMsg?: string;
+ isLoading: boolean;
+}
+
export interface ChatAIChatState {
/**
* is the AI message is generating
@@ -6,6 +15,12 @@ export interface ChatAIChatState {
chatLoadingIdsAbortController?: AbortController;
inputFiles: File[];
inputMessage: string;
+ mainInputEditor: ChatInputEditor | null;
+ /**
+ * sendMessageInServer operations map, keyed by sessionId|topicId
+ * Contains both loading state and AbortController
+ */
+ mainSendMessageOperations: Record;
messageInToolsCallingIds: string[];
/**
* is the message is in RAG flow
@@ -17,6 +32,7 @@ export interface ChatAIChatState {
*/
reasoningLoadingIds: string[];
searchWorkflowLoadingIds: string[];
+ threadInputEditor: ChatInputEditor | null;
/**
* the tool calling stream ids
*/
@@ -27,10 +43,13 @@ export const initialAiChatState: ChatAIChatState = {
chatLoadingIds: [],
inputFiles: [],
inputMessage: '',
+ mainInputEditor: null,
+ mainSendMessageOperations: {},
messageInToolsCallingIds: [],
messageRAGLoadingIds: [],
pluginApiLoadingIds: [],
reasoningLoadingIds: [],
searchWorkflowLoadingIds: [],
+ threadInputEditor: null,
toolCallingStreamIds: {},
};
diff --git a/src/store/chat/slices/aiChat/selectors.ts b/src/store/chat/slices/aiChat/selectors.ts
index e8379f090c..d13470648e 100644
--- a/src/store/chat/slices/aiChat/selectors.ts
+++ b/src/store/chat/slices/aiChat/selectors.ts
@@ -1,3 +1,5 @@
+import { messageMapKey } from '@/store/chat/utils/messageMapKey';
+
import type { ChatStoreState } from '../../initialState';
const isMessageInReasoning = (id: string) => (s: ChatStoreState) =>
@@ -9,8 +11,24 @@ const isMessageInSearchWorkflow = (id: string) => (s: ChatStoreState) =>
const isIntentUnderstanding = (id: string) => (s: ChatStoreState) =>
isMessageInSearchWorkflow(id)(s);
+const isCurrentSendMessageLoading = (s: ChatStoreState) => {
+ const operationKey = messageMapKey(s.activeId, s.activeTopicId);
+ return s.mainSendMessageOperations[operationKey]?.isLoading || false;
+};
+
+const isCurrentSendMessageError = (s: ChatStoreState) => {
+ const operationKey = messageMapKey(s.activeId, s.activeTopicId);
+ return s.mainSendMessageOperations[operationKey]?.inputSendErrorMsg;
+};
+
+const isSendMessageLoadingForTopic = (topicKey: string) => (s: ChatStoreState) =>
+ s.mainSendMessageOperations[topicKey]?.isLoading ?? false;
+
export const aiChatSelectors = {
+ isCurrentSendMessageError,
+ isCurrentSendMessageLoading,
isIntentUnderstanding,
isMessageInReasoning,
isMessageInSearchWorkflow,
+ isSendMessageLoadingForTopic,
};
diff --git a/src/store/global/action.test.ts b/src/store/global/action.test.ts
index 3c56e132d7..3eb8cce92f 100644
--- a/src/store/global/action.test.ts
+++ b/src/store/global/action.test.ts
@@ -252,13 +252,14 @@ describe('createPreferenceSlice', () => {
describe('updatePreference', () => {
it('should update status', () => {
const { result } = renderHook(() => useGlobalStore());
- const status = { inputHeight: 200 };
+ const status = { wideScreen: false };
act(() => {
+ useGlobalStore.setState({ isStatusInit: true });
result.current.updateSystemStatus(status);
});
- expect(result.current.status.inputHeight).toEqual(200);
+ expect(result.current.status.wideScreen).toEqual(false);
});
});
@@ -394,7 +395,7 @@ describe('createPreferenceSlice', () => {
it('should update with data', async () => {
const { result } = renderHook(() => useGlobalStore());
vi.spyOn(useGlobalStore.getState().statusStorage, 'getFromLocalStorage').mockReturnValueOnce({
- inputHeight: 300,
+ wideScreen: false,
} as any);
const { result: hooks } = renderHook(() => result.current.useInitSystemStatus(), {
@@ -402,10 +403,10 @@ describe('createPreferenceSlice', () => {
});
await waitFor(() => {
- expect(hooks.current.data).toEqual({ inputHeight: 300 });
+ expect(hooks.current.data).toEqual({ wideScreen: false });
});
- expect(result.current.status.inputHeight).toEqual(300);
+ expect(result.current.status.wideScreen).toEqual(false);
});
});
diff --git a/src/store/global/actions/__tests__/general.test.ts b/src/store/global/actions/__tests__/general.test.ts
index b483d59321..8ae3059d5f 100644
--- a/src/store/global/actions/__tests__/general.test.ts
+++ b/src/store/global/actions/__tests__/general.test.ts
@@ -34,7 +34,7 @@ describe('generalActionSlice', () => {
const { result } = renderHook(() => useGlobalStore());
act(() => {
- result.current.updateSystemStatus({ inputHeight: 200 });
+ result.current.updateSystemStatus({ wideScreen: false });
});
expect(result.current.status).toEqual(initialState.status);
@@ -45,10 +45,10 @@ describe('generalActionSlice', () => {
act(() => {
useGlobalStore.setState({ isStatusInit: true });
- result.current.updateSystemStatus({ inputHeight: 200 });
+ result.current.updateSystemStatus({ wideScreen: false });
});
- expect(result.current.status.inputHeight).toBe(200);
+ expect(result.current.status.wideScreen).toBe(false);
});
it('should not update if new status equals current status', () => {
@@ -57,7 +57,7 @@ describe('generalActionSlice', () => {
act(() => {
useGlobalStore.setState({ isStatusInit: true });
- result.current.updateSystemStatus({ inputHeight: initialState.status.inputHeight });
+ result.current.updateSystemStatus({ wideScreen: initialState.status.wideScreen });
});
expect(saveToLocalStorageSpy).not.toHaveBeenCalled();
@@ -69,11 +69,11 @@ describe('generalActionSlice', () => {
act(() => {
useGlobalStore.setState({ isStatusInit: true });
- result.current.updateSystemStatus({ inputHeight: 300 });
+ result.current.updateSystemStatus({ wideScreen: false });
});
expect(saveToLocalStorageSpy).toHaveBeenCalledWith(
- expect.objectContaining({ inputHeight: 300 }),
+ expect.objectContaining({ wideScreen: false }),
);
});
diff --git a/src/store/global/actions/workspacePane.ts b/src/store/global/actions/workspacePane.ts
index 5d2eba80ac..b86e811394 100644
--- a/src/store/global/actions/workspacePane.ts
+++ b/src/store/global/actions/workspacePane.ts
@@ -16,6 +16,7 @@ export interface GlobalWorkspacePaneAction {
toggleMobilePortal: (visible?: boolean) => void;
toggleMobileTopic: (visible?: boolean) => void;
toggleSystemRole: (visible?: boolean) => void;
+ toggleWideScreen: (enable?: boolean) => void;
toggleZenMode: () => void;
}
@@ -80,6 +81,11 @@ export const globalWorkspaceSlice: StateCreator<
get().updateSystemStatus({ showSystemRole }, n('toggleMobileTopic', newValue));
},
+ toggleWideScreen: (newValue) => {
+ const wideScreen = typeof newValue === 'boolean' ? newValue : !get().status.wideScreen;
+
+ get().updateSystemStatus({ wideScreen }, n('toggleWideScreen', newValue));
+ },
toggleZenMode: () => {
const { status } = get();
const nextZenMode = !status.zenMode;
diff --git a/src/store/global/initialState.ts b/src/store/global/initialState.ts
index 02f191cf66..f726d939e1 100644
--- a/src/store/global/initialState.ts
+++ b/src/store/global/initialState.ts
@@ -55,7 +55,6 @@ export interface SystemStatus {
hideThreadLimitAlert?: boolean;
imagePanelWidth: number;
imageTopicPanelWidth?: number;
- inputHeight: number;
/**
* 应用初始化时不启用 PGLite,只有当用户手动开启时才启用
*/
@@ -79,7 +78,7 @@ export interface SystemStatus {
* theme mode
*/
themeMode?: ThemeMode;
- threadInputHeight: number;
+ wideScreen?: boolean;
zenMode?: boolean;
}
@@ -114,7 +113,6 @@ export const INITIAL_STATUS = {
hideThreadLimitAlert: false,
imagePanelWidth: 320,
imageTopicPanelWidth: 80,
- inputHeight: 200,
mobileShowTopic: false,
portalWidth: 400,
sessionsWidth: 320,
@@ -127,7 +125,7 @@ export const INITIAL_STATUS = {
showSystemRole: false,
systemRoleExpandedMap: {},
themeMode: 'auto',
- threadInputHeight: 200,
+ wideScreen: true,
zenMode: false,
} satisfies SystemStatus;
diff --git a/src/store/global/selectors/systemStatus.test.ts b/src/store/global/selectors/systemStatus.test.ts
index 53e130aec8..be02ea4f8f 100644
--- a/src/store/global/selectors/systemStatus.test.ts
+++ b/src/store/global/selectors/systemStatus.test.ts
@@ -69,8 +69,7 @@ describe('systemStatusSelectors', () => {
expect(systemStatusSelectors.sessionWidth(s)).toBe(300);
expect(systemStatusSelectors.portalWidth(s)).toBe(500);
expect(systemStatusSelectors.filePanelWidth(s)).toBe(400);
- expect(systemStatusSelectors.inputHeight(s)).toBe(150);
- expect(systemStatusSelectors.threadInputHeight(s)).toBe(100);
+ expect(systemStatusSelectors.wideScreen(s)).toBe(true);
});
it('should handle zen mode effects', () => {
diff --git a/src/store/global/selectors/systemStatus.ts b/src/store/global/selectors/systemStatus.ts
index bf4a26805d..6da16cec24 100644
--- a/src/store/global/selectors/systemStatus.ts
+++ b/src/store/global/selectors/systemStatus.ts
@@ -28,9 +28,7 @@ const portalWidth = (s: GlobalState) => s.status.portalWidth || 400;
const filePanelWidth = (s: GlobalState) => s.status.filePanelWidth;
const imagePanelWidth = (s: GlobalState) => s.status.imagePanelWidth;
const imageTopicPanelWidth = (s: GlobalState) => s.status.imageTopicPanelWidth;
-const inputHeight = (s: GlobalState) => s.status.inputHeight;
-const threadInputHeight = (s: GlobalState) => s.status.threadInputHeight;
-
+const wideScreen = (s: GlobalState) => s.status.wideScreen;
const isPgliteNotEnabled = (s: GlobalState) =>
isUsePgliteDB && !isServerMode && s.isStatusInit && !s.status.isEnablePglite;
@@ -69,7 +67,6 @@ export const systemStatusSelectors = {
imagePanelWidth,
imageTopicPanelWidth,
inZenMode,
- inputHeight,
isDBInited,
isPgliteInited,
isPgliteNotEnabled,
@@ -90,5 +87,5 @@ export const systemStatusSelectors = {
showSystemRole,
systemStatus,
themeMode,
- threadInputHeight,
+ wideScreen,
};