@truedat/ai 8.3.5 → 8.3.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/ai",
3
- "version": "8.3.5",
3
+ "version": "8.3.6",
4
4
  "description": "Truedat Web Artificial Intelligence package",
5
5
  "sideEffects": false,
6
6
  "module": "src/index.js",
@@ -50,7 +50,7 @@
50
50
  "@testing-library/jest-dom": "^6.6.3",
51
51
  "@testing-library/react": "^16.3.0",
52
52
  "@testing-library/user-event": "^14.6.1",
53
- "@truedat/test": "8.3.5",
53
+ "@truedat/test": "8.3.6",
54
54
  "identity-obj-proxy": "^3.0.0",
55
55
  "jest": "^29.7.0",
56
56
  "redux-saga-test-plan": "^4.0.6"
@@ -62,6 +62,7 @@
62
62
  "lodash": "^4.17.21",
63
63
  "moment": "^2.30.1",
64
64
  "path-to-regexp": "^8.2.0",
65
+ "phoenix": "^1.7.10",
65
66
  "prop-types": "^15.8.1",
66
67
  "query-string": "^7.1.3",
67
68
  "react": "^19.1.0",
@@ -79,5 +80,5 @@
79
80
  "semantic-ui-react": "^3.0.0-beta.2",
80
81
  "swr": "^2.3.3"
81
82
  },
82
- "gitHead": "12d1969c3403861d360fbdc24fd270ab1fece070"
83
+ "gitHead": "767585383b6373e75fa50d7440e9ecf67b0573c2"
83
84
  }
package/src/api.js CHANGED
@@ -16,6 +16,8 @@ const API_SUGGESTIONS_REQUEST = "/api/suggestions/request";
16
16
  const API_TRANSLATIONS_AVAILABILITY_CHECK =
17
17
  "/api/translations/availability_check";
18
18
  const API_TRANSLATIONS_REQUEST = "/api/translations/request";
19
+ const API_AGENT_LAYER_CONVERSATION = "/api/agent_layer/conversation";
20
+ const API_SOCKET_ENDPOINT = "/api/socket";
19
21
 
