@truedat/ai 8.5.4 → 8.5.7
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/package.json +3 -3
- package/src/api.js +2 -0
- package/src/components/AiRoutes.js +3 -0
- package/src/components/ConceptSuggestions.js +128 -0
- package/src/components/SuggestLinkButton.js +60 -0
- package/src/components/__tests__/ConceptSuggestions.spec.js +268 -0
- package/src/components/__tests__/SuggestLinkButton.spec.js +40 -0
- package/src/components/assistant/Assistant.js +186 -78
- package/src/components/assistant/AssistantChat.js +91 -30
- package/src/components/assistant/AssistantConversations.js +78 -0
- package/src/components/assistant/AssistantPage.js +139 -0
- package/src/components/assistant/AssistantPageChat.js +447 -0
- package/src/components/assistant/__tests__/Assistant.spec.js +15 -0
- package/src/components/assistant/__tests__/AssistantChat.spec.js +18 -0
- package/src/components/assistant/__tests__/AssistantConversations.spec.js +111 -0
- package/src/components/assistant/__tests__/__snapshots__/Assistant.spec.js.snap +5 -5
- package/src/components/assistant/__tests__/__snapshots__/AssistantChat.spec.js.snap +18 -8
- package/src/components/assistant/__tests__/__snapshots__/AssistantConversations.spec.js.snap +73 -0
- package/src/components/assistant/hooks/useAgentConversation.js +9 -3
- package/src/components/assistant/hooks/useAssistantSocket.js +36 -3
- package/src/components/index.js +2 -1
- package/src/hooks/__tests__/useAgentConversations.spec.js +83 -0
- package/src/hooks/useAgentConversations.js +15 -0
- package/src/styles/assistant.less +281 -121
- package/src/components/assistant/AssistantBubble.js +0 -56
- package/src/components/assistant/__tests__/AssistantBubble.spec.js +0 -14
- package/src/components/assistant/__tests__/__snapshots__/AssistantBubble.spec.js.snap +0 -17
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { useLocation } from "react-router";
|
|
3
|
+
import { FormattedMessage } from "react-intl";
|
|
4
|
+
import { Icon } from "semantic-ui-react";
|
|
5
|
+
import { useWebContext } from "@truedat/core/webContext";
|
|
6
|
+
|
|
7
|
+
import useAssistantSocket from "./hooks/useAssistantSocket";
|
|
8
|
+
import useAgentConversation from "./hooks/useAgentConversation";
|
|
9
|
+
import useAgentConversations from "../../hooks/useAgentConversations";
|
|
10
|
+
import AssistantPageChat from "./AssistantPageChat";
|
|
11
|
+
import AssistantConversations from "./AssistantConversations";
|
|
12
|
+
|
|
13
|
+
const EMPTY_CONVERSATION = [];
|
|
14
|
+
|
|
15
|
+
const historyToConversation = (messages) =>
|
|
16
|
+
(messages || [])
|
|
17
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
18
|
+
.map((m) =>
|
|
19
|
+
m.role === "user"
|
|
20
|
+
? { eventType: "request", side: "user", content: m.message }
|
|
21
|
+
: {
|
|
22
|
+
eventType: "response",
|
|
23
|
+
side: "agent",
|
|
24
|
+
content: m.message,
|
|
25
|
+
streaming: false,
|
|
26
|
+
source: "history",
|
|
27
|
+
priority: 0,
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const AssistantPage = () => {
|
|
32
|
+
const {
|
|
33
|
+
disable_td_ai: disableTdAi = false,
|
|
34
|
+
assistant: {
|
|
35
|
+
name: assistantName = null,
|
|
36
|
+
welcome_message: welcomeMessage = null,
|
|
37
|
+
exact = false,
|
|
38
|
+
} = {},
|
|
39
|
+
} = useWebContext();
|
|
40
|
+
const { state: locationState } = useLocation();
|
|
41
|
+
|
|
42
|
+
const { conversationId, startNewConversation, selectConversation } =
|
|
43
|
+
useAgentConversation(true, locationState?.conversationId ?? null);
|
|
44
|
+
const { conversations, refresh: refreshConversations } =
|
|
45
|
+
useAgentConversations();
|
|
46
|
+
const [conversation, setConversation] = useState(EMPTY_CONVERSATION);
|
|
47
|
+
|
|
48
|
+
const prevConversationIdRef = useRef(null);
|
|
49
|
+
const serverMessageHandlerRef = useRef(null);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (conversationId !== prevConversationIdRef.current) {
|
|
53
|
+
prevConversationIdRef.current = conversationId;
|
|
54
|
+
if (conversationId != null) {
|
|
55
|
+
setConversation(EMPTY_CONVERSATION);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}, [conversationId]);
|
|
59
|
+
|
|
60
|
+
const onChannelJoined = useCallback(() => {}, []);
|
|
61
|
+
|
|
62
|
+
const handleServerMessage = useCallback(
|
|
63
|
+
(event, payload) => {
|
|
64
|
+
if (event === "history") {
|
|
65
|
+
setConversation(historyToConversation(payload.messages));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (event === "title") {
|
|
69
|
+
refreshConversations();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
serverMessageHandlerRef.current?.(event, payload);
|
|
73
|
+
},
|
|
74
|
+
[setConversation, refreshConversations]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const handleSelectConversation = useCallback(
|
|
78
|
+
(id) => {
|
|
79
|
+
selectConversation(id);
|
|
80
|
+
},
|
|
81
|
+
[selectConversation]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const handleNewConversation = useCallback(() => {
|
|
85
|
+
startNewConversation();
|
|
86
|
+
refreshConversations();
|
|
87
|
+
}, [startNewConversation, refreshConversations]);
|
|
88
|
+
|
|
89
|
+
const handleContinueInPanel = useCallback(() => {
|
|
90
|
+
if (!conversationId) return;
|
|
91
|
+
window.dispatchEvent(
|
|
92
|
+
new CustomEvent("assistant:open", { detail: { conversationId } })
|
|
93
|
+
);
|
|
94
|
+
}, [conversationId]);
|
|
95
|
+
|
|
96
|
+
const { socketReady, sendPrompt } = useAssistantSocket(disableTdAi, {
|
|
97
|
+
conversationId,
|
|
98
|
+
onChannelJoined,
|
|
99
|
+
onServerMessage: handleServerMessage,
|
|
100
|
+
assistantName,
|
|
101
|
+
welcomeMessage,
|
|
102
|
+
exact,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!socketReady) return null;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="ai-assistant-page">
|
|
109
|
+
<AssistantConversations
|
|
110
|
+
conversations={conversations}
|
|
111
|
+
activeId={conversationId}
|
|
112
|
+
onSelect={handleSelectConversation}
|
|
113
|
+
onNewConversation={handleNewConversation}
|
|
114
|
+
/>
|
|
115
|
+
<div className="ai-assistant-main">
|
|
116
|
+
{conversationId && (
|
|
117
|
+
<div className="ai-assistant-main__toolbar">
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
className="ai-assistant-main__continue"
|
|
121
|
+
onClick={handleContinueInPanel}
|
|
122
|
+
>
|
|
123
|
+
<Icon name="window restore outline" />
|
|
124
|
+
<FormattedMessage id="assistant.actions.continueInPanel" />
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
<AssistantPageChat
|
|
129
|
+
conversation={conversation}
|
|
130
|
+
setConversation={setConversation}
|
|
131
|
+
onSendMessage={sendPrompt}
|
|
132
|
+
serverMessageHandlerRef={serverMessageHandlerRef}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export default AssistantPage;
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
} from "react";
|
|
9
|
+
import PropTypes from "prop-types";
|
|
10
|
+
import { FormattedMessage, useIntl } from "react-intl";
|
|
11
|
+
import { Icon } from "semantic-ui-react";
|
|
12
|
+
import { MarkdownReader } from "@truedat/core/components";
|
|
13
|
+
import ThinkingOutLoud, { getSourceLabel } from "../ThinkingOutLoud";
|
|
14
|
+
|
|
15
|
+
const SCROLL_AT_BOTTOM_THRESHOLD = 50;
|
|
16
|
+
const SCROLL_OVERFLOW_EPSILON = 4;
|
|
17
|
+
const MAX_INPUT_LINES = 10;
|
|
18
|
+
|
|
19
|
+
function getVisibleMessages(conversation) {
|
|
20
|
+
if (!conversation || conversation.length === 0) return [];
|
|
21
|
+
return conversation.map((item, idx) => ({ ...item, index: idx }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getContentFromPayload(event, payload) {
|
|
25
|
+
const priority = payload?.priority ?? null;
|
|
26
|
+
const source = payload?.source ?? null;
|
|
27
|
+
if (!payload || typeof payload !== "object")
|
|
28
|
+
return { content: "", isError: false, priority, source };
|
|
29
|
+
switch (event) {
|
|
30
|
+
case "stream": {
|
|
31
|
+
const c = payload.content;
|
|
32
|
+
const streamChunk =
|
|
33
|
+
typeof c === "string"
|
|
34
|
+
? c
|
|
35
|
+
: c && typeof c === "object" && typeof c.message === "string"
|
|
36
|
+
? c.message
|
|
37
|
+
: "";
|
|
38
|
+
return { content: streamChunk, isError: false, priority, source };
|
|
39
|
+
}
|
|
40
|
+
case "error": {
|
|
41
|
+
const err = payload.error;
|
|
42
|
+
const errMsg =
|
|
43
|
+
err && typeof err === "object"
|
|
44
|
+
? err.message ?? err.reason ?? JSON.stringify(err)
|
|
45
|
+
: String(err ?? "");
|
|
46
|
+
return { content: errMsg, isError: true, priority, source };
|
|
47
|
+
}
|
|
48
|
+
case "log": {
|
|
49
|
+
const log = payload.content;
|
|
50
|
+
const logMsg =
|
|
51
|
+
log && typeof log === "object"
|
|
52
|
+
? log.message ?? log.level ?? JSON.stringify(log)
|
|
53
|
+
: String(log ?? "");
|
|
54
|
+
return { content: logMsg, isError: false, priority, source };
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
return { content: "", isError: false, priority, source };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function appendStreamChunk(prev, source, chunk, priority) {
|
|
62
|
+
const last = prev[prev.length - 1];
|
|
63
|
+
const sameSource =
|
|
64
|
+
last?.eventType === "response" &&
|
|
65
|
+
last?.streaming &&
|
|
66
|
+
last?.source === source;
|
|
67
|
+
if (sameSource) {
|
|
68
|
+
return [...prev.slice(0, -1), { ...last, content: last.content + chunk }];
|
|
69
|
+
}
|
|
70
|
+
return [
|
|
71
|
+
...prev,
|
|
72
|
+
{
|
|
73
|
+
eventType: "response",
|
|
74
|
+
side: "agent",
|
|
75
|
+
content: chunk || " ",
|
|
76
|
+
streaming: true,
|
|
77
|
+
source,
|
|
78
|
+
priority,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isThinkingStreamMessage(msg) {
|
|
84
|
+
return (
|
|
85
|
+
msg.eventType === "log" ||
|
|
86
|
+
(msg.eventType === "error" && Number(msg.priority) === 2)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildRenderItems(messages) {
|
|
91
|
+
const result = [];
|
|
92
|
+
let logBuffer = [];
|
|
93
|
+
let startIdx = null;
|
|
94
|
+
messages.forEach((msg) => {
|
|
95
|
+
if (isThinkingStreamMessage(msg)) {
|
|
96
|
+
if (startIdx === null) startIdx = msg.index;
|
|
97
|
+
logBuffer.push(msg);
|
|
98
|
+
} else {
|
|
99
|
+
if (logBuffer.length > 0) {
|
|
100
|
+
const isFollowedByP0 =
|
|
101
|
+
msg.eventType === "response" && msg.priority === 0;
|
|
102
|
+
result.push({
|
|
103
|
+
type: "thinking-block",
|
|
104
|
+
items: logBuffer,
|
|
105
|
+
active: false,
|
|
106
|
+
collapsed: isFollowedByP0,
|
|
107
|
+
key: `thinking-${startIdx}`,
|
|
108
|
+
});
|
|
109
|
+
logBuffer = [];
|
|
110
|
+
startIdx = null;
|
|
111
|
+
}
|
|
112
|
+
result.push({
|
|
113
|
+
type: "message",
|
|
114
|
+
msg,
|
|
115
|
+
key: `${msg.eventType}-${msg.index}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
if (logBuffer.length > 0) {
|
|
120
|
+
result.push({
|
|
121
|
+
type: "thinking-block",
|
|
122
|
+
items: logBuffer,
|
|
123
|
+
active: true,
|
|
124
|
+
collapsed: false,
|
|
125
|
+
key: `thinking-${startIdx}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const AssistantPageChat = ({
|
|
132
|
+
conversation,
|
|
133
|
+
setConversation,
|
|
134
|
+
onSendMessage,
|
|
135
|
+
serverMessageHandlerRef,
|
|
136
|
+
}) => {
|
|
137
|
+
const { formatMessage } = useIntl();
|
|
138
|
+
const [inputValue, setInputValue] = useState("");
|
|
139
|
+
const [inputExpanded, setInputExpanded] = useState(false);
|
|
140
|
+
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
|
141
|
+
const messagesRef = useRef(null);
|
|
142
|
+
const messagesEndRef = useRef(null);
|
|
143
|
+
const inputRef = useRef(null);
|
|
144
|
+
const scrollToBottomAfterSendRef = useRef(false);
|
|
145
|
+
|
|
146
|
+
const syncTextareaHeight = useCallback(() => {
|
|
147
|
+
const el = inputRef.current;
|
|
148
|
+
if (!el) return;
|
|
149
|
+
const style = window.getComputedStyle(el);
|
|
150
|
+
const lh = parseFloat(style.lineHeight) || 20;
|
|
151
|
+
const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
|
152
|
+
const maxHeight = lh * MAX_INPUT_LINES + padY;
|
|
153
|
+
el.style.overflowY = "hidden";
|
|
154
|
+
el.style.height = "auto";
|
|
155
|
+
const h = Math.min(el.scrollHeight, maxHeight);
|
|
156
|
+
el.style.height = `${h}px`;
|
|
157
|
+
el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
158
|
+
setInputExpanded(h > lh + padY + 2);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
useLayoutEffect(() => {
|
|
162
|
+
syncTextareaHeight();
|
|
163
|
+
}, [inputValue, syncTextareaHeight]);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const ref = serverMessageHandlerRef;
|
|
167
|
+
if (!ref) return;
|
|
168
|
+
ref.current = (event, payload) => {
|
|
169
|
+
const { content, isError, priority, source } = getContentFromPayload(
|
|
170
|
+
event,
|
|
171
|
+
payload
|
|
172
|
+
);
|
|
173
|
+
if (event === "stream") {
|
|
174
|
+
requestAnimationFrame(() => {
|
|
175
|
+
setConversation((prev) =>
|
|
176
|
+
appendStreamChunk(prev, source, content, priority)
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
} else if (event === "log") {
|
|
180
|
+
setConversation((prev) => [
|
|
181
|
+
...prev,
|
|
182
|
+
{
|
|
183
|
+
eventType: "log",
|
|
184
|
+
side: "agent",
|
|
185
|
+
content: content || " ",
|
|
186
|
+
priority,
|
|
187
|
+
source,
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
} else if (event === "error" && Number(priority) === 2) {
|
|
191
|
+
setConversation((prev) => [
|
|
192
|
+
...prev,
|
|
193
|
+
{
|
|
194
|
+
eventType: "error",
|
|
195
|
+
side: "agent",
|
|
196
|
+
content: content || " ",
|
|
197
|
+
priority: 2,
|
|
198
|
+
source,
|
|
199
|
+
isError: true,
|
|
200
|
+
},
|
|
201
|
+
]);
|
|
202
|
+
} else {
|
|
203
|
+
setConversation((prev) => [
|
|
204
|
+
...prev,
|
|
205
|
+
{
|
|
206
|
+
eventType: "response",
|
|
207
|
+
side: "agent",
|
|
208
|
+
content: content || "Error",
|
|
209
|
+
isError: true,
|
|
210
|
+
priority,
|
|
211
|
+
source,
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
scrollToBottomAfterSendRef.current = true;
|
|
216
|
+
};
|
|
217
|
+
return () => {
|
|
218
|
+
ref.current = null;
|
|
219
|
+
};
|
|
220
|
+
}, [serverMessageHandlerRef, setConversation]);
|
|
221
|
+
|
|
222
|
+
const visibleMessages = useMemo(
|
|
223
|
+
() => getVisibleMessages(conversation),
|
|
224
|
+
[conversation]
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const renderItems = useMemo(
|
|
228
|
+
() => buildRenderItems(visibleMessages),
|
|
229
|
+
[visibleMessages]
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const canSendMessages = useMemo(
|
|
233
|
+
() => conversation.some((m) => m.side === "agent"),
|
|
234
|
+
[conversation]
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const handleSubmit = useCallback(
|
|
238
|
+
(e) => {
|
|
239
|
+
e?.preventDefault?.();
|
|
240
|
+
if (!canSendMessages) return;
|
|
241
|
+
const text = inputValue.trim();
|
|
242
|
+
if (!text) return;
|
|
243
|
+
scrollToBottomAfterSendRef.current = true;
|
|
244
|
+
setConversation((prev) => [
|
|
245
|
+
...prev,
|
|
246
|
+
{ eventType: "request", side: "user", content: text },
|
|
247
|
+
]);
|
|
248
|
+
setInputValue("");
|
|
249
|
+
if (typeof onSendMessage === "function") onSendMessage(text);
|
|
250
|
+
},
|
|
251
|
+
[inputValue, onSendMessage, canSendMessages, setConversation]
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const handleKeyDown = useCallback(
|
|
255
|
+
(e) => {
|
|
256
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
if (!canSendMessages) return;
|
|
259
|
+
handleSubmit();
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
[handleSubmit, canSendMessages]
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const scrollToBottom = useCallback(() => {
|
|
266
|
+
const el = messagesRef.current;
|
|
267
|
+
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
268
|
+
}, []);
|
|
269
|
+
|
|
270
|
+
const checkScrollPositionFallback = useCallback(() => {
|
|
271
|
+
const el = messagesRef.current;
|
|
272
|
+
if (!el) return;
|
|
273
|
+
const { scrollHeight, scrollTop, clientHeight } = el;
|
|
274
|
+
if (clientHeight < 2) { setShowScrollToBottom(false); return; }
|
|
275
|
+
const hasOverflow = scrollHeight > clientHeight + SCROLL_OVERFLOW_EPSILON;
|
|
276
|
+
if (!hasOverflow) { setShowScrollToBottom(false); return; }
|
|
277
|
+
const atBottom = scrollHeight - scrollTop - clientHeight <= SCROLL_AT_BOTTOM_THRESHOLD;
|
|
278
|
+
setShowScrollToBottom(!atBottom);
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
281
|
+
useLayoutEffect(() => {
|
|
282
|
+
const root = messagesRef.current;
|
|
283
|
+
const sentinel = messagesEndRef.current;
|
|
284
|
+
if (!root || !sentinel) return undefined;
|
|
285
|
+
|
|
286
|
+
if (typeof IntersectionObserver !== "function") {
|
|
287
|
+
checkScrollPositionFallback();
|
|
288
|
+
const rafId = requestAnimationFrame(() => checkScrollPositionFallback());
|
|
289
|
+
root.addEventListener("scroll", checkScrollPositionFallback);
|
|
290
|
+
return () => {
|
|
291
|
+
cancelAnimationFrame(rafId);
|
|
292
|
+
root.removeEventListener("scroll", checkScrollPositionFallback);
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const io = new IntersectionObserver(
|
|
297
|
+
([entry]) => {
|
|
298
|
+
if (!entry) return;
|
|
299
|
+
setShowScrollToBottom(!entry.isIntersecting);
|
|
300
|
+
},
|
|
301
|
+
{ root, threshold: 0 }
|
|
302
|
+
);
|
|
303
|
+
io.observe(sentinel);
|
|
304
|
+
return () => io.disconnect();
|
|
305
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
useLayoutEffect(() => {
|
|
309
|
+
const el = messagesRef.current;
|
|
310
|
+
if (!el || !scrollToBottomAfterSendRef.current) return;
|
|
311
|
+
scrollToBottomAfterSendRef.current = false;
|
|
312
|
+
requestAnimationFrame(() => {
|
|
313
|
+
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
314
|
+
});
|
|
315
|
+
}, [visibleMessages.length]);
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<div className="ai-assistant-chat">
|
|
319
|
+
<div className="assistant-chat__messages-wrapper">
|
|
320
|
+
<div
|
|
321
|
+
ref={messagesRef}
|
|
322
|
+
className="assistant-chat__messages"
|
|
323
|
+
role="log"
|
|
324
|
+
aria-label={formatMessage({ id: "assistant.messages.history" })}
|
|
325
|
+
>
|
|
326
|
+
{renderItems.map((item) => {
|
|
327
|
+
if (item.type === "thinking-block") {
|
|
328
|
+
return (
|
|
329
|
+
<ThinkingOutLoud
|
|
330
|
+
key={item.key}
|
|
331
|
+
items={item.items}
|
|
332
|
+
active={item.active}
|
|
333
|
+
collapsed={item.collapsed}
|
|
334
|
+
/>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const { msg } = item;
|
|
338
|
+
const isP0 = msg.priority === 0;
|
|
339
|
+
const isErrorP0 = msg.isError && isP0;
|
|
340
|
+
const sourceLabel = isP0 && msg.source ? getSourceLabel(msg.source) : null;
|
|
341
|
+
return (
|
|
342
|
+
<div
|
|
343
|
+
key={item.key}
|
|
344
|
+
className={[
|
|
345
|
+
"assistant-chat__message",
|
|
346
|
+
`assistant-chat__message--${msg.eventType}`,
|
|
347
|
+
`assistant-chat__message--${msg.side}`,
|
|
348
|
+
isErrorP0 && "assistant-chat__message--error",
|
|
349
|
+
]
|
|
350
|
+
.filter(Boolean)
|
|
351
|
+
.join(" ")}
|
|
352
|
+
>
|
|
353
|
+
{sourceLabel && (
|
|
354
|
+
<div className="assistant-chat__message-source">
|
|
355
|
+
{sourceLabel}
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
<div className="assistant-chat__message-content">
|
|
359
|
+
{msg.content?.trim() ? (
|
|
360
|
+
<MarkdownReader content={msg.content} />
|
|
361
|
+
) : (
|
|
362
|
+
msg.content
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
})}
|
|
368
|
+
<div
|
|
369
|
+
ref={messagesEndRef}
|
|
370
|
+
className="assistant-chat__messages-end"
|
|
371
|
+
aria-hidden
|
|
372
|
+
/>
|
|
373
|
+
</div>
|
|
374
|
+
{!canSendMessages && (
|
|
375
|
+
<div
|
|
376
|
+
className="assistant-chat__waiting"
|
|
377
|
+
role="status"
|
|
378
|
+
aria-live="polite"
|
|
379
|
+
aria-label={formatMessage({ id: "assistant.chat.loading" })}
|
|
380
|
+
>
|
|
381
|
+
<span className="assistant-chat__waiting-spinner" aria-hidden />
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
{showScrollToBottom && (
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
className="assistant-chat__scroll-to-bottom"
|
|
388
|
+
onClick={scrollToBottom}
|
|
389
|
+
aria-label={formatMessage({
|
|
390
|
+
id: "assistant.actions.scrollToBottom",
|
|
391
|
+
})}
|
|
392
|
+
>
|
|
393
|
+
<Icon name="chevron down" />
|
|
394
|
+
</button>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
<div className="assistant-chat__footer">
|
|
398
|
+
<form
|
|
399
|
+
className={[
|
|
400
|
+
"assistant-chat__input-row",
|
|
401
|
+
inputExpanded && "assistant-chat__input-row--expanded",
|
|
402
|
+
]
|
|
403
|
+
.filter(Boolean)
|
|
404
|
+
.join(" ")}
|
|
405
|
+
onSubmit={handleSubmit}
|
|
406
|
+
>
|
|
407
|
+
<textarea
|
|
408
|
+
ref={inputRef}
|
|
409
|
+
rows={1}
|
|
410
|
+
className="assistant-chat__input"
|
|
411
|
+
placeholder={formatMessage({ id: "assistant.input.placeholder" })}
|
|
412
|
+
value={inputValue}
|
|
413
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
414
|
+
onKeyDown={handleKeyDown}
|
|
415
|
+
aria-label={formatMessage({ id: "assistant.input.label" })}
|
|
416
|
+
disabled={!canSendMessages}
|
|
417
|
+
/>
|
|
418
|
+
<button
|
|
419
|
+
type="submit"
|
|
420
|
+
className="assistant-chat__send"
|
|
421
|
+
aria-label={formatMessage({ id: "assistant.actions.send" })}
|
|
422
|
+
disabled={!canSendMessages}
|
|
423
|
+
>
|
|
424
|
+
<Icon name="arrow up" />
|
|
425
|
+
</button>
|
|
426
|
+
</form>
|
|
427
|
+
<div className="assistant-chat__disclaimer">
|
|
428
|
+
<FormattedMessage id="assistant.disclaimer" />
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
AssistantPageChat.propTypes = {
|
|
436
|
+
conversation: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
437
|
+
setConversation: PropTypes.func.isRequired,
|
|
438
|
+
onSendMessage: PropTypes.func,
|
|
439
|
+
serverMessageHandlerRef: PropTypes.shape({ current: PropTypes.any }),
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
AssistantPageChat.defaultProps = {
|
|
443
|
+
onSendMessage: undefined,
|
|
444
|
+
serverMessageHandlerRef: null,
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
export default AssistantPageChat;
|
|
@@ -19,9 +19,24 @@ jest.mock("../hooks/useAgentConversation", () =>
|
|
|
19
19
|
jest.fn(() => ({
|
|
20
20
|
conversationId: null,
|
|
21
21
|
startNewConversation: jest.fn(),
|
|
22
|
+
selectConversation: jest.fn(),
|
|
22
23
|
}))
|
|
23
24
|
);
|
|
24
25
|
|
|
26
|
+
jest.mock("../../../hooks/useAgentConversations", () =>
|
|
27
|
+
jest.fn(() => ({
|
|
28
|
+
conversations: [],
|
|
29
|
+
loading: false,
|
|
30
|
+
error: null,
|
|
31
|
+
refresh: jest.fn(),
|
|
32
|
+
}))
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
jest.mock("react-router", () => ({
|
|
36
|
+
...jest.requireActual("react-router"),
|
|
37
|
+
useNavigate: jest.fn(() => jest.fn()),
|
|
38
|
+
}));
|
|
39
|
+
|
|
25
40
|
describe("<Assistant />", () => {
|
|
26
41
|
it("renders nothing when socket is not ready", async () => {
|
|
27
42
|
useAssistantSocket.mockReturnValue({
|
|
@@ -25,6 +25,24 @@ describe("<AssistantChat />", () => {
|
|
|
25
25
|
expect(rendered.container).toMatchSnapshot();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
it("shows the title in the header when provided", async () => {
|
|
29
|
+
const rendered = render(
|
|
30
|
+
<AssistantChat {...defaultProps} title="My Conversation Title" />
|
|
31
|
+
);
|
|
32
|
+
await waitForLoad(rendered);
|
|
33
|
+
|
|
34
|
+
expect(rendered.getByText("My Conversation Title")).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("shows default new conversation text when title is not provided", async () => {
|
|
38
|
+
const rendered = render(<AssistantChat {...defaultProps} title={null} />);
|
|
39
|
+
await waitForLoad(rendered);
|
|
40
|
+
|
|
41
|
+
expect(
|
|
42
|
+
rendered.getByText("assistant.newConversation")
|
|
43
|
+
).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
28
46
|
it("groups logs in a thinking block and auto-collapses when a priority-0 response arrives", async () => {
|
|
29
47
|
const conversation = [
|
|
30
48
|
{
|