@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/configs/_common/.github/workflows/general-check.yml +1 -1
  3. package/dist/configs/_common/.github/workflows/release.yml +2 -2
  4. package/dist/index.cjs +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/templates/next-app/.env.template +9 -10
  7. package/dist/templates/next-app/eslint.config.mjs +5 -0
  8. package/dist/templates/next-app/next.config.ts +1 -1
  9. package/dist/templates/next-app/src/app/[locale]/login/page.tsx +1 -1
  10. package/dist/templates/next-app/src/app/[locale]/page.tsx +1 -1
  11. package/dist/templates/next-app/src/app/[locale]/register/page.tsx +1 -1
  12. package/dist/templates/next-app/src/app/api/locales/json/route.ts +2 -1
  13. package/dist/templates/next-app/src/i18n/request.ts +2 -2
  14. package/dist/templates/react-app/__tests__/__mocks__/{MockAppConfit.ts → MockAppConfig.ts} +1 -1
  15. package/dist/templates/react-app/__tests__/__mocks__/components/TestApp.tsx +10 -17
  16. package/dist/templates/react-app/__tests__/__mocks__/components/TestBootstrapsProvider.tsx +27 -8
  17. package/dist/templates/react-app/__tests__/__mocks__/createMockGlobals.ts +1 -1
  18. package/dist/templates/react-app/__tests__/__mocks__/i18nextHttpBackend.ts +110 -0
  19. package/dist/templates/react-app/__tests__/__mocks__/testIOC/TestIOCRegister.ts +3 -2
  20. package/dist/templates/react-app/__tests__/setup/setupGlobal.ts +13 -0
  21. package/dist/templates/react-app/__tests__/src/base/services/I18nService.test.ts +3 -1
  22. package/dist/templates/react-app/__tests__/src/uikit/components/chatMessage/ChatRoot.test.tsx +274 -0
  23. package/dist/templates/react-app/config/IOCIdentifier.ts +9 -3
  24. package/dist/templates/react-app/config/Identifier/components/component.chatMessage.ts +56 -0
  25. package/dist/templates/react-app/config/Identifier/components/component.messageBaseList.ts +103 -0
  26. package/dist/templates/react-app/config/Identifier/pages/index.ts +1 -0
  27. package/dist/templates/react-app/config/Identifier/pages/page.message.ts +20 -0
  28. package/dist/templates/react-app/config/app.router.ts +23 -0
  29. package/dist/templates/react-app/config/common.ts +38 -0
  30. package/dist/templates/react-app/config/feapi.mock.json +5 -12
  31. package/dist/templates/react-app/config/i18n/chatMessageI18n.ts +17 -0
  32. package/dist/templates/react-app/config/i18n/messageBaseListI18n.ts +22 -0
  33. package/dist/templates/react-app/config/i18n/messageI18n.ts +14 -0
  34. package/dist/templates/react-app/docs/en/components/chat-message-component.md +314 -0
  35. package/dist/templates/react-app/docs/en/components/chat-message-refactor.md +270 -0
  36. package/dist/templates/react-app/docs/en/components/message-base-list-component.md +172 -0
  37. package/dist/templates/react-app/docs/zh/components/chat-message-component.md +314 -0
  38. package/dist/templates/react-app/docs/zh/components/chat-message-refactor.md +270 -0
  39. package/dist/templates/react-app/docs/zh/components/message-base-list-component.md +172 -0
  40. package/dist/templates/react-app/eslint.config.mjs +6 -5
  41. package/dist/templates/react-app/package.json +1 -1
  42. package/dist/templates/react-app/playwright.config.ts +6 -6
  43. package/dist/templates/react-app/public/locales/en/common.json +44 -1
  44. package/dist/templates/react-app/public/locales/zh/common.json +44 -1
  45. package/dist/templates/react-app/src/base/apis/userApi/UserApi.ts +22 -13
  46. package/dist/templates/react-app/src/base/apis/userApi/UserApiBootstarp.ts +3 -3
  47. package/dist/templates/react-app/src/base/apis/userApi/UserApiType.ts +17 -12
  48. package/dist/templates/react-app/src/base/cases/I18nKeyErrorPlugin.ts +19 -2
  49. package/dist/templates/react-app/src/base/port/RouteServiceInterface.ts +2 -4
  50. package/dist/templates/react-app/src/base/port/UserServiceInterface.ts +15 -9
  51. package/dist/templates/react-app/src/base/services/BaseLayoutService.ts +55 -0
  52. package/dist/templates/react-app/src/base/services/I18nService.ts +1 -0
  53. package/dist/templates/react-app/src/base/services/UserBootstrap.ts +43 -0
  54. package/dist/templates/react-app/src/base/services/UserGatewayPlugin.ts +16 -0
  55. package/dist/templates/react-app/src/base/services/UserService.ts +51 -80
  56. package/dist/templates/react-app/src/core/bootstraps/BootstrapClient.ts +8 -3
  57. package/dist/templates/react-app/src/core/bootstraps/BootstrapsRegistry.ts +6 -6
  58. package/dist/templates/react-app/src/core/bootstraps/SaveAppInfo.ts +28 -0
  59. package/dist/templates/react-app/src/core/clientIoc/ClientIOCRegister.ts +24 -18
  60. package/dist/templates/react-app/src/core/globals.ts +10 -11
  61. package/dist/templates/react-app/src/main.tsx +1 -1
  62. package/dist/templates/react-app/src/pages/auth/Layout.tsx +4 -4
  63. package/dist/templates/react-app/src/pages/auth/RegisterPage.tsx +1 -1
  64. package/dist/templates/react-app/src/pages/base/Layout.tsx +3 -3
  65. package/dist/templates/react-app/src/pages/base/MessagePage.tsx +40 -0
  66. package/dist/templates/react-app/src/uikit/components/BaseLayoutProvider.tsx +44 -0
  67. package/dist/templates/react-app/src/uikit/components/LogoutButton.tsx +1 -3
  68. package/dist/templates/react-app/src/uikit/components/MessageBaseList.tsx +240 -0
  69. package/dist/templates/react-app/src/uikit/components/chatMessage/ChatMessageBridge.ts +176 -0
  70. package/dist/templates/react-app/src/uikit/components/chatMessage/ChatRoot.tsx +21 -0
  71. package/dist/templates/react-app/src/uikit/components/chatMessage/FocusBar.tsx +106 -0
  72. package/dist/templates/react-app/src/uikit/components/chatMessage/MessageApi.ts +271 -0
  73. package/dist/templates/react-app/src/uikit/components/chatMessage/MessageItem.tsx +102 -0
  74. package/dist/templates/react-app/src/uikit/components/chatMessage/MessagesList.tsx +86 -0
  75. package/dist/templates/react-app/src/uikit/hooks/useNavigateBridge.ts +9 -0
  76. package/dist/templates/react-app/src/uikit/hooks/{useI18nGuard.ts → useRouterI18nGuard.ts} +7 -4
  77. package/dist/templates/react-app/tsconfig.app.json +4 -2
  78. package/dist/templates/react-app/tsconfig.node.json +4 -0
  79. package/dist/templates/react-app/tsconfig.test.json +3 -1
  80. package/package.json +3 -3
  81. package/dist/templates/react-app/__tests__/src/base/cases/AppError.test.ts +0 -102
  82. package/dist/templates/react-app/__tests__/src/main.integration.test.tsx +0 -61
  83. package/dist/templates/react-app/src/base/services/ProcesserExecutor.ts +0 -57
  84. package/dist/templates/react-app/src/uikit/components/ProcessExecutorProvider.tsx +0 -28
  85. 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
+ }