20
22
  export {
21
23
  API_ACTION,
@@ -34,4 +36,6 @@ export {
34
36
  API_SUGGESTIONS_REQUEST,
35
37
  API_TRANSLATIONS_AVAILABILITY_CHECK,
36
38
  API_TRANSLATIONS_REQUEST,
39
+ API_AGENT_LAYER_CONVERSATION,
40
+ API_SOCKET_ENDPOINT,
37
41
  };
@@ -0,0 +1,126 @@
1
+ import { useRef, useState, useCallback, useEffect } from "react";
2
+ import { useWebContext } from "@truedat/core/webContext";
3
+
4
+ import useAssistantSocket from "./hooks/useAssistantSocket";
5
+ import useAgentConversation from "./hooks/useAgentConversation";
6
+ import AssistantBubble from "./AssistantBubble";
7
+ import AssistantChat from "./AssistantChat";
8
+
9
+ const FADE_DURATION_MS = 150;
10
+ const EMPTY_CONVERSATION = [];
11
+
12
+ const Assistant = () => {
13
+ const { disable_td_ai: disableTdAi = false } = useWebContext();
14
+
15
+ const [isOpen, setIsOpen] = useState(false);
16
+ const { conversationId, startNewConversation } = useAgentConversation(isOpen);
17
+ const [contentFadeOut, setContentFadeOut] = useState(false);
18
+ const [contentFadeIn, setContentFadeIn] = useState(false);
19
+
20
+ const [conversation, setConversation] = useState(EMPTY_CONVERSATION);
21
+ const prevConversationIdRef = useRef(null);
22
+
23
+ const containerRef = useRef(null);
24
+ const serverMessageHandlerRef = useRef(null);
25
+ const pendingTransitionEndRef = useRef(false);
26
+ const fadeTimeoutRef = useRef(null);
27
+ const fadeInTimeoutRef = useRef(null);
28
+
29
+ useEffect(() => {
30
+ if (conversationId !== prevConversationIdRef.current) {
31
+ prevConversationIdRef.current = conversationId;
32
+ if (conversationId != null) {
33
+ setConversation(EMPTY_CONVERSATION);
34
+ }
35
+ }
36
+ }, [conversationId]);
37
+
38
+ const onChannelJoined = useCallback(() => {}, []);
39
+ const handleServerMessage = useCallback((event, payload) => {
40
+ serverMessageHandlerRef.current?.(event, payload);
41
+ }, []);
42
+ const handleNewChat = useCallback(() => {
43
+ setConversation(EMPTY_CONVERSATION);
44
+ startNewConversation();
45
+ }, [startNewConversation]);
46
+
47
+ const effectiveConversationId = isOpen ? conversationId : null;
48
+ const { socketReady, sendPrompt } = useAssistantSocket(disableTdAi, {
49
+ conversationId: effectiveConversationId,
50
+ onChannelJoined,
51
+ onServerMessage: handleServerMessage,
52
+ });
53
+
54
+ const handleTransitionEnd = useCallback(() => {
55
+ if (!pendingTransitionEndRef.current) return;
56
+ pendingTransitionEndRef.current = false;
57
+ setContentFadeIn(true);
58
+ fadeInTimeoutRef.current = setTimeout(() => {
59
+ setContentFadeIn(false);
60
+ }, FADE_DURATION_MS);
61
+ }, []);
62
+
63
+ useEffect(() => {
64
+ const el = containerRef.current;
65
+ if (!el) return;
66
+ el.addEventListener("transitionend", handleTransitionEnd);
67
+ return () => {
68
+ el.removeEventListener("transitionend", handleTransitionEnd);
69
+ };
70
+ }, [handleTransitionEnd]);
71
+
72
+ useEffect(() => {
73
+ return () => {
74
+ if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current);
75
+ if (fadeInTimeoutRef.current) clearTimeout(fadeInTimeoutRef.current);
76
+ };
77
+ }, []);
78
+
79
+ const startOpenTransition = useCallback(() => {
80
+ setContentFadeOut(true);
81
+ fadeTimeoutRef.current = setTimeout(() => {
82
+ setContentFadeOut(false);
83
+ setIsOpen(true);
84
+ pendingTransitionEndRef.current = true;
85
+ }, FADE_DURATION_MS);
86
+ }, []);
87
+
88
+ const startCloseTransition = useCallback(() => {
89
+ setContentFadeOut(true);
90
+ fadeTimeoutRef.current = setTimeout(() => {
91
+ setContentFadeOut(false);
92
+ setIsOpen(false);
93
+ pendingTransitionEndRef.current = true;
94
+ }, FADE_DURATION_MS);
95
+ }, []);
96
+
97
+ if (!socketReady) return null;
98
+
99
+ return (
100
+ <div
101
+ ref={containerRef}
102
+ className={`assistant-container assistant-container--${isOpen ? "open" : "closed"}`}
103
+ >
104
+ {isOpen ? (
105
+ <AssistantChat
106
+ conversation={conversation}
107
+ setConversation={setConversation}
108
+ onNewChat={handleNewChat}
109
+ onClose={startCloseTransition}
110
+ onSendMessage={sendPrompt}
111
+ serverMessageHandlerRef={serverMessageHandlerRef}
112
+ fadeOut={contentFadeOut}
113
+ fadeIn={contentFadeIn}
114
+ />
115
+ ) : (
116
+ <AssistantBubble
117
+ onClick={startOpenTransition}
118
+ fadeOut={contentFadeOut}
119
+ fadeIn={contentFadeIn}
120
+ />
121
+ )}
122
+ </div>
123
+ );
124
+ };
125
+
126
+ export default Assistant;
@@ -0,0 +1,56 @@
1
+ import PropTypes from "prop-types";
2
+ import { useIntl } from "react-intl";
3
+ import { Icon } from "semantic-ui-react";
4
+ import truedatLogo from "assets/truedat-logo-home-no-text.png";
5
+
6
+ const AssistantBubble = ({ onClick, fadeOut, fadeIn }) => {
7
+ const { formatMessage } = useIntl();
8
+ const className = [
9
+ "assistant-bubble",
10
+ fadeOut && "assistant-content--fade-out",
11
+ fadeIn && "assistant-content--fade-in",
12
+ ]
13
+ .filter(Boolean)
14
+ .join(" ");
15
+
16
+ const isLogoSrcString = typeof truedatLogo === "string";
17
+
18
+ return (
19
+ <div
20
+ className={className}
21
+ role="button"
22
+ aria-label={formatMessage({ id: "assistant.bubble.label" })}
23
+ onClick={onClick}
24
+ onKeyDown={(e) => {
25
+ if (e.key === "Enter" || e.key === " ") {
26
+ e.preventDefault();
27
+ onClick();
28
+ }
29
+ }}
30
+ tabIndex={0}
31
+ >
32
+ {isLogoSrcString ? (
33
+ <img
34
+ src={truedatLogo}
35
+ alt={formatMessage({ id: "assistant.bubble.label" })}
36
+ className="assistant-bubble__logo"
37
+ />
38
+ ) : (
39
+ <Icon name="comments" />
40
+ )}
41
+ </div>
42
+ );
43
+ };
44
+
45
+ AssistantBubble.propTypes = {
46
+ onClick: PropTypes.func.isRequired,
47
+ fadeOut: PropTypes.bool,
48
+ fadeIn: PropTypes.bool,
49
+ };
50
+
51
+ AssistantBubble.defaultProps = {
52
+ fadeOut: false,
53
+ fadeIn: false,
54
+ };
55
+
56
+ export default AssistantBubble;
@@ -0,0 +1,312 @@
1
+ import { useState, useCallback, useMemo, useRef, useEffect } from "react";
2
+ import PropTypes from "prop-types";
3
+ import { FormattedMessage, useIntl } from "react-intl";
4
+ import { Icon } from "semantic-ui-react";
5
+ import { MarkdownReader } from "@truedat/core/components";
6
+
7
+ const SCROLL_AT_BOTTOM_THRESHOLD = 50;
8
+
9
+ function getVisibleMessages(conversation) {
10
+ if (!conversation || conversation.length === 0) return [];
11
+ let lastRequestOrResponseIndex = -1;
12
+ for (let i = conversation.length - 1; i >= 0; i--) {
13
+ const eventType = conversation[i].eventType;
14
+ if (eventType === "request" || eventType === "response") {
15
+ lastRequestOrResponseIndex = i;
16
+ break;
17
+ }
18
+ }
19
+ return conversation
20
+ .map((item, idx) => ({ ...item, index: idx }))
21
+ .filter(
22
+ (item) =>
23
+ item.eventType === "request" ||
24
+ item.eventType === "response" ||
25
+ (item.eventType === "log" && item.index > lastRequestOrResponseIndex)
26
+ );
27
+ }
28
+
29
+ function getContentFromPayload(event, payload) {
30
+ if (!payload || typeof payload !== "object") return { content: "", isError: false };
31
+ switch (event) {
32
+ case "stream": {
33
+ const c = payload.content;
34
+ const streamChunk =
35
+ typeof c === "string"
36
+ ? c
37
+ : c && typeof c === "object" && typeof c.message === "string"
38
+ ? c.message
39
+ : "";
40
+ return { content: streamChunk, isError: false };
41
+ }
42
+ case "error": {
43
+ const err = payload.error;
44
+ const errMsg =
45
+ err && typeof err === "object"
46
+ ? err.message ?? err.reason ?? JSON.stringify(err)
47
+ : String(err ?? "");
48
+ return { content: errMsg, isError: true };
49
+ }
50
+ case "log": {
51
+ const log = payload.content;
52
+ const logMsg =
53
+ log && typeof log === "object"
54
+ ? log.message ?? log.level ?? JSON.stringify(log)
55
+ : String(log ?? "");
56
+ return { content: logMsg, isError: false };
57
+ }
58
+ default:
59
+ return { content: "", isError: false };
60
+ }
61
+ }
62
+
63
+ function appendStreamChunk(prev, source, chunk) {
64
+ const last = prev[prev.length - 1];
65
+ const sameSource =
66
+ last?.eventType === "response" &&
67
+ last?.streaming &&
68
+ last?.source === source;
69
+ if (sameSource) {
70
+ return [
71
+ ...prev.slice(0, -1),
72
+ { ...last, content: last.content + chunk },
73
+ ];
74
+ }
75
+ return [
76
+ ...prev,
77
+ {
78
+ eventType: "response",
79
+ side: "agent",
80
+ content: chunk || " ",
81
+ streaming: true,
82
+ source,
83
+ },
84
+ ];
85
+ }
86
+
87
+ const AssistantChat = ({
88
+ conversation,
89
+ setConversation,
90
+ onNewChat,
91
+ onClose,
92
+ onSendMessage,
93
+ serverMessageHandlerRef,
94
+ fadeOut,
95
+ fadeIn,
96
+ }) => {
97
+ const { formatMessage } = useIntl();
98
+ const [inputValue, setInputValue] = useState("");
99
+ const [showScrollToBottom, setShowScrollToBottom] = useState(false);
100
+ const messagesRef = useRef(null);
101
+ const scrollToBottomAfterSendRef = useRef(false);
102
+
103
+ useEffect(() => {
104
+ const ref = serverMessageHandlerRef;
105
+ if (!ref) return;
106
+ ref.current = (event, payload) => {
107
+ const { content, isError } = getContentFromPayload(event, payload);
108
+ if (event === "stream") {
109
+ const source = payload?.source ?? null;
110
+ requestAnimationFrame(() => {
111
+ setConversation((prev) => appendStreamChunk(prev, source, content));
112
+ });
113
+ } else if (event === "log") {
114
+ setConversation((prev) => [
115
+ ...prev,
116
+ { eventType: "log", side: "agent", content: content || " " },
117
+ ]);
118
+ } else {
119
+ setConversation((prev) => [
120
+ ...prev,
121
+ {
122
+ eventType: "response",
123
+ side: "agent",
124
+ content: content || "Error",
125
+ isError: true,
126
+ },
127
+ ]);
128
+ }
129
+ scrollToBottomAfterSendRef.current = true;
130
+ };
131
+ return () => {
132
+ ref.current = null;
133
+ };
134
+ }, [serverMessageHandlerRef, setConversation]);
135
+
136
+ const visibleMessages = useMemo(
137
+ () => getVisibleMessages(conversation),
138
+ [conversation]
139
+ );
140
+
141
+ const handleSubmit = useCallback(
142
+ (e) => {
143
+ e?.preventDefault?.();
144
+ const text = inputValue.trim();
145
+ if (!text) return;
146
+ scrollToBottomAfterSendRef.current = true;
147
+ setConversation((prev) => [
148
+ ...prev,
149
+ { eventType: "request", side: "user", content: text },
150
+ ]);
151
+ setInputValue("");
152
+ if (typeof onSendMessage === "function") {
153
+ onSendMessage(text);
154
+ }
155
+ },
156
+ [inputValue, onSendMessage]
157
+ );
158
+
159
+ const handleKeyDown = useCallback(
160
+ (e) => {
161
+ if (e.key === "Enter" && !e.shiftKey) {
162
+ e.preventDefault();
163
+ handleSubmit();
164
+ }
165
+ },
166
+ [handleSubmit]
167
+ );
168
+
169
+ const handleNewChat = useCallback(() => {
170
+ if (typeof onNewChat === "function") onNewChat();
171
+ setInputValue("");
172
+ }, [onNewChat]);
173
+
174
+ const scrollToBottom = useCallback(() => {
175
+ const el = messagesRef.current;
176
+ if (el) {
177
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
178
+ }
179
+ }, []);
180
+
181
+ const checkScrollPosition = useCallback(() => {
182
+ const el = messagesRef.current;
183
+ if (!el) return;
184
+ const atBottom =
185
+ el.scrollHeight - el.scrollTop - el.clientHeight <=
186
+ SCROLL_AT_BOTTOM_THRESHOLD;
187
+ setShowScrollToBottom((prev) => (prev !== !atBottom ? !atBottom : prev));
188
+ }, []);
189
+
190
+ useEffect(() => {
191
+ const el = messagesRef.current;
192
+ if (!el) return;
193
+ checkScrollPosition();
194
+ if (scrollToBottomAfterSendRef.current) {
195
+ scrollToBottomAfterSendRef.current = false;
196
+ requestAnimationFrame(() => {
197
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
198
+ });
199
+ }
200
+ el.addEventListener("scroll", checkScrollPosition);
201
+ return () => el.removeEventListener("scroll", checkScrollPosition);
202
+ }, [checkScrollPosition, visibleMessages.length]);
203
+
204
+ const className = [
205
+ "assistant-chat",
206
+ fadeOut && "assistant-content--fade-out",
207
+ fadeIn && "assistant-content--fade-in",
208
+ ]
209
+ .filter(Boolean)
210
+ .join(" ");
211
+
212
+ return (
213
+ <div className={className}>
214
+ <div className="assistant-chat__actions">
215
+ <button
216
+ type="button"
217
+ className="assistant-chat__action assistant-chat__action--new-chat"
218
+ onClick={handleNewChat}
219
+ aria-label={formatMessage({ id: "assistant.actions.newChat" })}
220
+ >
221
+ <Icon name="comment outline" />
222
+ <FormattedMessage id="assistant.actions.newChat" />
223
+ </button>
224
+ <button
225
+ type="button"
226
+ className="assistant-chat__action assistant-chat__action--close"
227
+ onClick={onClose}
228
+ aria-label={formatMessage({ id: "assistant.actions.close" })}
229
+ >
230
+ <Icon name="close" />
231
+ </button>
232
+ </div>
233
+ <div className="assistant-chat__messages-wrapper">
234
+ <div
235
+ ref={messagesRef}
236
+ className="assistant-chat__messages"
237
+ role="log"
238
+ aria-label={formatMessage({ id: "assistant.messages.history" })}
239
+ >
240
+ {visibleMessages.map((msg) => (
241
+ <div
242
+ key={`${msg.eventType}-${msg.index}`}
243
+ className={`assistant-chat__message assistant-chat__message--${msg.eventType} assistant-chat__message--${msg.side}`}
244
+ >
245
+ <div className="assistant-chat__message-content">
246
+ {msg.side === "agent" && msg.content?.trim() ? (
247
+ <MarkdownReader content={msg.content} />
248
+ ) : (
249
+ msg.content
250
+ )}
251
+ </div>
252
+ </div>
253
+ ))}
254
+ </div>
255
+ {showScrollToBottom && (
256
+ <button
257
+ type="button"
258
+ className="assistant-chat__scroll-to-bottom"
259
+ onClick={scrollToBottom}
260
+ aria-label={formatMessage({ id: "assistant.actions.scrollToBottom" })}
261
+ >
262
+ <Icon name="chevron down" />
263
+ </button>
264
+ )}
265
+ </div>
266
+ <div className="assistant-chat__footer">
267
+ <form className="assistant-chat__input-row" onSubmit={handleSubmit}>
268
+ <input
269
+ type="text"
270
+ className="assistant-chat__input"
271
+ placeholder={formatMessage({ id: "assistant.input.placeholder" })}
272
+ value={inputValue}
273
+ onChange={(e) => setInputValue(e.target.value)}
274
+ onKeyDown={handleKeyDown}
275
+ aria-label={formatMessage({ id: "assistant.input.label" })}
276
+ />
277
+ <button
278
+ type="submit"
279
+ className="assistant-chat__send"
280
+ aria-label={formatMessage({ id: "assistant.actions.send" })}
281
+ >
282
+ <Icon name="send" />
283
+ </button>
284
+ </form>
285
+ <div className="assistant-chat__disclaimer">
286
+ <FormattedMessage id="assistant.disclaimer" />
287
+ </div>
288
+ </div>
289
+ </div>
290
+ );
291
+ };
292
+
293
+ AssistantChat.propTypes = {
294
+ conversation: PropTypes.arrayOf(PropTypes.object).isRequired,
295
+ setConversation: PropTypes.func.isRequired,
296
+ onNewChat: PropTypes.func,
297
+ onClose: PropTypes.func.isRequired,
298
+ onSendMessage: PropTypes.func,
299
+ serverMessageHandlerRef: PropTypes.shape({ current: PropTypes.any }),
300
+ fadeOut: PropTypes.bool,
301
+ fadeIn: PropTypes.bool,
302
+ };
303
+
304
+ AssistantChat.defaultProps = {
305
+ onNewChat: undefined,
306
+ onSendMessage: undefined,
307
+ serverMessageHandlerRef: null,
308
+ fadeOut: false,
309
+ fadeIn: false,
310
+ };
311
+
312
+ export default AssistantChat;
@@ -0,0 +1,45 @@
1
+ import React from "react";
2
+ import { render, waitForLoad } from "@truedat/test/render";
3
+
4
+ import Assistant from "../Assistant";
5
+ import useAssistantSocket from "../hooks/useAssistantSocket";
6
+
7
+ jest.mock("@truedat/core/webContext", () => ({
8
+ useWebContext: jest.fn(() => ({ disable_td_ai: false })),
9
+ }));
10
+
11
+ jest.mock("../hooks/useAssistantSocket", () =>
12
+ jest.fn(() => ({
13
+ socketReady: false,
14
+ sendPrompt: jest.fn(),
15
+ }))
16
+ );
17
+
18
+ jest.mock("../hooks/useAgentConversation", () =>
19
+ jest.fn(() => ({
20
+ conversationId: null,
21
+ startNewConversation: jest.fn(),
22
+ }))
23
+ );
24
+
25
+ describe("<Assistant />", () => {
26
+ it("renders nothing when socket is not ready", async () => {
27
+ useAssistantSocket.mockReturnValue({
28
+ socketReady: false,
29
+ sendPrompt: jest.fn(),
30
+ });
31
+ const rendered = render(<Assistant />);
32
+ await waitForLoad(rendered);
33
+ expect(rendered.container).toMatchSnapshot();
34
+ });
35
+
36
+ it("matches the latest snapshot when socket is ready", async () => {
37
+ useAssistantSocket.mockReturnValue({
38
+ socketReady: true,
39
+ sendPrompt: jest.fn(),
40
+ });
41
+ const rendered = render(<Assistant />);
42
+ await waitForLoad(rendered);
43
+ expect(rendered.container).toMatchSnapshot();
44
+ });
45
+ });
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+ import { render, waitForLoad } from "@truedat/test/render";
3
+
4
+ import AssistantBubble from "../AssistantBubble";
5
+
6
+ describe("<AssistantBubble />", () => {
7
+ it("matches the latest snapshot", async () => {
8
+ const rendered = render(
9
+ <AssistantBubble onClick={jest.fn()} />
10
+ );
11
+ await waitForLoad(rendered);
12
+ expect(rendered.container).toMatchSnapshot();
13
+ });
14
+ });
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { render, waitForLoad } from "@truedat/test/render";
3
+
4
+ import AssistantChat from "../AssistantChat";
5
+
6
+ const defaultProps = {
7
+ conversation: [
8
+ {
9
+ eventType: "log",
10
+ side: "agent",
11
+ content: "Requesting connection",
12
+ },
13
+ ],
14
+ setConversation: jest.fn(),
15
+ onClose: jest.fn(),
16
+ channelJoined: false,
17
+ conversationId: null,
18
+ serverMessageHandlerRef: { current: null },
19
+ };
20
+
21
+ describe("<AssistantChat />", () => {
22
+ it("matches the latest snapshot", async () => {
23
+ const rendered = render(<AssistantChat {...defaultProps} />);
24
+ await waitForLoad(rendered);
25
+ expect(rendered.container).toMatchSnapshot();
26
+ });
27
+ });
@@ -0,0 +1,23 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`<Assistant /> matches the latest snapshot when socket is ready 1`] = `
4
+ <div>
5
+ <div
6
+ class="assistant-container assistant-container--closed"
7
+ >
8
+ <div
9
+ aria-label="assistant.bubble.label"
10
+ class="assistant-bubble"
11
+ role="button"
12
+ tabindex="0"
13
+ >
14
+ <i
15
+ aria-hidden="true"
16
+ class="comments icon"
17
+ />
18
+ </div>
19
+ </div>
20
+ </div>
21
+ `;
22
+
23
+ exports[`<Assistant /> renders nothing when socket is not ready 1`] = `<div />`;
@@ -0,0 +1,17 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`<AssistantBubble /> matches the latest snapshot 1`] = `
4
+ <div>
5
+ <div
6
+ aria-label="assistant.bubble.label"
7
+ class="assistant-bubble"
8
+ role="button"
9
+ tabindex="0"
10
+ >
11
+ <i
12
+ aria-hidden="true"
13
+ class="comments icon"
14
+ />
15
+ </div>
16
+ </div>
17
+ `;
@@ -0,0 +1,92 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`<AssistantChat /> matches the latest snapshot 1`] = `
4
+ <div>
5
+ <div
6
+ class="assistant-chat"
7
+ >
8
+ <div
9
+ class="assistant-chat__actions"
10
+ >
11
+ <button
12
+ aria-label="assistant.actions.newChat"
13
+ class="assistant-chat__action assistant-chat__action--new-chat"
14
+ type="button"
15
+ >
16
+ <i
17
+ aria-hidden="true"
18
+ class="comment outline icon"
19
+ />
20
+ assistant.actions.newChat
21
+ </button>
22
+ <button
23
+ aria-label="assistant.actions.close"
24
+ class="assistant-chat__action assistant-chat__action--close"
25
+ type="button"
26
+ >
27
+ <i
28
+ aria-hidden="true"
29
+ class="close icon"
30
+ />
31
+ </button>
32
+ </div>
33
+ <div
34
+ class="assistant-chat__messages-wrapper"
35
+ >
36
+ <div
37
+ aria-label="assistant.messages.history"
38
+ class="assistant-chat__messages"
39
+ role="log"
40
+ >
41
+ <div
42
+ class="assistant-chat__message assistant-chat__message--log assistant-chat__message--agent"
43
+ >
44
+ <div
45
+ class="assistant-chat__message-content"
46
+ >
47
+ <div
48
+ class="markdown-reader"
49
+ >
50
+ <p>
51
+ Requesting connection
52
+ </p>
53
+
54
+
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ <div
61
+ class="assistant-chat__footer"
62
+ >
63
+ <form
64
+ class="assistant-chat__input-row"
65
+ >
66
+ <input
67
+ aria-label="assistant.input.label"
68
+ class="assistant-chat__input"
69
+ placeholder="assistant.input.placeholder"
70
+ type="text"
71
+ value=""
72
+ />
73
+ <button
74
+ aria-label="assistant.actions.send"
75
+ class="assistant-chat__send"
76
+ type="submit"
77
+ >
78
+ <i
79
+ aria-hidden="true"
80
+ class="send icon"
81
+ />
82
+ </button>
83
+ </form>
84
+ <div
85
+ class="assistant-chat__disclaimer"
86
+ >
87
+ assistant.disclaimer
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ `;
@@ -0,0 +1,38 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ import { apiJsonPost } from "@truedat/core/services/api";
4
+
5
+ import { API_AGENT_LAYER_CONVERSATION } from "../../../api";
6
+
7
+ const useAgentConversation = (isOpen) => {
8
+ const [conversationId, setConversationId] = useState(null);
9
+
10
+ useEffect(() => {
11
+ if (!isOpen || conversationId != null) return;
12
+
13
+ apiJsonPost(API_AGENT_LAYER_CONVERSATION, {})
14
+ .then((res) => {
15
+ const data = res?.data;
16
+ const payload = data?.data != null ? data.data : data;
17
+ const rawId =
18
+ payload?.agent_state_id ??
19
+ payload?.id ??
20
+ data?.agent_state_id ??
21
+ data?.id;
22
+ const id =
23
+ typeof rawId === "string" || typeof rawId === "number"
24
+ ? String(rawId)
25
+ : null;
26
+ if (id != null) setConversationId(id);
27
+ })
28
+ .catch(() => { });
29
+ }, [isOpen, conversationId]);
30
+
31
+ const startNewConversation = useCallback(() => {
32
+ setConversationId(null);
33
+ }, []);
34
+
35
+ return { conversationId, startNewConversation };
36
+ };
37
+
38
+ export default useAgentConversation;
@@ -0,0 +1,120 @@
1
+ import { useRef, useState, useEffect, useCallback } from "react";
2
+ import { Socket } from "phoenix";
3
+
4
+ import { readToken } from "@truedat/core/services/storage";
5
+
6
+ import { API_SOCKET_ENDPOINT } from "../../../api";
7
+
8
+ const KEEP_ALIVE_INTERVAL_MS = 20000;
9
+ const CONNECT_DELAY_MS = 150;
10
+ const RECONNECT_DELAY_MS = 2000;
11
+
12
+ const useAssistantSocket = (disabled = false, options = {}) => {
13
+ const { conversationId = null, onChannelJoined, onServerMessage } = options;
14
+ const [socketReady, setSocketReady] = useState(false);
15
+ const socketRef = useRef(null);
16
+ const channelRef = useRef(null);
17
+
18
+ useEffect(() => {
19
+ if (disabled) return;
20
+
21
+ const token = readToken();
22
+ if (!token) return;
23
+
24
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
25
+ const host = window.location.host;
26
+
27
+ const socket = new Socket(protocol + host + API_SOCKET_ENDPOINT, {
28
+ params: { token },
29
+ heartbeatIntervalMs: KEEP_ALIVE_INTERVAL_MS,
30
+ });
31
+ socketRef.current = socket;
32
+
33
+ const disconnectRequestedRef = { current: false };
34
+ let reconnectTimeoutId = null;
35
+ let connectTimeoutId = null;
36
+
37
+ socket.onOpen(() => setSocketReady(true));
38
+ socket.onClose(() => {
39
+ setSocketReady(false);
40
+ if (disconnectRequestedRef.current) return;
41
+ reconnectTimeoutId = setTimeout(() => {
42
+ if (!socketRef.current || disconnectRequestedRef.current) return;
43
+ socketRef.current.connect();
44
+ }, RECONNECT_DELAY_MS);
45
+ });
46
+ socket.onError(() => setSocketReady(false));
47
+
48
+ connectTimeoutId = setTimeout(() => {
49
+ if (disconnectRequestedRef.current || !socketRef.current) return;
50
+ socket.connect();
51
+ }, CONNECT_DELAY_MS);
52
+
53
+ return () => {
54
+ disconnectRequestedRef.current = true;
55
+ if (connectTimeoutId) clearTimeout(connectTimeoutId);
56
+ if (reconnectTimeoutId) clearTimeout(reconnectTimeoutId);
57
+ if (socketRef.current) {
58
+ socketRef.current.disconnect();
59
+ socketRef.current = null;
60
+ }
61
+ setSocketReady(false);
62
+ };
63
+ }, [disabled]);
64
+
65
+ useEffect(() => {
66
+ if (!socketReady || !conversationId || !socketRef.current) return;
67
+ const topic = `agent_layer:${conversationId}`;
68
+ const channel = socketRef.current.channel(topic, {});
69
+ channelRef.current = channel;
70
+
71
+ const handleEvent = (event, payload) => {
72
+ if (typeof onServerMessage === "function") {
73
+ onServerMessage(event, payload);
74
+ }
75
+ };
76
+
77
+ const refLog = channel.on("log", (payload) => handleEvent("log", payload));
78
+ const refError = channel.on("error", (payload) =>
79
+ handleEvent("error", payload),
80
+ );
81
+ const refStream = channel.on("stream", (payload) =>
82
+ handleEvent("stream", payload),
83
+ );
84
+
85
+ channel
86
+ .join()
87
+ .receive("ok", () => {
88
+ if (typeof onChannelJoined === "function") onChannelJoined();
89
+ })
90
+ .receive("error", () => {});
91
+
92
+ return () => {
93
+ channel.off("log", refLog);
94
+ channel.off("error", refError);
95
+ channel.off("stream", refStream);
96
+ channelRef.current = null;
97
+ channel.leave();
98
+ };
99
+ }, [socketReady, conversationId, onChannelJoined, onServerMessage]);
100
+
101
+ const sendPrompt = useCallback(
102
+ (prompt) => {
103
+ if (
104
+ !socketReady ||
105
+ !channelRef.current ||
106
+ !prompt ||
107
+ typeof prompt !== "string"
108
+ )
109
+ return;
110
+ const trimmed = prompt.trim();
111
+ if (!trimmed) return;
112
+ channelRef.current.push("prompt", { prompt: trimmed });
113
+ },
114
+ [socketReady],
115
+ );
116
+
117
+ return { socketReady, socketRef, sendPrompt };
118
+ };
119
+
120
+ export default useAssistantSocket;
@@ -1,3 +1,4 @@
1
1
  import AiRoutes from "./AiRoutes";
