@qlover/create-app 0.8.0 → 0.9.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 (33) hide show
  1. package/CHANGELOG.md +17 -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/react-app/__tests__/src/uikit/components/chatMessage/ChatRoot.test.tsx +274 -0
  7. package/dist/templates/react-app/config/IOCIdentifier.ts +3 -0
  8. package/dist/templates/react-app/config/Identifier/components/component.chatMessage.ts +56 -0
  9. package/dist/templates/react-app/config/Identifier/components/component.messageBaseList.ts +103 -0
  10. package/dist/templates/react-app/config/Identifier/pages/index.ts +1 -0
  11. package/dist/templates/react-app/config/Identifier/pages/page.message.ts +20 -0
  12. package/dist/templates/react-app/config/app.router.ts +23 -0
  13. package/dist/templates/react-app/config/i18n/chatMessageI18n.ts +17 -0
  14. package/dist/templates/react-app/config/i18n/messageBaseListI18n.ts +22 -0
  15. package/dist/templates/react-app/config/i18n/messageI18n.ts +14 -0
  16. package/dist/templates/react-app/docs/en/components/chat-message-component.md +314 -0
  17. package/dist/templates/react-app/docs/en/components/chat-message-refactor.md +270 -0
  18. package/dist/templates/react-app/docs/en/components/message-base-list-component.md +172 -0
  19. package/dist/templates/react-app/docs/zh/components/chat-message-component.md +314 -0
  20. package/dist/templates/react-app/docs/zh/components/chat-message-refactor.md +270 -0
  21. package/dist/templates/react-app/docs/zh/components/message-base-list-component.md +172 -0
  22. package/dist/templates/react-app/playwright.config.ts +6 -6
  23. package/dist/templates/react-app/public/locales/en/common.json +44 -1
  24. package/dist/templates/react-app/public/locales/zh/common.json +44 -1
  25. package/dist/templates/react-app/src/pages/base/MessagePage.tsx +40 -0
  26. package/dist/templates/react-app/src/uikit/components/MessageBaseList.tsx +240 -0
  27. package/dist/templates/react-app/src/uikit/components/chatMessage/ChatMessageBridge.ts +176 -0
  28. package/dist/templates/react-app/src/uikit/components/chatMessage/ChatRoot.tsx +21 -0
  29. package/dist/templates/react-app/src/uikit/components/chatMessage/FocusBar.tsx +106 -0
  30. package/dist/templates/react-app/src/uikit/components/chatMessage/MessageApi.ts +271 -0
  31. package/dist/templates/react-app/src/uikit/components/chatMessage/MessageItem.tsx +102 -0
  32. package/dist/templates/react-app/src/uikit/components/chatMessage/MessagesList.tsx +86 -0
  33. package/package.json +1 -1
