diff --git a/packages/api/src/ApiClient.ts b/packages/api/src/ApiClient.ts new file mode 100644 index 0000000000..ec0f45d845 --- /dev/null +++ b/packages/api/src/ApiClient.ts @@ -0,0 +1,64 @@ +import { RocketChatAuth } from "@embeddedchat/auth"; + +export class RCApiError extends Error { + constructor(public status: number, public body: unknown) { + super(`RC API Error ${status}`); + this.name = "RCApiError"; + } +} + +export class ApiClient { + private host: string; + private auth: RocketChatAuth; + + constructor(host: string, auth: RocketChatAuth) { + this.host = host; + this.auth = auth; + } + + private async buildHeaders(): Promise> { + const user = await this.auth.getCurrentUser(); + return { + "Content-Type": "application/json", + "X-Auth-Token": user?.authToken ?? "", + "X-User-Id": user?.userId ?? "", + }; + } + + async get(endpoint: string, params?: Record): Promise { + const url = new URL(`${this.host}/api/v1/${endpoint}`); + if (params) { + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + } + try { + const res = await fetch(url.toString(), { + method: "GET", + headers: await this.buildHeaders(), + }); + if (!res.ok) { + throw new RCApiError(res.status, await res.json()); + } + return res.json(); + } catch (err) { + if (err instanceof RCApiError) throw err; + throw new Error(`Network error on GET ${endpoint}: ${err}`); + } + } + + async post(endpoint: string, body?: object): Promise { + try { + const res = await fetch(`${this.host}/api/v1/${endpoint}`, { + method: "POST", + headers: await this.buildHeaders(), + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + throw new RCApiError(res.status, await res.json()); + } + return res.json(); + } catch (err) { + if (err instanceof RCApiError) throw err; + throw new Error(`Network error on POST ${endpoint}: ${err}`); + } + } +} diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 88eb4c23c2..b4f737f764 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -1,6 +1,7 @@ import { Rocketchat } from "@rocket.chat/sdk"; import cloneArray from "./cloneArray"; import { ROCKETCHAT_APP_ID } from "./utils/constants"; +import { ApiClient, RCApiError } from "./ApiClient"; import { IRocketChatAuthOptions, RocketChatAuth, @@ -20,6 +21,7 @@ export default class EmbeddedChatApi { onUiInteractionCallbacks: ((data: any) => void)[]; typingUsers: string[]; auth: RocketChatAuth; + apiClient: ApiClient; constructor( host: string, @@ -46,6 +48,7 @@ export default class EmbeddedChatApi { getToken, saveToken, }); + this.apiClient = new ApiClient(this.host, this.auth); } setAuth(auth: RocketChatAuth) { @@ -472,23 +475,7 @@ export default class EmbeddedChatApi { } async channelInfo() { - try { - const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; - const response = await fetch( - `${this.host}/api/v1/rooms.info?roomId=${this.rid}`, - { - headers: { - "Content-Type": "application/json", - "X-Auth-Token": authToken, - "X-User-Id": userId, - }, - method: "GET", - } - ); - return await response.json(); - } catch (err) { - console.error(err); - } + return this.apiClient.get("rooms.info", { roomId: this.rid }); } async getRoomInfo() { @@ -712,39 +699,11 @@ export default class EmbeddedChatApi { if (threadId) { messageObj.tmid = threadId; } - try { - const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; - const response = await fetch(`${this.host}/api/v1/chat.sendMessage`, { - body: JSON.stringify({ message: messageObj }), - headers: { - "Content-Type": "application/json", - "X-Auth-Token": authToken, - "X-User-Id": userId, - }, - method: "POST", - }); - return await response.json(); - } catch (err) { - console.error(err); - } + return this.apiClient.post("chat.sendMessage", { message: messageObj }); } async deleteMessage(msgId: string) { - try { - const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; - const response = await fetch(`${this.host}/api/v1/chat.delete`, { - body: JSON.stringify({ roomId: this.rid, msgId }), - headers: { - "Content-Type": "application/json", - "X-Auth-Token": authToken, - "X-User-Id": userId, - }, - method: "POST", - }); - return await response.json(); - } catch (err) { - console.error(err); - } + return this.apiClient.post("chat.delete", { roomId: this.rid, msgId }); } async updateMessage(msgId: string, text: string) { @@ -1166,17 +1125,7 @@ export default class EmbeddedChatApi { } async getCommandsList() { - const { userId, authToken } = (await this.auth.getCurrentUser()) || {}; - const response = await fetch(`${this.host}/api/v1/commands.list`, { - headers: { - "Content-Type": "application/json", - "X-Auth-Token": authToken, - "X-User-Id": userId, - }, - method: "GET", - }); - const data = await response.json(); - return data; + return this.apiClient.get("commands.list"); } async execCommand({ diff --git a/packages/api/src/ai/IAIAdapter.ts b/packages/api/src/ai/IAIAdapter.ts new file mode 100644 index 0000000000..65982dfbe5 --- /dev/null +++ b/packages/api/src/ai/IAIAdapter.ts @@ -0,0 +1,28 @@ +export type RCMessage = { + _id: string; + msg: string; + u: { + _id: string; + username: string; + name?: string; + }; + ts: string | number; +}; + +export interface IAIAdapter { + /** + * Given the recent message context in a room, + * return 2-3 suggested reply strings. + */ + suggestReply(context: RCMessage[]): Promise; + + /** + * Given a thread's messages, return a short human-readable summary. + */ + summarizeThread(messages: RCMessage[]): Promise; + + /** + * Optional: check if a message should be flagged before sending. + */ + moderateMessage?(text: string): Promise<{ flagged: boolean; reason?: string }>; +} diff --git a/packages/api/src/ai/MockAIAdapter.ts b/packages/api/src/ai/MockAIAdapter.ts new file mode 100644 index 0000000000..e5d54d0bdd --- /dev/null +++ b/packages/api/src/ai/MockAIAdapter.ts @@ -0,0 +1,27 @@ +import { IAIAdapter, RCMessage } from "./IAIAdapter"; + +export class MockAIAdapter implements IAIAdapter { + async suggestReply(context: RCMessage[]): Promise { + const lastMsg = context[context.length - 1]?.msg ?? ""; + // Simple rule-based mock — shows the shape works + if (lastMsg.includes("?")) { + return [ + "I'll look into this.", + "Can you share more details?", + "Sure, let me check.", + ]; + } + return ["Got it, thanks!", "Understood.", "Will do."]; + } + + async summarizeThread(messages: RCMessage[]): Promise { + return `Thread of ${messages.length} messages. Started by @${messages[0]?.u?.username ?? "unknown"}: "${messages[0]?.msg?.slice(0, 60)}..."`; + } + + async moderateMessage( + text: string + ): Promise<{ flagged: boolean; reason?: string }> { + // Mock: flag nothing + return { flagged: false }; + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b4ff83a1c6..3213d240fa 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1 +1,4 @@ export { default as EmbeddedChatApi } from "./EmbeddedChatApi"; +export { ApiClient, RCApiError } from "./ApiClient"; +export type { IAIAdapter, RCMessage } from "./ai/IAIAdapter"; +export { MockAIAdapter } from "./ai/MockAIAdapter"; diff --git a/packages/react/src/context/RCInstance.js b/packages/react/src/context/RCInstance.js index 16b6bc450e..40b93f599b 100644 --- a/packages/react/src/context/RCInstance.js +++ b/packages/react/src/context/RCInstance.js @@ -21,6 +21,7 @@ export const RCInstanceProvider = RCContext.Provider; * @typedef {Object} RCContext * @property {import('@embeddedchat/api').EmbeddedChatApi} RCInstance * @property {ECOptions} ECOptions + * @property {import('@embeddedchat/api').IAIAdapter | null} aiAdapter * @returns {RCContext} */ export const useRCContext = () => useContext(RCContext); diff --git a/packages/react/src/hooks/useAIAdapter.js b/packages/react/src/hooks/useAIAdapter.js new file mode 100644 index 0000000000..d7d663145d --- /dev/null +++ b/packages/react/src/hooks/useAIAdapter.js @@ -0,0 +1,50 @@ +import { useState, useCallback } from 'react'; +import { useRCContext } from '../context/RCInstance'; + +export const useAIAdapter = () => { + const { aiAdapter } = useRCContext(); + const [suggestions, setSuggestions] = useState([]); + const [summary, setSummary] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const getSuggestions = useCallback( + async (messages) => { + if (!aiAdapter) return; + setIsLoading(true); + try { + const result = await aiAdapter.suggestReply(messages); + setSuggestions(result); + } catch (e) { + console.error('AI suggestReply failed:', e); + } finally { + setIsLoading(false); + } + }, + [aiAdapter] + ); + + const getThreadSummary = useCallback( + async (messages) => { + if (!aiAdapter) return; + setIsLoading(true); + try { + const result = await aiAdapter.summarizeThread(messages); + setSummary(result); + } catch (e) { + console.error('AI summarizeThread failed:', e); + } finally { + setIsLoading(false); + } + }, + [aiAdapter] + ); + + return { + isAIEnabled: !!aiAdapter, + suggestions, + summary, + isLoading, + getSuggestions, + getThreadSummary, + }; +}; diff --git a/packages/react/src/stories/EmbeddedChat.stories.js b/packages/react/src/stories/EmbeddedChat.stories.js index 9983a5da9b..5085f3442d 100644 --- a/packages/react/src/stories/EmbeddedChat.stories.js +++ b/packages/react/src/stories/EmbeddedChat.stories.js @@ -1,3 +1,4 @@ +import { MockAIAdapter } from '@embeddedchat/api'; import { EmbeddedChat } from '..'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction @@ -22,5 +23,6 @@ export const Simple = { flow: 'PASSWORD', }, dark: false, + aiAdapter: new MockAIAdapter(), }, }; diff --git a/packages/react/src/stories/EmbeddedChatAuthToken.stories.js b/packages/react/src/stories/EmbeddedChatAuthToken.stories.js index 5c2ad9ce22..5286703057 100644 --- a/packages/react/src/stories/EmbeddedChatAuthToken.stories.js +++ b/packages/react/src/stories/EmbeddedChatAuthToken.stories.js @@ -1,3 +1,4 @@ +import { MockAIAdapter } from '@embeddedchat/api'; import { EmbeddedChat } from '..'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction @@ -24,5 +25,6 @@ export const WithAuthToken = { resume: 'resume_token', }, }, + aiAdapter: new MockAIAdapter(), }, }; diff --git a/packages/react/src/stories/EmbeddedChatSecureAuth.stories.js b/packages/react/src/stories/EmbeddedChatSecureAuth.stories.js index 7bf422688e..46c0c7f217 100644 --- a/packages/react/src/stories/EmbeddedChatSecureAuth.stories.js +++ b/packages/react/src/stories/EmbeddedChatSecureAuth.stories.js @@ -1,3 +1,4 @@ +import { MockAIAdapter } from '@embeddedchat/api'; import { EmbeddedChat } from '..'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction @@ -23,5 +24,6 @@ export const Secure_Auth = { }, secure: true, dark: false, + aiAdapter: new MockAIAdapter(), }, }; diff --git a/packages/react/src/stories/EmbeddedChatWithOAuth.stories.js b/packages/react/src/stories/EmbeddedChatWithOAuth.stories.js index 9cfd1c4fce..187ecfa6ed 100644 --- a/packages/react/src/stories/EmbeddedChatWithOAuth.stories.js +++ b/packages/react/src/stories/EmbeddedChatWithOAuth.stories.js @@ -1,3 +1,4 @@ +import { MockAIAdapter } from '@embeddedchat/api'; import { EmbeddedChat } from '..'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction @@ -21,5 +22,6 @@ export const WithOAuth = { auth: { flow: 'OAUTH', }, + aiAdapter: new MockAIAdapter(), }, }; diff --git a/packages/react/src/stories/EmbeddedChatWithRemote.stories.js b/packages/react/src/stories/EmbeddedChatWithRemote.stories.js index 564ccad067..bbaf8eb8ef 100644 --- a/packages/react/src/stories/EmbeddedChatWithRemote.stories.js +++ b/packages/react/src/stories/EmbeddedChatWithRemote.stories.js @@ -1,3 +1,4 @@ +import { MockAIAdapter } from '@embeddedchat/api'; import { EmbeddedChat } from '..'; export default { @@ -24,5 +25,6 @@ export const With_Remote_Opt = { flow: 'PASSWORD', }, dark: false, + aiAdapter: new MockAIAdapter(), }, }; diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index f6fb0e111f..fa9b0f96c0 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -34,6 +34,7 @@ import { getChatInputStyles } from './ChatInput.styles'; import useShowCommands from '../../hooks/useShowCommands'; import useSearchMentionUser from '../../hooks/useSearchMentionUser'; import useSearchEmoji from '../../hooks/useSearchEmoji'; +import { useAIAdapter } from '../../hooks/useAIAdapter'; import formatSelection from '../../lib/formatSelection'; import { parseEmoji } from '../../lib/emoji'; import useDropBox from '../../hooks/useDropBox'; @@ -60,6 +61,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const [showCommandList, setShowCommandList] = useState(false); const [filteredCommands, setFilteredCommands] = useState([]); const [showEmojiList, setShowEmojiList] = useState(false); + const [isInputFocused, setIsInputFocused] = useState(false); const [filteredEmojis, setFilteredEmojis] = useState([]); const [emojiIndex, setEmojiIndex] = useState(-1); const [startReadEmoji, setStartReadEmoji] = useState(false); @@ -102,6 +104,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const { editMessage, + messages, setEditMessage, quoteMessage, isRecordingMessage, @@ -112,6 +115,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { deletedMessage, } = useMessageStore((state) => ({ editMessage: state.editMessage, + messages: state.messages, setEditMessage: state.setEditMessage, quoteMessage: state.quoteMessage, isRecordingMessage: state.isRecordingMessage, @@ -152,6 +156,19 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { ); const { handlePaste } = useDropBox(); + const { isAIEnabled, suggestions, getSuggestions } = useAIAdapter(); + + const handleAISuggest = async () => { + if (messages.length === 0) return; + await getSuggestions(messages.slice(-5)); + }; + + useEffect(() => { + if (isAIEnabled && isInputFocused && messages.length > 0) { + getSuggestions(messages.slice(-5)); + } + }, [messages, isAIEnabled, isInputFocused, getSuggestions]); + const searchEmoji = useSearchEmoji( startReadEmoji, setStartReadEmoji, @@ -457,12 +474,14 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { if (chatInputContainer.current) { chatInputContainer.current.classList.add('focused'); } + setIsInputFocused(true); }; const handleBlur = () => { if (chatInputContainer.current) { chatInputContainer.current.classList.remove('focused'); } + setIsInputFocused(false); }; const handlePasting = (event) => { @@ -661,6 +680,32 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { /> )} + {isAIEnabled && isInputFocused && suggestions.length > 0 && ( + + {suggestions.map((s, i) => ( + + ))} + + )} + { sendTypingStop(); handleBlur(); }} - onFocus={handleFocus} + onFocus={() => { + handleFocus(); + handleAISuggest(); + }} onKeyDown={onKeyDown} onPaste={handlePasting} ref={messageRef} diff --git a/packages/react/src/views/ChatInput/ChatInput.styles.js b/packages/react/src/views/ChatInput/ChatInput.styles.js index 0841451324..18ab8dabe0 100644 --- a/packages/react/src/views/ChatInput/ChatInput.styles.js +++ b/packages/react/src/views/ChatInput/ChatInput.styles.js @@ -35,6 +35,19 @@ export const getChatInputStyles = (theme) => { cursor: pointer; `, + suggestionChip: (theme) => css` + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 12px; + cursor: pointer; + background: ${theme.colors?.surface?.default ?? '#f0f0f0'}; + border: 1px solid ${theme.colors?.auxiliary ?? '#ddd'}; + color: ${theme.colors?.font?.default ?? '#333'}; + &:hover { + background: ${theme.colors?.surface?.hover ?? '#e0e0e0'}; + } + `, + textInput: css` flex: 1; word-wrap: break-word; diff --git a/packages/react/src/views/EmbeddedChat.js b/packages/react/src/views/EmbeddedChat.js index f3b94c7b48..fc2fcb2a56 100644 --- a/packages/react/src/views/EmbeddedChat.js +++ b/packages/react/src/views/EmbeddedChat.js @@ -58,6 +58,7 @@ const EmbeddedChat = (props) => { secure = false, dark = false, remoteOpt = false, + aiAdapter = null, } = config; const hasMounted = useRef(false); @@ -218,8 +219,8 @@ const EmbeddedChat = (props) => { ); const RCContextValue = useMemo( - () => ({ RCInstance, ECOptions }), - [RCInstance, ECOptions] + () => ({ RCInstance, ECOptions, aiAdapter }), + [RCInstance, ECOptions, aiAdapter] ); if (!isSynced) return null; @@ -288,6 +289,7 @@ EmbeddedChat.propTypes = { style: PropTypes.object, hideHeader: PropTypes.bool, dark: PropTypes.bool, + aiAdapter: PropTypes.object, }; export default memo(EmbeddedChat);