2
2
  import TranslationModal from "./TranslationModal";
3
- export { AiRoutes, TranslationModal };
3
+ import Assistant from "./assistant/Assistant";
4
+ export { AiRoutes, TranslationModal, Assistant };
@@ -0,0 +1,353 @@
1
+ .assistant-container {
2
+ position: fixed;
3
+ bottom: calc(1.5rem + 10px);
4
+ right: calc(1.5rem + 10px);
5
+ z-index: 1000;
6
+ display: flex;
7
+ flex-direction: column;
8
+ overflow: hidden;
9
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
10
+ transition:
11
+ width 0.3s ease,
12
+ height 0.3s ease,
13
+ border-radius 0.3s ease,
14
+ background 0.2s ease 0s,
15
+ color 0.2s ease 0s;
16
+
17
+ &--closed {
18
+ width: 5rem;
19
+ height: 5rem;
20
+ border-radius: 50%;
21
+ align-items: center;
22
+ justify-content: center;
23
+ background: #fff;
24
+ color: #013c54;
25
+ border: 2px solid #013c54;
26
+ cursor: pointer;
27
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
28
+ transition:
29
+ width 0.3s ease,
30
+ height 0.3s ease,
31
+ border-radius 0.3s ease,
32
+ box-shadow 0.3s ease,
33
+ background 0.2s ease 0.3s,
34
+ color 0.2s ease 0.3s;
35
+
36
+ &:hover {
37
+ animation: assistant-aurora-halo 0.65s ease-in 1 forwards;
38
+ width: 5.15rem;
39
+ height: 5.15rem;
40
+ }
41
+ }
42
+
43
+ &--open {
44
+ width: 33vw;
45
+ min-width: 400px;
46
+ height: 80vh;
47
+ border-radius: 1rem;
48
+ background: #fff;
49
+ color: inherit;
50
+ transition:
51
+ width 0.3s ease,
52
+ height 0.3s ease,
53
+ border-radius 0.3s ease,
54
+ background 0.2s ease 0s,
55
+ color 0.2s ease 0s;
56
+ }
57
+ }
58
+
59
+ .assistant-content--fade-out {
60
+ opacity: 0;
61
+ transition: opacity 0.15s ease-out;
62
+ }
63
+
64
+ .assistant-content--fade-in {
65
+ opacity: 0;
66
+ animation: assistant-fade-in 0.15s ease-out forwards;
67
+ }
68
+
69
+ @keyframes assistant-fade-in {
70
+ to {
71
+ opacity: 1;
72
+ }
73
+ }
74
+
75
+ @keyframes assistant-aurora-halo {
76
+ 0% {
77
+ box-shadow:
78
+ 0 0 0 rgba(1, 60, 84, 0),
79
+ 0 0 0 rgba(1, 60, 84, 0),
80
+ 0 2px 8px rgba(0, 0, 0, 0.2);
81
+ }
82
+ 20% {
83
+ box-shadow:
84
+ 8px 0 14px rgba(1, 60, 84, 0.55),
85
+ 0 0 22px rgba(1, 60, 84, 0.2),
86
+ 0 2px 8px rgba(0, 0, 0, 0.2);
87
+ }
88
+ 40% {
89
+ box-shadow:
90
+ 0 8px 14px rgba(1, 60, 84, 0.55),
91
+ 0 0 22px rgba(1, 60, 84, 0.2),
92
+ 0 2px 8px rgba(0, 0, 0, 0.2);
93
+ }
94
+ 60% {
95
+ box-shadow:
96
+ -8px 0 14px rgba(1, 60, 84, 0.55),
97
+ 0 0 22px rgba(1, 60, 84, 0.2),
98
+ 0 2px 8px rgba(0, 0, 0, 0.2);
99
+ }
100
+ 80% {
101
+ box-shadow:
102
+ 0 -8px 14px rgba(1, 60, 84, 0.55),
103
+ 0 0 22px rgba(1, 60, 84, 0.2),
104
+ 0 2px 8px rgba(0, 0, 0, 0.2);
105
+ }
106
+ 100% {
107
+ box-shadow:
108
+ 0 0 18px rgba(1, 60, 84, 0.75),
109
+ 0 0 28px rgba(1, 60, 84, 0.4),
110
+ 0 2px 8px rgba(0, 0, 0, 0.2);
111
+ }
112
+ }
113
+
114
+ .assistant-bubble {
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ width: 100%;
119
+ height: 100%;
120
+ transition: opacity 0.15s ease-out;
121
+
122
+ .ui.icon {
123
+ margin: 0;
124
+ font-size: 1.5rem;
125
+ }
126
+
127
+ &__logo {
128
+ display: block;
129
+ width: 70%;
130
+ height: 70%;
131
+ object-fit: contain;
132
+ }
133
+ }
134
+
135
+ .assistant-chat {
136
+ display: flex;
137
+ flex-direction: column;
138
+ width: 100%;
139
+ height: 100%;
140
+
141
+ &__actions {
142
+ display: flex;
143
+ flex-shrink: 0;
144
+ align-items: center;
145
+ justify-content: space-between;
146
+ gap: 0.5rem;
147
+ padding: 0.75rem 1rem;
148
+ background: #013c54;
149
+ color: #fff;
150
+ }
151
+
152
+ &__action {
153
+ display: inline-flex;
154
+ align-items: center;
155
+ gap: 0.35rem;
156
+ padding: 0;
157
+ border: none;
158
+ border-radius: 0.25rem;
159
+ background: transparent;
160
+ color: inherit;
161
+ font-size: 0.875rem;
162
+ cursor: pointer;
163
+
164
+ .ui.icon {
165
+ margin: 0;
166
+ }
167
+
168
+ &--new-chat {
169
+ font-weight: 600;
170
+ cursor: default;
171
+ }
172
+
173
+ &--close {
174
+ margin-left: auto;
175
+ padding: 0.25rem;
176
+ width: 2rem;
177
+ height: 2rem;
178
+ border-radius: 999px;
179
+ background: transparent;
180
+
181
+ &:hover,
182
+ &:focus,
183
+ &:active {
184
+ background: transparent;
185
+ }
186
+ }
187
+ }
188
+
189
+ &__topic {
190
+ font-size: 0.75rem;
191
+ color: rgba(0, 0, 0, 0.5);
192
+ font-family: monospace;
193
+ overflow: hidden;
194
+ text-overflow: ellipsis;
195
+ max-width: 12rem;
196
+ }
197
+
198
+ &__messages-wrapper {
199
+ position: relative;
200
+ flex: 1;
201
+ min-height: 0;
202
+ display: flex;
203
+ flex-direction: column;
204
+ }
205
+
206
+ &__messages {
207
+ flex: 1;
208
+ padding: 1rem;
209
+ overflow: auto;
210
+ min-height: 0;
211
+ display: flex;
212
+ flex-direction: column;
213
+ gap: 0.75rem;
214
+ }
215
+
216
+ &__scroll-to-bottom {
217
+ position: absolute;
218
+ bottom: 1rem;
219
+ right: 1rem;
220
+ width: 2.25rem;
221
+ height: 2.25rem;
222
+ padding: 0;
223
+ border: none;
224
+ border-radius: 50%;
225
+ background: #2185d0;
226
+ color: #fff;
227
+ cursor: pointer;
228
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
229
+ z-index: 1;
230
+
231
+ .ui.icon {
232
+ margin: 0;
233
+ font-size: 1.125rem;
234
+ }
235
+
236
+ &:hover {
237
+ background: #1678c2;
238
+ }
239
+ }
240
+
241
+ &__message {
242
+ max-width: 85%;
243
+ padding: 0.4rem 0.75rem;
244
+ border-radius: 1rem;
245
+ font-size: 0.875rem;
246
+ line-height: 1.4;
247
+ word-break: break-word;
248
+
249
+ &--user {
250
+ align-self: flex-end;
251
+ }
252
+
253
+ &--agent {
254
+ align-self: flex-start;
255
+ }
256
+
257
+ &--request {
258
+ background: #ed5c17;
259
+ color: #fff;
260
+ }
261
+
262
+ &--response {
263
+ background: #e8e8e8;
264
+ color: #333;
265
+ }
266
+
267
+ &--log {
268
+ background: transparent;
269
+ color: #666;
270
+ font-style: italic;
271
+ font-size: 0.8125rem;
272
+ }
273
+ }
274
+
275
+ &__message-content {
276
+
277
+ .markdown-reader {
278
+ h1 {
279
+ font-size: 1.05rem;
280
+ }
281
+
282
+ h2 {
283
+ font-size: 1rem;
284
+ }
285
+
286
+ h3 {
287
+ font-size: 0.95rem;
288
+ }
289
+
290
+ h4,
291
+ h5,
292
+ h6 {
293
+ font-size: 0.9rem;
294
+ }
295
+ }
296
+ }
297
+
298
+ &__input-row {
299
+ display: flex;
300
+ flex-shrink: 0;
301
+ align-items: center;
302
+ gap: 0.5rem;
303
+ padding: 0.35rem 0.5rem;
304
+ border-radius: 999px;
305
+ background: #f4f6f8;
306
+ }
307
+
308
+ &__input {
309
+ flex: 1;
310
+ padding: 0.35rem 0.75rem;
311
+ border: none;
312
+ border-radius: 999px;
313
+ background: transparent;
314
+ font-size: 0.875rem;
315
+ outline: none;
316
+
317
+ &:focus {
318
+ border-color: #2185d0;
319
+ }
320
+ }
321
+
322
+ &__send {
323
+ flex-shrink: 0;
324
+ display: inline-flex;
325
+ align-items: center;
326
+ justify-content: center;
327
+ width: 2.25rem;
328
+ height: 2.25rem;
329
+ padding: 0;
330
+ border: none;
331
+ border-radius: 999px;
332
+ background: #013c54;
333
+ color: #fff;
334
+ cursor: pointer;
335
+
336
+ .ui.icon {
337
+ margin: 0;
338
+ }
339
+ }
340
+
341
+ &__footer {
342
+ padding: 0.75rem 1rem 1rem;
343
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
344
+ background: #fff;
345
+ }
346
+
347
+ &__disclaimer {
348
+ margin-top: 0.5rem;
349
+ font-size: 0.75rem;
350
+ color: #8e8e8e;
351
+ text-align: center;
352
+ }
353
+ }