mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ style: upload image to vision model adapting to mobile device (#457)
* ✨ style: 上传图片适配移动端
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
import { TextArea } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useSessionStore } from '@/store/session';
|
||||
|
||||
import { useSendMessage } from '../../../features/ChatInput/useSend';
|
||||
import InputAreaInner from '@/app/chat/features/ChatInput/InputAreaInner';
|
||||
|
||||
const useStyles = createStyles(({ css }) => {
|
||||
return {
|
||||
@@ -22,46 +18,11 @@ const useStyles = createStyles(({ css }) => {
|
||||
});
|
||||
|
||||
const InputArea = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
const { cx, styles } = useStyles();
|
||||
|
||||
const isChineseInput = useRef(false);
|
||||
|
||||
const [loading, message, updateInputMessage] = useSessionStore((s) => [
|
||||
!!s.chatLoadingId,
|
||||
s.inputMessage,
|
||||
s.updateInputMessage,
|
||||
]);
|
||||
|
||||
const handleSend = useSendMessage();
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<div className={cx(styles.textareaContainer)}>
|
||||
<TextArea
|
||||
className={styles.textarea}
|
||||
onBlur={(e) => {
|
||||
updateInputMessage(e.target.value);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
updateInputMessage(e.target.value);
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
isChineseInput.current = false;
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
isChineseInput.current = true;
|
||||
}}
|
||||
onPressEnter={(e) => {
|
||||
if (!loading && !e.shiftKey && !isChineseInput.current) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t('sendPlaceholder', { ns: 'chat' })}
|
||||
resize={false}
|
||||
type="pure"
|
||||
value={message}
|
||||
/>
|
||||
<div className={styles.textareaContainer}>
|
||||
<InputAreaInner className={styles.textarea} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import FileList from '@/app/chat/components/FileList';
|
||||
import { useFileStore } from '@/store/files';
|
||||
|
||||
const Files = memo(() => {
|
||||
const inputFilesList = useFileStore((s) => s.inputFilesList);
|
||||
|
||||
return (
|
||||
<Flexbox padding={12}>
|
||||
<FileList alwaysShowClose items={inputFilesList} />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default Files;
|
||||
@@ -1,79 +1,25 @@
|
||||
import { Icon, Input } from '@lobehub/ui';
|
||||
import { Button, type InputRef } from 'antd';
|
||||
import { Loader2, SendHorizonal } from 'lucide-react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
import useControlledState from 'use-merge-value';
|
||||
|
||||
import ActionBar from '@/app/chat/features/ChatInput/ActionBar';
|
||||
import InputAreaInner from '@/app/chat/features/ChatInput/InputAreaInner';
|
||||
import SaveTopic from '@/app/chat/features/ChatInput/Topic';
|
||||
|
||||
import SendButton from './SendButton';
|
||||
import { useStyles } from './style.mobile';
|
||||
|
||||
export type ChatInputAreaMobile = {
|
||||
loading?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onSend?: (value: string) => void;
|
||||
onStop?: () => void;
|
||||
value?: string;
|
||||
};
|
||||
const ChatInputArea = memo(() => {
|
||||
const { cx, styles } = useStyles();
|
||||
|
||||
const ChatInputArea = forwardRef<InputRef, ChatInputAreaMobile>(
|
||||
({ onSend, loading, onChange, onStop, value }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const [currentValue, setCurrentValue] = useControlledState<string>('', {
|
||||
onChange: onChange,
|
||||
value,
|
||||
});
|
||||
const { cx, styles } = useStyles();
|
||||
const isChineseInput = useRef(false);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (loading) return;
|
||||
if (onSend) onSend(currentValue);
|
||||
setCurrentValue('');
|
||||
}, [currentValue]);
|
||||
|
||||
return (
|
||||
<Flexbox className={cx(styles.container)} gap={12}>
|
||||
<ActionBar rightAreaStartRender={<SaveTopic />} />
|
||||
<Flexbox className={styles.inner} gap={8} horizontal>
|
||||
<Input
|
||||
className={cx(styles.input)}
|
||||
onBlur={(e) => {
|
||||
setCurrentValue(e.target.value);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setCurrentValue(e.target.value);
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
isChineseInput.current = false;
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
isChineseInput.current = true;
|
||||
}}
|
||||
onPressEnter={(e) => {
|
||||
if (!loading && !e.shiftKey && !isChineseInput.current) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t('sendPlaceholder')}
|
||||
type={'block'}
|
||||
value={currentValue}
|
||||
/>
|
||||
<div>
|
||||
{loading ? (
|
||||
<Button icon={loading && <Icon icon={Loader2} spin />} onClick={onStop} />
|
||||
) : (
|
||||
<Button icon={<Icon icon={SendHorizonal} />} onClick={handleSend} type={'primary'} />
|
||||
)}
|
||||
</div>
|
||||
</Flexbox>
|
||||
return (
|
||||
<Flexbox className={cx(styles.container)} gap={12}>
|
||||
<ActionBar rightAreaStartRender={<SaveTopic />} />
|
||||
<Flexbox className={styles.inner} gap={8} horizontal>
|
||||
<InputAreaInner mobile />
|
||||
<SendButton />
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChatInputArea;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { Loader2, SendHorizonal } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useSendMessage } from '@/app/chat/features/ChatInput/useSend';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
|
||||
const SendButton = memo(() => {
|
||||
const [loading, onStop] = useSessionStore((s) => [!!s.chatLoadingId, s.stopGenerateMessage]);
|
||||
|
||||
const handleSend = useSendMessage();
|
||||
|
||||
return loading ? (
|
||||
<Button
|
||||
icon={loading && <Icon icon={Loader2} spin />}
|
||||
onClick={onStop}
|
||||
style={{ flex: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
icon={<Icon icon={SendHorizonal} />}
|
||||
onClick={handleSend}
|
||||
style={{ flex: 'none' }}
|
||||
type={'primary'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default SendButton;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import SafeSpacing from '@/components/SafeSpacing';
|
||||
import { CHAT_TEXTAREA_HEIGHT_MOBILE } from '@/const/layoutTokens';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
|
||||
import Files from './Files';
|
||||
import ChatInputArea from './Mobile';
|
||||
|
||||
const useStyles = createStyles(
|
||||
@@ -23,25 +23,12 @@ const useStyles = createStyles(
|
||||
const ChatInputMobileLayout = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const [isLoading, sendMessage, stopGenerateMessage] = useSessionStore((s) => [
|
||||
!!s.chatLoadingId,
|
||||
s.sendMessage,
|
||||
s.stopGenerateMessage,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Files />
|
||||
<SafeSpacing height={CHAT_TEXTAREA_HEIGHT_MOBILE} mobile position={'bottom'} />
|
||||
<div className={styles}>
|
||||
<ChatInputArea
|
||||
loading={isLoading}
|
||||
onChange={setMessage}
|
||||
onSend={sendMessage}
|
||||
onStop={stopGenerateMessage}
|
||||
value={message}
|
||||
/>
|
||||
<ChatInputArea />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,9 @@ export const useStyles = createStyles(({ css, cx, token, isDarkMode }) => {
|
||||
`);
|
||||
|
||||
return {
|
||||
alwaysShowClose: css`
|
||||
opacity: 1 !important;
|
||||
`,
|
||||
closeIcon,
|
||||
container: css`
|
||||
cursor: pointer;
|
||||
|
||||
@@ -10,60 +10,64 @@ import { useFileStore } from '@/store/files';
|
||||
|
||||
import { IMAGE_SIZE, useStyles } from './FileItem.style';
|
||||
|
||||
const FileItem = memo<{ editable: boolean; id: string; onClick: () => void }>(
|
||||
({ editable, id, onClick }) => {
|
||||
const { styles } = useStyles();
|
||||
const [useFetchFile, removeFile] = useFileStore((s) => [s.useFetchFile, s.removeFile]);
|
||||
interface FileItemProps {
|
||||
alwaysShowClose?: boolean;
|
||||
editable: boolean;
|
||||
id: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
const FileItem = memo<FileItemProps>(({ editable, id, onClick, alwaysShowClose }) => {
|
||||
const { styles, cx } = useStyles();
|
||||
const [useFetchFile, removeFile] = useFileStore((s) => [s.useFetchFile, s.removeFile]);
|
||||
|
||||
const { data, isLoading } = useFetchFile(id);
|
||||
const { data, isLoading } = useFetchFile(id);
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} onClick={onClick}>
|
||||
{isLoading ? (
|
||||
<Skeleton
|
||||
active
|
||||
title={{
|
||||
style: { borderRadius: 8, height: IMAGE_SIZE },
|
||||
width: IMAGE_SIZE,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Flexbox className={styles.imageCtn}>
|
||||
<div className={styles.imageWrapper}>
|
||||
{data ? (
|
||||
<Image
|
||||
alt={data.name || ''}
|
||||
className={styles.image}
|
||||
fetchPriority={'high'}
|
||||
height={IMAGE_SIZE}
|
||||
loading={'lazy'}
|
||||
src={data.url}
|
||||
width={IMAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<Center className={styles.notFound} height={'100%'}>
|
||||
<Icon icon={LucideImageOff} size={{ fontSize: 28 }} />
|
||||
</Center>
|
||||
)}
|
||||
</div>
|
||||
</Flexbox>
|
||||
)}
|
||||
{/* only show close icon when editable */}
|
||||
{editable && (
|
||||
<Center
|
||||
className={styles.closeIcon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
return (
|
||||
<Flexbox className={styles.container} onClick={onClick}>
|
||||
{isLoading ? (
|
||||
<Skeleton
|
||||
active
|
||||
title={{
|
||||
style: { borderRadius: 8, height: IMAGE_SIZE },
|
||||
width: IMAGE_SIZE,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Flexbox className={styles.imageCtn}>
|
||||
<div className={styles.imageWrapper}>
|
||||
{data ? (
|
||||
<Image
|
||||
alt={data.name || ''}
|
||||
className={styles.image}
|
||||
fetchPriority={'high'}
|
||||
height={IMAGE_SIZE}
|
||||
loading={'lazy'}
|
||||
src={data.url}
|
||||
width={IMAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<Center className={styles.notFound} height={'100%'}>
|
||||
<Icon icon={LucideImageOff} size={{ fontSize: 28 }} />
|
||||
</Center>
|
||||
)}
|
||||
</div>
|
||||
</Flexbox>
|
||||
)}
|
||||
{/* only show close icon when editable */}
|
||||
{editable && (
|
||||
<Center
|
||||
className={cx(styles.closeIcon, alwaysShowClose && styles.alwaysShowClose)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
removeFile(id);
|
||||
}}
|
||||
>
|
||||
<CloseCircleFilled />
|
||||
</Center>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
removeFile(id);
|
||||
}}
|
||||
>
|
||||
<CloseCircleFilled />
|
||||
</Center>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileItem;
|
||||
|
||||
@@ -14,11 +14,14 @@ import Lightbox from './Lightbox';
|
||||
// }));
|
||||
|
||||
interface FileListProps {
|
||||
alwaysShowClose?: boolean;
|
||||
|
||||
editable?: boolean;
|
||||
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const FileList = memo<FileListProps>(({ items, editable = true }) => {
|
||||
const FileList = memo<FileListProps>(({ items, editable = true, alwaysShowClose }) => {
|
||||
// const { styles } = useStyles();
|
||||
|
||||
const [showLightbox, setShowLightbox] = useState(false);
|
||||
@@ -38,6 +41,7 @@ const FileList = memo<FileListProps>(({ items, editable = true }) => {
|
||||
>
|
||||
{items.map((i, index) => (
|
||||
<FileItem
|
||||
alwaysShowClose={alwaysShowClose}
|
||||
editable={editable}
|
||||
id={i}
|
||||
key={i}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Input, TextArea } from '@lobehub/ui';
|
||||
import { memo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSessionStore } from '@/store/session';
|
||||
|
||||
import { useSendMessage } from '../useSend';
|
||||
|
||||
export interface InputAreaInnerProps {
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
const InputAreaInner = memo<InputAreaInnerProps>(({ className, mobile }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isChineseInput = useRef(false);
|
||||
|
||||
const [loading, message, updateInputMessage] = useSessionStore((s) => [
|
||||
!!s.chatLoadingId,
|
||||
s.inputMessage,
|
||||
s.updateInputMessage,
|
||||
]);
|
||||
|
||||
const handleSend = useSendMessage();
|
||||
|
||||
const Render = mobile ? Input : TextArea;
|
||||
|
||||
return (
|
||||
<Render
|
||||
className={className}
|
||||
onBlur={(e) => {
|
||||
updateInputMessage(e.target.value);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
updateInputMessage(e.target.value);
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
isChineseInput.current = false;
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
isChineseInput.current = true;
|
||||
}}
|
||||
onPressEnter={(e) => {
|
||||
if (!loading && !e.shiftKey && !isChineseInput.current) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t('sendPlaceholder')}
|
||||
resize={false}
|
||||
type={mobile ? 'block' : 'pure'}
|
||||
value={message}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default InputAreaInner;
|
||||
Reference in New Issue
Block a user