@lobehub/chat 0.12.0 → 0.13.0
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.
- package/CHANGELOG.md +50 -0
- package/next.config.mjs +3 -3
- package/package.json +1 -1
- package/src/const/message.ts +2 -0
- package/src/locales/default/common.ts +1 -0
- package/src/pages/api/openai.api.ts +4 -63
- package/src/pages/api/openai.ts +50 -0
- package/src/pages/api/plugins.api.ts +34 -0
- package/src/pages/chat/[id]/Conversation/ChatList.tsx +12 -2
- package/src/pages/chat/[id]/Conversation/FunctionMessage.tsx +31 -0
- package/src/pages/chat/[id]/Conversation/Input/Action.tsx +24 -0
- package/src/pages/chat/[id]/Conversation/Input/Token.tsx +32 -0
- package/src/pages/chat/[id]/Conversation/Input/index.tsx +62 -0
- package/src/pages/chat/[id]/Conversation/MessageExtra.tsx +62 -15
- package/src/pages/chat/[id]/Conversation/index.tsx +1 -1
- package/src/services/chatModel.ts +3 -3
- package/src/services/url.ts +1 -0
- package/src/store/session/slices/chat/action.ts +85 -33
- package/src/store/session/slices/chat/selectors/chat.ts +5 -45
- package/src/store/session/slices/chat/selectors/utils.test.ts +264 -0
- package/src/store/session/slices/chat/selectors/utils.ts +73 -0
- package/src/types/chatMessage.ts +3 -1
- package/src/types/llm.ts +1 -1
- package/src/utils/fetch.ts +2 -2
- package/src/utils/message.ts +5 -0
- package/src/pages/chat/[id]/Conversation/Input.tsx +0 -73
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 0.13.0](https://github.com/lobehub/lobe-chat/compare/v0.12.1...v0.13.0)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2023-07-23**</sup>
|
|
8
|
+
|
|
9
|
+
#### ✨ Features
|
|
10
|
+
|
|
11
|
+
- **misc**: 优化插件模式下的用户体验.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's improved
|
|
19
|
+
|
|
20
|
+
- **misc**: 优化插件模式下的用户体验, closes [#13](https://github.com/lobehub/lobe-chat/issues/13) ([4596f12](https://github.com/lobehub/lobe-chat/commit/4596f12))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
### [Version 0.12.1](https://github.com/lobehub/lobe-chat/compare/v0.12.0...v0.12.1)
|
|
31
|
+
|
|
32
|
+
<sup>Released on **2023-07-23**</sup>
|
|
33
|
+
|
|
34
|
+
#### 🐛 Bug Fixes
|
|
35
|
+
|
|
36
|
+
- **misc**: 修正 message parentId 不正确的问题.
|
|
37
|
+
|
|
38
|
+
<br/>
|
|
39
|
+
|
|
40
|
+
<details>
|
|
41
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
42
|
+
|
|
43
|
+
#### What's fixed
|
|
44
|
+
|
|
45
|
+
- **misc**: 修正 message parentId 不正确的问题 ([f86852a](https://github.com/lobehub/lobe-chat/commit/f86852a))
|
|
46
|
+
|
|
47
|
+
</details>
|
|
48
|
+
|
|
49
|
+
<div align="right">
|
|
50
|
+
|
|
51
|
+
[](#readme-top)
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
|
|
5
55
|
## [Version 0.12.0](https://github.com/lobehub/lobe-chat/compare/v0.11.0...v0.12.0)
|
|
6
56
|
|
|
7
57
|
<sup>Released on **2023-07-23**</sup>
|
package/next.config.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const withPWA = nextPWA({
|
|
|
13
13
|
const nextConfig = {
|
|
14
14
|
reactStrictMode: true,
|
|
15
15
|
pageExtensions: ['page.tsx', 'api.ts'],
|
|
16
|
-
transpilePackages: ['@lobehub/ui'
|
|
16
|
+
transpilePackages: ['@lobehub/ui'],
|
|
17
17
|
|
|
18
18
|
webpack(config) {
|
|
19
19
|
config.experiments = {
|
|
@@ -31,8 +31,8 @@ const nextConfig = {
|
|
|
31
31
|
destination: `${API_END_PORT_URL}/api/openai`,
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
source: '/api/
|
|
35
|
-
destination: `${API_END_PORT_URL}/api/
|
|
34
|
+
source: '/api/plugins-dev',
|
|
35
|
+
destination: `${API_END_PORT_URL}/api/plugins`,
|
|
36
36
|
},
|
|
37
37
|
];
|
|
38
38
|
},
|
package/package.json
CHANGED
|
@@ -1,74 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Configuration, OpenAIApi } from 'openai-edge';
|
|
3
|
-
import { ChatCompletionFunctions, ChatCompletionRequestMessage } from 'openai-edge/types/api';
|
|
1
|
+
import { StreamingTextResponse } from 'ai';
|
|
4
2
|
|
|
5
3
|
import { OpenAIStreamPayload } from '@/types/openai';
|
|
6
4
|
|
|
7
|
-
import
|
|
5
|
+
import { createChatCompletion } from './openai';
|
|
8
6
|
|
|
9
7
|
export const runtime = 'edge';
|
|
10
8
|
|
|
11
|
-
const isDev = process.env.NODE_ENV === 'development';
|
|
12
|
-
const OPENAI_PROXY_URL = process.env.OPENAI_PROXY_URL;
|
|
13
|
-
|
|
14
|
-
// Create an OpenAI API client (that's edge friendly!)
|
|
15
|
-
const config = new Configuration({
|
|
16
|
-
apiKey: process.env.OPENAI_API_KEY,
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const openai = new OpenAIApi(config, isDev && OPENAI_PROXY_URL ? OPENAI_PROXY_URL : undefined);
|
|
20
|
-
|
|
21
9
|
export default async function handler(req: Request) {
|
|
22
|
-
const
|
|
23
|
-
messages,
|
|
24
|
-
plugins: enabledPlugins,
|
|
25
|
-
...params
|
|
26
|
-
} = (await req.json()) as OpenAIStreamPayload;
|
|
27
|
-
|
|
28
|
-
// ============ 1. 前置处理 functions ============ //
|
|
29
|
-
|
|
30
|
-
const filterFunctions: ChatCompletionFunctions[] = pluginList
|
|
31
|
-
.filter((p) => {
|
|
32
|
-
// 如果不存在 enabledPlugins,那么全部不启用
|
|
33
|
-
if (!enabledPlugins) return false;
|
|
34
|
-
|
|
35
|
-
// 如果存在 enabledPlugins,那么只启用 enabledPlugins 中的插件
|
|
36
|
-
return enabledPlugins.includes(p.name);
|
|
37
|
-
})
|
|
38
|
-
.map((f) => f.schema);
|
|
39
|
-
|
|
40
|
-
const functions = filterFunctions.length === 0 ? undefined : filterFunctions;
|
|
41
|
-
|
|
42
|
-
// ============ 2. 前置处理 messages ============ //
|
|
43
|
-
const formatMessages = messages.map((m) => ({ content: m.content, role: m.role }));
|
|
44
|
-
|
|
45
|
-
const response = await openai.createChatCompletion({
|
|
46
|
-
functions,
|
|
47
|
-
messages: formatMessages,
|
|
48
|
-
stream: true,
|
|
49
|
-
...params,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const stream = OpenAIStream(response, {
|
|
53
|
-
experimental_onFunctionCall: async ({ name, arguments: args }, createFunctionCallMessages) => {
|
|
54
|
-
console.log(`执行 functionCall [${name}]`, 'args:', args);
|
|
55
|
-
|
|
56
|
-
const func = pluginList.find((f) => f.name === name);
|
|
57
|
-
|
|
58
|
-
if (func) {
|
|
59
|
-
const result = await func.runner(args as any);
|
|
60
|
-
|
|
61
|
-
const newMessages = createFunctionCallMessages(result) as ChatCompletionRequestMessage[];
|
|
10
|
+
const payload = (await req.json()) as OpenAIStreamPayload;
|
|
62
11
|
|
|
63
|
-
|
|
64
|
-
functions,
|
|
65
|
-
messages: [...formatMessages, ...newMessages],
|
|
66
|
-
stream: true,
|
|
67
|
-
...params,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
},
|
|
71
|
-
});
|
|
12
|
+
const stream = await createChatCompletion(payload);
|
|
72
13
|
|
|
73
14
|
return new StreamingTextResponse(stream);
|
|
74
15
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { OpenAIStream, OpenAIStreamCallbacks } from 'ai';
|
|
2
|
+
import { Configuration, OpenAIApi } from 'openai-edge';
|
|
3
|
+
import { ChatCompletionFunctions } from 'openai-edge/types/api';
|
|
4
|
+
|
|
5
|
+
import { OpenAIStreamPayload } from '@/types/openai';
|
|
6
|
+
|
|
7
|
+
import pluginList from '../../plugins';
|
|
8
|
+
|
|
9
|
+
const isDev = process.env.NODE_ENV === 'development';
|
|
10
|
+
const OPENAI_PROXY_URL = process.env.OPENAI_PROXY_URL;
|
|
11
|
+
|
|
12
|
+
// Create an OpenAI API client (that's edge friendly!)
|
|
13
|
+
const config = new Configuration({
|
|
14
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const openai = new OpenAIApi(
|
|
18
|
+
config,
|
|
19
|
+
isDev && OPENAI_PROXY_URL ? OPENAI_PROXY_URL : undefined,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export const createChatCompletion = async (
|
|
23
|
+
payload: OpenAIStreamPayload,
|
|
24
|
+
callbacks?: (payload: OpenAIStreamPayload) => OpenAIStreamCallbacks,
|
|
25
|
+
) => {
|
|
26
|
+
const { messages, plugins: enabledPlugins, ...params } = payload;
|
|
27
|
+
|
|
28
|
+
// ============ 1. 前置处理 functions ============ //
|
|
29
|
+
|
|
30
|
+
const filterFunctions: ChatCompletionFunctions[] = pluginList
|
|
31
|
+
.filter((p) => {
|
|
32
|
+
// 如果不存在 enabledPlugins,那么全部不启用
|
|
33
|
+
if (!enabledPlugins) return false;
|
|
34
|
+
|
|
35
|
+
// 如果存在 enabledPlugins,那么只启用 enabledPlugins 中的插件
|
|
36
|
+
return enabledPlugins.includes(p.name);
|
|
37
|
+
})
|
|
38
|
+
.map((f) => f.schema);
|
|
39
|
+
|
|
40
|
+
const functions = filterFunctions.length === 0 ? undefined : filterFunctions;
|
|
41
|
+
|
|
42
|
+
// ============ 2. 前置处理 messages ============ //
|
|
43
|
+
const formatMessages = messages.map((m) => ({ content: m.content, role: m.role }));
|
|
44
|
+
|
|
45
|
+
const requestParams = { functions, messages: formatMessages, stream: true, ...params };
|
|
46
|
+
|
|
47
|
+
const response = await openai.createChatCompletion(requestParams);
|
|
48
|
+
|
|
49
|
+
return OpenAIStream(response, callbacks?.(requestParams));
|
|
50
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { StreamingTextResponse } from 'ai';
|
|
2
|
+
import { ChatCompletionRequestMessage } from 'openai-edge';
|
|
3
|
+
|
|
4
|
+
import { OpenAIStreamPayload } from '@/types/openai';
|
|
5
|
+
|
|
6
|
+
import pluginList from '../../plugins';
|
|
7
|
+
import { createChatCompletion, openai } from './openai';
|
|
8
|
+
|
|
9
|
+
export const runtime = 'edge';
|
|
10
|
+
|
|
11
|
+
export default async function handler(req: Request) {
|
|
12
|
+
const payload = (await req.json()) as OpenAIStreamPayload;
|
|
13
|
+
|
|
14
|
+
const stream = await createChatCompletion(payload, (payload) => ({
|
|
15
|
+
experimental_onFunctionCall: async ({ name, arguments: args }, createFunctionCallMessages) => {
|
|
16
|
+
console.log(`执行 functionCall [${name}]`, 'args:', args);
|
|
17
|
+
|
|
18
|
+
const func = pluginList.find((f) => f.name === name);
|
|
19
|
+
|
|
20
|
+
if (func) {
|
|
21
|
+
const result = await func.runner(args as any);
|
|
22
|
+
|
|
23
|
+
const newMessages = createFunctionCallMessages(result) as ChatCompletionRequestMessage[];
|
|
24
|
+
|
|
25
|
+
return openai.createChatCompletion({
|
|
26
|
+
...payload,
|
|
27
|
+
messages: [...payload.messages, ...newMessages],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
return new StreamingTextResponse(stream);
|
|
34
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { ChatList } from '@lobehub/ui';
|
|
1
|
+
import { ChatList, ChatMessage } from '@lobehub/ui';
|
|
2
2
|
import isEqual from 'fast-deep-equal';
|
|
3
|
-
import { memo } from 'react';
|
|
3
|
+
import { ReactNode, memo } from 'react';
|
|
4
4
|
import { shallow } from 'zustand/shallow';
|
|
5
5
|
|
|
6
6
|
import { chatSelectors, useSessionStore } from '@/store/session';
|
|
7
|
+
import { isFunctionMessage } from '@/utils/message';
|
|
7
8
|
|
|
9
|
+
import FunctionMessage from './FunctionMessage';
|
|
8
10
|
import MessageExtra from './MessageExtra';
|
|
9
11
|
|
|
10
12
|
const List = () => {
|
|
@@ -14,6 +16,13 @@ const List = () => {
|
|
|
14
16
|
shallow,
|
|
15
17
|
);
|
|
16
18
|
|
|
19
|
+
const renderMessage = (content: ReactNode, message: ChatMessage) => {
|
|
20
|
+
if (message.role === 'function')
|
|
21
|
+
return isFunctionMessage(message.content) ? <FunctionMessage /> : content;
|
|
22
|
+
|
|
23
|
+
return content;
|
|
24
|
+
};
|
|
25
|
+
|
|
17
26
|
return (
|
|
18
27
|
<ChatList
|
|
19
28
|
data={data}
|
|
@@ -33,6 +42,7 @@ const List = () => {
|
|
|
33
42
|
onMessageChange={(id, content) => {
|
|
34
43
|
dispatchMessage({ id, key: 'content', type: 'updateMessage', value: content });
|
|
35
44
|
}}
|
|
45
|
+
renderMessage={renderMessage}
|
|
36
46
|
renderMessageExtra={MessageExtra}
|
|
37
47
|
style={{ marginTop: 24 }}
|
|
38
48
|
/>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { LoadingOutlined } from '@ant-design/icons';
|
|
2
|
+
import { createStyles } from 'antd-style';
|
|
3
|
+
import { memo } from 'react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Flexbox } from 'react-layout-kit';
|
|
6
|
+
|
|
7
|
+
const useStyles = createStyles(({ css, token }) => ({
|
|
8
|
+
container: css`
|
|
9
|
+
padding: 4px 8px;
|
|
10
|
+
|
|
11
|
+
color: ${token.colorTextSecondary};
|
|
12
|
+
|
|
13
|
+
background: ${token.colorFillTertiary};
|
|
14
|
+
border: 1px solid ${token.colorBorder};
|
|
15
|
+
border-radius: 6px;
|
|
16
|
+
`,
|
|
17
|
+
}));
|
|
18
|
+
const FunctionMessage = memo(() => {
|
|
19
|
+
const { t } = useTranslation();
|
|
20
|
+
const { styles } = useStyles();
|
|
21
|
+
return (
|
|
22
|
+
<Flexbox className={styles.container} gap={8} horizontal>
|
|
23
|
+
<div>
|
|
24
|
+
<LoadingOutlined />
|
|
25
|
+
</div>
|
|
26
|
+
{t('pluginLoading')}
|
|
27
|
+
</Flexbox>
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export default FunctionMessage;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ActionIcon } from '@lobehub/ui';
|
|
2
|
+
import { Popconfirm } from 'antd';
|
|
3
|
+
import { Eraser, Languages } from 'lucide-react';
|
|
4
|
+
import { memo } from 'react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { shallow } from 'zustand/shallow';
|
|
7
|
+
|
|
8
|
+
import { useSessionStore } from '@/store/session';
|
|
9
|
+
|
|
10
|
+
const InputActions = memo(() => {
|
|
11
|
+
const { t } = useTranslation();
|
|
12
|
+
const [clearMessage] = useSessionStore((s) => [s.clearMessage], shallow);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<ActionIcon icon={Languages} />
|
|
17
|
+
<Popconfirm onConfirm={() => clearMessage()} title={t('confirmClearCurrentMessages')}>
|
|
18
|
+
<ActionIcon icon={Eraser} title={t('clearCurrentMessages')} />
|
|
19
|
+
</Popconfirm>
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export default InputActions;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { TokenTag, Tooltip } from '@lobehub/ui';
|
|
2
|
+
import { encode } from 'gpt-tokenizer';
|
|
3
|
+
import { memo, useMemo } from 'react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { shallow } from 'zustand/shallow';
|
|
6
|
+
|
|
7
|
+
import { ModelTokens } from '@/const/modelTokens';
|
|
8
|
+
import { agentSelectors, chatSelectors, useSessionStore } from '@/store/session';
|
|
9
|
+
|
|
10
|
+
const Token = memo<{ input: string }>(({ input }) => {
|
|
11
|
+
const { t } = useTranslation();
|
|
12
|
+
|
|
13
|
+
const inputTokenCount = useMemo(() => encode(input).length, [input]);
|
|
14
|
+
|
|
15
|
+
const [totalToken, systemRoleToken, chatsToken, model] = useSessionStore(
|
|
16
|
+
(s) => [
|
|
17
|
+
chatSelectors.totalTokenCount(s),
|
|
18
|
+
chatSelectors.systemRoleTokenCount(s),
|
|
19
|
+
chatSelectors.chatsTokenCount(s),
|
|
20
|
+
agentSelectors.currentAgentModel(s),
|
|
21
|
+
],
|
|
22
|
+
shallow,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Tooltip title={t('tokenDetail', { chatsToken, systemRoleToken })}>
|
|
27
|
+
<TokenTag maxValue={ModelTokens[model]} value={totalToken + inputTokenCount} />
|
|
28
|
+
</Tooltip>
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export default Token;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ChatInputArea, DraggablePanel, Icon } from '@lobehub/ui';
|
|
2
|
+
import { Button } from 'antd';
|
|
3
|
+
import { Archive } from 'lucide-react';
|
|
4
|
+
import { memo, useMemo, useState } from 'react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { shallow } from 'zustand/shallow';
|
|
7
|
+
|
|
8
|
+
import { CHAT_TEXTAREA_HEIGHT, HEADER_HEIGHT } from '@/const/layoutTokens';
|
|
9
|
+
import { useSessionStore } from '@/store/session';
|
|
10
|
+
import { useSettings } from '@/store/settings';
|
|
11
|
+
|
|
12
|
+
import InputActions from './Action';
|
|
13
|
+
import Token from './Token';
|
|
14
|
+
|
|
15
|
+
const ChatInput = () => {
|
|
16
|
+
const { t } = useTranslation();
|
|
17
|
+
const [expand, setExpand] = useState<boolean>(false);
|
|
18
|
+
const [text, setText] = useState('');
|
|
19
|
+
|
|
20
|
+
const [inputHeight] = useSettings((s) => [s.inputHeight], shallow);
|
|
21
|
+
const [sendMessage] = useSessionStore((s) => [s.createOrSendMsg], shallow);
|
|
22
|
+
|
|
23
|
+
const footer = useMemo(
|
|
24
|
+
() => <Button icon={<Icon icon={Archive} title={t('archiveCurrentMessages')} />} />,
|
|
25
|
+
[],
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<DraggablePanel
|
|
30
|
+
expandable={false}
|
|
31
|
+
fullscreen={expand}
|
|
32
|
+
headerHeight={HEADER_HEIGHT}
|
|
33
|
+
minHeight={CHAT_TEXTAREA_HEIGHT}
|
|
34
|
+
onSizeChange={(_, size) => {
|
|
35
|
+
if (!size) return;
|
|
36
|
+
useSettings.setState({
|
|
37
|
+
inputHeight: typeof size.height === 'string' ? Number.parseInt(size.height) : size.height,
|
|
38
|
+
});
|
|
39
|
+
}}
|
|
40
|
+
placement="bottom"
|
|
41
|
+
size={{ height: inputHeight, width: '100%' }}
|
|
42
|
+
style={{ zIndex: 10 }}
|
|
43
|
+
>
|
|
44
|
+
<ChatInputArea
|
|
45
|
+
actions={
|
|
46
|
+
<>
|
|
47
|
+
<InputActions />
|
|
48
|
+
<Token input={text} />
|
|
49
|
+
</>
|
|
50
|
+
}
|
|
51
|
+
expand={expand}
|
|
52
|
+
footer={footer}
|
|
53
|
+
minHeight={CHAT_TEXTAREA_HEIGHT}
|
|
54
|
+
onExpandChange={setExpand}
|
|
55
|
+
onInputChange={setText}
|
|
56
|
+
onSend={sendMessage}
|
|
57
|
+
/>
|
|
58
|
+
</DraggablePanel>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default memo(ChatInput);
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { Avatar, Icon, Tooltip } from '@lobehub/ui';
|
|
1
2
|
import { Tag } from 'antd';
|
|
2
3
|
import { createStyles } from 'antd-style';
|
|
4
|
+
import { LucideToyBrick } from 'lucide-react';
|
|
3
5
|
import { ReactNode } from 'react';
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
4
7
|
import { Flexbox } from 'react-layout-kit';
|
|
5
8
|
import { shallow } from 'zustand/shallow';
|
|
6
9
|
|
|
10
|
+
import pluginList from '@/plugins';
|
|
7
11
|
import { agentSelectors, useSessionStore } from '@/store/session';
|
|
8
12
|
import { ChatMessage } from '@/types/chatMessage';
|
|
9
13
|
|
|
@@ -11,27 +15,70 @@ const useStyles = createStyles(({ css }) => ({
|
|
|
11
15
|
container: css`
|
|
12
16
|
margin-top: 8px;
|
|
13
17
|
`,
|
|
18
|
+
plugin: css`
|
|
19
|
+
display: flex;
|
|
20
|
+
gap: 4px;
|
|
21
|
+
align-items: center;
|
|
22
|
+
width: fit-content;
|
|
23
|
+
`,
|
|
14
24
|
}));
|
|
15
25
|
|
|
16
|
-
const MessageExtra = ({ role, extra }: ChatMessage): ReactNode => {
|
|
26
|
+
const MessageExtra = ({ role, extra, function_call }: ChatMessage): ReactNode => {
|
|
17
27
|
const { styles } = useStyles();
|
|
18
28
|
|
|
29
|
+
const { t } = useTranslation();
|
|
19
30
|
const [model] = useSessionStore((s) => [agentSelectors.currentAgentModel(s)], shallow);
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const plugin = pluginList.find((p) => p.name === function_call?.name);
|
|
33
|
+
const funcTag = (
|
|
34
|
+
<Tooltip title={function_call?.arguments}>
|
|
35
|
+
<Tag bordered={false} className={styles.plugin} style={{ borderRadius: 6 }}>
|
|
36
|
+
{plugin?.avatar ? (
|
|
37
|
+
<Avatar avatar={plugin?.avatar} size={18} />
|
|
38
|
+
) : (
|
|
39
|
+
<Icon icon={LucideToyBrick} />
|
|
40
|
+
)}
|
|
41
|
+
{t(`plugin-${function_call?.name}` as any)}
|
|
42
|
+
</Tag>
|
|
43
|
+
</Tooltip>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const modelTag = (
|
|
47
|
+
<div>
|
|
48
|
+
<Tag bordered={false} style={{ borderRadius: 6 }}>
|
|
49
|
+
{extra?.fromModel}
|
|
50
|
+
</Tag>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// 1. 存在 fromModel
|
|
55
|
+
// 2. 且当前的 model 和 fromModel 不一致时
|
|
56
|
+
const hasModelTag = extra?.fromModel && model !== extra?.fromModel;
|
|
57
|
+
|
|
58
|
+
const hasFuncTag = !!function_call;
|
|
59
|
+
|
|
60
|
+
switch (role) {
|
|
61
|
+
case 'user':
|
|
62
|
+
case 'system': {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case 'assistant': {
|
|
66
|
+
// 1. 只有 ai 的 message
|
|
67
|
+
// 2. 且存在 fromModel
|
|
68
|
+
// 3. 且当前的 model 和 fromModel 不一致时
|
|
69
|
+
if (!(hasModelTag || hasFuncTag)) return;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Flexbox className={styles.container}>
|
|
73
|
+
{hasFuncTag && funcTag}
|
|
74
|
+
{hasModelTag && modelTag}
|
|
75
|
+
</Flexbox>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
case 'function': {
|
|
79
|
+
return <Flexbox className={styles.container}>{funcTag}</Flexbox>;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
35
82
|
};
|
|
36
83
|
|
|
37
84
|
export default MessageExtra;
|
|
@@ -2,9 +2,9 @@ import { memo } from 'react';
|
|
|
2
2
|
import { Flexbox } from 'react-layout-kit';
|
|
3
3
|
|
|
4
4
|
import HeaderSpacing from '@/components/HeaderSpacing';
|
|
5
|
-
import ChatInput from '@/pages/chat/[id]/Conversation/Input';
|
|
6
5
|
|
|
7
6
|
import ChatList from './ChatList';
|
|
7
|
+
import ChatInput from './Input';
|
|
8
8
|
|
|
9
9
|
const Conversation = () => {
|
|
10
10
|
return (
|
|
@@ -9,7 +9,7 @@ import { URLS } from './url';
|
|
|
9
9
|
*/
|
|
10
10
|
export const fetchChatModel = (
|
|
11
11
|
params: Partial<OpenAIStreamPayload>,
|
|
12
|
-
signal?: AbortSignal | undefined,
|
|
12
|
+
options?: { signal?: AbortSignal | undefined; withPlugin?: boolean },
|
|
13
13
|
) => {
|
|
14
14
|
const payload = merge(
|
|
15
15
|
{
|
|
@@ -23,12 +23,12 @@ export const fetchChatModel = (
|
|
|
23
23
|
params,
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
return fetch(URLS.openai, {
|
|
26
|
+
return fetch(options?.withPlugin ? URLS.plugins : URLS.openai, {
|
|
27
27
|
body: JSON.stringify(payload),
|
|
28
28
|
headers: {
|
|
29
29
|
'Content-Type': 'application/json',
|
|
30
30
|
},
|
|
31
31
|
method: 'POST',
|
|
32
|
-
signal,
|
|
32
|
+
signal: options?.signal,
|
|
33
33
|
});
|
|
34
34
|
};
|
package/src/services/url.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { StateCreator } from 'zustand/vanilla';
|
|
|
3
3
|
import { fetchChatModel } from '@/services/chatModel';
|
|
4
4
|
import { SessionStore, agentSelectors, chatSelectors, sessionSelectors } from '@/store/session';
|
|
5
5
|
import { ChatMessage } from '@/types/chatMessage';
|
|
6
|
-
import {
|
|
6
|
+
import { fetchSSE } from '@/utils/fetch';
|
|
7
|
+
import { isFunctionMessage } from '@/utils/message';
|
|
7
8
|
import { nanoid } from '@/utils/uuid';
|
|
8
9
|
|
|
9
10
|
import { MessageDispatch, messagesReducer } from './messageReducer';
|
|
@@ -38,7 +39,11 @@ export interface ChatAction {
|
|
|
38
39
|
* @param messages - 聊天消息数组
|
|
39
40
|
* @param options - 获取 SSE 选项
|
|
40
41
|
*/
|
|
41
|
-
generateMessage: (
|
|
42
|
+
generateMessage: (
|
|
43
|
+
messages: ChatMessage[],
|
|
44
|
+
assistantMessageId: string,
|
|
45
|
+
withPlugin?: boolean,
|
|
46
|
+
) => Promise<{ isFunctionCall: boolean; output: string }>;
|
|
42
47
|
/**
|
|
43
48
|
* 处理消息编辑
|
|
44
49
|
* @param messageId - 消息 ID,可选
|
|
@@ -50,7 +55,7 @@ export interface ChatAction {
|
|
|
50
55
|
* @param messages - 聊天消息数组
|
|
51
56
|
* @param parentId - 父消息 ID,可选
|
|
52
57
|
*/
|
|
53
|
-
realFetchAIResponse: (messages: ChatMessage[], parentId
|
|
58
|
+
realFetchAIResponse: (messages: ChatMessage[], parentId: string) => Promise<void>;
|
|
54
59
|
/**
|
|
55
60
|
* 重新发送消息
|
|
56
61
|
* @param id - 消息 ID
|
|
@@ -100,23 +105,64 @@ export const createChatSlice: StateCreator<
|
|
|
100
105
|
get().dispatchSession({ chats, id: activeId, type: 'updateSessionChat' });
|
|
101
106
|
},
|
|
102
107
|
|
|
103
|
-
generateMessage: async (messages,
|
|
108
|
+
generateMessage: async (messages, assistantId, withPlugin) => {
|
|
109
|
+
const { dispatchMessage } = get();
|
|
104
110
|
set({ chatLoading: true });
|
|
105
111
|
const config = agentSelectors.currentAgentConfigSafe(get());
|
|
106
112
|
|
|
107
113
|
const fetcher = () =>
|
|
108
|
-
fetchChatModel(
|
|
114
|
+
fetchChatModel(
|
|
115
|
+
{ messages, model: config.model, ...config.params, plugins: config.plugins },
|
|
116
|
+
{ withPlugin },
|
|
117
|
+
);
|
|
109
118
|
|
|
110
|
-
|
|
119
|
+
let output = '';
|
|
120
|
+
let isFunctionCall = false;
|
|
121
|
+
|
|
122
|
+
await fetchSSE(fetcher, {
|
|
123
|
+
onErrorHandle: (error) => {
|
|
124
|
+
dispatchMessage({ id: assistantId, key: 'error', type: 'updateMessage', value: error });
|
|
125
|
+
},
|
|
126
|
+
onMessageHandle: (text) => {
|
|
127
|
+
output += text;
|
|
128
|
+
|
|
129
|
+
dispatchMessage({
|
|
130
|
+
id: assistantId,
|
|
131
|
+
key: 'content',
|
|
132
|
+
type: 'updateMessage',
|
|
133
|
+
value: output,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 如果是 function call
|
|
137
|
+
if (isFunctionMessage(output)) {
|
|
138
|
+
isFunctionCall = true;
|
|
139
|
+
// 设为 function
|
|
140
|
+
dispatchMessage({
|
|
141
|
+
id: assistantId,
|
|
142
|
+
key: 'role',
|
|
143
|
+
type: 'updateMessage',
|
|
144
|
+
value: 'function',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 滚动到最后一条消息
|
|
149
|
+
const item = document.querySelector('#for-loading');
|
|
150
|
+
if (!item) return;
|
|
151
|
+
|
|
152
|
+
item.scrollIntoView({ behavior: 'smooth' });
|
|
153
|
+
},
|
|
154
|
+
});
|
|
111
155
|
|
|
112
156
|
set({ chatLoading: false });
|
|
157
|
+
|
|
158
|
+
return { isFunctionCall, output };
|
|
113
159
|
},
|
|
114
160
|
|
|
115
161
|
handleMessageEditing: (messageId) => {
|
|
116
162
|
set({ editingMessageId: messageId });
|
|
117
163
|
},
|
|
118
164
|
|
|
119
|
-
realFetchAIResponse: async (messages
|
|
165
|
+
realFetchAIResponse: async (messages, userMessageId) => {
|
|
120
166
|
const { dispatchMessage, generateMessage } = get();
|
|
121
167
|
|
|
122
168
|
// 添加 systemRole
|
|
@@ -128,12 +174,11 @@ export const createChatSlice: StateCreator<
|
|
|
128
174
|
// 再添加一个空的信息用于放置 ai 响应,注意顺序不能反
|
|
129
175
|
// 因为如果顺序反了,messages 中将包含新增的 ai message
|
|
130
176
|
const assistantId = nanoid();
|
|
131
|
-
const userId = parentId ?? nanoid();
|
|
132
177
|
|
|
133
178
|
dispatchMessage({
|
|
134
179
|
id: assistantId,
|
|
135
180
|
message: LOADING_FLAT,
|
|
136
|
-
parentId:
|
|
181
|
+
parentId: userMessageId,
|
|
137
182
|
role: 'assistant',
|
|
138
183
|
type: 'addMessage',
|
|
139
184
|
});
|
|
@@ -146,29 +191,36 @@ export const createChatSlice: StateCreator<
|
|
|
146
191
|
value: model,
|
|
147
192
|
});
|
|
148
193
|
|
|
149
|
-
let output = '';
|
|
150
194
|
// 生成 ai message
|
|
151
|
-
await generateMessage(messages,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
const { output, isFunctionCall } = await generateMessage(messages, assistantId);
|
|
196
|
+
|
|
197
|
+
// 如果是 function,则发送函数调用方法
|
|
198
|
+
if (isFunctionCall) {
|
|
199
|
+
const { function_call } = JSON.parse(output);
|
|
200
|
+
|
|
201
|
+
dispatchMessage({
|
|
202
|
+
id: assistantId,
|
|
203
|
+
key: 'function_call',
|
|
204
|
+
type: 'updateMessage',
|
|
205
|
+
value: function_call,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await generateMessage(
|
|
209
|
+
[
|
|
210
|
+
...messages,
|
|
211
|
+
{ content: '', function_call, id: assistantId, role: 'assistant' } as ChatMessage,
|
|
212
|
+
],
|
|
213
|
+
assistantId,
|
|
214
|
+
true,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
dispatchMessage({
|
|
218
|
+
id: assistantId,
|
|
219
|
+
key: 'role',
|
|
220
|
+
type: 'updateMessage',
|
|
221
|
+
value: 'assistant',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
172
224
|
},
|
|
173
225
|
|
|
174
226
|
resendMessage: async (messageId) => {
|
|
@@ -183,7 +235,7 @@ export const createChatSlice: StateCreator<
|
|
|
183
235
|
|
|
184
236
|
const histories = chats
|
|
185
237
|
.slice(0, currentIndex + 1)
|
|
186
|
-
// 如果点击重新发送的 message 其 role 是 assistant,那么需要移除
|
|
238
|
+
// 如果点击重新发送的 message 其 role 是 assistant 或者 function,那么需要移除
|
|
187
239
|
// 如果点击重新发送的 message 其 role 是 user,则不需要移除
|
|
188
240
|
.filter((c) => !(c.role === 'assistant' && c.id === messageId));
|
|
189
241
|
|
|
@@ -209,7 +261,7 @@ export const createChatSlice: StateCreator<
|
|
|
209
261
|
// 先拿到当前的 messages
|
|
210
262
|
const messages = chatSelectors.currentChats(get());
|
|
211
263
|
|
|
212
|
-
await realFetchAIResponse(messages);
|
|
264
|
+
await realFetchAIResponse(messages, userId);
|
|
213
265
|
|
|
214
266
|
const chats = chatSelectors.currentChats(get());
|
|
215
267
|
if (chats.length >= 4) {
|
|
@@ -5,57 +5,17 @@ import { ChatMessage } from '@/types/chatMessage';
|
|
|
5
5
|
import type { SessionStore } from '../../../store';
|
|
6
6
|
import { DEFAULT_AVATAR } from '../../agentConfig';
|
|
7
7
|
import { sessionSelectors } from '../../session';
|
|
8
|
+
import { organizeChats } from './utils';
|
|
8
9
|
|
|
9
10
|
// 展示在聊天框中的消息
|
|
10
11
|
export const currentChats = (s: SessionStore): ChatMessage[] => {
|
|
11
12
|
const session = sessionSelectors.currentSession(s);
|
|
12
13
|
if (!session) return [];
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
.filter((m) => !m.archive)
|
|
19
|
-
// 映射头像关系
|
|
20
|
-
.map((m) => {
|
|
21
|
-
return {
|
|
22
|
-
...m,
|
|
23
|
-
meta:
|
|
24
|
-
m.role === 'assistant'
|
|
25
|
-
? {
|
|
26
|
-
avatar: agentSelectors.currentAgentAvatar(s),
|
|
27
|
-
title: session.meta.title,
|
|
28
|
-
}
|
|
29
|
-
: m.role === 'user'
|
|
30
|
-
? {
|
|
31
|
-
avatar: useSettings.getState().settings.avatar || DEFAULT_AVATAR,
|
|
32
|
-
}
|
|
33
|
-
: m.meta,
|
|
34
|
-
};
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const finalList: ChatMessage[] = [];
|
|
38
|
-
|
|
39
|
-
const addItem = (item: ChatMessage) => {
|
|
40
|
-
const isExist = finalList.findIndex((i) => item.id === i.id) > -1;
|
|
41
|
-
if (!isExist) {
|
|
42
|
-
finalList.push(item);
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
// 基于添加逻辑进行重排序
|
|
47
|
-
for (const item of basic) {
|
|
48
|
-
// 先判存在与否,不存在就加入
|
|
49
|
-
addItem(item);
|
|
50
|
-
|
|
51
|
-
for (const another of basic) {
|
|
52
|
-
if (another.parentId === item.id) {
|
|
53
|
-
addItem(another);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return finalList;
|
|
15
|
+
return organizeChats(session, {
|
|
16
|
+
assistant: agentSelectors.currentAgentAvatar(s),
|
|
17
|
+
user: useSettings.getState().settings.avatar || DEFAULT_AVATAR,
|
|
18
|
+
});
|
|
59
19
|
};
|
|
60
20
|
|
|
61
21
|
export const systemRoleSel = (s: SessionStore): string => {
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { LobeAgentSession } from '@/types/session';
|
|
4
|
+
|
|
5
|
+
import { organizeChats } from './utils';
|
|
6
|
+
|
|
7
|
+
let session: LobeAgentSession;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
session = {
|
|
11
|
+
chats: {
|
|
12
|
+
'1': {
|
|
13
|
+
id: '1',
|
|
14
|
+
createAt: 1639440000000,
|
|
15
|
+
updateAt: 1639440000000,
|
|
16
|
+
meta: {},
|
|
17
|
+
content: 'Message 1',
|
|
18
|
+
role: 'assistant',
|
|
19
|
+
},
|
|
20
|
+
'2': {
|
|
21
|
+
id: '2',
|
|
22
|
+
createAt: 1639450000000,
|
|
23
|
+
updateAt: 1639450000000,
|
|
24
|
+
meta: {},
|
|
25
|
+
content: 'Message 2',
|
|
26
|
+
role: 'user',
|
|
27
|
+
},
|
|
28
|
+
'3': {
|
|
29
|
+
id: '3',
|
|
30
|
+
createAt: 1639460000000,
|
|
31
|
+
updateAt: 1639460000000,
|
|
32
|
+
meta: {},
|
|
33
|
+
content: 'Message 3',
|
|
34
|
+
role: 'assistant',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
config: {
|
|
38
|
+
model: 'gpt-4',
|
|
39
|
+
params: {
|
|
40
|
+
temperature: 0.6,
|
|
41
|
+
},
|
|
42
|
+
systemRole: '',
|
|
43
|
+
},
|
|
44
|
+
type: 'agent',
|
|
45
|
+
createAt: 1690110700808,
|
|
46
|
+
id: 'abc',
|
|
47
|
+
meta: {},
|
|
48
|
+
updateAt: 1690110700808,
|
|
49
|
+
} as LobeAgentSession;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('organizeChats', () => {
|
|
53
|
+
it('should return an array of chat messages', () => {
|
|
54
|
+
const result = organizeChats(session, { assistant: '', user: '' });
|
|
55
|
+
expect(Array.isArray(result)).toBe(true);
|
|
56
|
+
expect(result.length).toBe(3);
|
|
57
|
+
expect(result[0].id).toBe('1');
|
|
58
|
+
expect(result[1].id).toBe('2');
|
|
59
|
+
expect(result[2].id).toBe('3');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should sort chat messages in ascending order of createAt', () => {
|
|
63
|
+
const result = organizeChats(session, { assistant: '', user: '' });
|
|
64
|
+
expect(result[0].id).toBe('1');
|
|
65
|
+
expect(result[1].id).toBe('2');
|
|
66
|
+
expect(result[2].id).toBe('3');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should filter out archived messages', () => {
|
|
70
|
+
session.chats['2'].archive = true;
|
|
71
|
+
const result = organizeChats(session, { assistant: '', user: '' });
|
|
72
|
+
expect(result.length).toBe(2);
|
|
73
|
+
expect(result[0].id).toBe('1');
|
|
74
|
+
expect(result[1].id).toBe('3');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should map avatars correctly', () => {
|
|
78
|
+
const avatar = 'https://example.com/avatar.png';
|
|
79
|
+
const settings = {
|
|
80
|
+
avatar: 'https://example.com/user-avatar.png',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = organizeChats(session, {
|
|
84
|
+
assistant: avatar,
|
|
85
|
+
user: settings.avatar,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result[0].meta.avatar).toBe(avatar);
|
|
89
|
+
expect(result[1].meta.avatar).toBe(settings.avatar);
|
|
90
|
+
expect(result[2].meta.avatar).toBe(avatar);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should reorder messages based on parent-child relationship', () => {
|
|
94
|
+
session.chats['2'].parentId = '1';
|
|
95
|
+
session.chats['3'].parentId = '2';
|
|
96
|
+
const result = organizeChats(session, { assistant: '', user: '' });
|
|
97
|
+
expect(result.length).toBe(3);
|
|
98
|
+
expect(result[0].id).toBe('1');
|
|
99
|
+
expect(result[1].id).toBe('2');
|
|
100
|
+
expect(result[2].id).toBe('3');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should remove duplicate messages', () => {
|
|
104
|
+
session.chats['2'].parentId = '1';
|
|
105
|
+
session.chats['3'].parentId = '2';
|
|
106
|
+
session.chats['3'].id = '2';
|
|
107
|
+
const result = organizeChats(session, { assistant: '', user: '' });
|
|
108
|
+
expect(result.length).toBe(2);
|
|
109
|
+
expect(result[0].id).toBe('1');
|
|
110
|
+
expect(result[1].id).toBe('2');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return an empty array for empty session', () => {
|
|
114
|
+
const emptySession = {
|
|
115
|
+
chats: {},
|
|
116
|
+
config: {},
|
|
117
|
+
type: 'agent',
|
|
118
|
+
} as LobeAgentSession;
|
|
119
|
+
const result = organizeChats(emptySession, { assistant: '', user: '' });
|
|
120
|
+
expect(Array.isArray(result)).toBe(true);
|
|
121
|
+
expect(result.length).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should handle large number of chat messages', () => {
|
|
125
|
+
const largeSession = {
|
|
126
|
+
chats: {},
|
|
127
|
+
config: {},
|
|
128
|
+
type: 'agent',
|
|
129
|
+
meta: {},
|
|
130
|
+
} as LobeAgentSession;
|
|
131
|
+
|
|
132
|
+
const numMessages = 1000;
|
|
133
|
+
|
|
134
|
+
for (let i = 1; i <= numMessages; i++) {
|
|
135
|
+
largeSession.chats[i.toString()] = {
|
|
136
|
+
id: i.toString(),
|
|
137
|
+
createAt: i,
|
|
138
|
+
updateAt: i,
|
|
139
|
+
meta: {},
|
|
140
|
+
content: `Message ${i}`,
|
|
141
|
+
role: 'assistant',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = organizeChats(largeSession, { assistant: '', user: '' });
|
|
146
|
+
expect(result.length).toBe(numMessages);
|
|
147
|
+
expect(result[0].id).toBe('1');
|
|
148
|
+
expect(result[numMessages - 1].id).toBe(numMessages.toString());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('按照预期进行排序', () => {
|
|
152
|
+
const realSession = {
|
|
153
|
+
chats: {
|
|
154
|
+
'Ftei28dF': {
|
|
155
|
+
content: '鲁迅为何暴打周树人',
|
|
156
|
+
createAt: 1690111354731,
|
|
157
|
+
id: 'Ftei28dF',
|
|
158
|
+
meta: {},
|
|
159
|
+
role: 'user',
|
|
160
|
+
updateAt: 1690111354731,
|
|
161
|
+
},
|
|
162
|
+
'9bQW9hTs': {
|
|
163
|
+
content:
|
|
164
|
+
'这是一种误解。鲁迅和周树人是同一人,指的都是现代文学家周樟寿。"鲁迅"是他的笔名,"周树人"则是他的原名。所以,鲁迅并没有暴打周树人,这只是一种说法上的误解。',
|
|
165
|
+
createAt: 1690111354734,
|
|
166
|
+
id: '9bQW9hTs',
|
|
167
|
+
meta: {},
|
|
168
|
+
parentId: 'Ftei28dF',
|
|
169
|
+
role: 'assistant',
|
|
170
|
+
updateAt: 1690111361514,
|
|
171
|
+
extra: {
|
|
172
|
+
fromModel: 'gpt-4',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
'HRQGSszU': {
|
|
176
|
+
content:
|
|
177
|
+
'这个问题基于一个误解。实际上,鲁迅和周树人是同一个人,这是中国现代文学的开山鼻祖周树人的笔名。他的本名是周树人,鲁迅则是他在30岁时开始使用的笔名。因此,鲁迅不能暴打周树人,因为他们是同一个人。',
|
|
178
|
+
createAt: 1690111364344,
|
|
179
|
+
id: 'HRQGSszU',
|
|
180
|
+
meta: {},
|
|
181
|
+
parentId: 'Ftei28dF',
|
|
182
|
+
role: 'assistant',
|
|
183
|
+
updateAt: 1690111369519,
|
|
184
|
+
extra: {
|
|
185
|
+
fromModel: 'gpt-4',
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
'981qr9n0': {
|
|
189
|
+
content:
|
|
190
|
+
'这是一个误解。鲁迅和周树人是同一个人,是中国现代文学的奠基人。鲁迅是他的笔名,周树人是他的本名。所以,鲁迅不可能暴打周树人。这种说法可能是源于一些误解或误传。',
|
|
191
|
+
createAt: 1690111375456,
|
|
192
|
+
id: '981qr9n0',
|
|
193
|
+
meta: {},
|
|
194
|
+
parentId: 'Ftei28dF',
|
|
195
|
+
role: 'assistant',
|
|
196
|
+
updateAt: 1690111381458,
|
|
197
|
+
extra: {
|
|
198
|
+
fromModel: 'gpt-4',
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
'ddd': {
|
|
202
|
+
content: '鲁迅是谁',
|
|
203
|
+
createAt: 1690211354731,
|
|
204
|
+
id: 'ddd',
|
|
205
|
+
meta: {},
|
|
206
|
+
role: 'user',
|
|
207
|
+
updateAt: 1690211354731,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
config: {
|
|
211
|
+
model: 'gpt-4',
|
|
212
|
+
params: {
|
|
213
|
+
temperature: 0.6,
|
|
214
|
+
},
|
|
215
|
+
systemRole: '',
|
|
216
|
+
},
|
|
217
|
+
createAt: 1690110700808,
|
|
218
|
+
id: '1515e861-0c64-49a3-bb85-2b24d65a19d6',
|
|
219
|
+
meta: {},
|
|
220
|
+
type: 'agent',
|
|
221
|
+
updateAt: 1690110700808,
|
|
222
|
+
} as LobeAgentSession;
|
|
223
|
+
|
|
224
|
+
const result = organizeChats(realSession, { assistant: '', user: '' });
|
|
225
|
+
|
|
226
|
+
expect(
|
|
227
|
+
result.map((i) => ({ id: i.id, content: i.content, role: i.role, createAt: i.createAt })),
|
|
228
|
+
).toEqual([
|
|
229
|
+
{
|
|
230
|
+
content: '鲁迅为何暴打周树人',
|
|
231
|
+
createAt: 1690111354731,
|
|
232
|
+
id: 'Ftei28dF',
|
|
233
|
+
role: 'user',
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
content:
|
|
237
|
+
'这是一种误解。鲁迅和周树人是同一人,指的都是现代文学家周樟寿。"鲁迅"是他的笔名,"周树人"则是他的原名。所以,鲁迅并没有暴打周树人,这只是一种说法上的误解。',
|
|
238
|
+
createAt: 1690111354734,
|
|
239
|
+
id: '9bQW9hTs',
|
|
240
|
+
role: 'assistant',
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
content:
|
|
244
|
+
'这个问题基于一个误解。实际上,鲁迅和周树人是同一个人,这是中国现代文学的开山鼻祖周树人的笔名。他的本名是周树人,鲁迅则是他在30岁时开始使用的笔名。因此,鲁迅不能暴打周树人,因为他们是同一个人。',
|
|
245
|
+
createAt: 1690111364344,
|
|
246
|
+
id: 'HRQGSszU',
|
|
247
|
+
role: 'assistant',
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
content:
|
|
251
|
+
'这是一个误解。鲁迅和周树人是同一个人,是中国现代文学的奠基人。鲁迅是他的笔名,周树人是他的本名。所以,鲁迅不可能暴打周树人。这种说法可能是源于一些误解或误传。',
|
|
252
|
+
createAt: 1690111375456,
|
|
253
|
+
id: '981qr9n0',
|
|
254
|
+
role: 'assistant',
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
content: '鲁迅是谁',
|
|
258
|
+
createAt: 1690211354731,
|
|
259
|
+
id: 'ddd',
|
|
260
|
+
role: 'user',
|
|
261
|
+
},
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import pluginList from '@/plugins';
|
|
2
|
+
import { ChatMessage } from '@/types/chatMessage';
|
|
3
|
+
import { LobeAgentSession } from '@/types/session';
|
|
4
|
+
|
|
5
|
+
export const organizeChats = (
|
|
6
|
+
session: LobeAgentSession,
|
|
7
|
+
avatar: { assistant: string; user: string },
|
|
8
|
+
) => {
|
|
9
|
+
const getMeta = (message: ChatMessage) => {
|
|
10
|
+
switch (message.role) {
|
|
11
|
+
case 'user': {
|
|
12
|
+
return {
|
|
13
|
+
avatar: avatar.user,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
case 'system': {
|
|
18
|
+
return message.meta;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
case 'assistant': {
|
|
22
|
+
return {
|
|
23
|
+
avatar: avatar.assistant,
|
|
24
|
+
title: session.meta.title,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
case 'function': {
|
|
29
|
+
const plugin = pluginList.find((p) => p.name === message.function_call?.name);
|
|
30
|
+
return {
|
|
31
|
+
avatar: plugin?.avatar || '🧩',
|
|
32
|
+
title: plugin?.name || 'plugin-unknown',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const basic = Object.values<ChatMessage>(session.chats)
|
|
39
|
+
// 首先按照时间顺序排序,越早的在越前面
|
|
40
|
+
.sort((pre, next) => pre.createAt - next.createAt)
|
|
41
|
+
// 过滤掉已归档的消息,归档消息不应该出现在聊天框中
|
|
42
|
+
.filter((m) => !m.archive)
|
|
43
|
+
// 映射头像关系
|
|
44
|
+
.map((m) => {
|
|
45
|
+
return {
|
|
46
|
+
...m,
|
|
47
|
+
meta: getMeta(m),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const finalList: ChatMessage[] = [];
|
|
52
|
+
|
|
53
|
+
const addItem = (item: ChatMessage) => {
|
|
54
|
+
const isExist = finalList.findIndex((i) => item.id === i.id) > -1;
|
|
55
|
+
if (!isExist) {
|
|
56
|
+
finalList.push(item);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// 基于添加逻辑进行重排序
|
|
61
|
+
for (const item of basic) {
|
|
62
|
+
// 先判存在与否,不存在就加入
|
|
63
|
+
addItem(item);
|
|
64
|
+
|
|
65
|
+
for (const another of basic) {
|
|
66
|
+
if (another.parentId === item.id) {
|
|
67
|
+
addItem(another);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return finalList;
|
|
73
|
+
};
|
package/src/types/chatMessage.ts
CHANGED
|
@@ -22,7 +22,6 @@ export interface ChatMessage extends BaseDataModel {
|
|
|
22
22
|
*/
|
|
23
23
|
content: string;
|
|
24
24
|
error?: any;
|
|
25
|
-
|
|
26
25
|
// 扩展字段
|
|
27
26
|
extra?: {
|
|
28
27
|
fromModel?: string;
|
|
@@ -33,6 +32,9 @@ export interface ChatMessage extends BaseDataModel {
|
|
|
33
32
|
};
|
|
34
33
|
} & Record<string, any>;
|
|
35
34
|
|
|
35
|
+
function_call?: { arguments?: string; name: string };
|
|
36
|
+
name?: string;
|
|
37
|
+
|
|
36
38
|
parentId?: string;
|
|
37
39
|
// 引用
|
|
38
40
|
quotaId?: string;
|
package/src/types/llm.ts
CHANGED
package/src/utils/fetch.ts
CHANGED
|
@@ -91,7 +91,7 @@ interface FetchAITaskResultParams<T> {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
export const fetchAIFactory =
|
|
94
|
-
<T>(fetcher: (params: T, signal?: AbortSignal) => Promise<Response>) =>
|
|
94
|
+
<T>(fetcher: (params: T, options: { signal?: AbortSignal }) => Promise<Response>) =>
|
|
95
95
|
async ({
|
|
96
96
|
params,
|
|
97
97
|
onMessageHandle,
|
|
@@ -117,7 +117,7 @@ export const fetchAIFactory =
|
|
|
117
117
|
|
|
118
118
|
onLoadingChange?.(true);
|
|
119
119
|
|
|
120
|
-
const data = await fetchSSE(() => fetcher(params, abortController?.signal), {
|
|
120
|
+
const data = await fetchSSE(() => fetcher(params, { signal: abortController?.signal }), {
|
|
121
121
|
onErrorHandle: (error) => {
|
|
122
122
|
errorHandle(new Error(error.message));
|
|
123
123
|
},
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { ActionIcon, ChatInputArea, DraggablePanel, Icon, TokenTag, Tooltip } from '@lobehub/ui';
|
|
2
|
-
import { Button, Popconfirm } from 'antd';
|
|
3
|
-
import { encode } from 'gpt-tokenizer';
|
|
4
|
-
import { Archive, Eraser, Languages } from 'lucide-react';
|
|
5
|
-
import { memo, useMemo, useState } from 'react';
|
|
6
|
-
import { useTranslation } from 'react-i18next';
|
|
7
|
-
import { shallow } from 'zustand/shallow';
|
|
8
|
-
|
|
9
|
-
import { CHAT_TEXTAREA_HEIGHT, HEADER_HEIGHT } from '@/const/layoutTokens';
|
|
10
|
-
import { ModelTokens } from '@/const/modelTokens';
|
|
11
|
-
import { agentSelectors, chatSelectors, useSessionStore } from '@/store/session';
|
|
12
|
-
import { useSettings } from '@/store/settings';
|
|
13
|
-
|
|
14
|
-
const ChatInput = () => {
|
|
15
|
-
const { t } = useTranslation();
|
|
16
|
-
const [expand, setExpand] = useState<boolean>(false);
|
|
17
|
-
const [text, setText] = useState('');
|
|
18
|
-
const inputTokenCount = useMemo(() => encode(text).length, [text]);
|
|
19
|
-
|
|
20
|
-
const [inputHeight] = useSettings((s) => [s.inputHeight], shallow);
|
|
21
|
-
const [totalToken, systemRoleToken, chatsToken, model, sendMessage, clearMessage] =
|
|
22
|
-
useSessionStore(
|
|
23
|
-
(s) => [
|
|
24
|
-
chatSelectors.totalTokenCount(s),
|
|
25
|
-
chatSelectors.systemRoleTokenCount(s),
|
|
26
|
-
chatSelectors.chatsTokenCount(s),
|
|
27
|
-
agentSelectors.currentAgentModel(s),
|
|
28
|
-
s.createOrSendMsg,
|
|
29
|
-
s.clearMessage,
|
|
30
|
-
],
|
|
31
|
-
shallow,
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<DraggablePanel
|
|
36
|
-
expandable={false}
|
|
37
|
-
fullscreen={expand}
|
|
38
|
-
headerHeight={HEADER_HEIGHT}
|
|
39
|
-
minHeight={CHAT_TEXTAREA_HEIGHT}
|
|
40
|
-
onSizeChange={(_, size) => {
|
|
41
|
-
if (!size) return;
|
|
42
|
-
useSettings.setState({
|
|
43
|
-
inputHeight: typeof size.height === 'string' ? Number.parseInt(size.height) : size.height,
|
|
44
|
-
});
|
|
45
|
-
}}
|
|
46
|
-
placement="bottom"
|
|
47
|
-
size={{ height: inputHeight, width: '100%' }}
|
|
48
|
-
style={{ zIndex: 10 }}
|
|
49
|
-
>
|
|
50
|
-
<ChatInputArea
|
|
51
|
-
actions={
|
|
52
|
-
<>
|
|
53
|
-
<ActionIcon icon={Languages} />
|
|
54
|
-
<Popconfirm onConfirm={() => clearMessage()} title={t('confirmClearCurrentMessages')}>
|
|
55
|
-
<ActionIcon icon={Eraser} title={t('clearCurrentMessages')} />
|
|
56
|
-
</Popconfirm>
|
|
57
|
-
<Tooltip title={t('tokenDetail', { chatsToken, systemRoleToken })}>
|
|
58
|
-
<TokenTag maxValue={ModelTokens[model]} value={totalToken + inputTokenCount} />
|
|
59
|
-
</Tooltip>
|
|
60
|
-
</>
|
|
61
|
-
}
|
|
62
|
-
expand={expand}
|
|
63
|
-
footer={<Button icon={<Icon icon={Archive} title={t('archiveCurrentMessages')} />} />}
|
|
64
|
-
minHeight={CHAT_TEXTAREA_HEIGHT}
|
|
65
|
-
onExpandChange={setExpand}
|
|
66
|
-
onInputChange={setText}
|
|
67
|
-
onSend={sendMessage}
|
|
68
|
-
/>
|
|
69
|
-
</DraggablePanel>
|
|
70
|
-
);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export default memo(ChatInput);
|