@@ -0,0 +1,240 @@
1
+ import { useFactory, useStore } from '@brain-toolkit/react-kit';
2
+ import { messageBaseListI18n } from '@config/i18n/messageBaseListI18n';
3
+ import { I } from '@config/IOCIdentifier';
4
+ import {
5
+ MessageSender,
6
+ MessagesStore,
7
+ MessageStatus,
8
+ SenderStrategyPlugin,
9
+ SendFailureStrategy,
10
+ ThreadUtil
11
+ } from '@qlover/corekit-bridge';
12
+ import { Button, Input } from 'antd';
13
+ import { random } from 'lodash';
14
+ import { useCallback, useMemo, useState } from 'react';
15
+ import { useI18nInterface } from '../hooks/useI18nInterface';
16
+ import { useIOC } from '../hooks/useIOC';
17
+ import type {
18
+ MessageGetwayInterface,
19
+ MessagesStateInterface,
20
+ MessageStoreMsg
21
+ } from '@qlover/corekit-bridge';
22
+
23
+ interface MessageBaseMsg extends MessageStoreMsg<string, unknown> {}
24
+
25
+ function createMessagesState(): MessagesStateInterface<MessageBaseMsg> {
26
+ return {
27
+ messages: []
28
+ };
29
+ }
30
+
31
+ class MessageBaseApi implements MessageGetwayInterface {
32
+ async sendMessage<M extends MessageStoreMsg<string>>(
33
+ message: M
34
+ ): Promise<unknown> {
35
+ const times = random(200, 1000);
36
+
37
+ await ThreadUtil.sleep(times);
38
+
39
+ const messageContent = message.content ?? '';
40
+ if (messageContent.includes('Failed') || messageContent.includes('error')) {
41
+ throw new Error('Failed to send message');
42
+ }
43
+
44
+ if (times % 5 === 0) {
45
+ throw new Error(`Network error(${times})`);
46
+ }
47
+
48
+ // Return object response to demonstrate formatting
49
+ return {
50
+ status: 'success',
51
+ timestamp: new Date().toISOString(),
52
+ delay: `${times}ms`,
53
+ echo: message.content,
54
+ data: {
55
+ message: 'Message received successfully',
56
+ processed: true,
57
+ metadata: {
58
+ length: message.content?.length || 0,
59
+ type: 'text'
60
+ }
61
+ }
62
+ };
63
+ }
64
+ }
65
+
66
+ export function MessageBaseList() {
67
+ const [inputValue, setInputValue] = useState('');
68
+ const logger = useIOC(I.Logger);
69
+ const tt = useI18nInterface(messageBaseListI18n);
70
+ const messagesStore = useFactory(
71
+ MessagesStore<MessageBaseMsg>,
72
+ createMessagesState
73
+ );
74
+ const messageBaseApi = useFactory(MessageBaseApi);
75
+
76
+ const [messagesSender] = useState(() =>
77
+ new MessageSender<MessageBaseMsg>(messagesStore, {
78
+ gateway: messageBaseApi,
79
+ logger
80
+ }).use(new SenderStrategyPlugin(SendFailureStrategy.KEEP_FAILED))
81
+ );
82
+
83
+ const messages = useStore(messagesStore, (state) => state.messages);
84
+
85
+ const loadingMessage = useMemo(() => {
86
+ return messages.find((message) => message.loading);
87
+ }, [messages]);
88
+
89
+ const onSend = useCallback(() => {
90
+ messagesSender.send({
91
+ content: inputValue
92
+ });
93
+ setInputValue('');
94
+ }, [inputValue, messagesSender]);
95
+
96
+ /**
97
+ * Render message result with proper formatting
98
+ * - For objects/arrays: display as formatted JSON
99
+ * - For strings: display as plain text
100
+ */
101
+ const renderResult = (result: unknown) => {
102
+ if (!result) return null;
103
+
104
+ // Check if result is an object or array
105
+ if (typeof result === 'object') {
106
+ try {
107
+ const jsonString = JSON.stringify(result, null, 2);
108
+ return (
109
+ <pre
110
+ data-testid="MessageResultJson"
111
+ className="text-xs font-mono bg-green-100 p-2 rounded mt-1 overflow-x-auto whitespace-pre-wrap"
112
+ >
113
+ {jsonString}
114
+ </pre>
115
+ );
116
+ } catch {
117
+ return String(result);
118
+ }
119
+ }
120
+
121
+ // For strings and other primitives
122
+ return <span data-testid="MessageResultText">{String(result)}</span>;
123
+ };
124
+
125
+ return (
126
+ <div data-testid="MessageBaseList" className="max-w-4xl mx-auto">
127
+ {/* Page Title */}
128
+ <div className="mb-6">
129
+ <h1 className="text-2xl font-bold text-text mb-2">{tt.title}</h1>
130
+ <p className="text-sm text-text-secondary">{tt.description}</p>
131
+ </div>
132
+
133
+ {/* Messages Container */}
134
+ <div className="bg-secondary rounded-lg shadow-sm border border-primary mb-4">
135
+ <div
136
+ data-testid="MessageBaseListItems"
137
+ className="flex flex-col gap-3 p-4 max-h-96 min-h-64 overflow-y-auto"
138
+ >
139
+ {messages.length === 0 ? (
140
+ <div className="flex items-center justify-center h-full text-text-secondary">
141
+ <div className="text-center">
142
+ <p className="text-base mb-1">{tt.noMessages}</p>
143
+ <p className="text-sm">{tt.getStarted}</p>
144
+ </div>
145
+ </div>
146
+ ) : (
147
+ messages.map((message) => (
148
+ <div
149
+ key={message.id}
150
+ data-testid="MessageBaseListItem"
151
+ className="space-y-2"
152
+ >
153
+ {/* User Message */}
154
+ <div className="flex justify-end">
155
+ <div className="max-w-[70%] bg-blue-500 text-white rounded-lg px-4 py-2 shadow-sm">
156
+ <div className="text-sm font-medium mb-1">{tt.user}</div>
157
+ <div className="wrap-break-word">{message.content}</div>
158
+ </div>
159
+ </div>
160
+
161
+ {/* Gateway Response */}
162
+ <div className="flex justify-start">
163
+ <div className="max-w-[70%]">
164
+ {message.loading ? (
165
+ <div className="bg-base rounded-lg px-4 py-2 shadow-sm border border-primary">
166
+ <div className="text-sm font-medium text-text-secondary mb-1">
167
+ {tt.gateway}
168
+ </div>
169
+ <div className="flex items-center gap-2 text-text-secondary">
170
+ <div className="flex gap-1">
171
+ <span
172
+ className="animate-bounce inline-block w-1.5 h-1.5 bg-text-secondary rounded-full"
173
+ style={{ animationDelay: '0ms' }}
174
+ ></span>
175
+ <span
176
+ className="animate-bounce inline-block w-1.5 h-1.5 bg-text-secondary rounded-full"
177
+ style={{ animationDelay: '150ms' }}
178
+ ></span>
179
+ <span
180
+ className="animate-bounce inline-block w-1.5 h-1.5 bg-text-secondary rounded-full"
181
+ style={{ animationDelay: '300ms' }}
182
+ ></span>
183
+ </div>
184
+ <span className="text-sm">{tt.processing}</span>
185
+ </div>
186
+ </div>
187
+ ) : message.status === MessageStatus.FAILED ? (
188
+ <div className="bg-red-50 border border-red-200 rounded-lg px-4 py-2 shadow-sm">
189
+ <div className="text-sm font-medium text-red-800 mb-1">
190
+ {tt.gatewayFailed}
191
+ </div>
192
+ <div className="text-red-600 text-sm">
193
+ ❌ {(message as any).error?.message || tt.sendFailed}
194
+ </div>
195
+ </div>
196
+ ) : message.result ? (
197
+ <div className="bg-green-50 border border-green-200 rounded-lg px-4 py-2 shadow-sm">
198
+ <div className="text-sm font-medium text-green-800 mb-1">
199
+ ✓ {tt.gatewayResponse}
200
+ </div>
201
+ <div className="text-green-900">
202
+ {renderResult(message.result)}
203
+ </div>
204
+ </div>
205
+ ) : null}
206
+ </div>
207
+ </div>
208
+ </div>
209
+ ))
210
+ )}
211
+ </div>
212
+ </div>
213
+
214
+ {/* Input Area */}
215
+ <div className="bg-secondary rounded-lg shadow-sm border border-primary p-4">
216
+ <div className="flex gap-2">
217
+ <Input
218
+ value={inputValue}
219
+ onPressEnter={onSend}
220
+ onChange={(e) => setInputValue(e.target.value)}
221
+ placeholder={tt.inputPlaceholder}
222
+ size="large"
223
+ className="flex-1"
224
+ disabled={loadingMessage?.loading}
225
+ />
226
+ <Button
227
+ disabled={!inputValue || loadingMessage?.loading}
228
+ loading={loadingMessage?.loading}
229
+ type="primary"
230
+ size="large"
231
+ onClick={onSend}
232
+ >
233
+ {tt.sendButton}
234
+ </Button>
235
+ </div>
236
+ <div className="mt-2 text-xs text-text-secondary">{tt.errorTip}</div>
237
+ </div>
238
+ </div>
239
+ );
240
+ }
@@ -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
+ }