@fe-free/ai 5.0.0 → 6.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ import { MessageThink } from '@fe-free/ai';
2
+ import { CheckCircleOutlined } from '@fe-free/icons';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ const meta: Meta<typeof MessageThink> = {
6
+ title: '@fe-free/ai/MessageThink',
7
+ component: MessageThink,
8
+ tags: ['autodocs'],
9
+ };
10
+
11
+ type Story = StoryObj<typeof MessageThink>;
12
+
13
+ export const Default: Story = {
14
+ args: {
15
+ title: '思考',
16
+ children: '这是 Think 的内容',
17
+ },
18
+ };
19
+
20
+ export const Loading: Story = {
21
+ args: {
22
+ title: '思考中...',
23
+ loading: true,
24
+ children: '这是 Think 的内容',
25
+ },
26
+ };
27
+
28
+ export const Icon: Story = {
29
+ args: {
30
+ title: '思考',
31
+ children: '这是 Think 的内容',
32
+ icon: <CheckCircleOutlined className="text-green08" />,
33
+ },
34
+ };
35
+
36
+ export const DeepSeek: Story = {
37
+ args: {
38
+ title: '思考中...',
39
+ loading: true,
40
+ children: '这是 Think 的内容',
41
+ },
42
+ };
43
+
44
+ export default meta;
@@ -0,0 +1,169 @@
1
+ import { PageLayout, ScrollFixed } from '@fe-free/core';
2
+ import { ArrowDownOutlined } from '@fe-free/icons';
3
+ import { useMemoizedFn } from 'ahooks';
4
+ import { Button } from 'antd';
5
+ import { useEffect, useMemo, useRef, useState } from 'react';
6
+ import { EnumChatMessageType, type ChatMessage } from '../store/types';
7
+
8
+ interface MessagesProps<UserData, AIData> {
9
+ refList?: React.RefObject<HTMLDivElement | null>;
10
+ messages?: ChatMessage<UserData, AIData>[];
11
+ /** 含所有 */
12
+ renderMessage?: (props: { message: ChatMessage<UserData, AIData> }) => React.ReactNode;
13
+ /** 系统消息 */
14
+ renderMessageOfSystem?: (props: { message: ChatMessage<UserData, AIData> }) => React.ReactNode;
15
+ /** 用户消息 */
16
+ renderMessageOfUser?: (props: { message: ChatMessage<UserData, AIData> }) => React.ReactNode;
17
+ /** AI消息 */
18
+ renderMessageOfAI?: (props: { message: ChatMessage<UserData, AIData> }) => React.ReactNode;
19
+ }
20
+
21
+ function useScrollToBottom({ ref }) {
22
+ const [showScrollBottom, setShowScrollBottom] = useState(false);
23
+
24
+ useEffect(() => {
25
+ const handleScroll = () => {
26
+ if (!ref.current) {
27
+ return;
28
+ }
29
+
30
+ const { scrollTop, clientHeight, scrollHeight } = ref.current;
31
+
32
+ const isNearBottom =
33
+ scrollHeight > clientHeight && scrollTop + clientHeight + 200 <= scrollHeight;
34
+ setShowScrollBottom(isNearBottom);
35
+ };
36
+
37
+ if (ref.current) {
38
+ ref.current.addEventListener('scroll', handleScroll);
39
+ }
40
+
41
+ // first
42
+ handleScroll();
43
+
44
+ return () => {
45
+ ref.current?.removeEventListener('scroll', handleScroll);
46
+ };
47
+ }, [ref]);
48
+
49
+ return showScrollBottom;
50
+ }
51
+
52
+ function Messages<UserData, AIData>(props: MessagesProps<UserData, AIData>) {
53
+ const {
54
+ refList,
55
+ messages,
56
+ renderMessage,
57
+ renderMessageOfSystem,
58
+ renderMessageOfUser,
59
+ renderMessageOfAI,
60
+ } = props;
61
+
62
+ const innerRef = useRef<HTMLDivElement>(null);
63
+ const ref = refList || innerRef;
64
+
65
+ const lastMessage = useMemo(() => {
66
+ return messages?.[messages.length - 1];
67
+ }, [messages]);
68
+
69
+ const scrollToBottom = useMemoizedFn(() => {
70
+ if (!lastMessage?.uuid) {
71
+ return;
72
+ }
73
+
74
+ // 延迟下,因为 markdown 可能没渲染出来
75
+ setTimeout(() => {
76
+ const element = document.querySelector(`[data-uuid="${lastMessage.uuid}"]`);
77
+ if (element) {
78
+ element.scrollIntoView({ behavior: 'smooth', block: 'end' });
79
+ }
80
+ }, 100);
81
+ });
82
+
83
+ // 首次和更新时滚动到最新消息
84
+ useEffect(() => {
85
+ if (lastMessage?.uuid) {
86
+ scrollToBottom();
87
+ }
88
+ }, [scrollToBottom, lastMessage?.uuid]);
89
+
90
+ // 数据更新是,如果 dom 处于可视区域,则滚动
91
+ useEffect(() => {
92
+ if (!lastMessage?.updatedAt || !lastMessage?.uuid || !ref.current) {
93
+ return;
94
+ }
95
+
96
+ // 延迟下,因为 markdown 可能没渲染出来
97
+ setTimeout(() => {
98
+ const element = document.querySelector(`[data-uuid="${lastMessage.uuid}"]`);
99
+ if (!element) {
100
+ return;
101
+ }
102
+
103
+ const { top: listTop, bottom: listBottom } = ref.current!.getBoundingClientRect();
104
+ const { top, bottom } = element.getBoundingClientRect();
105
+
106
+ // 如果最后一个元素可见,则滚动到底部
107
+ const isVisible = top < listBottom && bottom > listTop;
108
+ if (isVisible) {
109
+ scrollToBottom();
110
+ }
111
+ }, 100);
112
+ }, [lastMessage?.updatedAt, lastMessage?.uuid, ref, scrollToBottom]);
113
+
114
+ const showScrollBottom = useScrollToBottom({ ref });
115
+
116
+ return (
117
+ <PageLayout>
118
+ <ScrollFixed
119
+ refScroll={ref}
120
+ className="fea-messages-scroll relative flex h-full flex-col overflow-x-hidden overflow-y-auto"
121
+ style={{
122
+ transform: `translateZ(0)`,
123
+ }}
124
+ >
125
+ {messages?.map((message) => {
126
+ return (
127
+ <div key={message.uuid} data-uuid={message.uuid} className="flex flex-col">
128
+ {renderMessage ? (
129
+ renderMessage?.({ message })
130
+ ) : (
131
+ <>
132
+ {message.type === EnumChatMessageType.SYSTEM && message.system && (
133
+ <div className="flex justify-center">
134
+ {renderMessageOfSystem?.({ message })}
135
+ </div>
136
+ )}
137
+ {message.type !== EnumChatMessageType.SYSTEM && message.user && (
138
+ <div className="flex justify-end">{renderMessageOfUser?.({ message })}</div>
139
+ )}
140
+ {message.type !== EnumChatMessageType.SYSTEM && message.ai && (
141
+ <div className="flex justify-start">{renderMessageOfAI?.({ message })}</div>
142
+ )}
143
+ </>
144
+ )}
145
+ </div>
146
+ );
147
+ })}
148
+ <div className="pointer-events-none sticky bottom-2 mx-auto flex justify-center">
149
+ <Button
150
+ shape="circle"
151
+ icon={<ArrowDownOutlined />}
152
+ onClick={() => {
153
+ scrollToBottom();
154
+ }}
155
+ className="pointer-events-auto! bg-white! text-2xl! shadow-[0px_1px_12px_0px_#2921391F]!"
156
+ style={{
157
+ transform: `translateY(${showScrollBottom ? 0 : 30}px) scale(${showScrollBottom ? 1 : 0})`,
158
+ width: 44,
159
+ height: 44,
160
+ }}
161
+ />
162
+ </div>
163
+ </ScrollFixed>
164
+ </PageLayout>
165
+ );
166
+ }
167
+
168
+ export { Messages };
169
+ export type { MessagesProps };
@@ -1,74 +1,37 @@
1
1
  import Icons from '@fe-free/icons';
