@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.
- package/CHANGELOG.md +17 -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/react-app/__tests__/src/uikit/components/chatMessage/ChatRoot.test.tsx +274 -0
- package/dist/templates/react-app/config/IOCIdentifier.ts +3 -0
- 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/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/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/pages/base/MessagePage.tsx +40 -0
- 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/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
|
+
}
|