@qlover/create-app 0.8.0 → 0.10.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 +32 -0
- package/dist/configs/_common/.github/workflows/general-check.yml +1 -1
- package/dist/configs/_common/.github/workflows/release.yml +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/templates/next-app/.env.template +9 -10
- package/dist/templates/next-app/eslint.config.mjs +5 -0
- package/dist/templates/next-app/next.config.ts +1 -1
- package/dist/templates/next-app/src/app/[locale]/login/page.tsx +1 -1
- package/dist/templates/next-app/src/app/[locale]/page.tsx +1 -1
- package/dist/templates/next-app/src/app/[locale]/register/page.tsx +1 -1
- package/dist/templates/next-app/src/app/api/locales/json/route.ts +2 -1
- package/dist/templates/next-app/src/i18n/request.ts +2 -2
- package/dist/templates/react-app/__tests__/__mocks__/{MockAppConfit.ts → MockAppConfig.ts} +1 -1
- package/dist/templates/react-app/__tests__/__mocks__/components/TestApp.tsx +10 -17
- package/dist/templates/react-app/__tests__/__mocks__/components/TestBootstrapsProvider.tsx +27 -8
- package/dist/templates/react-app/__tests__/__mocks__/createMockGlobals.ts +1 -1
- package/dist/templates/react-app/__tests__/__mocks__/i18nextHttpBackend.ts +110 -0
- package/dist/templates/react-app/__tests__/__mocks__/testIOC/TestIOCRegister.ts +3 -2
- package/dist/templates/react-app/__tests__/setup/setupGlobal.ts +13 -0
- package/dist/templates/react-app/__tests__/src/base/services/I18nService.test.ts +3 -1
- package/dist/templates/react-app/__tests__/src/uikit/components/chatMessage/ChatRoot.test.tsx +274 -0
- package/dist/templates/react-app/config/IOCIdentifier.ts +9 -3
- package/dist/templates/react-app/config/Identifier/components/component.chatMessage.ts +56 -0
- package/dist/templates/react-app/config/Identifier/components/component.messageBaseList.ts +103 -0
- package/dist/templates/react-app/config/Identifier/pages/index.ts +1 -0
- package/dist/templates/react-app/config/Identifier/pages/page.message.ts +20 -0
- package/dist/templates/react-app/config/app.router.ts +23 -0
- package/dist/templates/react-app/config/common.ts +38 -0
- package/dist/templates/react-app/config/feapi.mock.json +5 -12
- package/dist/templates/react-app/config/i18n/chatMessageI18n.ts +17 -0
- package/dist/templates/react-app/config/i18n/messageBaseListI18n.ts +22 -0
- package/dist/templates/react-app/config/i18n/messageI18n.ts +14 -0
- package/dist/templates/react-app/docs/en/components/chat-message-component.md +314 -0
- package/dist/templates/react-app/docs/en/components/chat-message-refactor.md +270 -0
- package/dist/templates/react-app/docs/en/components/message-base-list-component.md +172 -0
- package/dist/templates/react-app/docs/zh/components/chat-message-component.md +314 -0
- package/dist/templates/react-app/docs/zh/components/chat-message-refactor.md +270 -0
- package/dist/templates/react-app/docs/zh/components/message-base-list-component.md +172 -0
- package/dist/templates/react-app/eslint.config.mjs +6 -5
- package/dist/templates/react-app/package.json +1 -1
- package/dist/templates/react-app/playwright.config.ts +6 -6
- package/dist/templates/react-app/public/locales/en/common.json +44 -1
- package/dist/templates/react-app/public/locales/zh/common.json +44 -1
- package/dist/templates/react-app/src/base/apis/userApi/UserApi.ts +22 -13
- package/dist/templates/react-app/src/base/apis/userApi/UserApiBootstarp.ts +3 -3
- package/dist/templates/react-app/src/base/apis/userApi/UserApiType.ts +17 -12
- package/dist/templates/react-app/src/base/cases/I18nKeyErrorPlugin.ts +19 -2
- package/dist/templates/react-app/src/base/port/RouteServiceInterface.ts +2 -4
- package/dist/templates/react-app/src/base/port/UserServiceInterface.ts +15 -9
- package/dist/templates/react-app/src/base/services/BaseLayoutService.ts +55 -0
- package/dist/templates/react-app/src/base/services/I18nService.ts +1 -0
- package/dist/templates/react-app/src/base/services/UserBootstrap.ts +43 -0
- package/dist/templates/react-app/src/base/services/UserGatewayPlugin.ts +16 -0
- package/dist/templates/react-app/src/base/services/UserService.ts +51 -80
- package/dist/templates/react-app/src/core/bootstraps/BootstrapClient.ts +8 -3
- package/dist/templates/react-app/src/core/bootstraps/BootstrapsRegistry.ts +6 -6
- package/dist/templates/react-app/src/core/bootstraps/SaveAppInfo.ts +28 -0
- package/dist/templates/react-app/src/core/clientIoc/ClientIOCRegister.ts +24 -18
- package/dist/templates/react-app/src/core/globals.ts +10 -11
- package/dist/templates/react-app/src/main.tsx +1 -1
- package/dist/templates/react-app/src/pages/auth/Layout.tsx +4 -4
- package/dist/templates/react-app/src/pages/auth/RegisterPage.tsx +1 -1
- package/dist/templates/react-app/src/pages/base/Layout.tsx +3 -3
- package/dist/templates/react-app/src/pages/base/MessagePage.tsx +40 -0
- package/dist/templates/react-app/src/uikit/components/BaseLayoutProvider.tsx +44 -0
- package/dist/templates/react-app/src/uikit/components/LogoutButton.tsx +1 -3
- package/dist/templates/react-app/src/uikit/components/MessageBaseList.tsx +240 -0
- package/dist/templates/react-app/src/uikit/components/chatMessage/ChatMessageBridge.ts +176 -0
- package/dist/templates/react-app/src/uikit/components/chatMessage/ChatRoot.tsx +21 -0
- package/dist/templates/react-app/src/uikit/components/chatMessage/FocusBar.tsx +106 -0
- package/dist/templates/react-app/src/uikit/components/chatMessage/MessageApi.ts +271 -0
- package/dist/templates/react-app/src/uikit/components/chatMessage/MessageItem.tsx +102 -0
- package/dist/templates/react-app/src/uikit/components/chatMessage/MessagesList.tsx +86 -0
- package/dist/templates/react-app/src/uikit/hooks/useNavigateBridge.ts +9 -0
- package/dist/templates/react-app/src/uikit/hooks/{useI18nGuard.ts → useRouterI18nGuard.ts} +7 -4
- package/dist/templates/react-app/tsconfig.app.json +4 -2
- package/dist/templates/react-app/tsconfig.node.json +4 -0
- package/dist/templates/react-app/tsconfig.test.json +3 -1
- package/package.json +3 -3
- package/dist/templates/react-app/__tests__/src/base/cases/AppError.test.ts +0 -102
- package/dist/templates/react-app/__tests__/src/main.integration.test.tsx +0 -61
- package/dist/templates/react-app/src/base/services/ProcesserExecutor.ts +0 -57
- package/dist/templates/react-app/src/uikit/components/ProcessExecutorProvider.tsx +0 -28
- package/dist/templates/react-app/src/uikit/components/UserAuthProvider.tsx +0 -16
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MessageSender,
|
|
3
|
+
ChatMessageRole,
|
|
4
|
+
MessageStatus
|
|
5
|
+
} from '@qlover/corekit-bridge';
|
|
6
|
+
import type {
|
|
7
|
+
ChatMessage,
|
|
8
|
+
ChatMessageStore,
|
|
9
|
+
ChatMessageBridgeInterface,
|
|
10
|
+
ChatMessageBridgePlugin,
|
|
11
|
+
DisabledSendParams,
|
|
12
|
+
MessageSenderConfig,
|
|
13
|
+
GatewayOptions
|
|
14
|
+
} from '@qlover/corekit-bridge';
|
|
15
|
+
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
|
16
|
+
|
|
17
|
+
export class ChatMessageBridge<T = string>
|
|
18
|
+
implements ChatMessageBridgeInterface<T>
|
|
19
|
+
{
|
|
20
|
+
protected ref: TextAreaRef | null = null;
|
|
21
|
+
protected readonly messageSender: MessageSender<ChatMessage<T>>;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
protected readonly messages: ChatMessageStore<T>,
|
|
25
|
+
config?: MessageSenderConfig
|
|
26
|
+
) {
|
|
27
|
+
this.messageSender = new MessageSender(messages, config);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
use(plugin: ChatMessageBridgePlugin<T> | ChatMessageBridgePlugin<T>[]): this {
|
|
31
|
+
if (Array.isArray(plugin)) {
|
|
32
|
+
plugin.forEach((p) => this.messageSender.use(p));
|
|
33
|
+
} else {
|
|
34
|
+
this.messageSender.use(plugin);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getMessageStore(): ChatMessageStore<T> {
|
|
41
|
+
return this.messages;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Disable rules
|
|
46
|
+
* 1. If streaming, don't allow sending
|
|
47
|
+
* 2. If sending, don't allow sending
|
|
48
|
+
*/
|
|
49
|
+
getDisabledSend(params?: DisabledSendParams<T>): boolean {
|
|
50
|
+
const disabledSend =
|
|
51
|
+
params?.disabledSend || this.messages.state.disabledSend;
|
|
52
|
+
|
|
53
|
+
if (disabledSend || this.messages.state.streaming) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sendingMessage = this.getSendingMessage();
|
|
58
|
+
if (sendingMessage) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onChangeContent(content: T): void {
|
|
66
|
+
const firstDraft = this.getFirstDraftMessage();
|
|
67
|
+
|
|
68
|
+
// If draft message exists, update its content
|
|
69
|
+
if (firstDraft && firstDraft.id) {
|
|
70
|
+
this.messages.updateDraftMessage(firstDraft.id, {
|
|
71
|
+
content
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
// If no draft message, create a new one
|
|
75
|
+
this.messages.addDraftMessage({
|
|
76
|
+
content,
|
|
77
|
+
role: ChatMessageRole.USER
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getFirstDraftMessage(): ChatMessage<T> | null {
|
|
83
|
+
return this.messages.getFirstDraftMessage();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setRef(ref: unknown): void {
|
|
87
|
+
this.ref = ref as TextAreaRef;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
focus(): void {
|
|
91
|
+
requestAnimationFrame(() => {
|
|
92
|
+
this.ref?.focus();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
sendMessage(
|
|
97
|
+
messages: ChatMessage<T>,
|
|
98
|
+
gatewayOptions?: GatewayOptions<ChatMessage<T>>
|
|
99
|
+
): Promise<ChatMessage<T>> {
|
|
100
|
+
if (this.messages.state.disabledSend) {
|
|
101
|
+
throw new Error('Send is disabled');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return this.messageSender.send(messages, gatewayOptions);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Send message
|
|
109
|
+
*
|
|
110
|
+
* - If passing an object that doesn't exist, sending is not allowed; only draft messages and resending history messages are allowed
|
|
111
|
+
* - If streaming, sending is not allowed
|
|
112
|
+
* - If loading, sending is not allowed
|
|
113
|
+
* - If no message to send, sending is not allowed
|
|
114
|
+
*
|
|
115
|
+
* @param message - Message object
|
|
116
|
+
* @param gatewayOptions - Gateway options
|
|
117
|
+
*/
|
|
118
|
+
send(
|
|
119
|
+
message?: ChatMessage<T>,
|
|
120
|
+
gatewayOptions?: GatewayOptions<ChatMessage<T>>
|
|
121
|
+
): Promise<ChatMessage<T>> {
|
|
122
|
+
const disabledSend = this.getDisabledSend();
|
|
123
|
+
|
|
124
|
+
if (disabledSend) {
|
|
125
|
+
throw new Error('Send is not allowed');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 3. If no message to send, sending is not allowed
|
|
129
|
+
const targetMessage = this.messages.getReadySendMessage(message);
|
|
130
|
+
|
|
131
|
+
if (!targetMessage) {
|
|
132
|
+
throw new Error('No message to send');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 4. If loading, sending is not allowed
|
|
136
|
+
if (targetMessage.loading) {
|
|
137
|
+
return Promise.resolve(targetMessage);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return this.sendMessage(targetMessage, gatewayOptions);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
getSendingMessage(messages?: ChatMessage<T>[]): ChatMessage<T> | null {
|
|
144
|
+
messages = messages || this.messages.getMessages();
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
messages.find(
|
|
148
|
+
(msg) =>
|
|
149
|
+
msg.status === MessageStatus.SENDING &&
|
|
150
|
+
msg.role === ChatMessageRole.USER
|
|
151
|
+
) || null
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
stop(messageId?: string): boolean {
|
|
156
|
+
if (!messageId) {
|
|
157
|
+
const sendingMessage = this.getSendingMessage();
|
|
158
|
+
if (sendingMessage) {
|
|
159
|
+
messageId = sendingMessage.id;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!messageId) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return this.messageSender.stop(messageId);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Stop all sending messages
|
|
172
|
+
*/
|
|
173
|
+
stopAll(): void {
|
|
174
|
+
this.messageSender.stopAll();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ChatMessageI18nInterface } from '@config/i18n/chatMessageI18n';
|
|
2
|
+
import { FocusBar } from './FocusBar';
|
|
3
|
+
import { MessagesList } from './MessagesList';
|
|
4
|
+
import type { ChatMessageBridge } from './ChatMessageBridge';
|
|
5
|
+
|
|
6
|
+
export interface ChatRootProps {
|
|
7
|
+
bridge: ChatMessageBridge<string>;
|
|
8
|
+
tt: ChatMessageI18nInterface;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ChatRoot({ bridge, tt }: ChatRootProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
data-testid="ChatRoot"
|
|
15
|
+
className="flex flex-col h-full max-w-5xl mx-auto bg-primary rounded-lg shadow-lg overflow-hidden"
|
|
16
|
+
>
|
|
17
|
+
<MessagesList bridge={bridge} tt={tt} />
|
|
18
|
+
<FocusBar bridge={bridge} tt={tt} />
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { SendOutlined, StopOutlined } from '@ant-design/icons';
|
|
2
|
+
import { useStore } from '@brain-toolkit/react-kit';
|
|
3
|
+
import { Button, Input } from 'antd';
|
|
4
|
+
import { useCallback, useMemo } from 'react';
|
|
5
|
+
import type { ChatMessageI18nInterface } from '@config/i18n/chatMessageI18n';
|
|
6
|
+
import type { ChatMessageBridgeInterface } from '@qlover/corekit-bridge';
|
|
7
|
+
|
|
8
|
+
const { TextArea } = Input;
|
|
9
|
+
|
|
10
|
+
export interface FocusBarProps {
|
|
11
|
+
bridge: ChatMessageBridgeInterface<string>;
|
|
12
|
+
tt: ChatMessageI18nInterface;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FocusBar({ bridge, tt }: FocusBarProps) {
|
|
16
|
+
const messagesStore = bridge.getMessageStore();
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
messages: historyMessages,
|
|
20
|
+
draftMessages,
|
|
21
|
+
disabledSend,
|
|
22
|
+
streaming
|
|
23
|
+
} = useStore(messagesStore);
|
|
24
|
+
|
|
25
|
+
const firstDraft = useMemo(
|
|
26
|
+
() => bridge.getFirstDraftMessage(draftMessages),
|
|
27
|
+
[bridge, draftMessages]
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const sendingMessage = useMemo(
|
|
31
|
+
() => bridge.getSendingMessage(historyMessages),
|
|
32
|
+
[bridge, historyMessages]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const disabledSendButton = useMemo(
|
|
36
|
+
() => bridge.getDisabledSend({ firstDraft, sendingMessage, disabledSend }),
|
|
37
|
+
[bridge, firstDraft, sendingMessage, disabledSend]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const inputText = firstDraft?.content ?? '';
|
|
41
|
+
const loading = sendingMessage?.loading || firstDraft?.loading;
|
|
42
|
+
|
|
43
|
+
const handleKeyDown = useCallback(
|
|
44
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
45
|
+
// Ctrl+Enter to send
|
|
46
|
+
if (e.key === 'Enter' && e.ctrlKey) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
if (!disabledSendButton) {
|
|
49
|
+
bridge.send();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[bridge, disabledSendButton]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const handleSend = useCallback(() => {
|
|
57
|
+
if (!disabledSendButton) {
|
|
58
|
+
bridge.send();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
bridge.stop();
|
|
62
|
+
}, [bridge, disabledSendButton]);
|
|
63
|
+
|
|
64
|
+
const buttonText = streaming ? tt.stop : loading ? tt.loading : tt.send;
|
|
65
|
+
|
|
66
|
+
const buttonIcon = streaming ? (
|
|
67
|
+
<StopOutlined />
|
|
68
|
+
) : loading ? null : (
|
|
69
|
+
<SendOutlined />
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
data-testid="FocusBar"
|
|
75
|
+
className="border-t border-primary p-4 bg-secondary"
|
|
76
|
+
>
|
|
77
|
+
<div data-testid="FocusBarMain" className="mb-2">
|
|
78
|
+
<TextArea
|
|
79
|
+
ref={bridge.setRef.bind(bridge)}
|
|
80
|
+
autoFocus
|
|
81
|
+
value={inputText}
|
|
82
|
+
onKeyDown={handleKeyDown}
|
|
83
|
+
onChange={(e) => bridge.onChangeContent(e.target.value)}
|
|
84
|
+
placeholder={tt.inputPlaceholder}
|
|
85
|
+
autoSize={{ minRows: 2, maxRows: 6 }}
|
|
86
|
+
className="resize-none"
|
|
87
|
+
disabled={loading && !streaming}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div data-testid="FocusBarFooter" className="flex justify-end gap-2">
|
|
92
|
+
<Button
|
|
93
|
+
type="primary"
|
|
94
|
+
icon={buttonIcon}
|
|
95
|
+
loading={loading && !streaming}
|
|
96
|
+
danger={streaming}
|
|
97
|
+
data-testid="FocusBar-Button-Send"
|
|
98
|
+
onClick={handleSend}
|
|
99
|
+
disabled={!inputText.trim() && !streaming}
|
|
100
|
+
>
|
|
101
|
+
{buttonText}
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ThreadUtil,
|
|
3
|
+
MessageStatus,
|
|
4
|
+
ChatMessageRole
|
|
5
|
+
} from '@qlover/corekit-bridge';
|
|
6
|
+
import { random } from 'lodash';
|
|
7
|
+
import type {
|
|
8
|
+
MessageStoreMsg,
|
|
9
|
+
ChatMessageStore,
|
|
10
|
+
MessageGetwayInterface,
|
|
11
|
+
GatewayOptions
|
|
12
|
+
} from '@qlover/corekit-bridge';
|
|
13
|
+
|
|
14
|
+
export class MessageApi implements MessageGetwayInterface {
|
|
15
|
+
constructor(protected messagesStore: ChatMessageStore<unknown>) {
|
|
16
|
+
this.messagesStore = messagesStore;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Send message (supports both normal and streaming modes)
|
|
21
|
+
* - If options.stream === true, use streaming mode (progressive output)
|
|
22
|
+
* - If options provided but stream !== true, use interruptible normal mode
|
|
23
|
+
* - If no options, use fast normal mode (non-interruptible)
|
|
24
|
+
*/
|
|
25
|
+
async sendMessage<M extends MessageStoreMsg<string>>(
|
|
26
|
+
message: M,
|
|
27
|
+
options?: GatewayOptions<M>
|
|
28
|
+
): Promise<M> {
|
|
29
|
+
const messageContent = message.content ?? '';
|
|
30
|
+
|
|
31
|
+
// Check if error simulation is needed
|
|
32
|
+
if (messageContent.includes('Failed') || messageContent.includes('error')) {
|
|
33
|
+
const error = new Error('Failed to send message');
|
|
34
|
+
await options?.onError?.(error);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Determine which mode to use
|
|
39
|
+
if (options?.stream === true) {
|
|
40
|
+
// Streaming mode: progressive output
|
|
41
|
+
return this.sendStreamMode(message, options);
|
|
42
|
+
} else if (options) {
|
|
43
|
+
// Interruptible normal mode: one-time return, but supports stop
|
|
44
|
+
return this.sendInterruptibleMode(message, options);
|
|
45
|
+
} else {
|
|
46
|
+
// Fast normal mode: non-interruptible
|
|
47
|
+
return this.sendNormalMode(message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Streaming send mode
|
|
53
|
+
* - Simulates character-by-character output
|
|
54
|
+
*/
|
|
55
|
+
private async sendStreamMode<M extends MessageStoreMsg<string>>(
|
|
56
|
+
message: M,
|
|
57
|
+
options: GatewayOptions<M>
|
|
58
|
+
): Promise<M> {
|
|
59
|
+
const messageContent = message.content ?? '';
|
|
60
|
+
|
|
61
|
+
// Simulate generated reply content
|
|
62
|
+
const responseText = `Hello! You sent: ${messageContent}. This is a streaming response that will be sent word by word to simulate real streaming behavior.`;
|
|
63
|
+
const words = responseText.split(' ');
|
|
64
|
+
|
|
65
|
+
// Create initial assistant message
|
|
66
|
+
const assistantMessageId =
|
|
67
|
+
ChatMessageRole.ASSISTANT + message.id + Date.now();
|
|
68
|
+
let accumulatedContent = '';
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Simulate connection establishment - call onConnected
|
|
72
|
+
await ThreadUtil.sleep(random(200, 500));
|
|
73
|
+
|
|
74
|
+
// Note: Framework will automatically intercept this call, trigger plugin system, then call user's original callback
|
|
75
|
+
await options.onConnected?.();
|
|
76
|
+
|
|
77
|
+
// Send word by word
|
|
78
|
+
for (let i = 0; i < words.length; i++) {
|
|
79
|
+
// Check if cancelled
|
|
80
|
+
if (options.signal?.aborted) {
|
|
81
|
+
throw new DOMException('Request aborted', 'AbortError');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Accumulate content
|
|
85
|
+
accumulatedContent += (i > 0 ? ' ' : '') + words[i];
|
|
86
|
+
|
|
87
|
+
// Create message object for current chunk
|
|
88
|
+
const chunkMessage = this.messagesStore.createMessage({
|
|
89
|
+
...message,
|
|
90
|
+
id: assistantMessageId,
|
|
91
|
+
role: ChatMessageRole.ASSISTANT,
|
|
92
|
+
content: accumulatedContent,
|
|
93
|
+
error: null,
|
|
94
|
+
loading: true,
|
|
95
|
+
status: MessageStatus.SENDING,
|
|
96
|
+
startTime: message.startTime,
|
|
97
|
+
endTime: 0,
|
|
98
|
+
result: null
|
|
99
|
+
}) as unknown as M;
|
|
100
|
+
|
|
101
|
+
// Call onChunk callback
|
|
102
|
+
await options.onChunk?.(chunkMessage);
|
|
103
|
+
|
|
104
|
+
// Calculate progress
|
|
105
|
+
const progress = ((i + 1) / words.length) * 100;
|
|
106
|
+
await options.onProgress?.(progress);
|
|
107
|
+
|
|
108
|
+
// Simulate network delay (random delay)
|
|
109
|
+
await ThreadUtil.sleep(random(100, 300));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Create final completed message
|
|
113
|
+
const finalMessage = this.messagesStore.createMessage({
|
|
114
|
+
...message,
|
|
115
|
+
id: assistantMessageId,
|
|
116
|
+
role: ChatMessageRole.ASSISTANT,
|
|
117
|
+
content: accumulatedContent,
|
|
118
|
+
error: null,
|
|
119
|
+
loading: false,
|
|
120
|
+
status: MessageStatus.SENT,
|
|
121
|
+
startTime: message.startTime,
|
|
122
|
+
endTime: Date.now(),
|
|
123
|
+
result: null
|
|
124
|
+
}) as unknown as M;
|
|
125
|
+
|
|
126
|
+
// Call completion callback
|
|
127
|
+
await options.onComplete?.(finalMessage);
|
|
128
|
+
|
|
129
|
+
return finalMessage;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// Check if it's a stop error
|
|
132
|
+
if (
|
|
133
|
+
error instanceof DOMException &&
|
|
134
|
+
error.name === 'AbortError' &&
|
|
135
|
+
typeof options.onAborted === 'function'
|
|
136
|
+
) {
|
|
137
|
+
// Call onAborted callback
|
|
138
|
+
// Note: The message here contains accumulated content
|
|
139
|
+
const abortedMessage = this.messagesStore.createMessage({
|
|
140
|
+
...message,
|
|
141
|
+
id: assistantMessageId,
|
|
142
|
+
role: ChatMessageRole.ASSISTANT,
|
|
143
|
+
content: accumulatedContent,
|
|
144
|
+
error: null,
|
|
145
|
+
loading: false,
|
|
146
|
+
status: MessageStatus.STOPPED,
|
|
147
|
+
startTime: message.startTime,
|
|
148
|
+
endTime: Date.now(),
|
|
149
|
+
result: null
|
|
150
|
+
}) as unknown as M;
|
|
151
|
+
|
|
152
|
+
await options.onAborted(abortedMessage);
|
|
153
|
+
} else {
|
|
154
|
+
// Other errors, call onError callback
|
|
155
|
+
await options.onError?.(error);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Interruptible normal mode
|
|
164
|
+
* - Returns complete message at once (no character-by-character output)
|
|
165
|
+
* - Supports stop control (via signal)
|
|
166
|
+
* - Supports event callbacks (onComplete, onAborted, onError)
|
|
167
|
+
*/
|
|
168
|
+
private async sendInterruptibleMode<M extends MessageStoreMsg<string>>(
|
|
169
|
+
message: M,
|
|
170
|
+
options: GatewayOptions<M>
|
|
171
|
+
): Promise<M> {
|
|
172
|
+
const messageContent = message.content ?? '';
|
|
173
|
+
|
|
174
|
+
// Simulate connection establishment - call onConnected
|
|
175
|
+
await options.onConnected?.();
|
|
176
|
+
|
|
177
|
+
// Simulate random delay
|
|
178
|
+
const times = random(200, 1000);
|
|
179
|
+
|
|
180
|
+
// Check if cancelled during delay
|
|
181
|
+
const startTime = Date.now();
|
|
182
|
+
while (Date.now() - startTime < times) {
|
|
183
|
+
if (options.signal?.aborted) {
|
|
184
|
+
// Cancelled, create stopped message
|
|
185
|
+
const abortedMessage = this.messagesStore.createMessage({
|
|
186
|
+
...message,
|
|
187
|
+
id: ChatMessageRole.ASSISTANT + message.id,
|
|
188
|
+
role: ChatMessageRole.ASSISTANT,
|
|
189
|
+
content: '', // Content not generated yet
|
|
190
|
+
error: null,
|
|
191
|
+
loading: false,
|
|
192
|
+
status: MessageStatus.STOPPED,
|
|
193
|
+
endTime: Date.now(),
|
|
194
|
+
result: null
|
|
195
|
+
}) as unknown as M;
|
|
196
|
+
|
|
197
|
+
// Call onAborted
|
|
198
|
+
await options.onAborted?.(abortedMessage);
|
|
199
|
+
throw new DOMException('Request aborted', 'AbortError');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Check every 50ms
|
|
203
|
+
await ThreadUtil.sleep(50);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check if error simulation is needed
|
|
207
|
+
if (messageContent.includes('Failed') || messageContent.includes('error')) {
|
|
208
|
+
const error = new Error('Failed to send message');
|
|
209
|
+
await options.onError?.(error);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (times % 5 === 0) {
|
|
214
|
+
const error = new Error(`Network error(${times})`);
|
|
215
|
+
await options.onError?.(error);
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const endTime = Date.now();
|
|
220
|
+
const finalMessage = this.messagesStore.createMessage({
|
|
221
|
+
...message,
|
|
222
|
+
id: ChatMessageRole.ASSISTANT + message.id,
|
|
223
|
+
role: ChatMessageRole.ASSISTANT,
|
|
224
|
+
content: `(${endTime - message.startTime}ms) Hello! You sent: ${message.content}`,
|
|
225
|
+
error: null,
|
|
226
|
+
loading: false,
|
|
227
|
+
status: MessageStatus.SENT,
|
|
228
|
+
endTime: endTime,
|
|
229
|
+
result: null
|
|
230
|
+
}) as unknown as M;
|
|
231
|
+
|
|
232
|
+
// Call onComplete
|
|
233
|
+
await options.onComplete?.(finalMessage);
|
|
234
|
+
|
|
235
|
+
return finalMessage;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Fast normal mode
|
|
240
|
+
* - Returns complete message at once
|
|
241
|
+
* - Non-interruptible (doesn't check signal)
|
|
242
|
+
*/
|
|
243
|
+
private async sendNormalMode<M extends MessageStoreMsg<string>>(
|
|
244
|
+
message: M
|
|
245
|
+
): Promise<M> {
|
|
246
|
+
const times = random(200, 1000);
|
|
247
|
+
await ThreadUtil.sleep(times);
|
|
248
|
+
|
|
249
|
+
const messageContent = message.content ?? '';
|
|
250
|
+
if (messageContent.includes('Failed') || messageContent.includes('error')) {
|
|
251
|
+
throw new Error('Failed to send message');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (times % 5 === 0) {
|
|
255
|
+
throw new Error(`Network error(${times})`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const endTime = Date.now();
|
|
259
|
+
return this.messagesStore.createMessage({
|
|
260
|
+
...message,
|
|
261
|
+
id: ChatMessageRole.ASSISTANT + message.id,
|
|
262
|
+
role: ChatMessageRole.ASSISTANT,
|
|
263
|
+
content: `(${endTime - message.startTime}ms) Hello! You sent: ${message.content}`,
|
|
264
|
+
error: null,
|
|
265
|
+
loading: false,
|
|
266
|
+
status: MessageStatus.SENT,
|
|
267
|
+
endTime: endTime,
|
|
268
|
+
result: null
|
|
269
|
+
}) as unknown as M;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ReloadOutlined } from '@ant-design/icons';
|
|
2
|
+
import { ChatMessageRole } from '@qlover/corekit-bridge';
|
|
3
|
+
import { Spin, Button } from 'antd';
|
|
4
|
+
import { clsx } from 'clsx';
|
|
5
|
+
import { useMemo } from 'react';
|
|
6
|
+
import type { ChatMessageI18nInterface } from '@config/i18n/chatMessageI18n';
|
|
7
|
+
import type {
|
|
8
|
+
ChatMessage,
|
|
9
|
+
ChatMessageBridgeInterface
|
|
10
|
+
} from '@qlover/corekit-bridge';
|
|
11
|
+
|
|
12
|
+
export interface MessageItemProps<T, MessageType extends ChatMessage<T>> {
|
|
13
|
+
message: MessageType;
|
|
14
|
+
index: number;
|
|
15
|
+
bridge?: ChatMessageBridgeInterface<T>;
|
|
16
|
+
tt: ChatMessageI18nInterface;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function MessageItem<T, MessageType extends ChatMessage<T>>({
|
|
20
|
+
message,
|
|
21
|
+
bridge,
|
|
22
|
+
index,
|
|
23
|
+
tt
|
|
24
|
+
}: MessageItemProps<T, MessageType>) {
|
|
25
|
+
const messageText = useMemo(() => {
|
|
26
|
+
return message.content as string;
|
|
27
|
+
}, [message.content]);
|
|
28
|
+
|
|
29
|
+
const duration = useMemo(() => {
|
|
30
|
+
return message.endTime ? message.endTime - message.startTime : 0;
|
|
31
|
+
}, [message.endTime, message.startTime]);
|
|
32
|
+
|
|
33
|
+
const errorMessage = useMemo(() => {
|
|
34
|
+
return message.error instanceof Error ? message.error.message : null;
|
|
35
|
+
}, [message.error]);
|
|
36
|
+
|
|
37
|
+
const isUserMessage = message.role === ChatMessageRole.USER;
|
|
38
|
+
const isAssistant = message.role === ChatMessageRole.ASSISTANT;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
data-testid="MessageItem"
|
|
43
|
+
data-message-id={message.id}
|
|
44
|
+
data-message-role={message.role}
|
|
45
|
+
data-message-index={index}
|
|
46
|
+
className={clsx('w-full flex mb-3', {
|
|
47
|
+
'justify-end': isUserMessage,
|
|
48
|
+
'justify-start': !isUserMessage
|
|
49
|
+
})}
|
|
50
|
+
>
|
|
51
|
+
<div
|
|
52
|
+
className={clsx('flex flex-col px-4 py-3 rounded-xl shadow-sm', {
|
|
53
|
+
'max-w-[80%] bg-blue-500 text-white': isUserMessage,
|
|
54
|
+
'max-w-[85%] bg-secondary border border-primary': isAssistant
|
|
55
|
+
})}
|
|
56
|
+
>
|
|
57
|
+
{/* Message content */}
|
|
58
|
+
<div className="flex items-start gap-2">
|
|
59
|
+
{isUserMessage && !message.loading && (
|
|
60
|
+
<Button
|
|
61
|
+
type="text"
|
|
62
|
+
size="small"
|
|
63
|
+
icon={<ReloadOutlined className="text-white" />}
|
|
64
|
+
disabled={message.loading}
|
|
65
|
+
onClick={() => bridge?.send(message)}
|
|
66
|
+
className="flex-shrink-0 hover:bg-blue-600"
|
|
67
|
+
title={tt.retry}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
<div
|
|
72
|
+
data-testid="MessageContent"
|
|
73
|
+
className={clsx('flex-1 whitespace-pre-wrap break-words', {
|
|
74
|
+
'text-white': isUserMessage,
|
|
75
|
+
'text-text': isAssistant
|
|
76
|
+
})}
|
|
77
|
+
>
|
|
78
|
+
{message.loading && <Spin size="small" className="mr-2" />}
|
|
79
|
+
{messageText}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Duration for user messages */}
|
|
84
|
+
{isUserMessage && duration > 0 && (
|
|
85
|
+
<div className="text-right text-xs mt-1 opacity-80">
|
|
86
|
+
{tt.duration}: {duration}ms
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{/* Error message */}
|
|
91
|
+
{errorMessage && (
|
|
92
|
+
<div
|
|
93
|
+
data-testid="MessageError"
|
|
94
|
+
className="mt-2 text-sm p-2 bg-red-100 text-red-700 rounded border border-red-200"
|
|
95
|
+
>
|
|
96
|
+
❌ {errorMessage}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|