2
2
  import type { UploadFile } from 'antd';
3
- import { Button, Divider } from 'antd';
4
- import { useCallback, type RefObject } from 'react';
3
+ import { Button } from 'antd';
4
+ import type { RefObject } from 'react';
5
5
  import SendIcon from '../svgs/send.svg?react';
6
6
  import { FileAction } from './files';
7
7
  import { RecordAction } from './record';
8
- import './style.scss';
9
8
  import type { SenderProps } from './types';
10
9
 
11
10
  function Actions(
12
11
  props: SenderProps & {
13
- refText: RefObject<HTMLTextAreaElement>;
14
- refUpload: RefObject<HTMLDivElement>;
12
+ refText: RefObject<HTMLTextAreaElement | null>;
13
+ refUpload: RefObject<HTMLDivElement | null>;
15
14
  isUploading: boolean;
16
15
  fileList: UploadFile[];
17
16
  setFileList: (fileList: UploadFile[]) => void;
18
17
  fileUrls: string[];
19
18
  setFileUrls: (fileUrls: string[]) => void;
19
+ onSubmit: () => Promise<void>;
20
20
  },
21
21
  ) {
22
22
  const {
23
- refText,
24
- loading,
25
- onSubmit,
26
- value,
27
- onChange,
28
23
  refUpload,
29
24
  isUploading,
30
- setFileList,
31
25
  fileUrls,
32
26
  setFileUrls,
33
27
  allowUpload,
34
28
  allowSpeech,
29
+ loading,
30
+ onSubmit,
35
31
  } = props;
36
32
 
37
33
  const isLoading = loading || isUploading;
38
34
 
39
- const handleSubmit = useCallback(async () => {
40
- if (isLoading || allowSpeech?.recording) {
41
- return;
42
- }
43
-
44
- const newValue = {
45
- ...value,
46
- text: value?.text?.trim(),
47
- };
48
-
49
- // 有内容才提交
50
- if (newValue.text || (newValue.files && newValue.files.length > 0)) {
51
- await Promise.resolve(onSubmit?.(newValue));
52
-
53
- // reset
54
- setFileList([]);
55
- setFileUrls([]);
56
- onChange?.({});
57
-
58
- // focus
59
- refText.current?.focus();
60
- }
61
- }, [
62
- isLoading,
63
- allowSpeech?.recording,
64
- value,
65
- onSubmit,
66
- setFileList,
67
- setFileUrls,
68
- onChange,
69
- refText,
70
- ]);
71
-
72
35
  return (
73
36
  <div className="flex items-center gap-2">
74
37
  <div className="flex flex-1 gap-1">
@@ -81,16 +44,16 @@ function Actions(
81
44
  />
82
45
  )}
83
46
  </div>
84
- <Divider type="vertical" />
47
+ {/* <Divider type="vertical" /> */}
85
48
  <div className="flex items-center gap-2">
86
49
  {allowSpeech && <RecordAction {...props} />}
87
50
  <Button
88
51
  type="primary"
89
52
  shape="circle"
90
- icon={<Icons component={SendIcon} className="!text-lg" />}
53
+ icon={<Icons component={SendIcon} className="h-[28px]! text-lg!" />}
91
54
  loading={isLoading}
92
55
  // disabled={loading}
93
- onClick={handleSubmit}
56
+ onClick={onSubmit}
94
57
  />
95
58
  </div>
96
59
  </div>
@@ -5,12 +5,13 @@ import { App, Button, Dropdown, Input, Modal, Upload } from 'antd';
5
5
  import type { RefObject } from 'react';
6
6
  import { useState } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
8
+ import { FileView } from '../files';
8
9
  import FilesIcon from '../svgs/files.svg?react';
9
10
  import type { SenderProps } from './types';
10
11
 
11
12
  function FileAction(
12
13
  props: SenderProps & {
13
- refUpload: RefObject<HTMLDivElement>;
14
+ refUpload: RefObject<HTMLDivElement | null>;
14
15
  fileUrls: string[];
15
16
  setFileUrls: (fileUrls: string[]) => void;
16
17
  },
@@ -88,7 +89,7 @@ function FileAction(
88
89
 
89
90
  function FileUpload(
90
91
  props: SenderProps & {
91
- refUpload: RefObject<HTMLDivElement>;
92
+ refUpload: RefObject<HTMLDivElement | null>;
92
93
  fileList: UploadFile[];
93
94
  setFileList: (fileList: UploadFile[]) => void;
94
95
  uploadMaxCount?: number;
@@ -136,16 +137,9 @@ function UploadFileItem({ file, onDelete }: { file: UploadFile; onDelete: () =>
136
137
  return (
137
138
  <div className="group relative">
138
139
  {isImage ? (
139
- <img
140
- src={file.originFileObj && URL.createObjectURL(file.originFileObj)}
141
- className="h-[53px] w-[53px] rounded-lg border border-01 bg-01 object-cover"
142
- />
140
+ <FileView url={URL.createObjectURL(file.originFileObj!)} isImage={isImage} />
143
141
  ) : (
144
- <div className="flex h-[53px] w-[200px] items-center overflow-hidden rounded bg-01 px-1">
145
- <div className="min-w-0">
146
- <FileCard name={file.name} size={file.size} />
147
- </div>
148
- </div>
142
+ <FileView url={file.name} />
149
143
  )}
150
144
  {!isDone && (
151
145
  <div className="absolute inset-0 flex items-center justify-center bg-01/80">
@@ -163,7 +157,7 @@ function UploadFileItem({ file, onDelete }: { file: UploadFile; onDelete: () =>
163
157
  function UrlFileItem({ url, onDelete }: { url: string; onDelete: () => void }) {
164
158
  return (
165
159
  <div className="group relative">
166
- <div className="flex h-[53px] w-[200px] items-center rounded bg-01 px-2">
160
+ <div className="flex h-[60px] w-[250px] items-center rounded bg-01 px-2">
167
161
  <div className="line-clamp-2">{url}</div>
168
162
  </div>
169
163
  <CloseOutlined
@@ -186,7 +180,7 @@ function Files(
186
180
 
187
181
  return (
188
182
  <>
189
- {fileList && fileList.length > 0 && (
183
+ {((fileList && fileList.length > 0) || (fileUrls && fileUrls.length > 0)) && (
190
184
  <div className="scrollbar-hide mb-2 flex gap-2 overflow-x-auto">
191
185
  {fileList.map((file) => (
192
186
  <UploadFileItem
@@ -7,11 +7,31 @@ import { useCallback, useMemo, useRef, useState } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
8
8
  import { Actions } from './actions';
9
9
  import { FileUpload, Files } from './files';
10
- import './style.scss';
11
10
  import type { SenderProps, SenderRef } from './types';
12
11
 
13
- function Text(props: SenderProps & { refText: RefObject<HTMLTextAreaElement> }) {
14
- const { value, onChange, placeholder, refText } = props;
12
+ function Text(
13
+ props: SenderProps & {
14
+ refText: RefObject<HTMLTextAreaElement | null>;
15
+ onSubmit?: () => void;
16
+ },
17
+ ) {
18
+ const { value, onChange, placeholder, refText, onSubmit } = props;
19
+
20
+ const handleKeyDown = useCallback(
21
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
22
+ // Shift + Enter: 换行(默认行为)
23
+ if (e.key === 'Enter' && e.shiftKey) {
24
+ return;
25
+ }
26
+
27
+ // Enter: 提交
28
+ if (e.key === 'Enter' && !e.shiftKey) {
29
+ e.preventDefault();
30
+ onSubmit?.();
31
+ }
32
+ },
33
+ [onSubmit],
34
+ );
15
35
 
16
36
  return (
17
37
  <Input.TextArea
@@ -20,6 +40,7 @@ function Text(props: SenderProps & { refText: RefObject<HTMLTextAreaElement> })
20
40
  onChange={(e) => {
21
41
  onChange?.({ ...value, text: e.target.value });
22
42
  }}
43
+ onKeyDown={handleKeyDown}
23
44
  placeholder={placeholder}
24
45
  autoSize={{ minRows: 2, maxRows: 8 }}
25
46
  className="mb-1 px-1 py-0"
@@ -33,21 +54,22 @@ function Sender(originProps: SenderProps) {
33
54
  const props = useMemo(() => {
34
55
  return {
35
56
  placeholder:
36
- originProps.placeholder ?? t('@fe-free/ai.sender.describeYourQuestion', '描述你的问题'),
57
+ originProps.placeholder ??
58
+ t('@fe-free/ai.sender.describeYourQuestion', '描述你的问题, shift + enter 换行'),
37
59
  ...originProps,
38
60
  };
39
61
  }, [originProps, t]);
40
62
 
41
63
  const refText = useRef<HTMLTextAreaElement>(null);
42
64
 
43
- const { value, onChange, allowUpload } = props;
65
+ const { value, onChange, allowUpload, onSubmit, loading, allowSpeech } = props;
44
66
  const { filesMaxCount } = allowUpload || {};
45
67
 
46
68
  const refContainer = useRef<HTMLDivElement>(null);
47
69
  const refUpload = useRef<HTMLDivElement>(null);
48
70
  const [dragHover, setDragHover] = useState(false);
49
71
 
50
- useDrop(refContainer, {
72
+ useDrop(allowUpload ? refContainer : null, {
51
73
  onDragEnter: () => {
52
74
  setDragHover(true);
53
75
  },
@@ -95,16 +117,54 @@ function Sender(originProps: SenderProps) {
95
117
  return fileList.some((file) => !file.response?.data?.url);
96
118
  }, [fileList]);
97
119
 
120
+ const handleSubmit = useCallback(async () => {
121
+ const isLoading = loading || isUploading;
122
+
123
+ if (isLoading || allowSpeech?.recording) {
124
+ return;
125
+ }
126
+
127
+ const newValue = {
128
+ ...value,
129
+ text: value?.text?.trim(),
130
+ };
131
+
132
+ // 有内容才提交
133
+ if (newValue.text || (newValue.files && newValue.files.length > 0)) {
134
+ await Promise.resolve(onSubmit?.(newValue));
135
+
136
+ // reset
137
+ setFileList([]);
138
+ setFileUrls([]);
139
+ onChange?.({});
140
+
141
+ // focus
142
+ refText.current?.focus();
143
+ }
144
+ }, [
145
+ loading,
146
+ isUploading,
147
+ allowSpeech?.recording,
148
+ value,
149
+ onSubmit,
150
+ setFileList,
151
+ setFileUrls,
152
+ onChange,
153
+ ]);
154
+
98
155
  return (
99
156
  <div className="fea-sender-wrap">
100
157
  <div
101
158
  ref={refContainer}
102
159
  className={classNames(
103
- 'fea-sender relative flex flex-col rounded-lg border border-01 bg-white p-2',
160
+ 'fea-sender relative flex flex-col rounded-xl border border-01 bg-white p-2',
104
161
  {
105
162
  'fea-sender-drag-hover': dragHover,
106
163
  },
107
164
  )}
165
+ style={{
166
+ boxShadow: '0px 2px 12px 0px #00000014',
167
+ }}
108
168
  >
109
169
  <Files
110
170
  {...props}
@@ -114,7 +174,7 @@ function Sender(originProps: SenderProps) {
114
174
  setFileUrls={setFileUrls}
115
175
  />
116
176
  <div className="flex">
117
- <Text {...props} refText={refText} />
177
+ <Text {...props} refText={refText} onSubmit={handleSubmit} />
118
178
  </div>
119
179
  <Actions
120
180
  {...props}
@@ -125,21 +185,19 @@ function Sender(originProps: SenderProps) {
125
185
  setFileList={setFileList}
126
186
  fileUrls={fileUrls}
127
187
  setFileUrls={setFileUrls}
188
+ onSubmit={handleSubmit}
128
189
  />
129
- <FileUpload
130
- {...props}
131
- refUpload={refUpload}
132
- fileList={fileList}
133
- setFileList={setFileList}
134
- uploadMaxCount={filesMaxCount ? filesMaxCount - fileUrls.length : undefined}
135
- />
136
- </div>
137
- <div className="mt-1 text-center text-xs text-03">
138
- {t(
139
- '@fe-free/ai.sender.aiGeneratedDisclaimer',
140
- '内容由 AI 生成,无法确保信息的真实准确,仅供参考',
190
+ {allowUpload && (
191
+ <FileUpload
192
+ {...props}
193
+ refUpload={refUpload}
194
+ fileList={fileList}
195
+ setFileList={setFileList}
196
+ uploadMaxCount={filesMaxCount ? filesMaxCount - fileUrls.length : undefined}
197
+ />
141
198
  )}
142
199
  </div>
200
+ {props.statement && <div className="mt-1 text-center text-xs text-03">{props.statement}</div>}
143
201
  </div>
144
202
  );
145
203
  }
@@ -1,5 +1,6 @@
1
1
  import { AudioOutlined } from '@fe-free/icons';
2
2
  import { Button } from 'antd';
3
+ import { RecordLoading } from '../helper';
3
4
  import type { SenderProps } from './types';
4
5
 
5
6
  function RecordAction(props: SenderProps) {
@@ -9,12 +10,7 @@ function RecordAction(props: SenderProps) {
9
10
  if (recording) {
10
11
  return (
11
12
  <Button type="text" shape="circle" onClick={() => onRecordingChange?.(false)}>
12
- <div className="fea-sender-spinner">
13
- <div className="fea-sender-spinner-line fea-sender-spinner-line1" />
14
- <div className="fea-sender-spinner-line fea-sender-spinner-line2" />
15
- <div className="fea-sender-spinner-line fea-sender-spinner-line3" />
16
- <div className="fea-sender-spinner-line fea-sender-spinner-line4" />
17
- </div>
13
+ <RecordLoading count={4} color="primary" />
18
14
  </Button>
19
15
  );
20
16
  }
@@ -23,7 +19,7 @@ function RecordAction(props: SenderProps) {
23
19
  <Button
24
20
  type="text"
25
21
  shape="circle"
26
- icon={<AudioOutlined className="!text-lg" />}
22
+ icon={<AudioOutlined className="text-lg!" />}
27
23
  onClick={() => onRecordingChange?.(true)}
28
24
  />
29
25
  );
@@ -66,4 +66,11 @@ export const AllowSpeech: Story = {
66
66
  },
67
67
  };
68
68
 
69
+ export const Statement: Story = {
70
+ args: {
71
+ statement: '内容由 AI 生成,无法确保信息的真实准确,仅供参考',
72
+ },
73
+ render: (props) => <Component {...props} />,
74
+ };
75
+
69
76
  export default meta;
@@ -9,10 +9,10 @@ interface SenderValue {
9
9
 
10
10
  interface SenderProps {
11
11
  value?: SenderValue;
12
- onChange: (value?: SenderValue) => void;
12
+ onChange?: (value?: SenderValue) => void;
13
13
 
14
14
  loading?: boolean;
15
- onSubmit: (value?: SenderValue) => void | Promise<void>;
15
+ onSubmit?: (value?: SenderValue) => void | Promise<void>;
16
16
 
17
17
  placeholder?: string;
18
18
 
@@ -30,6 +30,8 @@ interface SenderProps {
30
30
  /** 录音状态变化时回调 */
31
31
  onRecordingChange?: (recording: boolean) => void;
32
32
  };
33
+
34
+ statement?: string;
33
35
  }
34
36
 
35
37
  export type { SenderProps, SenderRef, SenderValue };